diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..b36fd570 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,7 @@ +self-hosted-runner: + labels: + - bloom-quality + - apple-m1-max + - nvidia-rtx4080 + +config-variables: null diff --git a/.github/actions/upload-ci-failure/action.yml b/.github/actions/upload-ci-failure/action.yml new file mode 100644 index 00000000..f02653ad --- /dev/null +++ b/.github/actions/upload-ci-failure/action.yml @@ -0,0 +1,18 @@ +name: Upload Bloom CI failure evidence +description: Preserve lane summaries and renderer diagnostics from a failed job. +inputs: + name: + description: Unique artifact prefix for this job. + required: true +runs: + using: composite + steps: + - uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.name }}-${{ github.run_attempt }} + path: | + target/ci + native/shared/tests/golden/**/*.actual.png + tools/quality/out + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 00000000..da97923b --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,184 @@ +name: Renderer quality + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "17 3 * * 1" + workflow_dispatch: + inputs: + suite: + description: Qualification suite + type: choice + options: [full, quick] + default: full + machine: + description: Hardware runner + type: choice + options: [all, apple-m1-max-metal, nvidia-rtx4080-vulkan] + default: all + +concurrency: + group: renderer-quality-${{ github.ref }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + MACOSX_DEPLOYMENT_TARGET: "13.0" + +jobs: + contract: + name: Quality contract + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: dtolnay/rust-toolchain@stable + + - name: quick / quality-contract + run: ./scripts/ci-check.sh --quick --component quality-contract + + - name: Upload failure evidence + if: failure() + uses: ./.github/actions/upload-ci-failure + with: + name: quality-contract + + metal: + name: Full quality — Apple M1 Max / Metal + if: >- + github.event_name == 'schedule' || + (github.event_name == 'workflow_dispatch' && + (inputs.machine == 'all' || inputs.machine == 'apple-m1-max-metal')) + runs-on: [self-hosted, bloom-quality, macos, apple-m1-max] + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: dtolnay/rust-toolchain@stable + + - name: Runner preflight + run: | + python3 --version + cargo --version + command -v perry + + - name: Compile every canonical TypeScript example + run: ./scripts/ci-check.sh --hardware --component example-compile + + - name: Validate manifest, assets, and approved baselines + run: ./scripts/ci-check.sh --hardware --component quality-check + + - name: Prove seeded regressions are detected + env: + BLOOM_QUALITY_FAULTS_OUT: tools/quality/out/ci-faults + BLOOM_QUALITY_TIMEOUT: "900" + run: ./scripts/ci-check.sh --hardware --component quality-faults + + - name: Run selected high-end suite + if: always() + env: + BLOOM_QUALITY_SUITE: ${{ inputs.suite || 'full' }} + BLOOM_QUALITY_MACHINE_CLASS: apple-m1-max-metal + BLOOM_QUALITY_OUT: tools/quality/out/ci-metal + BLOOM_QUALITY_TIMEOUT: "1800" + run: ./scripts/ci-check.sh --hardware --component quality-run + + - name: Hard-gate the constrained tier + if: always() + env: + BLOOM_QUALITY_SUITE: full + BLOOM_QUALITY_CASE: pbr-spheres-constrained + BLOOM_QUALITY_MACHINE_CLASS: apple-m1-metal-constrained + BLOOM_QUALITY_OUT: tools/quality/out/ci-metal-constrained + BLOOM_QUALITY_TIMEOUT: "1800" + run: ./scripts/ci-check.sh --hardware --component quality-run + + - name: Publish compact job summary + if: always() + run: | + if [[ -f tools/quality/out/ci-metal/summary.md ]]; then + cat tools/quality/out/ci-metal/summary.md >> "$GITHUB_STEP_SUMMARY" + fi + if [[ -f tools/quality/out/ci-metal-constrained/summary.md ]]; then + cat tools/quality/out/ci-metal-constrained/summary.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload all success/failure evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: quality-apple-m1-max-metal-${{ github.run_attempt }} + path: | + target/ci/examples + tools/quality/out/ci-metal + tools/quality/out/ci-metal-constrained + tools/quality/out/ci-faults + if-no-files-found: warn + retention-days: 30 + + vulkan: + name: Full quality — RTX 4080 / Vulkan + if: >- + github.event_name == 'schedule' || + (github.event_name == 'workflow_dispatch' && + (inputs.machine == 'all' || inputs.machine == 'nvidia-rtx4080-vulkan')) + runs-on: [self-hosted, bloom-quality, linux, nvidia-rtx4080] + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: dtolnay/rust-toolchain@stable + + - name: Runner preflight + run: | + python3 --version + cargo --version + command -v perry + nvidia-smi + + - name: Validate manifest, assets, and approved baselines + run: ./scripts/ci-check.sh --hardware --component quality-check + + - name: Prove seeded regressions are detected + env: + BLOOM_QUALITY_FAULTS_OUT: tools/quality/out/ci-faults + BLOOM_QUALITY_TIMEOUT: "900" + run: ./scripts/ci-check.sh --hardware --component quality-faults + + - name: Run selected high-end suite + if: always() + env: + BLOOM_QUALITY_SUITE: ${{ inputs.suite || 'full' }} + BLOOM_QUALITY_MACHINE_CLASS: nvidia-rtx4080-vulkan + BLOOM_QUALITY_OUT: tools/quality/out/ci-vulkan + BLOOM_QUALITY_TIMEOUT: "1800" + run: ./scripts/ci-check.sh --hardware --component quality-run + + - name: Publish compact job summary + if: always() + run: | + if [[ -f tools/quality/out/ci-vulkan/summary.md ]]; then + cat tools/quality/out/ci-vulkan/summary.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload all success/failure evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: quality-nvidia-rtx4080-vulkan-${{ github.run_attempt }} + path: | + tools/quality/out/ci-vulkan + tools/quality/out/ci-faults + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index effe6c7b..7ca617cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,8 @@ name: Release # secret needed. Provenance attestation is automatic under this flow. # # Job order: -# await-tests — gate on Tests workflow passing for the tag SHA +# resolve-release — resolve/peel the requested tag to one commit SHA +# await-tests — gate on Tests workflow passing for that exact SHA # github-release — create/keep the GH Release object # build-jolt-prebuilt — matrix: build libJolt + libbloom_jolt for every # (os, arch) variant on its native runner @@ -35,13 +36,71 @@ on: required: true concurrency: - group: release-${{ github.ref }} + group: release-${{ github.event.inputs.tag || github.ref }} cancel-in-progress: false # never cancel a release mid-flight permissions: contents: write jobs: + # --------------------------------------------------------------------------- + # Resolve both tag-push and manual-republish events to the same immutable + # commit. Annotated tags are peeled until they reach a commit; every later + # checkout and test query consumes these outputs. + # --------------------------------------------------------------------------- + resolve-release: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.ref.outputs.tag }} + version: ${{ steps.ref.outputs.version }} + sha: ${{ steps.ref.outputs.sha }} + steps: + - name: Resolve and peel release tag + id: ref + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + DISPATCH_TAG: ${{ github.event.inputs.tag }} + EVENT: ${{ github.event_name }} + PUSH_REF: ${{ github.ref }} + run: | + set -euo pipefail + if [ "$EVENT" = "workflow_dispatch" ]; then + TAG="$DISPATCH_TAG" + else + TAG="${PUSH_REF#refs/tags/}" + fi + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::invalid release tag: $TAG" + exit 1 + fi + + object=$(gh api "/repos/$REPO/git/ref/tags/$TAG" --jq '.object') + type=$(jq -r '.type' <<<"$object") + sha=$(jq -r '.sha' <<<"$object") + for _ in 1 2 3 4; do + if [ "$type" = "commit" ]; then + break + fi + if [ "$type" != "tag" ]; then + echo "::error::tag $TAG resolves to unsupported object type $type" + exit 1 + fi + object=$(gh api "/repos/$REPO/git/tags/$sha" --jq '.object') + type=$(jq -r '.type' <<<"$object") + sha=$(jq -r '.sha' <<<"$object") + done + if [ "$type" != "commit" ] || [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::tag $TAG did not resolve to a commit SHA" + exit 1 + fi + { + echo "tag=$TAG" + echo "version=${TAG#v}" + echo "sha=$sha" + } >> "$GITHUB_OUTPUT" + echo "Resolved $TAG to $sha" + # --------------------------------------------------------------------------- # Gate: wait for `Tests` to pass on this commit before we create the GitHub # Release. Same pattern as Perry's release-packages.yml — query the API by @@ -49,6 +108,7 @@ jobs: # Tests failed so we don't ship a known-broken tag. # --------------------------------------------------------------------------- await-tests: + needs: resolve-release runs-on: ubuntu-latest timeout-minutes: 60 steps: @@ -56,15 +116,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} - SHA: ${{ github.sha }} - EVENT: ${{ github.event_name }} + SHA: ${{ needs.resolve-release.outputs.sha }} + TAG: ${{ needs.resolve-release.outputs.tag }} run: | set -euo pipefail - if [ "$EVENT" = "workflow_dispatch" ]; then - echo "workflow_dispatch — bypassing test gate (manual republish)." - exit 0 - fi - echo "Gating release on commit $SHA" + echo "Gating release $TAG on commit $SHA" deadline=$(( SECONDS + 45 * 60 )) retry_on_error=1 while :; do @@ -118,30 +174,29 @@ jobs: # creates one with auto-generated notes. # --------------------------------------------------------------------------- github-release: - needs: await-tests + needs: [resolve-release, await-tests] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ needs.resolve-release.outputs.sha }} - - name: Resolve tag - id: tag + - name: Verify package.json version matches tag env: - DISPATCH_TAG: ${{ github.event.inputs.tag }} + VERSION: ${{ needs.resolve-release.outputs.version }} + TAG: ${{ needs.resolve-release.outputs.tag }} run: | - if [ -n "$DISPATCH_TAG" ]; then - TAG="$DISPATCH_TAG" - else - TAG="${GITHUB_REF#refs/tags/}" + PKG_VERSION=$(node -p "require('./package.json').version") + if [ "$PKG_VERSION" != "$VERSION" ]; then + echo "::error::Tag $TAG ($VERSION) does not match package.json ($PKG_VERSION) — refusing to release." + exit 1 fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" - name: Create GitHub Release (if missing) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.tag.outputs.tag }} + TAG: ${{ needs.resolve-release.outputs.tag }} run: | if gh release view "$TAG" >/dev/null 2>&1; then echo "Release $TAG already exists — leaving body alone." @@ -152,19 +207,6 @@ jobs: --title "$TAG" \ --generate-notes - - name: Verify package.json version matches tag - env: - VERSION: ${{ steps.tag.outputs.version }} - run: | - PKG_VERSION=$(node -p "require('./package.json').version") - if [ "$PKG_VERSION" != "$VERSION" ]; then - echo "WARNING package.json version ($PKG_VERSION) does not match tag ($VERSION)." - echo " This is normally a bug — the /release skill should bump package.json" - echo " and commit before tagging. Continuing, but investigate." - else - echo "OK package.json version matches tag ($VERSION)" - fi - # --------------------------------------------------------------------------- # Build the JoltPhysics + bloom_jolt static libraries for every (os, arch) # combination Bloom supports. Each matrix entry runs on its native runner @@ -180,7 +222,7 @@ jobs: # README update + corresponding arch mapping in native/shared/build.rs. # --------------------------------------------------------------------------- build-jolt-prebuilt: - needs: await-tests + needs: [resolve-release, await-tests] strategy: fail-fast: false matrix: @@ -260,6 +302,7 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + ref: ${{ needs.resolve-release.outputs.sha }} - name: Configure cmake shell: bash @@ -308,13 +351,15 @@ jobs: # an old tag won't republish the same version, won't fail either). # --------------------------------------------------------------------------- publish-jolt-prebuilt: - needs: build-jolt-prebuilt + needs: [resolve-release, build-jolt-prebuilt] runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.resolve-release.outputs.sha }} - uses: actions/setup-node@v5 with: @@ -370,7 +415,7 @@ jobs: # package vendors them rather than relying on a postinstall git clone. # --------------------------------------------------------------------------- publish-npm: - needs: [github-release, publish-jolt-prebuilt] + needs: [resolve-release, github-release, publish-jolt-prebuilt] runs-on: ubuntu-latest permissions: contents: read @@ -379,29 +424,17 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + ref: ${{ needs.resolve-release.outputs.sha }} - uses: actions/setup-node@v5 with: node-version: "24" registry-url: "https://registry.npmjs.org" - - name: Resolve tag - id: tag - env: - DISPATCH_TAG: ${{ github.event.inputs.tag }} - run: | - if [ -n "$DISPATCH_TAG" ]; then - TAG="$DISPATCH_TAG" - else - TAG="${GITHUB_REF#refs/tags/}" - fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" - - name: Verify package.json version matches tag env: - VERSION: ${{ steps.tag.outputs.version }} - TAG: ${{ steps.tag.outputs.tag }} + VERSION: ${{ needs.resolve-release.outputs.version }} + TAG: ${{ needs.resolve-release.outputs.tag }} run: | PKG_VERSION=$(node -p "require('./package.json').version") if [ "$PKG_VERSION" != "$VERSION" ]; then diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 28fcab9e..6441e949 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,9 +67,14 @@ jobs: key: ${{ runner.os }}-shared-${{ hashFiles('native/shared/Cargo.lock') }} restore-keys: ${{ runner.os }}-shared- - - name: cargo test (bloom-shared + jolt) - working-directory: native/shared - run: cargo test --release + - name: quick / shared-tests + run: ./scripts/ci-check.sh --quick --component shared-tests + + - name: Upload failure evidence + if: failure() + uses: ./.github/actions/upload-ci-failure + with: + name: shared-tests-${{ runner.os }} # --------------------------------------------------------------------------- # FFI parity: every function in package.json's perry.nativeLibrary manifest @@ -83,14 +88,21 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - - name: validate-ffi - run: node tools/validate-ffi.js - - name: file line limit (2000, ratcheting baseline) - run: node tools/check-file-lines.js + - name: quick / contracts + run: ./scripts/ci-check.sh --quick --component contracts + - name: quick / example-inventory + run: ./scripts/ci-check.sh --quick --component example-inventory + + - name: Upload failure evidence + if: failure() + uses: ./.github/actions/upload-ci-failure + with: + name: ffi-contracts # --------------------------------------------------------------------------- - # Lint: rustfmt + clippy. Advisory while the codebase is still in flux. - # Flip `continue-on-error: false` once the lint baseline is clean. + # Lint: rustfmt plus a strict clippy correctness/performance ratchet. + # Legacy style debt is documented in scripts/ci-check.sh rather than making + # findings advisory or silently accepting new correctness regressions. # --------------------------------------------------------------------------- lint: runs-on: macos-14 @@ -104,15 +116,14 @@ jobs: with: components: rustfmt, clippy - - name: rustfmt --check (shared) - working-directory: native/shared - continue-on-error: true - run: cargo fmt --check + - name: quick / lint + run: ./scripts/ci-check.sh --quick --component lint - - name: clippy (shared, advisory) - working-directory: native/shared - continue-on-error: true - run: cargo clippy --release --no-deps -- -D warnings + - name: Upload failure evidence + if: failure() + uses: ./.github/actions/upload-ci-failure + with: + name: lint # --------------------------------------------------------------------------- # Per-platform builds: each native crate is a separate Cargo workspace root @@ -143,9 +154,14 @@ jobs: key: ${{ runner.os }}-macos-crate-${{ hashFiles('native/macos/Cargo.lock') }} restore-keys: ${{ runner.os }}-macos-crate- - - name: cargo build --release (bloom-macos) - working-directory: native/macos - run: cargo build --release + - name: full / host-build + run: ./scripts/ci-check.sh --full --component host-build + + - name: Upload failure evidence + if: failure() + uses: ./.github/actions/upload-ci-failure + with: + name: build-macos build-linux: runs-on: ubuntu-22.04 @@ -175,9 +191,14 @@ jobs: key: ${{ runner.os }}-linux-crate-${{ hashFiles('native/linux/Cargo.lock') }} restore-keys: ${{ runner.os }}-linux-crate- - - name: cargo build --release (bloom-linux) - working-directory: native/linux - run: cargo build --release + - name: full / host-build + run: ./scripts/ci-check.sh --full --component host-build + + - name: Upload failure evidence + if: failure() + uses: ./.github/actions/upload-ci-failure + with: + name: build-linux build-windows: runs-on: windows-latest @@ -206,9 +227,76 @@ jobs: key: ${{ runner.os }}-windows-crate-${{ hashFiles('native/windows/Cargo.lock') }} restore-keys: ${{ runner.os }}-windows-crate- - - name: cargo build --release (bloom-windows) - working-directory: native/windows - run: cargo build --release + - name: full / host-build + run: ./scripts/ci-check.sh --full --component host-build + + - name: Upload failure evidence + if: failure() + uses: ./.github/actions/upload-ci-failure + with: + name: build-windows + + # --------------------------------------------------------------------------- + # Mobile/Apple cross-compiles. Renderer platform glue is checked on every PR; + # Jolt is disabled here because release.yml independently builds every mobile + # Jolt archive from source. Together the two workflows cover both halves + # without rebuilding the large C++ dependency for every Rust target on every + # pull request. + # --------------------------------------------------------------------------- + mobile-targets: + strategy: + fail-fast: false + matrix: + include: + - { crate: android, target: aarch64-linux-android, runner: ubuntu-22.04, features: "models3d,image-extras" } + - { crate: android, target: armv7-linux-androideabi, runner: ubuntu-22.04, features: "models3d,image-extras" } + - { crate: android, target: x86_64-linux-android, runner: ubuntu-22.04, features: "models3d,image-extras" } + - { crate: ios, target: aarch64-apple-ios, runner: macos-14, features: "models3d,image-extras" } + - { crate: ios, target: aarch64-apple-ios-sim, runner: macos-14, features: "models3d,image-extras" } + - { crate: ios, target: x86_64-apple-ios, runner: macos-14, features: "models3d,image-extras" } + - { crate: tvos, target: aarch64-apple-tvos, runner: macos-14, features: "models3d,image-extras" } + - { crate: tvos, target: aarch64-apple-tvos-sim, runner: macos-14, features: "models3d,image-extras" } + - { crate: visionos, target: aarch64-apple-visionos, runner: macos-14, features: "models3d,image-extras" } + - { crate: visionos, target: aarch64-apple-visionos-sim, runner: macos-14, features: "models3d,image-extras" } + - { crate: watchos, target: aarch64-apple-watchos, runner: macos-14, features: "" } + - { crate: watchos, target: aarch64-apple-watchos-sim, runner: macos-14, features: "" } + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + + - name: Install Rust target + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install Android native build tools + if: matrix.crate == 'android' + run: | + sudo apt-get update + sudo apt-get install -y cmake ninja-build build-essential + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/${{ matrix.crate }}/target + key: ${{ runner.os }}-${{ matrix.crate }}-${{ matrix.target }}-${{ hashFiles(format('native/{0}/Cargo.lock', matrix.crate)) }} + restore-keys: ${{ runner.os }}-${{ matrix.crate }}-${{ matrix.target }}- + + - name: cross / target-check + env: + BLOOM_CROSS_CRATE: ${{ matrix.crate }} + BLOOM_CROSS_TARGET: ${{ matrix.target }} + BLOOM_CROSS_FEATURES: ${{ matrix.features }} + run: ./scripts/ci-check.sh --cross --component target-check + + - name: Upload failure evidence + if: failure() + uses: ./.github/actions/upload-ci-failure + with: + name: mobile-${{ matrix.crate }}-${{ matrix.target }} # --------------------------------------------------------------------------- # Web / WASM. No submodule needed: bloom-shared's build.rs early-exits on @@ -239,10 +327,47 @@ jobs: key: ${{ runner.os }}-web-${{ hashFiles('native/web/Cargo.lock', 'native/shared/Cargo.lock') }} restore-keys: ${{ runner.os }}-web- - - name: cargo check (shared, wasm32, web feature) - working-directory: native/shared - run: cargo check --target wasm32-unknown-unknown --no-default-features --features web + - name: web / wasm-check + run: ./scripts/ci-check.sh --web --component wasm-check + + - name: web / wasm-build + run: ./scripts/ci-check.sh --web --component wasm-build - - name: wasm-pack build (bloom-web) - working-directory: native/web - run: wasm-pack build --release --target web + - name: Upload qualified web package + uses: actions/upload-artifact@v4 + with: + name: bloom-web-package-${{ github.run_id }} + path: native/web/pkg + if-no-files-found: error + retention-days: 1 + + - name: Upload failure evidence + if: failure() + uses: ./.github/actions/upload-ci-failure + with: + name: build-web + + # Linux remains the required WASM build host. Its GPU-less Chrome image can + # expose a SwiftShader adapter but cannot allocate a WebGPU swap-chain shared + # image, so validate the exact Linux-built package in macOS Chrome, which has + # a working hosted WebGPU presentation path. + browser-smoke: + needs: build-web + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Download qualified web package + uses: actions/download-artifact@v4 + with: + name: bloom-web-package-${{ github.run_id }} + path: native/web/pkg + + - name: web / browser-smoke + run: ./scripts/ci-check.sh --web --component browser-smoke + + - name: Upload failure evidence + if: failure() + uses: ./.github/actions/upload-ci-failure + with: + name: browser-smoke diff --git a/.gitignore b/.gitignore index 08a79256..1d92b200 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ node_modules/ *_ts.o package-lock.json +# Python tooling cache +__pycache__/ +*.py[cod] + # macOS junk .DS_Store diff --git a/README.md b/README.md index aeb91b75..6150ee0a 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,9 @@ cd dist/web && python3 -m http.server 8080 - **True native** — Compiles to Metal, DirectX 12, Vulkan, OpenGL, and WebGPU via wgpu. - **Ship everywhere** — macOS, Windows, Linux, iOS, tvOS, Android, and Web from one codebase. - **Unified 2D/3D** — Shapes, textures, text, 3D models, and audio in one engine. +- **Coherent quality tiers** — Resolution, TAA, upscale filtering, sharpening, + and effects move together from 0.50-scale Off to native-resolution Ultra. + ([quality preset guide](docs/quality-presets.md)) - **Zero magic** — Explicit game loops, no hidden framework overhead. ## How Bloom relates to raylib @@ -150,6 +153,46 @@ examples/ pong/ Complete working example (~170 lines) ``` +## Contributing checks + +Install Node.js, Python 3.11 or newer, the stable Rust toolchain with +`rustfmt`, `clippy`, and the `wasm32-unknown-unknown` target. The full and web +lanes also require +[wasm-pack](https://rustwasm.github.io/wasm-pack/installer/) and Chrome or +Chromium for the real-browser WebGPU smoke. Native builds need the platform +dependencies listed in `.github/workflows/test.yml` (CMake and a C++ compiler +everywhere, X11/audio development packages on Linux, and the MSVC developer +environment on Windows). + +Run the platform-independent PR gates while iterating: + +```bash +./scripts/ci-check.sh --quick +``` + +Before handing off a change, run the complete suite for the current host, +including its native crate and the packaged WebAssembly build: + +```bash +./scripts/ci-check.sh --full +``` + +Mobile jobs use the same entry point through the `cross` lane. Select one +declared crate/target with `BLOOM_CROSS_CRATE`, `BLOOM_CROSS_TARGET`, and +optionally `BLOOM_CROSS_FEATURES`; Android additionally requires +`ANDROID_NDK_HOME` or `ANDROID_NDK_LATEST_HOME`. For example: + +```bash +BLOOM_CROSS_CRATE=ios \ +BLOOM_CROSS_TARGET=aarch64-apple-ios-sim \ +BLOOM_CROSS_FEATURES=models3d,image-extras \ +./scripts/ci-check.sh --cross --component target-check +``` + +Every invocation writes a machine-readable `bloom-ci-summary-v1` result under +`target/ci/`. GitHub Actions selects components of these same lanes so its +command inventory cannot silently differ from local development. + ## Types Plain interfaces, no classes: diff --git a/docs/compiled-render-graph.md b/docs/compiled-render-graph.md new file mode 100644 index 00000000..03218ef8 --- /dev/null +++ b/docs/compiled-render-graph.md @@ -0,0 +1,119 @@ +# Compiled render graph + +Bloom's retained 3D renderer executes from an immutable, cached frame plan. +The implementation deliberately preserves the established serial command +order and rendered pixels while making resource lifetime and pass contracts +explicit. + +## Runtime contract + +`renderer/graph/model.rs` is the declaration API. Textures and buffers use +typed logical handles; each write produces a new version. Imported persistent +and external resources declare their initial/final usage and ownership. +Transient descriptors include extent policy, format, dimension, mip/sample +counts, allowed usage, load policy, and alias class. + +`renderer/graph/compiler.rs`: + +- validates producers, versions, usage permissions, typed handles, divergent + writers, and cycles before wgpu validation; +- derives deterministic resource and explicit-dependency edges; +- topologically sorts by declaration ID when multiple orders are valid; +- computes inclusive first/last-use positions and usage/queue transitions; +- assigns compatible, strictly non-overlapping transient lifetimes to + physical slots; and +- generates a stable plan ID from the complete execution contract. + +The live topology is declared in `renderer/graph/frame_plan.rs`. Optional +SSAO, SSR, SSGI, PT, bloom, scene-snapshot, and capture work is selected before +compilation by `FramePlanKey`. SSGI and PT independently select their shared +acceleration/card-capture prefix; SDF, clipmap, radiance-cache, and card-light +preparation remain SSGI-only. Uniform-only changes do not affect the key. +`ExecutableGraph` binds only frame-local recording closures to cached pass +positions; it does not rebuild or schedule a declaration graph per frame. + +Current backends execute the compiled order on one serial wgpu queue. Queue +capability and transitions are diagnostic metadata, not a second barrier or +multi-queue API. + +## Allocation and resize + +`TransientPool::prepare_compiled_plan` caches physical texture allocations by +plan ID and exact render/output extents. A stable plan and extent performs no +new physical allocation. An exact resize invalidates the allocation +generation. Persistent resources and temporal histories never enter the +alias set. + +The current Ultra Sponza qualification topology has no eligible transient +texture: its post-FX targets are persistent imports with stable views or +temporal/history ownership. It therefore reports 0 unaliased bytes, 0 +physical slots, and no meaningful percentage reduction. + +The only currently materialized optional transients are the refractive +material's scene-color and scene-depth snapshots. Their lifetimes overlap in +the translucent pass and their exact descriptors differ (`Rgba16Float` +color versus `Depth32Float` depth), so conservative aliasing correctly keeps +two slots. At 1920×1080 they total 24,883,200 bytes and save 0%; at the +800×450 Sponza qualification extent they would total 4,320,000 bytes. +Converting persistent post-FX targets would require a broader view/bind-group +rebinding migration and was excluded from this no-regression change. + +Synthetic allocator tests cover the positive case and reduce two compatible, +non-overlapping textures from two physical allocations to one (50%). They +also prove that overlapping or descriptor-incompatible resources never +alias. + +## Capture and diagnostics + +Set `BLOOM_GRAPH_DEBUG_MARKERS=1` while taking a GPU capture to bracket every +compiled pass with its stable graph name using wgpu debug groups. The opt-in +keeps ordinary release frames free of marker encoding overhead while making +passes visible in RenderDoc, Xcode GPU captures, and PIX-compatible backends. +Qualification readback is a keyed terminal `capture_readback` pass. +It copies the output plus these logical resource names: + +- `hdr-scene` +- `scene-depth` +- `shadow-cascade-0` +- `shadow-cascade-1` +- `shadow-cascade-2` + +The capture path resolves names explicitly and rejects unknown resources. +Normal frames do not include the capture pass or allocate staging buffers. + +Set `BLOOM_GRAPH_DUMP_DIR` to write each distinct plan once: + +```sh +BLOOM_GRAPH_DEBUG_MARKERS=1 \ +BLOOM_GRAPH_DUMP_DIR=/tmp/bloom-graphs \ + python3 tools/quality/run.py run quick --report-only +``` + +Each `bloom-frame-.json` contains pass accesses, dependencies, +resource lifetimes, physical slots, and transitions. The adjacent DOT file +is a compact dependency graph. + +Native qualification telemetry exposes: + +- `compile_count`, `cache_hit_count`, and `cached_plan_count`; +- the current `plan_id` and `pass_count`; +- aliasing state, planned/unaliased transient bytes, and physical slot count. + +A 420-frame high-quality PBR run records one compile and 419 cache hits for +the stable normal-frame configuration. The post-measurement capture uses a +separate cached topology; telemetry is intentionally serialized before that +debug-only frame. + +## Regression procedure + +Run compiler and allocator tests first: + +```sh +cd native/shared +cargo test --release renderer::graph +cargo test --release renderer::transient +``` + +Then run the report-only quality corpus and compare final plus intermediate +outputs to the pre-change evidence. A missing human-approved baseline remains +a qualification failure; `--report-only` does not convert it into a pass. diff --git a/docs/evidence/issue-131-atomic-hierarchy-v1.json b/docs/evidence/issue-131-atomic-hierarchy-v1.json new file mode 100644 index 00000000..a0972590 --- /dev/null +++ b/docs/evidence/issue-131-atomic-hierarchy-v1.json @@ -0,0 +1,83 @@ +{ + "schema": "bloom-virtual-geometry-hierarchy-evidence-v1", + "issue": 131, + "qualified_revision": "845ff9cbd535c54c85a4bcfd291427090f4bc911", + "format": { + "magic": "BLMGEO1\\0", + "version": 1, + "cluster_record_bytes": 128, + "default_max_vertices": 64, + "default_max_triangles": 124, + "default_page_budget_bytes": 65536, + "atomic_parent_groups": true, + "maximum_group_fanout": 8, + "absolute_accumulated_error": true, + "locked_group_borders": true + }, + "deterministic_hierarchy_asset": { + "path": "examples/renderer-test/assets/DamagedHelmet.glb", + "source_sha256": "3de5114323cba08aaef85757a90ed9685f1597b5ea0e7d7913f9fa45eeacaae7", + "requested_levels": 8, + "maximum_level": 3, + "leaf_clusters": 247, + "leaf_triangles": 15452, + "leaf_payload_bytes": 1176828, + "parent_clusters": 385, + "all_level_clusters": 632, + "all_level_triangles": 33706, + "root_clusters": 100, + "root_triangles": 4510, + "root_payload_bytes": 468570, + "root_cluster_reduction_percent": 59.514170, + "root_triangle_reduction_percent": 70.812840, + "root_payload_reduction_percent": 60.183646, + "maximum_absolute_error": 2.467940092086792, + "pages": 47, + "maximum_page_bytes": 65536, + "payload_bytes": 2937040, + "file_bytes": 3021104, + "payload_sha256": "3546847f428431bd15681e32d451561744d5f5162729eb4c9cbeb35dc3b12915", + "artifact_sha256": "57f8cc200da84c7ab63cdcd7bf02cb0c8336a96777a2ccb7fc49beaa0af3fd10", + "independent_cooks_byte_identical": true, + "strict_inspection": "pass" + }, + "default_leaf_regression": { + "artifact_sha256": "df089b23324fe8a8e00842a80b44894fd27b276c9124b8ff81dd77f8cf7b2cd2", + "payload_sha256": "67b0352a54d3d669d76d688f94899bcb8a665c561587123ea85d5d6a2b1061b0", + "matches_qualified_milestone": true + }, + "compatibility_control": { + "path": "examples/test-gltf-watch/assets/Fox.glb", + "reason": "skinned", + "meshlets": 0, + "file_bytes": 176, + "artifact_sha256": "5e4125b6ba31649f5ebbd694d79242e19fdb0c561073beee08f490c12be063e2", + "matches_qualified_milestone": true + }, + "qualification": { + "release_tests_passed": 16, + "strict_clippy": "pass", + "format": "pass", + "atomic_relation_corruption_rejected": true, + "locked_outer_boundary_test": "pass" + }, + "shipping_runtime_delta": { + "passes": 0, + "draws": 0, + "buffers": 0, + "images": 0, + "allocations": 0, + "bindings": 0, + "shader_branches": 0, + "engine_dependencies": 0, + "pixels_changed": false + }, + "remaining": [ + "quantized and compressed geometry payload qualification", + "coarse root and streaming page placement", + "runtime artifact database integration", + "residency and streaming budgets", + "GPU traversal, Hi-Z culling, and indirect selection", + "runtime visual and performance qualification" + ] +} diff --git a/docs/evidence/issue-131-atomic-hierarchy-v1.md b/docs/evidence/issue-131-atomic-hierarchy-v1.md new file mode 100644 index 00000000..b02856f8 --- /dev/null +++ b/docs/evidence/issue-131-atomic-hierarchy-v1.md @@ -0,0 +1,88 @@ +# Issue #131 atomic hierarchy v1 evidence + +This checkpoint qualifies the opt-in offline hierarchy implemented at revision +`845ff9cbd535c54c85a4bcfd291427090f4bc911`. It does not enable runtime +selection, residency, streaming, or rendering. + +## Hierarchy contract + +- A hierarchy edge atomically replaces one contiguous child range with one + contiguous parent range. It never forces a child group into one oversized + meshlet. +- All parents in a replacement group share the child range. All children + share the parent range. The strict reader validates both directions, + increasing LOD levels, non-decreasing accumulated error, identity/material + boundaries, root flags, and table bounds. +- Leaf and parent meshlets keep the configured 64-vertex/124-triangle default + limits and remain independently pageable. +- Exact complete-vertex welding does not merge normal, tangent, UV, or color + discontinuities. +- Attribute-aware simplification includes normals, tangents, UV0, UV1, and + color. The complete group border is locked so independently selected + neighboring groups retain their shared boundary. +- Parent errors add the simplifier's absolute object-space result to the + maximum accumulated child error. Parent AABB/sphere bounds cover the full + child group. +- A replacement that does not reduce cluster count is discarded. Its children + become explicit coarse roots rather than adding a duplicate-only level. + +## Deterministic static-asset proof + +Two independent release cooks of +`examples/renderer-test/assets/DamagedHelmet.glb` with +`--hierarchy-levels 8` are byte-identical: + +- 15,452 source triangles in 247 locality-aware leaf clusters; +- 385 parent clusters and 632 clusters across all levels; +- maximum generated level 3; +- 100 terminal coarse roots, all at level 3; +- coarse roots contain 4,510 triangles, a 70.812840% reduction from the leaf + triangle set; +- coarse-root raw payload is 468,570 bytes, a 60.183646% reduction from the + 1,176,828-byte leaf payload; +- maximum accumulated absolute object-space error: + `2.467940092086792`; +- 47 pages, each within the 65,536-byte hard budget; +- complete artifact: 3,021,104 bytes; +- artifact SHA-256: + `57f8cc200da84c7ab63cdcd7bf02cb0c8336a96777a2ccb7fc49beaa0af3fd10`; +- payload SHA-256: + `3546847f428431bd15681e32d451561744d5f5162729eb4c9cbeb35dc3b12915`. + +The root set is deliberately reported, not declared a final runtime residency +budget. Quantized/compressed vertex payloads, page placement, traversal, and +measured residency remain separate gates. + +## Regression and compatibility proof + +The same Damaged Helmet cook without `--hierarchy-levels` remains byte-for-byte +identical to the first qualified leaf milestone: + +- 258 leaf meshlets, 20 pages, and 1,230,128 payload bytes; +- artifact SHA-256: + `df089b23324fe8a8e00842a80b44894fd27b276c9124b8ff81dd77f8cf7b2cd2`; +- payload SHA-256: + `67b0352a54d3d669d76d688f94899bcb8a665c561587123ea85d5d6a2b1061b0`. + +The hierarchy-enabled Fox control also remains the exact 176-byte, +metadata-only `skinned` compatibility artifact with SHA-256 +`5e4125b6ba31649f5ebbd694d79242e19fdb0c561073beee08f490c12be063e2`. +Unsupported geometry is not silently treated as static. + +## Automated gates and runtime neutrality + +Sixteen release tests, formatting, and strict Clippy pass. The focused +hierarchy tests cover byte determinism, reciprocal atomic groups, monotonic +levels/error/bounds, locked outer-boundary preservation, and rejection of a +corrupted parent-group count. + +This checkpoint changes only the offline `bloom-cook` tool and documentation: + +- zero production render passes, draws, buffers, images, allocations, + bindings, or shader branches; +- zero engine/runtime dependencies; +- zero changes to current renderer pixels or frame cost. + +The next #131 work is runtime-independent payload quantization/compression and +page/root placement qualification, followed by #136 artifact integration. +GPU traversal remains gated on a measured net-positive #27 path. diff --git a/docs/evidence/issue-131-coarse-page-prefix-v1.json b/docs/evidence/issue-131-coarse-page-prefix-v1.json new file mode 100644 index 00000000..e0b9867c --- /dev/null +++ b/docs/evidence/issue-131-coarse-page-prefix-v1.json @@ -0,0 +1,61 @@ +{ + "schema": "bloom-virtual-geometry-coarse-pages-evidence-v1", + "issue": 131, + "qualified_revision": "fe92915450dce3d45acc6a0192b4b9b0c3e3290c", + "contract": { + "coarse_roots_are_cluster_prefix": true, + "coarse_roots_are_page_prefix": true, + "mixed_root_streamable_pages_allowed": false, + "mixed_lod_pages_allowed": false, + "atomic_ranges_remapped": true, + "strict_reader_validates_placement": true + }, + "deterministic_static_asset": { + "path": "examples/renderer-test/assets/DamagedHelmet.glb", + "requested_levels": 8, + "root_clusters": 100, + "root_level": 3, + "root_pages": 8, + "root_cluster_payload_bytes": 468570, + "root_page_payload_bytes": 469360, + "root_page_packing_overhead_bytes": 790, + "fixed_page_slot_upper_bound_bytes": 524288, + "page_budget_bytes": 65536, + "maximum_page_bytes": 65472, + "total_pages": 48, + "total_payload_bytes": 2937040, + "file_bytes": 3021168, + "payload_sha256": "176aba193c2a0c4d8ac3910a3c4c9db4b661f5a0d8a234a5574317c8a51d6d30", + "artifact_sha256": "da0f68d731ca42a95a54e8ff157a62315a71dfa5c78d62b2bccd8ec59c0124f7", + "independent_cooks_byte_identical": true, + "strict_inspection": "pass" + }, + "default_leaf_regression": { + "artifact_sha256": "df089b23324fe8a8e00842a80b44894fd27b276c9124b8ff81dd77f8cf7b2cd2", + "matches_qualified_milestone": true + }, + "qualification": { + "release_tests_passed": 17, + "strict_clippy": "pass", + "format": "pass", + "mixed_page_class_corruption_rejected": true + }, + "shipping_runtime_delta": { + "passes": 0, + "draws": 0, + "buffers": 0, + "images": 0, + "allocations": 0, + "bindings": 0, + "shader_branches": 0, + "engine_dependencies": 0, + "pixels_changed": false + }, + "remaining": [ + "runtime allocation and staging overhead", + "residency and eviction budgets", + "missing-page ancestor fallback", + "GPU traversal and culling", + "runtime visual and performance qualification" + ] +} diff --git a/docs/evidence/issue-131-coarse-page-prefix-v1.md b/docs/evidence/issue-131-coarse-page-prefix-v1.md new file mode 100644 index 00000000..0d4d5383 --- /dev/null +++ b/docs/evidence/issue-131-coarse-page-prefix-v1.md @@ -0,0 +1,56 @@ +# Issue #131 coarse-page prefix v1 evidence + +This checkpoint qualifies deterministic coarse-first page placement at +revision `fe92915450dce3d45acc6a0192b4b9b0c3e3290c`. It builds on the atomic +hierarchy evidence and remains an offline-only artifact contract. + +## Placement contract + +- All coarse-root clusters are a prefix of the cluster table and page table. +- Roots and streamable clusters never share a page. +- Different hierarchy levels never share a page. +- Reordering remaps both sides of every parent/child group and fails if an + atomic range would lose contiguity. +- The strict reader rejects mixed LOD/residency classes and any root page after + the first streamable page. +- Cook and inspect reports expose root-page count and logical payload bytes. + +A future loader can therefore upload a bounded page prefix without scanning +cluster records or accidentally pulling fine geometry into the always-resident +set. + +## Canonical static-asset proof + +Two independent release cooks of Damaged Helmet with +`--hierarchy-levels 8` are byte-identical: + +- 100 level-3 roots occupy the first 8 pages; +- raw root-cluster payload: 468,570 bytes; +- logical root-page payload: 469,360 bytes; +- packing overhead: 790 bytes, far below one 65,536-byte page; +- fixed 64 KiB slot-allocation upper bound: 524,288 bytes; +- complete hierarchy payload remains 2,937,040 bytes; +- 48 total pages (one more page than mixed-level packing); +- largest page: 65,472 bytes under the 65,536-byte hard budget; +- complete artifact: 3,021,168 bytes; +- artifact SHA-256: + `da0f68d731ca42a95a54e8ff157a62315a71dfa5c78d62b2bccd8ec59c0124f7`; +- payload SHA-256: + `176aba193c2a0c4d8ac3910a3c4c9db4b661f5a0d8a234a5574317c8a51d6d30`. + +Separating LOD classes adds one 64-byte page record and does not duplicate or +change geometry payload. It trades a negligible metadata increase for an +exact coarse residency boundary. + +## Regression gates + +- Seventeen release tests, formatting, and strict Clippy pass. +- A negative test corrupts one child LOD so a page mixes classes; the reader + rejects it before exposing the archive. +- The default leaf cook remains byte-identical to its qualified artifact: + `df089b23324fe8a8e00842a80b44894fd27b276c9124b8ff81dd77f8cf7b2cd2`. +- No engine/runtime file or dependency changes; current pixels and frame cost + remain untouched. + +This is not a measured runtime residency result. Allocation strategy, staging +overhead, eviction, missing-page fallback, and GPU traversal remain open. diff --git a/docs/evidence/issue-131-cooked-geometry-v1.json b/docs/evidence/issue-131-cooked-geometry-v1.json new file mode 100644 index 00000000..50499ad5 --- /dev/null +++ b/docs/evidence/issue-131-cooked-geometry-v1.json @@ -0,0 +1,76 @@ +{ + "schema": "bloom-cooked-geometry-evidence-v1", + "issue": 131, + "qualified_revision": "59c8df794b7c119cca00c03bb299179f3331c486", + "format": { + "magic": "BLMGEO1\\0", + "version": 1, + "byte_order": "little-endian", + "header_bytes": 160, + "cluster_record_bytes": 128, + "page_record_bytes": 64, + "compatibility_record_bytes": 16, + "vertex_bytes": 72, + "default_max_vertices": 64, + "default_max_triangles": 124, + "default_page_budget_bytes": 65536, + "hash": "SHA-256", + "leaf_hierarchy_only": true + }, + "deterministic_static_asset": { + "path": "examples/renderer-test/assets/DamagedHelmet.glb", + "source_sha256": "3de5114323cba08aaef85757a90ed9685f1597b5ea0e7d7913f9fa45eeacaae7", + "eligible_triangles": 15452, + "meshlets": 258, + "pages": 20, + "payload_bytes": 1230128, + "file_bytes": 1264592, + "maximum_page_bytes": 63216, + "page_budget_bytes": 65536, + "payload_sha256": "67b0352a54d3d669d76d688f94899bcb8a665c561587123ea85d5d6a2b1061b0", + "artifact_sha256": "df089b23324fe8a8e00842a80b44894fd27b276c9124b8ff81dd77f8cf7b2cd2", + "independent_cooks_byte_identical": true + }, + "compatibility_asset": { + "path": "examples/test-gltf-watch/assets/Fox.glb", + "source_sha256": "310e02a618f906534aef8651359257bd6c6bfd99d3f140c56108677626acda12", + "meshlets": 0, + "pages": 0, + "payload_bytes": 0, + "file_bytes": 176, + "reason": "skinned", + "mesh_index": 0, + "primitive_index": 0, + "artifact_sha256": "5e4125b6ba31649f5ebbd694d79242e19fdb0c561073beee08f490c12be063e2" + }, + "qualification": { + "release_tests_passed": 13, + "strict_clippy": "pass", + "format": "pass", + "file_size_ratchet": "pass", + "git_diff_check": "pass", + "ci_component": "quick/quality-contract", + "ci_component_status": "pass", + "ci_component_duration_seconds": 1 + }, + "shipping_runtime_delta": { + "passes": 0, + "draws": 0, + "buffers": 0, + "images": 0, + "allocations": 0, + "bindings": 0, + "shader_branches": 0, + "engine_dependencies": 0, + "pixels_changed": false + }, + "remaining": [ + "hierarchical LOD and geometric error", + "coarse always-resident fallback", + "runtime asset database integration", + "residency and streaming budgets", + "GPU traversal, Hi-Z culling, and indirect selection", + "runtime quality and performance qualification" + ], + "issue_acceptance_boxes_checked": 0 +} diff --git a/docs/evidence/issue-131-cooked-geometry-v1.md b/docs/evidence/issue-131-cooked-geometry-v1.md new file mode 100644 index 00000000..9b279128 --- /dev/null +++ b/docs/evidence/issue-131-cooked-geometry-v1.md @@ -0,0 +1,75 @@ +# Issue #131 cooked-geometry v1 evidence + +This checkpoint establishes Bloom's opt-in offline meshlet/page artifact at +revision `59c8df7`. It does not enable a virtualized runtime path or change +accepted pixels. + +## Static geometry proof + +Two independent release cooks of +`examples/renderer-test/assets/DamagedHelmet.glb` are byte-identical: + +- source: 15,452 eligible triangles; +- output: 258 leaf meshlets in 20 pages; +- page budget: 65,536 bytes; +- largest page: 63,216 bytes; +- payload: 1,230,128 bytes; +- complete artifact: 1,264,592 bytes; +- artifact SHA-256: + `df089b23324fe8a8e00842a80b44894fd27b276c9124b8ff81dd77f8cf7b2cd2`; +- payload SHA-256: + `67b0352a54d3d669d76d688f94899bcb8a665c561587123ea85d5d6a2b1061b0`. + +The default 64-vertex/124-triangle leaf limits and 64 KiB page budget +therefore hold for a canonical textured glTF asset. Material identity and all +static shading vertex attributes remain in the artifact. + +## Compatibility proof + +`examples/test-gltf-watch/assets/Fox.glb` produces a valid 176-byte +metadata-only artifact: + +- zero meshlets/pages/payload bytes; +- mesh 0, primitive 0 records the stable `skinned` reason; +- artifact SHA-256: + `5e4125b6ba31649f5ebbd694d79242e19fdb0c561073beee08f490c12be063e2`. + +The cooker does not reinterpret skinned geometry as static or silently omit +the reason. Equivalent records exist for morph targets, BLEND materials, and +non-triangle topology. + +## Integrity and determinism gates + +Thirteen release tests plus strict Clippy pass. They cover: + +- deterministic triangle-order partitioning and byte-exact serialization; +- hard vertex/triangle/page limits and independently hashed pages; +- conservative AABB/sphere/normal-cone construction; +- deterministic missing-normal generation; +- real GLB buffer import without image decoding; +- metadata-only compatibility artifacts; +- atomic replacement of an existing artifact; +- rejection of bad magic/version/endian, non-canonical or overlapping ranges, + truncated data, payload corruption, invalid indices, invalid hierarchy + links, bad counts/stride/bounds, and NaN/Inf data. + +The repository's quick `quality-contract` component runs format, Clippy, and +release tests for `bloom-cook`; it passed in one second from a warm build. +`geometry-inspect` validates the full file before reporting any records. + +## Runtime neutrality + +This commit changes only `tools/bloom-cook`, documentation, and its CI gate: + +- zero production render passes or draws; +- zero runtime buffers, images, allocations, bindings, or shader branches; +- zero changes to ordinary glTF/immediate-mode dispatch; +- zero engine/runtime dependencies. + +The current artifact contains leaf clusters only. Runtime loading, hierarchical +LOD construction, coarse fallback clusters, residency, GPU traversal, and +streaming remain later #131 milestones and must be qualified before any +end-state acceptance box is checked. + +The detailed format and compatibility contract is in +`docs/virtualized-geometry.md`. diff --git a/docs/evidence/issue-134-clearcoat-normal-pt.json b/docs/evidence/issue-134-clearcoat-normal-pt.json new file mode 100644 index 00000000..17a5fd4e --- /dev/null +++ b/docs/evidence/issue-134-clearcoat-normal-pt.json @@ -0,0 +1,109 @@ +{ + "schema": "bloom-layered-pbr-pt-clearcoat-normal-v1", + "issue": 134, + "qualified_revision": "5c0ab2080515bf6761d5069e7a37388cecf95c56", + "commits": { + "transport": "e69b23e3baf40f1ad3794ceae6822e972a069b04", + "resource_refactor": "8585425240faedde15edd753992c6b0f09b15e98", + "texture_array_harness": "a44bc9bfd40088a01b541bdca148bf500f83af6f", + "fixture_isolation": "5c0ab2080515bf6761d5069e7a37388cecf95c56" + }, + "adapter": { + "name": "Apple M1 Max", + "backend": "Metal", + "ray_query": true, + "texture_arrays": true + }, + "resource_contract": { + "record_version": 1, + "record_bytes_per_instance": 48, + "group": 2, + "binding": 8, + "resource_key_bit": 7, + "resource_key_value": 128, + "pipeline_key_bit": 9, + "pipeline_key_value": 512, + "scalar_record_growth_bytes": 0, + "clearcoat_factor_roughness_record_growth_bytes": 0, + "base_allocated_bytes": 0, + "scalar_clearcoat_allocated_bytes": 0, + "factor_roughness_only_normal_allocated_bytes": 0, + "added_render_graph_passes": 0, + "added_images": 0, + "base_pipeline_bindings_added": 0, + "base_pipeline_branches_added": 0 + }, + "image_qualification": { + "resolution": [ + 128, + 128 + ], + "flat_zero_scale_matches_scalar_clearcoat_byte_exact": true, + "normal_response": { + "mean_rgba": 0.4766960144042969, + "mean_rgb": 0.6355946858723959, + "max_diff": 146, + "outlier_pixel_fraction": 0.00787353515625, + "outlier_channel_fraction": 0.00405120849609375, + "ssim": 0.9804097084084754 + }, + "uv_rotation_90_degrees": { + "mean_rgba": 0.4037361145019531, + "mean_rgb": 0.5383148193359375, + "max_diff": 150, + "outlier_pixel_fraction": 0.0068817138671875, + "outlier_channel_fraction": 0.002933502197265625, + "ssim": 0.9854075707759584 + }, + "uv1_selection": { + "mean_rgba": 0.45088958740234375, + "mean_rgb": 0.6011861165364584, + "max_diff": 154, + "outlier_pixel_fraction": 0.0072479248046875, + "outlier_channel_fraction": 0.003269195556640625, + "ssim": 0.9833753383415546 + }, + "flat_mean_display_luminance": 84.161719, + "mapped_mean_display_luminance": 83.784545, + "mean_energy_gain": false, + "minimum_transform_mean_rgb": 0.02, + "thresholds_weakened": false + }, + "fallback_contract": { + "missing_texture_arrays_preserves_scalar_clearcoat": true, + "missing_texture_arrays_ignores_only_normal_map": true, + "missing_texture_arrays_allocates_normal_sidecar": false, + "missing_texture_arrays_allocates_uv1_sidecar": false + }, + "regression_qualification": { + "lane": "quick", + "status": "pass", + "duration_seconds": 23, + "unit_tests_passed": 328, + "unit_tests_ignored": 1, + "gpu_goldens_passed": 57, + "gpu_goldens_ignored": 2, + "render_target_tests_passed": 4, + "physical_transmission_gi_control": { + "status": "pass", + "affected_pixels": 20050, + "rgb_delta": [ + 28, + 19, + 247 + ] + }, + "wasm32_web_check": "pass", + "ffi_schema_parity": "pass", + "quality_governance": "pass", + "canonical_example_inventory": "pass", + "naga_feature_matrix": "pass", + "git_diff_check": "pass" + }, + "file_policy": { + "layered_pbr_pt_lines": 1958, + "resource_helper_lines": 34, + "clearcoat_normal_module_lines": 328, + "new_over_limit_file": false + } +} diff --git a/docs/evidence/issue-134-clearcoat-normal-pt.md b/docs/evidence/issue-134-clearcoat-normal-pt.md new file mode 100644 index 00000000..025eb962 --- /dev/null +++ b/docs/evidence/issue-134-clearcoat-normal-pt.md @@ -0,0 +1,110 @@ +# Issue #134 path-traced clearcoat-normal evidence + +This checkpoint closes the remaining independent-clearcoat-normal gap in +Bloom's layered GPU path tracer. It is qualified at revision `5c0ab20` on an +Apple M1 Max / Metal adapter with ray query and texture arrays enabled. + +## Transport contract + +The path tracer now preserves the imported clearcoat-normal texture, UV set, +`KHR_texture_transform`, and normal scale at primary and bounce intersections. +It reconstructs the committed vertex tangent and handedness only for a +qualified anisotropy or clearcoat-normal record, maps the coat normal without +changing the base normal, and uses the mapped normal for: + +- direct-light next-event evaluation; +- clearcoat importance sampling; +- clearcoat PDFs and MIS; +- reciprocal attenuation of the undercoat; +- subsequent-bounce surface state. + +The mapped normal is constrained to the base geometric hemisphere. The +normal-length/variance stored by the existing vector-mip upload path widens +the coat roughness for minification. Path tracing deliberately does not use +screen derivatives; realtime shading additionally retains its established +screen-space curvature variance. + +On adapters without the complete texture-array feature pair, only the normal +map is ignored. Scalar clearcoat factor and roughness remain active, and no +normal or UV1 sidecar is allocated. + +## Lazy resource cost + +Clearcoat-normal metadata has its own 48-byte-per-instance sidecar: + +- 16-byte header; +- 2x2 UV transform matrix; +- UV offset and normal scale. + +It uses group 2 binding 8, resource-key bit 7, and pipeline-key bit 9. The +existing 64-byte clearcoat factor/roughness record and 96-byte scalar layered +record do not grow. Base, scalar-clearcoat, and factor/roughness-textured +scenes report: + +- `path_tracing_clearcoat_normal_specialization_initialized = false`; +- `path_tracing_clearcoat_normal_sidecar_allocated_bytes = 0`. + +A normal-only clearcoat material allocates no factor/roughness texture +sidecar. There is no added render-graph pass, image, base-material branch, or +base-pipeline binding. The shared sidecar upload helper retains the established +power-of-two capacity, dirty-upload, and bind-group invalidation behavior. + +## Metal image gates + +The release ray-query golden runs the complete texture-array path rather than +the former scalar-fallback path. Its fixture owns real per-face UVs locally, +so unrelated transparent-GI geometry remains unchanged. + +The zero-scale flat normal is byte-identical to scalar clearcoat. A directional +normal map produces a bounded response without mean display-energy gain: + +| Comparison | Mean RGB difference | Maximum channel difference | SSIM | +|---|---:|---:|---:| +| Flat vs directional normal | 0.635595 | 146 | 0.980410 | +| Directional vs 90-degree UV rotation | 0.538315 | 150 | 0.985408 | +| Directional UV0 vs UV1 | 0.601186 | 154 | 0.983375 | + +Flat and mapped mean display luminance are `84.161719` and `83.784545` +respectively. The existing layered transport gate supplies the finite, +visible-response, and bounded-energy checks; its thresholds were not weakened. +The rotation and UV1 controls retain their established minimum mean-RGB +difference of `0.02`. + +Enabling texture arrays exposed a previous golden-harness blind spot: the +device requested ray query but not its supported texture-array feature pair, +so older textured-lobe assertions always exercised the documented fallback. +The harness now requests both supported features. All textured specular, +clearcoat, sheen, iridescence, and anisotropy variants consequently execute on +Metal. The anisotropy fixture was strengthened to full authored strength and a +clear directional texture while retaining its original visibility threshold. + +## Regression qualification + +`./scripts/ci-check.sh --quick` passed in 23 seconds: + +- 328 shared unit tests passed, 1 intentionally ignored; +- 57 runnable GPU goldens passed, 2 intentionally ignored; +- all 4 render-target integration tests passed; +- format, strict lint policy, FFI/schema parity, web arity, wasm32 + compilation, quality governance, visual fault controls, and canonical + example inventory passed. + +The formerly affected +`physical_transmission_gi_specializations_run_on_hardware_ray_query` control +also passes after localizing the fixture UVs (`20,050` affected pixels, +RGB delta `[28, 19, 247]`). + +All clearcoat-normal WGSL feature combinations parse through Naga. The +layered PT implementation remains below the repository's 2,000-line file +limit (`layered_pbr_pt.rs`: 1,958 lines; shared resource helper: 34 lines; +clearcoat-normal module: 328 lines). + +## Commits + +- `e69b23e` — independent clearcoat-normal transport and qualification; +- `8585425` — shared typed sidecar resource helper and line-policy split; +- `a44bc9b` — real Metal texture-array golden coverage; +- `5c0ab20` — fixture-local UVs preserving unrelated test geometry. + +Machine-readable measurements accompany this note in +`docs/evidence/issue-134-clearcoat-normal-pt.json`. diff --git a/docs/evidence/issue-134-layered-pbr-qualification.json b/docs/evidence/issue-134-layered-pbr-qualification.json new file mode 100644 index 00000000..ca1d4971 --- /dev/null +++ b/docs/evidence/issue-134-layered-pbr-qualification.json @@ -0,0 +1,144 @@ +{ + "schema": "bloom-layered-pbr-qualification-v1", + "issue": 134, + "qualified_revision": "21356b8541b52155d2136044b39a927ab3373949", + "commits": { + "clearcoat_normal_motion": "eca754ee28ff9cdef04277398b33e867f55e603e", + "angular_reference": "ad97e77fc3558c86d5774d63d16e28b76a0a15ac", + "renderer_parity": "21356b8541b52155d2136044b39a927ab3373949" + }, + "adapter": { + "name": "Apple M1 Max", + "backend": "Metal", + "ray_query": true + }, + "angular_reference": { + "path": "tools/bloom-reference/reference/layered-pbr-angular-v1.json", + "sha256": "110142a66bc22acaf0e7f6664a86f135b194fc62d203f1a92b685fbf2487f309", + "corpus_version": 1, + "material_contract_version": 4, + "rows": 63, + "scenarios": 7, + "view_directions_per_scenario": 3, + "light_directions_per_view": 3, + "serialized_decimal_places": 6, + "regeneration": "byte exact", + "cpu_component_absolute_tolerance": 0.00003, + "reciprocity_max_absolute_tolerance": 0.00003, + "measured_maximum_reciprocity_error": 0.0, + "known_model_differences_recorded": 5 + }, + "renderer_parity": { + "path_tracing_frames": 64, + "sample_region": [32, 32], + "base_forward_mean_rgb": [90.0, 51.0, 9.0], + "base_path_traced_mean_rgb": [77.52734375, 43.26953125, 6.0], + "thresholds": { + "forward_path_mean_rgb_mae_max": 24.0, + "final_rgb_cosine_min": 0.96, + "relative_display_luminance_max": 0.3, + "significant_reference_response_length_min": 0.01, + "path_reference_response_cosine_min": 0.85, + "small_reference_display_response_max": 12.0 + }, + "lobes": { + "specular_ior": { + "reference_response": [0.007436, -0.000063, -0.00079], + "forward_display_response": [-1.9345703125, -3.951171875, -3.0], + "path_traced_display_response": [5.4453125, -0.259765625, -2.0], + "mean_rgb_mae": 3.7106, + "final_rgb_cosine": 0.999774, + "relative_display_luminance": 0.0779, + "path_reference_response_cosine": 0.969229 + }, + "clearcoat": { + "reference_response": [-0.005981, -0.002855, -0.000934], + "forward_display_response": [-3.5361328125, -5.1328125, -3.3291015625], + "path_traced_display_response": [-4.3955078125, -3.03125, -1.1025390625], + "mean_rgb_mae": 6.5781, + "final_rgb_cosine": 0.999883, + "relative_display_luminance": 0.134, + "path_reference_response_cosine": 0.985841 + }, + "sheen": { + "reference_response": [-0.002722, 0.012178, 0.021441], + "forward_display_response": [-4.798828125, 7.388671875, 31.435546875], + "path_traced_display_response": [-4.404296875, 12.037109375, 35.904296875], + "mean_rgb_mae": 5.543, + "final_rgb_cosine": 0.997531, + "relative_display_luminance": 0.0743, + "path_reference_response_cosine": 0.98164 + }, + "anisotropy": { + "reference_response": [0.07438, 0.038742, 0.023386], + "forward_display_response": [6.533203125, 4.552734375, 7.3076171875], + "path_traced_display_response": [35.2548828125, 31.4404296875, 36.201171875], + "mean_rgb_mae": 20.4333, + "final_rgb_cosine": 0.985835, + "relative_display_luminance": 0.2365, + "path_reference_response_cosine": 0.904537 + }, + "iridescence": { + "reference_response": [0.010727, 0.000695, -0.000338], + "forward_display_response": [0.3837890625, -2.263671875, -2.64453125], + "path_traced_display_response": [7.42578125, 0.7099609375, -1.0], + "mean_rgb_mae": 3.8477, + "final_rgb_cosine": 0.999813, + "relative_display_luminance": 0.0854, + "path_reference_response_cosine": 0.994356 + }, + "combined": { + "reference_response": [0.005716, 0.024878, 0.021724], + "forward_display_response": [-9.62109375, 1.978515625, 21.970703125], + "path_traced_display_response": [0.814453125, 22.419921875, 35.2548828125], + "mean_rgb_mae": 8.3441, + "final_rgb_cosine": 0.991555, + "relative_display_luminance": 0.1411, + "path_reference_response_cosine": 0.948319 + } + }, + "status": "pass" + }, + "clearcoat_normal_motion": { + "camera_motion_mean_rgb": 2.432037, + "visible_filtered_response_mean_rgb": 0.205539, + "maximum_texture_specific_adjacent_frame_residual_mean_rgb": 0.532235, + "coherent_outlier_channel_fraction": 0.0, + "thresholds": { + "maximum_residual_mean_rgb": 1.5, + "maximum_coherent_outlier_channel_fraction": 0.02 + }, + "status": "pass" + }, + "regression_qualification": { + "lane": "quick", + "status": "pass", + "duration_seconds": 25, + "unit_tests_passed": 328, + "unit_tests_ignored": 1, + "gpu_goldens_passed": 59, + "gpu_goldens_ignored": 2, + "render_target_tests_passed": 4, + "reference_release_tests_passed": 19, + "strict_lint": "pass", + "ffi_schema_parity": "pass", + "wasm32_web_check": "pass", + "quality_governance": "pass", + "visual_fault_controls": "pass", + "canonical_examples_passed": 20 + }, + "shipping_runtime_delta": { + "render_graph_passes": 0, + "images": 0, + "shader_branches": 0, + "gpu_allocations": 0, + "bindings": 0, + "per_frame_work": 0 + }, + "remaining_dependency": { + "issue": 27, + "component": "visibility-buffer renderer", + "implemented": false, + "issue_134_cross_path_acceptance_complete": false + } +} diff --git a/docs/evidence/issue-134-layered-pbr-qualification.md b/docs/evidence/issue-134-layered-pbr-qualification.md new file mode 100644 index 00000000..6caf948a --- /dev/null +++ b/docs/evidence/issue-134-layered-pbr-qualification.md @@ -0,0 +1,98 @@ +# Issue #134 layered-PBR qualification + +This checkpoint qualifies Bloom's shared layered material model against a +generated angular oracle, the realtime forward renderer, the hardware +ray-query path tracer, and a motion sequence. It is qualified at revision +`21356b8` on an Apple M1 Max / Metal adapter. + +## Angular reference corpus + +`bloom-brdf-reference --angular` generates the checked +`tools/bloom-reference/reference/layered-pbr-angular-v1.json` corpus. Its 63 +rows cover base, specular/IOR, clearcoat, sheen, anisotropy, iridescence, and +combined materials at three view directions by three light directions per +scenario. Every row records the material, directions, individual BRDF lobes, +total `BRDF * NdotL`, PDF, and reciprocity error. + +Regeneration is byte-exact. CPU component and reciprocity tolerances are both +`3e-5`; the measured maximum reciprocity error is zero. The corpus records five +intentional comparison boundaries: it excludes environment lighting and +post-processing, realtime finite-highlight compression is assessed in image +space, normal minification belongs to the motion corpus, stochastic path +transport uses converged-radiance tolerances, and iridescence uses Bloom's +bounded Khronos approximation rather than a spectral conductor model. + +The checked corpus SHA-256 is +`110142a66bc22acaf0e7f6664a86f135b194fc62d203f1a92b685fbf2487f309`. + +## Renderer parity + +`layered_pbr_parity` selects the same `v1-l2` record for every lobe, disables +IBL, SSR, SSGI, SSAO, bloom, automatic exposure, motion blur, shadows, and +environment energy, then compares the 32x32 center region of the forward MRT +renderer and a 64-frame ray-query accumulation. It also compares each +path-traced lobe response with the linear oracle direction. + +| Lobe | Forward/PT mean RGB MAE | Final RGB cosine | Luminance relative error | PT/oracle response cosine | +|---|---:|---:|---:|---:| +| Specular/IOR | 3.7106 | 0.999774 | 0.0779 | 0.969229 | +| Clearcoat | 6.5781 | 0.999883 | 0.1340 | 0.985841 | +| Sheen | 5.5430 | 0.997531 | 0.0743 | 0.981640 | +| Anisotropy | 20.4333 | 0.985835 | 0.2365 | 0.904537 | +| Iridescence | 3.8477 | 0.999813 | 0.0854 | 0.994356 | +| Combined | 8.3441 | 0.991555 | 0.1411 | 0.948319 | + +The gates require mean display RGB MAE at most 24, final RGB cosine at least +0.96, relative display luminance error at most 0.30, and PT/oracle response +cosine at least 0.85 for significant linear responses. Smaller responses are +bounded to a maximum display response of 12. The worst measured values are the +anisotropy row, and all retain useful margin without weakening a threshold. + +## Clearcoat-normal motion stability + +`layered_pbr_motion` renders a high-frequency alternating tangent-space +clearcoat normal through Bloom's vector/variance mip chain while the camera +moves slowly, then subtracts the flat-normal control. The measured camera +motion mean RGB is `2.432037`, the filtered response mean is `0.205539`, the +maximum texture-specific adjacent-frame residual mean is `0.532235`, and the +coherent outlier-channel fraction is `0.0%`. + +The hard sparkle gates are a maximum residual mean of `1.5` and maximum +coherent outlier fraction of `2%`. This test covers minification and temporal +stability independently from the static clearcoat-normal qualification in +`issue-134-clearcoat-normal-pt.md`. + +## Regression and cost qualification + +`./scripts/ci-check.sh --quick --summary +target/ci/quick-issue-134-final.json` passed at the qualified revision in 25 +seconds: + +- 328 shared unit tests passed, 1 intentionally ignored; +- 59 runnable GPU goldens passed, 2 intentionally ignored; +- all 4 render-target integration tests passed; +- strict lint, formatting, FFI/schema parity, web/wasm compilation, quality + governance, visual-fault controls, and 20 canonical examples passed; +- 19 release tests and strict Clippy passed for `bloom-reference`. + +The angular generator and both renderer gates add no shipping render pass, +image, shader branch, GPU allocation, binding, or per-frame work. Their +production runtime and binary shader cost is zero. + +## Remaining cross-path dependency + +Bloom currently uses the forward MRT renderer; the visibility-buffer renderer +tracked by issue #27 is not implemented. Consequently, issue #134's +forward/visibility/path-traced parity item must remain open. When #27 lands, +its material evaluator must consume this same checked corpus and satisfy the +same documented parity contract. The generated-matrix and normal-minification +acceptance items are independently complete. + +## Commits + +- `eca754e` — clearcoat-normal motion stability gate; +- `ad97e77` — versioned layered-PBR angular reference corpus; +- `21356b8` — forward, path-traced, and oracle parity gate. + +Machine-readable measurements accompany this note in +`docs/evidence/issue-134-layered-pbr-qualification.json`. diff --git a/docs/evidence/issue-135-custom-reactive-particles.json b/docs/evidence/issue-135-custom-reactive-particles.json new file mode 100644 index 00000000..1b3411a8 --- /dev/null +++ b/docs/evidence/issue-135-custom-reactive-particles.json @@ -0,0 +1,51 @@ +{ + "schema": "bloom-temporal-motion-evidence-v1", + "issue": 135, + "producer": "custom-instanced-particles", + "comparison_base": "80ce85ce2ce361636fd00bc0670b64edb9dbfcb6", + "qualified_revision": "8724f3007b93704d46e740765502d0ab62d6ea1d", + "adapter": "Apple M1 Max", + "backend": "Metal", + "motion_sequence": { + "reactive_rejection_pixels": 2222, + "movement_mean": 5.5887, + "frame_four_mean": 0.5551, + "frame_four_outlier_fraction": 0.010361, + "severe_trail_frames": 0, + "stable_flicker": 0.4743, + "ordinary_control_reactive_topology_active": false, + "ordinary_control_severe_trail_frames": 0 + }, + "performance": { + "resolution": [1920, 1080], + "warmup_frames": 300, + "measured_frames": 900, + "alternating_runs_per_revision": 4, + "before": { + "render_submit_mean_ms": [3.022422, 3.116155, 2.286328, 2.313249], + "render_submit_p50_ms": [2.790583, 2.8865, 2.524625, 2.580666], + "render_submit_p95_ms": [6.603958, 5.797625, 3.391, 3.110417], + "render_submit_p99_ms": [9.547833, 8.835916, 4.400625, 4.244125], + "prepare_mean_ms": [0.112385, 0.098009, 0.058487, 0.057564] + }, + "after": { + "render_submit_mean_ms": [2.954485, 2.542662, 2.273735, 2.249427], + "render_submit_p50_ms": [2.827, 2.642875, 2.544709, 2.512459], + "render_submit_p95_ms": [5.198, 4.191833, 3.186375, 2.826708], + "render_submit_p99_ms": [6.618833, 5.749166, 4.156083, 3.629917], + "prepare_mean_ms": [0.09667, 0.065556, 0.058652, 0.051343] + }, + "steady_upload_bytes_per_frame_before": 23264, + "steady_upload_bytes_per_frame_after": 23264 + }, + "resources": { + "ordinary_frame_additional_image_bytes": 0, + "ordinary_frame_added_passes": 0, + "ordinary_frame_added_draws": 0, + "active_bytes_per_render_pixel": 1, + "active_image_bytes_at_1920x1080": 2073600, + "active_added_passes": 0, + "active_added_draws": 0, + "active_lazy_pipeline_count_per_material": 1 + } +} diff --git a/docs/evidence/issue-135-custom-reactive-particles.md b/docs/evidence/issue-135-custom-reactive-particles.md new file mode 100644 index 00000000..c4c3e86d --- /dev/null +++ b/docs/evidence/issue-135-custom-reactive-particles.md @@ -0,0 +1,75 @@ +# Issue #135 custom-reactive particle evidence + +This qualification covers the optional custom-translucency +`fs_reactive -> ReactiveTranslucentOut` contract. The comparison base is the +immediately preceding renderer checkpoint, +`80ce85ce2ce361636fd00bc0670b64edb9dbfcb6`. + +## Motion sequence + +The headless Metal test uses the production cached mesh, instanced material, +additive bucket, sorted custom-translucency pass, temporal-reactive target, and +TAA resolve. It warms one particle position for eight frames, teleports the +instance from x=-1.35 to x=1.35, captures the first moved frame's rejection +map, and evaluates 24 output frames: + +- 2,222 pixels are classified as authored reactive rejection; +- the visible move is strong (`movement_mean = 5.5887`); +- severe trail duration is zero frames; +- coherent frame-four outliers are 1.0361%, below the 2% gate; +- settled jitter-cycle flicker is 0.4743, below the 2.0 gate. + +The sequence then repeats with the same shader and motion but without +`fs_reactive`. Its reactive topology remains inactive, and its existing +color/depth/neighborhood rejection also meets the same recovery bounds. A +low-level two-material GPU test separately proves that an ordinary shader's +attachment-compatible sibling has an empty R8 write mask while an opt-in +shader unions its authored coverage. + +Malformed `fs_reactive` output locations or types fail at material compile +time. A same-named helper function is not treated as an entry point. + +## Performance and memory + +The ordinary controlled `tools/render-perf` scene contains no custom +translucency. It therefore exercises the unchanged topology and proves the +new command scan is short-circuited. Four frozen before/after binaries ran in +alternating order with 300 warm-up and 900 measured frames at 1920x1080 on +Apple M1 Max / Metal. Values below are medians of four runs per revision. + +| CPU metric | Before | After | Change | +|---|---:|---:|---:| +| Full render-submit mean | 2.6678 ms | 2.4082 ms | -9.73% | +| Full render-submit P50 | 2.6856 ms | 2.5938 ms | -3.42% | +| Full render-submit P95 | 4.5943 ms | 3.6891 ms | -19.70% | +| Full render-submit P99 | 6.6183 ms | 4.9526 ms | -25.17% | +| Submission preparation mean | 0.0782 ms | 0.0621 ms | -20.63% | + +Host scheduling was noisy and trended downward across the alternating pairs, +so the lower after values are not claimed as an optimization. They do rule out +a measurable regression: every paired full-frame mean was lower after the +change, and the affected no-custom-material route performs no command scan. + +Traced uploads remain exactly 23,264 bytes per frame before and after. +Ordinary custom-translucency frames add no image, pass, draw, upload, or +pipeline. An active opt-in frame adds one transient R8 image +(one byte per render pixel; 2,073,600 bytes at 1920x1080) and one lazily +compiled material sibling. Coverage is a second attachment on the existing +translucent pass, so added passes and draws remain zero. + +## Commands + +```sh +cargo test --manifest-path native/shared/Cargo.toml \ + instanced_particle_reactive_opt_in_bounds_trails_without_taxing_opt_out \ + --test golden_render -- --nocapture + +BLOOM_RENDER_PERF_ENGINE_REVISION= \ +cargo run --release --manifest-path tools/render-perf/Cargo.toml -- \ + --width 1920 --height 1080 --warmup 300 --frames 900 --out + +BLOOM_RENDER_PERF_ENGINE_REVISION= \ +cargo run --release --manifest-path tools/render-perf/Cargo.toml -- \ + --width 1920 --height 1080 --warmup 32 --frames 32 \ + --trace-dir --out +``` diff --git a/docs/evidence/issue-135-immediate-motion.json b/docs/evidence/issue-135-immediate-motion.json new file mode 100644 index 00000000..b6ed1345 --- /dev/null +++ b/docs/evidence/issue-135-immediate-motion.json @@ -0,0 +1,45 @@ +{ + "schema": "bloom-temporal-motion-evidence-v1", + "issue": 135, + "producer": "immediate-primitives", + "comparison_base": "5d5d9a1e4e15b991111984a8cefd5d9b1307d15e", + "adapter": "Apple M1 Max", + "backend": "Metal", + "motion_sequence": { + "meaningful_motion_pixels": 7234, + "movement_mean": 15.8512, + "frame_four_mean": 0.4309, + "frame_four_outlier_fraction": 0.007599, + "severe_trail_frames": 0, + "stable_flicker": 0.4695 + }, + "performance": { + "resolution": [1920, 1080], + "warmup_frames": 300, + "measured_frames": 900, + "runs_per_revision": 3, + "before": { + "render_submit_mean_ms": [2.234708, 2.239589, 2.244215], + "render_submit_p50_ms": [2.341041, 2.350833, 2.350458], + "render_submit_p95_ms": [2.556375, 2.60725, 2.587625], + "render_submit_p99_ms": [3.43775, 3.792833, 3.775958], + "prepare_mean_ms": [0.034143, 0.035865, 0.033959] + }, + "after": { + "render_submit_mean_ms": [2.232462, 2.236045, 2.243486], + "render_submit_p50_ms": [2.390333, 2.3855, 2.370917], + "render_submit_p95_ms": [2.606375, 2.612417, 2.600292], + "render_submit_p99_ms": [3.376, 3.61725, 3.632875], + "prepare_mean_ms": [0.039801, 0.038652, 0.035247] + }, + "steady_upload_bytes_per_frame_before": 23264, + "steady_upload_bytes_per_frame_after": 23264 + }, + "resources": { + "three_primitive_cpu_capacity_bytes": 1728, + "gpu_bytes": 0, + "added_passes": 0, + "added_draws": 0, + "vertex_stride_change_bytes": 0 + } +} diff --git a/docs/evidence/issue-135-immediate-motion.md b/docs/evidence/issue-135-immediate-motion.md new file mode 100644 index 00000000..f3fb101c --- /dev/null +++ b/docs/evidence/issue-135-immediate-motion.md @@ -0,0 +1,57 @@ +# Issue #135 immediate-primitive motion evidence + +This qualification covers raylib-style immediate 3D submissions before and +after adding previous-position ownership. The comparison base is +`5d5d9a1e4e15b991111984a8cefd5d9b1307d15e`. + +## Motion sequence + +The headless Metal test warms a static pose for eight frames, translates an +immediate cube from x=-1.6 to x=1.6, captures the first moved frame's temporal +diagnostics, and evaluates 24 output frames: + +- 7,234 pixels carry meaningful motion; +- the visible move is strong (`movement_mean = 15.8512`); +- severe trail duration is zero frames; +- coherent frame-four outliers are 0.7599%, below the 2% gate; +- settled jitter-cycle flicker is 0.4695, below the 2.0 gate. + +First appearances, primitive-kind changes, vertex-count changes, empty +intervening frames, and explicit temporal resets have unit-tested +previous=current seeding. They cannot inherit an unrelated slot's motion. + +## Performance and memory + +The controlled `tools/render-perf` scene is the affected path: one immediate +plane and cube, Ultra preset, 40 point lights, 300 warm-up plus 900 measured +frames at 1920x1080 on Apple M1 Max / Metal. Values are medians of three runs +per revision. + +| CPU metric | Before | After | Change | +|---|---:|---:|---:| +| Full render-submit mean | 2.2396 ms | 2.2360 ms | -0.16% | +| Full render-submit P50 | 2.3505 ms | 2.3855 ms | +1.49% | +| Full render-submit P95 | 2.5876 ms | 2.6064 ms | +0.72% | +| Full render-submit P99 | 3.7760 ms | 3.6173 ms | -4.20% | +| Submission preparation mean | 0.0341 ms | 0.0387 ms | +0.0045 ms | + +An immediate alternating fourth pair moved from 2.2342 to 2.2199 ms mean; +the small percentile changes above are below run-to-run scheduling variance, +while the complete-frame mean shows no regression. + +The affected steady upload remains exactly 23,264 bytes per frame. There is no +vertex-stride change, texture upload, GPU allocation, bind group, draw, or +render pass. The three-primitive motion test retains 1,728 bytes of grow-only +CPU capacity. Runtime telemetry reports the live entry count and capacity, +plus zero GPU bytes and zero added passes. + +## Commands + +```sh +BLOOM_RENDER_PERF_ENGINE_REVISION= \ +cargo run --release --manifest-path tools/render-perf/Cargo.toml -- \ + --width 1920 --height 1080 --warmup 300 --frames 900 --out + +cargo test --manifest-path native/shared/Cargo.toml --test golden_render \ + immediate_primitive_motion_writes_velocity_and_bounds_trails -- --nocapture +``` diff --git a/docs/evidence/issue-135-motion-producer-audit.json b/docs/evidence/issue-135-motion-producer-audit.json new file mode 100644 index 00000000..be688e54 --- /dev/null +++ b/docs/evidence/issue-135-motion-producer-audit.json @@ -0,0 +1,77 @@ +{ + "schema": "bloom-temporal-motion-evidence-v1", + "issue": 135, + "producer": "remaining-engine-motion-producers", + "comparison_base": "6921e4146fa46f09f57ddb314f3dbed56ab8c07e", + "qualified_revision": "53ce363cf33f7ddd305f078d7a7f95bd54b820d5", + "adapter": "Apple M1 Max", + "backend": "Metal", + "motion_sequences": { + "legacy_skinned": { + "meaningful_motion_pixels": 10214, + "movement_mean": 28.0587, + "frame_four_outlier_fraction": 0.008713, + "severe_trail_frames": 0, + "stable_flicker": 0.4927 + }, + "unkeyed_editor_skin": { + "meaningful_motion_pixels": 10214, + "movement_mean": 30.0509, + "frame_four_outlier_fraction": 0.008636, + "severe_trail_frames": 0, + "stable_flicker": 0.5114, + "post_gap_motion_pixels": 0, + "two_joint_cpu_capacity_bytes": 448 + }, + "procedural_foliage_wind": { + "meaningful_motion_pixels": 7348 + }, + "static_decal_spawn": { + "movement_mean": 5.6502, + "frame_four_outlier_fraction": 0.006393, + "severe_trail_frames": 0 + }, + "static_decal_expiry": { + "movement_mean": 5.6851, + "frame_four_outlier_fraction": 0.005157, + "severe_trail_frames": 0 + }, + "procedural_cloud_field_change": { + "movement_mean": 9.3560, + "frame_four_outlier_fraction": 0.011368, + "severe_trail_frames": 0, + "stable_flicker": 1.5439 + } + }, + "performance": { + "resolution": [1920, 1080], + "warmup_frames": 300, + "measured_frames": 900, + "alternating_runs_per_revision": 4, + "before": { + "render_submit_mean_ms": [3.219333, 2.242543, 2.220985, 2.241560], + "render_submit_p50_ms": [2.779375, 2.456041, 2.457292, 2.441041], + "render_submit_p95_ms": [7.605250, 2.755958, 2.695250, 2.672291], + "render_submit_p99_ms": [12.868459, 3.350459, 2.934417, 3.622667], + "prepare_mean_ms": [0.128309, 0.048898, 0.046537, 0.046111] + }, + "after": { + "render_submit_mean_ms": [2.299320, 2.221371, 2.220672, 2.233175], + "render_submit_p50_ms": [2.525375, 2.465625, 2.452459, 2.473541], + "render_submit_p95_ms": [3.513208, 2.764208, 2.693667, 2.741166], + "render_submit_p99_ms": [4.365250, 3.799875, 3.812625, 3.315375], + "prepare_mean_ms": [0.064853, 0.050508, 0.046952, 0.049146] + }, + "steady_upload_bytes_per_frame_before": 23264, + "steady_upload_bytes_per_frame_after": 23264 + }, + "resources": { + "ordinary_frame_additional_gpu_bytes": 0, + "ordinary_frame_added_passes": 0, + "ordinary_frame_added_draws": 0, + "ordinary_frame_added_upload_bytes": 0, + "unkeyed_skin_added_gpu_bytes": 0, + "unkeyed_skin_added_passes": 0, + "unkeyed_skin_added_draws": 0 + } +} diff --git a/docs/evidence/issue-135-motion-producer-audit.md b/docs/evidence/issue-135-motion-producer-audit.md new file mode 100644 index 00000000..775654cf --- /dev/null +++ b/docs/evidence/issue-135-motion-producer-audit.md @@ -0,0 +1,68 @@ +# Issue #135 remaining motion-producer evidence + +This qualification closes the engine-owned motion-producer audit after the +retained-model, immediate-primitive, and custom-particle slices. The comparison +base is `6921e4146fa46f09f57ddb314f3dbed56ab8c07e`; the qualified implementation +is `53ce363cf33f7ddd305f078d7a7f95bd54b820d5`. + +## Visual sequences + +The native Metal corpus runs the production velocity MRT and TAA resolve: + +- legacy immediate skinning writes 10,214 meaningful motion pixels, leaves + zero severe trail frames, and has 0.8713% frame-four outliers; +- the unkeyed editor skin API also writes 10,214 meaningful motion pixels, + leaves zero severe trail frames, and has 0.8636% frame-four outliers; +- after an empty intervening frame, that unkeyed skin writes zero stale-motion + pixels on reappearance; +- procedural foliage wind writes 7,348 meaningful prior-deformation pixels; +- static decal spawn and expiry both leave zero severe trail frames, with + 0.6393% and 0.5157% frame-four outliers respectively; +- a procedural cloud-field discontinuity leaves zero severe trail frames and + 1.1368% frame-four outliers. + +Every moving-geometry sequence captures `taa-motion.png` and asserts a visible +negative control. The decal and cloud tests deliberately qualify their +zero-velocity contracts through rejection/recovery instead of inventing +geometric motion for radiance or lifecycle changes. + +## Performance and memory + +Four frozen before/after binaries ran in alternating order with 300 warm-up and +900 measured frames at 1920x1080 on Apple M1 Max / Metal. The median +full-frame mean changed from 2.2421 ms to 2.2273 ms (-0.66%). All four paired +means were lower after the change. Median P50 changed from 2.4567 ms to +2.4696 ms (+0.53%) and median P95 from 2.7256 ms to 2.7527 ms (+0.99%), both +inside the run-to-run scheduling range; the deliberately noisy first pair was +also the largest improvement. + +Separate 32-frame traces are exact: 23,264 upload bytes per frame before and +after. The controlled scene adds no GPU allocation, pass, draw, bind, or +upload. Active legacy skinning reuses the existing previous-joint buffer, +binding, upload, and velocity attachment; only weighted vertices execute the +previous-palette transform already used by retained skinned geometry. + +The unkeyed API adds two grow-on-demand CPU palette streams and reuses their +inner allocations after warm-up. One active two-joint skin retains 448 bytes +of measured CPU capacity. Telemetry reports this capacity and confirms zero +added GPU bytes and passes. + +## Commands + +```sh +cargo test --manifest-path native/shared/Cargo.toml \ + --test golden_render motion_producer_audit -- --nocapture + +cargo test --manifest-path native/shared/Cargo.toml \ + --test golden_render legacy_skinned_motion_uses_staged_previous_palette_and_bounds_trails \ + -- --nocapture + +BLOOM_RENDER_PERF_ENGINE_REVISION= \ +cargo run --release --manifest-path tools/render-perf/Cargo.toml -- \ + --width 1920 --height 1080 --warmup 300 --frames 900 --out + +BLOOM_RENDER_PERF_ENGINE_REVISION= \ +cargo run --release --manifest-path tools/render-perf/Cargo.toml -- \ + --width 1920 --height 1080 --warmup 32 --frames 32 \ + --trace-dir --out +``` diff --git a/docs/evidence/issue-135-temporal-cost-ledger.json b/docs/evidence/issue-135-temporal-cost-ledger.json new file mode 100644 index 00000000..2b7b8bba --- /dev/null +++ b/docs/evidence/issue-135-temporal-cost-ledger.json @@ -0,0 +1,145 @@ +{ + "schema": "bloom-temporal-cost-ledger-v1", + "issue": 135, + "qualified_revision": "29abf8065c775d59e44fa88f2f77584a21185f23", + "adapter": "Apple M1 Max", + "backend": "Metal", + "production_storage": { + "existing_history_systems": { + "ssr_added_history_bytes": 0, + "ssr_added_passes": 0, + "ssgi_added_history_bytes": 0, + "ssgi_added_passes": 0, + "taa_added_history_bytes": 0, + "taa_added_passes": 0, + "exposure_added_history_bytes": 0, + "exposure_added_passes": 0, + "pt_added_history_bytes": 0, + "pt_added_passes": 0 + }, + "ssgi_cached_diffuse": { + "bytes_per_probe": 16, + "bytes_at_native_1920x1080": 32640, + "added_passes": 0, + "removed_resolve_texture_lookups_per_sample": 1 + }, + "motion_cpu_histories": { + "cached_model_one_instance_capacity_bytes": 576, + "immediate_three_primitive_capacity_bytes": 1728, + "unkeyed_two_joint_capacity_bytes": 448, + "added_gpu_bytes": 0, + "added_passes": 0, + "added_draws": 0 + }, + "reactive_mask": { + "format": "r8unorm", + "bytes_per_render_pixel": 1, + "medium_default_extent": [1440, 810], + "medium_default_bytes": 1166400, + "ultra_extent": [1920, 1080], + "ultra_bytes": 2073600, + "added_passes": 0, + "added_draws": 0, + "inactive_bytes": 0, + "steady_graph_compiles_after_warmup": 0, + "steady_transient_physical_creations_after_warmup": 0, + "steady_bind_group_creations_after_warmup": 0 + } + }, + "reactive_mask_timing": { + "resolution": [1920, 1080], + "warmup_frames": 300, + "measured_frames": 900, + "paired_runs": 4, + "medium_default": { + "render_scale": 0.75, + "feature_off": { + "render_submit_mean_ms": [2.437030, 2.190597, 2.221297, 2.578430], + "render_submit_p50_ms": [2.499542, 2.439958, 2.441125, 2.392625], + "render_submit_p95_ms": [4.198708, 2.814875, 2.881917, 4.878292], + "prepare_mean_ms": [0.072348, 0.057259, 0.054824, 0.063143], + "graph_passes": 15 + }, + "active_mask": { + "render_submit_mean_ms": [2.406731, 2.237507, 2.239897, 2.303341], + "render_submit_p50_ms": [2.534458, 2.456041, 2.455125, 2.448375], + "render_submit_p95_ms": [3.882000, 2.863000, 2.945167, 3.184958], + "prepare_mean_ms": [0.069925, 0.055786, 0.056626, 0.062372], + "graph_passes": 15 + }, + "median": { + "feature_off_mean_ms": 2.329164, + "active_mask_mean_ms": 2.271619, + "feature_off_p50_ms": 2.440542, + "active_mask_p50_ms": 2.455583, + "feature_off_p95_ms": 3.540313, + "active_mask_p95_ms": 3.065063 + } + }, + "ultra_wall_submit_noise_context": { + "render_scale": 1.0, + "feature_off_median_mean_ms": 4.203101, + "active_mask_median_mean_ms": 4.782741, + "minimum_observed_p95_ms": 4.999084, + "maximum_observed_p95_ms": 19.667542, + "hard_regression_claim": false + }, + "ultra_timestamped_pass_profile": { + "warmup_frames": 180, + "measured_frames": 120, + "feature_off_translucent_gpu_mean_ms": 1.615791, + "active_mask_translucent_gpu_mean_ms": 1.429513, + "feature_off_taa_gpu_mean_ms": 1.906318, + "active_mask_taa_gpu_mean_ms": 1.872667, + "added_passes": 0, + "lower_active_values_claimed_as_optimization": false + } + }, + "frozen_revision_regression_gates": { + "immediate_primitives_median_mean_ms_before": 2.2396, + "immediate_primitives_median_mean_ms_after": 2.2360, + "custom_reactive_ordinary_path_median_mean_ms_before": 2.6678, + "custom_reactive_ordinary_path_median_mean_ms_after": 2.4082, + "remaining_motion_producers_median_mean_ms_before": 2.2421, + "remaining_motion_producers_median_mean_ms_after": 2.2273, + "steady_upload_bytes_per_frame_before": 23264, + "steady_upload_bytes_per_frame_after": 23264 + }, + "capture_only_diagnostics_at_native_1920x1080": { + "taa": { + "texture_bytes": 33177600, + "readback_bytes": 33177600, + "persistent_bytes": 0, + "passes": 1 + }, + "ssr": { + "texture_bytes": 1036800, + "readback_bytes": 3179520, + "persistent_bytes": 0, + "passes": 1 + }, + "ssgi": { + "texture_bytes": 1044480, + "readback_bytes": 1114112, + "persistent_bytes": 0, + "passes": 1 + }, + "realtime_pt": { + "texture_bytes": 8294400, + "readback_bytes": 8294400, + "persistent_bytes": 0, + "passes": 1 + }, + "resources_live_after_capture": false + }, + "regression_qualification": { + "lane": "quick", + "status": "pass", + "unit_tests_passed": 325, + "unit_tests_ignored": 1, + "gpu_goldens_passed": 57, + "gpu_goldens_ignored": 2, + "render_target_tests_passed": 4, + "production_renderer_changed_by_ledger_checkpoint": false + } +} diff --git a/docs/evidence/issue-135-temporal-cost-ledger.md b/docs/evidence/issue-135-temporal-cost-ledger.md new file mode 100644 index 00000000..6ef91602 --- /dev/null +++ b/docs/evidence/issue-135-temporal-cost-ledger.md @@ -0,0 +1,129 @@ +# Issue #135 temporal cost ledger + +This ledger consolidates the timing and storage cost of every production +history or mask added while implementing issue #135. It also records the +capture-only diagnostic masks so their temporary cost cannot be mistaken for +normal-frame renderer memory. + +The qualified renderer revision is `29abf80` on Apple M1 Max / Metal. The +performance harness changes used to select the retained-transparency workload +and pass profiler do not modify the renderer. + +## Production history and mask inventory + +| Change | Added storage | Added production passes/draws | Qualification | +|---|---:|---:|---| +| SSR validity and firefly bound | 0 B | 0 / 0 | Reuses the two existing HDR histories and temporal pass | +| SSGI validity and finite seeding | 0 B of history | 0 / 0 | Reuses the two existing 3D histories | +| SSGI cached diffuse probe value | 32,640 B at native 1080p | 0 / 0 | Adds 16 B/probe to the existing header and removes a resolve texture lookup | +| TAA validity and ownership | 0 B | 0 / 0 | Reuses the two existing TAA histories | +| Auto-exposure validity | 0 B | 0 / 0 | Reuses the existing two 1x1 histories | +| PT reset/reseed contract | 0 B | 0 / 0 | Reuses compatible SVGF histories; no retained bytes can seed a zero-sample epoch | +| Cached-model motion | 576 B CPU for the measured one-instance stream | 0 / 0 | Reuses `prev_mvp`, the velocity MRT, and the existing draw | +| Immediate-primitive motion | 1,728 B CPU for the measured three-primitive stream | 0 / 0 | Reuses the vertex tangent lane, upload, velocity MRT, and draw | +| Unkeyed two-joint skin motion | 448 B CPU | 0 / 0 | Reuses the existing current/previous joint buffers and draw | +| Keyed/legacy skin and foliage motion | 0 added GPU storage | 0 / 0 | Reuses existing palette buffers, velocity MRT, uploads, and draw | +| Reactive transparency coverage | 1 B/render pixel while active | 0 / 0 | One transient R8 attachment on the existing translucent pass; absent otherwise | +| Camera-cut/reset API and validity flags | No dynamic allocation | 0 / 0 | Invalidates compatible storage in place | + +The active mask is 1,166,400 bytes at the shipped Medium default +(1440x810 render extent on a 1920x1080 surface) and 2,073,600 bytes at native +Ultra. Ordinary opaque, TAA-disabled, and non-reactive-translucency frames do +not materialize it. After warm-up, both mask states report zero render-graph +compiles, zero transient physical creations, and zero bind-group creations +per frame. + +## Active-mask timing + +The qualification tool renders the same retained glTF BLEND cube in both +states. `BLOOM_TEMPORAL_REACTIVE=off` is the feature-off control; +`BLOOM_TEMPORAL_REACTIVE=on` selects the production R8 target and reactive TAA +pipeline. Four paired runs used 300 warm-up and 900 measured uncapped frames +at 1920x1080. + +At the shipped Medium default: + +| Full-frame CPU metric | Feature off | Active mask | Change | +|---|---:|---:|---:| +| Mean | 2.3292 ms | 2.2716 ms | -2.47% | +| P50 | 2.4405 ms | 2.4556 ms | +0.0150 ms / +0.62% | +| P95 | 3.5403 ms | 3.0651 ms | -13.42% | +| Preparation mean | 0.0602 ms | 0.0595 ms | -0.0007 ms | +| Compiled graph passes | 15 | 15 | 0 | + +The P50 delta is below run-to-run scheduling noise and there is no measurable +default-frame regression. Ultra wall-submit runs were scheduling-unstable: +their feature-off/active median means were 4.2031/4.7827 ms while individual +P95 values ranged from 4.9991 to 19.6675 ms. A dedicated 120-frame +timestamped pass run therefore isolated the affected work: + +| Ultra GPU pass mean | Feature off | Active mask | +|---|---:|---:| +| Existing translucent pass | 1.6158 ms | 1.4295 ms | +| Existing TAA pass | 1.9063 ms | 1.8727 ms | + +Metal timestamp noise means the lower active values are not claimed as an +optimization. They do show no localized pass regression and confirm that no +pass was added. The profiler deliberately blocks for timestamp readback, so +its wall-frame time is not used as a performance result. + +The earlier frozen-revision producer comparisons remain the before/after code +regression gates: + +| Affected path | Before median full-frame mean | After | Uploads | +|---|---:|---:|---:| +| Immediate primitives | 2.2396 ms | 2.2360 ms (-0.16%) | 23,264 B/frame unchanged | +| Custom reactive authoring, ordinary path | 2.6678 ms | 2.4082 ms (-9.73%; noisy, not claimed) | 23,264 B/frame unchanged | +| Legacy/unkeyed skin and producer audit | 2.2421 ms | 2.2273 ms (-0.66%) | 23,264 B/frame unchanged | + +## Capture-only diagnostic memory + +The following native-1080p telemetry is descriptive, not steady-state cost. +Each diagnostic family is created only after a capture request, records one +extra pass, and is released after readback. + +| Diagnostic family | Temporary textures | Temporary readback | Persistent | Capture passes | +|---|---:|---:|---:|---:| +| TAA | 33,177,600 B | 33,177,600 B | 0 B | 1 | +| SSR | 1,036,800 B | 3,179,520 B | 0 B | 1 | +| SSGI | 1,044,480 B | 1,114,112 B | 0 B | 1 | +| Realtime PT | 8,294,400 B | 8,294,400 B | 0 B | 1 | + +All four telemetry groups reported `resources_live=false` after capture. Raw +SSR/SSGI/HDR copies reuse production textures and add no diagnostic render +pass by themselves. + +## Regression qualification + +The full quick lane passes on the ledger/tooling tree: + +- 325 unit tests passed, 1 intentionally ignored; +- 57 runnable GPU goldens passed, 2 intentionally ignored; +- all 4 render-target integration tests passed; +- strict clippy/format, all-platform FFI/schema parity, web arity, Wasm + compilation, quality governance, visual fault controls, and canonical + examples passed. + +The qualification tool is the only executable changed by this checkpoint. +Production renderer shaders, resources, passes, and accepted images are +unchanged. + +## Commands + +```sh +BLOOM_TEMPORAL_REACTIVE= \ +BLOOM_RENDER_PERF_ENGINE_REVISION= \ +cargo run --release --manifest-path tools/render-perf/Cargo.toml -- \ + --width 1920 --height 1080 --warmup 300 --frames 900 \ + --quality-preset 2 --reactive-transparency --out + +BLOOM_TEMPORAL_REACTIVE= \ +cargo run --release --manifest-path tools/render-perf/Cargo.toml -- \ + --width 1920 --height 1080 --warmup 180 --frames 120 \ + --quality-preset 4 --reactive-transparency --profile-passes \ + --out + +cargo test --manifest-path native/shared/Cargo.toml --test golden_render \ + cached_alpha_tested_card_motion_writes_velocity_and_bounds_trails \ + -- --nocapture +``` diff --git a/docs/evidence/issue-139-steady-state-performance.json b/docs/evidence/issue-139-steady-state-performance.json new file mode 100644 index 00000000..be564995 --- /dev/null +++ b/docs/evidence/issue-139-steady-state-performance.json @@ -0,0 +1,76 @@ +{ + "schema": "bloom-render-perf-evidence-v1", + "issue": 139, + "machine": { + "adapter": "Apple M1 Max", + "backend": "Metal", + "device_type": "IntegratedGpu" + }, + "revisions": { + "before": "0bc6fabc7bb868f2ec8cb25469853ae2a3b74e4e", + "after": "93dfd942373024d4899071c9e3b8e561647906ef" + }, + "timing": { + "warmup_frames": 300, + "measured_frames": 900, + "runs_per_revision_resolution": 3, + "aggregate": "median of per-run percentiles", + "trace_enabled": false, + "quality_preset": 4, + "headless_uncapped": true, + "resolutions": { + "1920x1080": { + "before_runs_ms": [ + { "p50": 2.467834, "p95": 4.069458, "p99": 12.179083 }, + { "p50": 2.798000, "p95": 10.465416, "p99": 32.675083 }, + { "p50": 2.654000, "p95": 2.953167, "p99": 3.094875 } + ], + "after_runs_ms": [ + { "p50": 2.229750, "p95": 3.351584, "p99": 3.507458 }, + { "p50": 2.732750, "p95": 7.643542, "p99": 13.848625 }, + { "p50": 2.465375, "p95": 2.699541, "p99": 2.949875 } + ], + "median_ms": { + "before": { "p50": 2.654000, "p95": 4.069458, "p99": 12.179083 }, + "after": { "p50": 2.465375, "p95": 3.351584, "p99": 3.507458 } + }, + "delta_percent": { "p50": -7.107, "p95": -17.641, "p99": -71.201 }, + "median_prepare_mean_ms": { "before": 0.261216, "after": 0.046771 }, + "median_end_frame_mean_ms": { "before": 2.452808, "after": 2.168491 } + }, + "3840x2160": { + "before_runs_ms": [ + { "p50": 6.300917, "p95": 6.527375, "p99": 6.668166 }, + { "p50": 6.435250, "p95": 7.711333, "p99": 7.899500 }, + { "p50": 6.279666, "p95": 6.557583, "p99": 7.687584 } + ], + "after_runs_ms": [ + { "p50": 6.099917, "p95": 6.270541, "p99": 6.386958 }, + { "p50": 6.099625, "p95": 6.273042, "p99": 6.364458 }, + { "p50": 6.109709, "p95": 6.279458, "p99": 7.039625 } + ], + "median_ms": { + "before": { "p50": 6.300917, "p95": 6.557583, "p99": 7.687584 }, + "after": { "p50": 6.099917, "p95": 6.273042, "p99": 6.386958 } + }, + "delta_percent": { "p50": -3.190, "p95": -4.339, "p99": -16.919 }, + "median_prepare_mean_ms": { "before": 0.217251, "after": 0.061485 }, + "median_end_frame_mean_ms": { "before": 5.834215, "after": 5.838722 } + } + } + }, + "uploads": { + "warmup_frames": 32, + "measured_frames": 32, + "trace_enabled": true, + "trace_timings_used": false, + "per_frame_bytes": { + "1920x1080": { "before": 422120, "after": 23264 }, + "3840x2160": { "before": 422120, "after": 23264 } + }, + "reduction_bytes": 398856, + "reduction_percent": 94.489, + "reduction_ratio": 18.145, + "steady_texture_upload_bytes": 0 + } +} diff --git a/docs/evidence/issue-139-steady-state-performance.md b/docs/evidence/issue-139-steady-state-performance.md new file mode 100644 index 00000000..e49b82d0 --- /dev/null +++ b/docs/evidence/issue-139-steady-state-performance.md @@ -0,0 +1,79 @@ +# Issue #139 steady-state performance evidence + +This qualification compares the last engine revision before the #139 +steady-state optimization series (`0bc6fabc`) with the instrumented current +revision (`93dfd942`). The benchmark tool commits were cherry-picked unchanged +onto the detached before worktree; `BLOOM_RENDER_PERF_ENGINE_REVISION` preserves +the actual engine revision in each report. + +## Controlled workload + +- Apple M1 Max / Metal +- release build +- production headless renderer, FPS cap disabled +- Ultra preset 4 +- static plane and cube with 40 colored point lights +- 300 warm-up frames, then 900 measured frames +- three runs per revision and resolution +- P50/P95/P99 below are medians of the three per-run percentiles +- primary `cpu_render_submit_ms` covers `begin_frame`, fixed renderer + draw/light submission, and `end_frame` +- timing runs have wgpu API tracing disabled + +## CPU render-submit result + +| Resolution | Metric | Before (ms) | After (ms) | Change | +|---|---:|---:|---:|---:| +| 1920×1080 | P50 | 2.654 | 2.465 | −7.1% | +| 1920×1080 | P95 | 4.069 | 3.352 | −17.6% | +| 1920×1080 | P99 | 12.179 | 3.507 | −71.2% | +| 3840×2160 | P50 | 6.301 | 6.100 | −3.2% | +| 3840×2160 | P95 | 6.558 | 6.273 | −4.3% | +| 3840×2160 | P99 | 7.688 | 6.387 | −16.9% | + +Renderer prepare mean fell from 0.261 to 0.047 ms at 1080p (−82.1%) and +from 0.217 to 0.061 ms at 4K (−71.7%). The separately reported 4K +`end_frame` mean was effectively flat (5.834 vs 5.839 ms, +0.005 ms / ++0.08%) because the headless three-frame queue moves GPU back-pressure into +that phase. The complete render-submit measurement, which is the acceptance +metric, improved at every percentile and resolution. + +## Total upload result + +A separate 32-warm-up / 32-measured-frame run enabled wgpu API tracing only in +the qualification binary. Every traced buffer and texture payload between the +final 32 submits was summed. Trace-mode timings are explicitly marked invalid +and were not used above. + +| Resolution | Before | After | Change | +|---|---:|---:|---:| +| 1920×1080 | 422,120 B/frame | 23,264 B/frame | −94.5% | +| 3840×2160 | 422,120 B/frame | 23,264 B/frame | −94.5% | + +All 32 measured frames at each revision/resolution had the exact reported +value; steady texture-upload bytes were zero. The optimized frame transfers +398,856 fewer bytes, an 18.14× reduction. + +## Commands + +Timing: + +```sh +BLOOM_RENDER_PERF_ENGINE_REVISION= \ +cargo run --release --manifest-path tools/render-perf/Cargo.toml -- \ + --width <1920-or-3840> --height <1080-or-2160> \ + --warmup 300 --frames 900 --out +``` + +Upload volume: + +```sh +BLOOM_RENDER_PERF_ENGINE_REVISION= \ +cargo run --release --manifest-path tools/render-perf/Cargo.toml -- \ + --width <1920-or-3840> --height <1080-or-2160> \ + --warmup 32 --frames 32 --trace-dir \ + --out +``` + +The raw values and aggregate calculation are preserved in +[`issue-139-steady-state-performance.json`](issue-139-steady-state-performance.json). diff --git a/docs/evidence/issue-56-quality-presets.json b/docs/evidence/issue-56-quality-presets.json new file mode 100644 index 00000000..e14bf028 --- /dev/null +++ b/docs/evidence/issue-56-quality-presets.json @@ -0,0 +1,119 @@ +{ + "schema": "bloom-quality-preset-evidence-v1", + "issue": 56, + "comparison_base": "30a3493", + "qualified_revision": "d2c2939", + "adapter": "Apple M1 Max", + "backend": "Metal", + "visual": { + "legacy_half_scale_detail_energy": 3.7882, + "default_075_detail_energy": 4.9969, + "ultra_native_detail_energy": 4.7173, + "legacy_half_to_native_mean_difference": 2.1083, + "default_075_to_native_mean_difference": 1.3781 + }, + "matched_performance": { + "resolution": [1920, 1080], + "quality_preset": 4, + "render_scale": 0.5, + "warmup_frames": 300, + "measured_frames": 900, + "alternating_runs_per_revision": 4, + "before": { + "render_submit_mean_ms": [2.201955, 2.217295, 2.230422, 2.208373], + "render_submit_p50_ms": [2.423209, 2.439500, 2.441209, 2.462708], + "render_submit_p95_ms": [2.711125, 2.735000, 2.767875, 2.814584], + "render_submit_p99_ms": [2.906875, 3.269041, 3.744833, 3.238792], + "prepare_mean_ms": [0.044282, 0.044905, 0.045262, 0.049310] + }, + "after": { + "render_submit_mean_ms": [2.220145, 2.206539, 2.207559, 2.212751], + "render_submit_p50_ms": [2.432000, 2.425000, 2.423459, 2.411917], + "render_submit_p95_ms": [2.749875, 2.676208, 2.705042, 2.684750], + "render_submit_p99_ms": [3.708583, 2.887208, 2.910584, 2.822917], + "prepare_mean_ms": [0.044971, 0.043514, 0.044187, 0.041972] + }, + "steady_upload_bytes_per_frame_before": 23264, + "steady_upload_bytes_per_frame_after": 23264 + }, + "tier_budget_1080p": [ + { + "preset": 0, + "render_scale": 0.50, + "render_extent": [960, 540], + "render_pixels": 518400, + "graph_passes": 11, + "render_submit_mean_ms": 0.668259, + "render_submit_p50_ms": 0.269375, + "render_submit_p95_ms": 1.814792 + }, + { + "preset": 1, + "render_scale": 0.67, + "render_extent": [1286, 724], + "render_pixels": 931064, + "graph_passes": 12, + "render_submit_mean_ms": 0.943018, + "render_submit_p50_ms": 0.553291, + "render_submit_p95_ms": 2.206416 + }, + { + "preset": 2, + "render_scale": 0.75, + "render_extent": [1440, 810], + "render_pixels": 1166400, + "graph_passes": 15, + "render_submit_mean_ms": 2.428378, + "render_submit_p50_ms": 2.557875, + "render_submit_p95_ms": 4.268500 + }, + { + "preset": 3, + "render_scale": 0.85, + "render_extent": [1632, 918], + "render_pixels": 1498176, + "graph_passes": 24, + "render_submit_mean_ms": 3.260070, + "render_submit_p50_ms": 3.019083, + "render_submit_p95_ms": 4.679166 + }, + { + "preset": 4, + "render_scale": 1.00, + "render_extent": [1920, 1080], + "render_pixels": 2073600, + "graph_passes": 24, + "render_submit_mean_ms": 4.409377, + "render_submit_p50_ms": 4.425542, + "render_submit_p95_ms": 5.871292 + } + ], + "resources": { + "new_persistent_images": 0, + "new_persistent_buffers": 0, + "new_history_images": 0, + "new_render_passes_at_matched_settings": 0, + "new_upload_bytes_per_frame": 0, + "cas_pass_enabled_by_any_preset": false, + "tracked_frame_cpu_capacity_bytes_all_tiers": 1918560 + }, + "regression_qualification": { + "lane": "quick", + "status": "pass", + "unit_tests_passed": 325, + "unit_tests_ignored": 1, + "gpu_goldens_passed": 57, + "gpu_goldens_ignored": 2, + "render_target_tests_passed": 4, + "thresholds_changed": false, + "golden_references_changed": false, + "completed_components": [ + "contracts", + "lint", + "shared-tests", + "wasm-check", + "quality-contract", + "example-inventory" + ] + } +} diff --git a/docs/evidence/issue-56-quality-presets.md b/docs/evidence/issue-56-quality-presets.md new file mode 100644 index 00000000..3c02553a --- /dev/null +++ b/docs/evidence/issue-56-quality-presets.md @@ -0,0 +1,104 @@ +# Issue #56 coherent quality-preset evidence + +This qualification compares the pre-preset checkpoint `30a3493` with the +coherent preset implementation in `d2c2939` on Apple M1 Max / Metal. + +## Visual result + +The production TAA/upscale/composite chain renders a high-frequency grid and +alternating thin geometry at legacy scale 0.50, the balanced 0.75 tier, and +native Ultra. A four-neighbor luma Laplacian measures resolved output detail; +whole-frame difference to native Ultra is the independent fidelity check. + +| Configuration | Detail energy | Mean difference to native | +|---|---:|---:| +| Legacy 0.50 | 3.7882 | 2.1083 | +| Balanced 0.75 | 4.9969 | 1.3781 | +| Native Ultra | 4.7173 | 0 | + +The balanced tier resolves 31.9% more detail than legacy half scale and is +34.6% closer to native. Native has slightly lower Laplacian energy because +actual subpixel resolution produces smoother anti-aliased edges rather than +the 0.75 image's residual high-frequency edge energy; the native-reference +difference prevents treating aliasing as quality. + +The engine's first-run scale is now 0.75. Presets explicitly select 0.50, +0.67, 0.75, 0.85, and 1.00 from Off through Ultra. TAA, upscale filtering, +and composite sharpening are part of the same policy. TAA toggles no longer +change resolution or rebuild resolution-dependent targets. + +## Matched performance + +The code-path comparison holds effects and render scale at the former Ultra +configuration (preset 4, scale 0.50). Four frozen before/after binaries ran in +alternating order with 300 warm-up and 900 measured 1920x1080 frames. + +| Full-frame CPU metric | Before median | After median | Change | +|---|---:|---:|---:| +| Mean | 2.2128 ms | 2.2102 ms | -0.12% | +| P50 | 2.4404 ms | 2.4242 ms | -0.66% | +| P95 | 2.7514 ms | 2.6949 ms | -2.05% | + +Traced uploads remain exactly 23,264 bytes/frame. The policy adds no image, +buffer, history, render pass, draw, or per-frame upload. Every preset keeps +the separate CAS pass disabled and uses sharpening already present in the +final composite, avoiding both a second pass and double halos. + +## Tier budgets + +Each tier ran the same controlled scene for 300 warm-up plus 900 measured +frames at 1920x1080. These rows intentionally contain different workloads: +they are budgets for the advertised quality choices, not before/after +regression comparisons. + +| Preset | Scale / extent | Graph passes | Mean | P50 | P95 | +|---|---|---:|---:|---:|---:| +| Off | 0.50 / 960x540 | 11 | 0.6683 ms | 0.2694 ms | 1.8148 ms | +| Low | 0.67 / 1286x724 | 12 | 0.9430 ms | 0.5533 ms | 2.2064 ms | +| Medium | 0.75 / 1440x810 | 15 | 2.4284 ms | 2.5579 ms | 4.2685 ms | +| High | 0.85 / 1632x918 | 24 | 3.2601 ms | 3.0191 ms | 4.6792 ms | +| Ultra | 1.00 / 1920x1080 | 24 | 4.4094 ms | 4.4255 ms | 5.8713 ms | + +All tiers stay below the 16.67 ms 60-fps budget in this controlled workload; +Off and Low preserve explicit headroom for slow GPUs. Existing +resolution-dependent targets scale with the chosen render-pixel count; no +new target topology is introduced. Renderer-owned frame CPU capacity is the +same 1,918,560 bytes in every tier. + +## Regression qualification + +The full quick lane passed on the qualified tree: + +- 325 unit tests passed, 1 hardware/file-watcher test ignored; +- 57 runnable GPU goldens passed, 2 long-running diagnostic goldens ignored; +- all 4 render-target integration tests passed; +- native FFI/schema parity, web FFI parity/arity, Wasm compilation, strict + clippy, formatting, quality governance, visual-metric fault tests, and the + canonical example inventory passed. + +The existing half-scale TAA golden and native-resolution hardware-ray goldens +now select their intended scale explicitly. No comparison threshold or golden +reference was changed. + +## Commands + +```sh +cargo test --manifest-path native/shared/Cargo.toml \ + --test golden_render quality_presets -- --nocapture + +cargo test --manifest-path native/shared/Cargo.toml \ + --test render_targets -- --nocapture + +BLOOM_RENDER_PERF_ENGINE_REVISION= \ +cargo run --release --manifest-path tools/render-perf/Cargo.toml -- \ + --width 1920 --height 1080 --warmup 300 --frames 900 \ + --quality-preset 4 --render-scale 0.5 --out + +BLOOM_RENDER_PERF_ENGINE_REVISION= \ +cargo run --release --manifest-path tools/render-perf/Cargo.toml -- \ + --width 1920 --height 1080 --warmup 300 --frames 900 \ + --quality-preset <0..4> --out + +./scripts/ci-check.sh --quick \ + --summary target/ci/quick-quality-presets.json +``` diff --git a/docs/imported-refraction.md b/docs/imported-refraction.md new file mode 100644 index 00000000..2aef33fe --- /dev/null +++ b/docs/imported-refraction.md @@ -0,0 +1,124 @@ +# Imported glTF refraction + +Bloom routes contributing `KHR_materials_transmission` materials through a +dedicated forward bucket. The route is selected at renderer startup and is +reported by `getImportedRefractionMode()`: + +- `scene-snapshot`: desktop/native targets sample immutable pre-translucency + HDR color and depth supplied by the compiled render graph; +- `environment-fallback`: WebGPU and Android preserve the dielectric material, + IOR, texture modulation, Fresnel response, thickness, and absorption, but + source transmitted radiance from the environment because those targets fold + the renderer to four bind groups; +- `disabled-legacy`: diagnostic opt-out selected by setting + `BLOOM_GLTF_REFRACTION=0` before renderer and asset creation. + +The default is physical refraction. The legacy mode exists only as an A/B kill +switch and restores the previous mirror-like import approximation. + +## Shading contract + +The physical material bind group is allocated only for an active authored +transmission factor. Ordinary materials keep their existing material layout, +shader, graph topology, and pass list. + +The shader applies: + +1. transmission/thickness texture modulation, including + `KHR_texture_transform` and independent `TEXCOORD_0`/`TEXCOORD_1` + selection for each texture; +2. Snell refraction with the authored/default IOR; +3. a depth-guarded, 64-pixel-bounded screen-space offset on snapshot-capable + targets; +4. deterministic rough-transmission filtering; +5. Beer-Lambert attenuation using thickness, attenuation color, and + attenuation distance; +6. Schlick Fresnel partitioning between transmitted and reflected energy, + using the bounded planar/screen-space/environment hierarchy on native + targets and the prefiltered environment on folded targets. + +Model scale promotes authored thickness into world units. The pass writes +per-object motion vectors, depth-tests against opaque geometry, and does not +write opaque depth. With TAA active, it also writes transmitted contribution +to the lazy `r8unorm` reactive target so background-dependent radiance cannot +leave stale temporal trails. See +[Temporal reactive coverage](temporal-reactive-coverage.md). +The reflection source and its exact fallback/allocation contract are described +in [Refractive reflection sources](refractive-reflections.md). + +### Secondary-UV cost contract + +`Vertex3D` remains the established 96-byte ordinary vertex ABI and every +opaque, MASK, BLEND, UV0-refraction, depth, GI, and ordinary shadow pipeline +keeps its original vertex layout. When an active transmission or thickness +texture actually requests `TEXCOORD_1`, the importer retains a compact +8-byte-per-vertex sidecar. The renderer uploads and fetches that sidecar only +when the referenced texture is usable, then selects a separately compiled +two-stream refraction pipeline. + +Transmission and thickness select UV0 or UV1 independently before applying +their own `KHR_texture_transform`; mixed-UV materials therefore do not need +duplicated vertices. Cached meshes localize their shared-arena primary/index +windows so the mesh-local sidecar uses the same zero-based indices. Retained, +cached, cached-skinned, directional-shadow, and TAA-reactive routes share this +contract. + +An unreferenced `TEXCOORD_1` accessor incurs no retained CPU data, GPU buffer, +second vertex fetch, or extra pipeline. A requested but missing/malformed UV1 +accessor is never synthesized from UV0: Bloom preserves the source binding, +diagnoses the mismatch, and uses the scalar physical factor. + +## Composition and shadows + +Retained and cached imported materials share one deterministic back-to-front +key: view depth followed by stable object ID. The shader composites against an +immutable snapshot and writes a fully resolved HDR result, preventing +read/write feedback and double application of the background. + +Physical-transmission materials cast bounded colored directional shadows +without entering the opaque CSM. The existing 2048² opaque/MASK cascades remain +authoritative for blockers. When the scene actually submits an eligible +transmission caster, Bloom lazily adds one 1024² RGB transmittance texture and +one 1024² nearest-layer depth texture per cascade. The resolve subtracts only +the absorbed portion of the already-shaded primary sun contribution, including +opaque and cloud visibility. + +Ordinary scenes do not allocate these textures, compile the caster/resolve +pipelines, add graph resources, or add a pass. Set +`BLOOM_TRANSMITTED_SHADOWS=0` before renderer creation for an exact A/B opt-out. +The selected policy and live caster count are exposed in native quality +telemetry. See [Transmitted directional shadows](transmitted-shadows.md). + +## Global illumination + +With screen-probe GI enabled, retained physical-transmission instances use one +bounded colored continuation rather than behaving as opaque GI blockers. +Hardware ray query performs at most one extra opaque-only query when glass is +the nearest hit. The software SDF route keeps glass out of the opaque clipmap +and selects one nearest conservative transmission AABB from existing instance +metadata. + +The route adds no GPU resource, graph pass, or transient slot and creates its +specialized pipelines only after an eligible retained instance appears. Set +`BLOOM_TRANSPARENT_GI=0` for the exact opaque-GI A/B control. See +[Transparent global illumination](transparent-gi.md). + +## Explicit limits + +- Physical textures authored against `TEXCOORD_2` or higher remain preserved + in metadata, are diagnosed at material creation, and fall back to their + scalar factors instead of sampling the wrong coordinates. +- The environment fallback cannot show on-screen background distortion. +- V1 native reflection routing consumes only the first explicit planar probe; + non-matching glass safely falls through to screen space/environment. +- The native reflection hierarchy deliberately does not launch a per-fragment + ray query on screen misses; see the bounded-cost rationale in + [Refractive reflection sources](refractive-reflections.md). +- Arbitrarily deep nested/order-independent dielectric refraction is outside + this baseline. +- The directional-shadow representation keeps the nearest transmission layer + per light texel. Arbitrarily nested transparent shadow casters are outside + this bounded baseline. +- Transparent GI keeps one nearest transmission instance per ray. Its software + fallback uses a conservative instance AABB, and GI transport does not sample + per-hit transmission/thickness textures. diff --git a/docs/layered-pbr.md b/docs/layered-pbr.md new file mode 100644 index 00000000..55d8b9b8 --- /dev/null +++ b/docs/layered-pbr.md @@ -0,0 +1,437 @@ +# Layered PBR contract + +Bloom's layered material work uses one versioned reference model before a lobe +is added to a realtime shader. The checked-in version-1 contract covers the +existing metallic/roughness base layer. Version 2 composes clearcoat and +dielectric specular/IOR over that base. Version 3 adds Charlie sheen and +tangent-space anisotropic GGX. All three are deliberately independent from the +renderer, textures, tone mapping, and scene lighting so energy and reciprocity +failures cannot hide inside an image. + +## Version 1 base layer + +`tools/bloom-reference/src/layered_pbr.rs` is the authoritative target +evaluator for new layered-material work. The companion +`bloom-brdf-reference` binary uses it to generate the parameter matrix. +Version 1 defines: + +- linear base color in `[0, 1]`; +- metallic weight in `[0, 1]`; +- perceptual roughness in `[0.04, 1]`, with `alpha = roughness²`; +- dielectric normal-incidence reflectance `F0 = 0.04`; +- Schlick Fresnel; +- GGX/Trowbridge-Reitz distribution; +- height-correlated Smith visibility including + `1 / (4 NdotV NdotL)`; +- energy-normalized Burley diffuse; +- reciprocal diffuse interface transmission + `(1 - F(NdotV)) (1 - F(NdotL))`. + +The view/light Fresnel factors matter. Diffuse light enters and leaves a +dielectric interface, while reflected energy remains in the specular lobe. +Using only the half-vector Fresnel term allowed a smooth white dielectric at a +grazing view to retain nearly full diffuse response while its specular +response approached one. + +The Burley lobe uses the roughness-dependent Frostbite normalization, fading +from `1` to `1/1.51`. This retains the reciprocal rough-surface response +without the original Disney form's white-furnace gain. + +## Version 2 clearcoat and dielectric specular/IOR + +Version 2 retains the version-1 result exactly when all new parameters are at +their glTF defaults. When either new lobe is active it defines: + +- dielectric `F0 = ((ior - 1) / (ior + 1))²`; +- glTF's `ior = 0` compatibility mode as positive-infinite IOR and `F0 = 1`; +- `KHR_materials_specular` color clamped only after multiplication by the IOR + reflectance, followed by its scalar factor; +- explicit `F90 = specularFactor`, while pure conductors remain unaffected; +- a max-RGB dielectric diffuse complement so colored specular cannot create + inverse-colored diffuse energy; +- fixed clearcoat IOR `1.5`, independent perceptual roughness, and the common + GGX microfacet distribution; +- reciprocal clearcoat transmission at the view and light interfaces, + attenuating diffuse and base specular before the coat response is added. + +The final point is a deliberate energy-conserving refinement of the +non-normative one-sided glTF sample formula. The first implementation used +that one-sided mix and reached approximately `1.252` for a rough conductor +under a coat in a white furnace. Treating the coat as a real interface crossed +on entry and exit removes that unexplained gain while retaining reciprocity. +This convention is the target for every Bloom realtime and path-traced +consumer. + +The independent clearcoat normal uses the same authored tangent-space +orientation in realtime and path-traced shading. Import preserves its +texture, transform, UV set, and scale losslessly. The mapped normal affects +only the coat interface, is constrained to the base geometric hemisphere, and +widens coat roughness from baked normal-length/variance metadata. Realtime +shading additionally applies screen-space curvature variance; path tracing +has no derivatives and therefore consumes only the baked terms. + +## Version 3 sheen and anisotropy + +Version 3 remains exactly version 2 when sheen color and anisotropy strength +are zero. Active lobes follow the ratified Khronos conventions: + +- sheen uses the Charlie distribution with + `alphaG = sheenPerceptualRoughness²`; +- visibility uses the full fitted Charlie lambda function, not the older + Ashikhmin shortcut; +- the base below sheen is scaled by the maximum sheen-color channel and the + greater of the view/light directional albedos, preserving reciprocity; +- the directional-albedo oracle is a checked 128×128 R16F LUT generated with + 4,096 Charlie-importance samples per texel; +- sheen sits below clearcoat in direct, environment, and physical-transmission + composition; +- anisotropy uses Burley anisotropic GGX with + `alphaT = mix(alpha, 1, strength²)` and `alphaB = alpha`; +- visibility is height-correlated anisotropic Smith; +- positive rotation is counter-clockwise in the glTF tangent frame; +- texture RG maps from `[0,1]` to `[-1,1]`, texture B multiplies strength, and + `KHR_texture_transform` changes lookup coordinates without rotating the + physical tangent frame. + +Valid mesh tangents are authoritative, including `tangent.w` and mirrored +model handedness. If the tangent is absent, zero, or parallel to the normal, +the shader reconstructs a frame from the selected untransformed UV set and +screen-space derivatives. A finite orthogonal fallback covers degenerate UV +derivatives. The realtime roughness floor is `0.04`, matching the established +specular-AA stability contract. + +## Qualification corpora + +`bloom-brdf-reference` writes a deterministic 48-case sphere parameter matrix: + +```shell +cargo run --release \ + --manifest-path tools/bloom-reference/Cargo.toml \ + --bin bloom-brdf-reference -- \ + --out tools/bloom-reference/reference/layered-pbr-v1.json +``` + +The matrix spans two base colors, dielectric and conductor endpoints, four +roughness values, and three view angles. Each row records separate direct +diffuse/specular values, `BRDF * NdotL`, the current MIS PDF, and white-furnace +directional reflectance. Values are rounded to six decimal places only when +serialized; all tests evaluate full-precision values. + +The checked-in JSON is a contract, not an automatically refreshed golden. +Unit tests regenerate it in memory and fail on any drift. Updating it requires +reviewing the formula change and its visual/reference evidence. + +White-furnace integration uses deterministic GGX visible-normal importance +sampling for specular and cosine sampling for diffuse. Uniform hemisphere +quadrature is intentionally not used: it aliases the near-delta GGX peak at +the roughness floor and can report false energy gain. + +The current gates require: + +- finite, non-negative output across the parameter matrix; +- reciprocal BRDF values when view and light are exchanged; +- no more than 2% white-furnace gain at the deliberately conservative + numerical integration resolution; +- one clamping contract for invalid CPU parameters; +- exact agreement between the generator and checked-in versioned matrix. + +The version-2 matrix is generated explicitly: + +```shell +cargo run --release \ + --manifest-path tools/bloom-reference/Cargo.toml \ + --bin bloom-brdf-reference -- \ + --version 2 \ + --out tools/bloom-reference/reference/layered-pbr-v2.json +``` + +It contains 39 cases: 13 base, IOR, specular, clearcoat, and combined +scenarios at three view angles. The checked file's SHA-256 is +`f8b1cdf215b6df2e264b2499b5a2cfaf81da1b18e547ad6aa04af8e21e178497`. +At the deterministic corpus resolution, reflectance ranges from `0.032862` to +`0.967294`. Broader unit sweeps cover metallic `0/0.5/1`, four base +roughnesses, five IOR/specular configurations, four clearcoat +factor/roughness configurations, and four view angles with a `1.02` numerical +tolerance. + +The version-3 matrix is generated with: + +```shell +cargo run --release \ + --manifest-path tools/bloom-reference/Cargo.toml \ + --bin bloom-brdf-reference -- \ + --version 3 \ + --out tools/bloom-reference/reference/layered-pbr-v3.json +``` + +Its 30 rows cover ten default, sheen, anisotropy, fabric, coated-fabric, and +all-lobe scenarios at three view angles. The checked JSON SHA-256 is +`0324c95b489f611888163df8ac879033313af6b9190e05af251db91aaca06bfa`. +The checked LUT SHA-256 is +`c4433c235610212432e0da83a0106b8289f08beec9c2524fd82b677945235118`. +Recorded furnace channels range from `0.030422` to `0.834041`; broader tests +also pin reciprocity, finite output, rotation periodicity, the version-2 +default, and LUT agreement with a 65,536-sample oracle. + +Version 4 adds bounded thin-film interference. Its 39-row checked matrix is +`tools/bloom-reference/reference/layered-pbr-v4.json`, SHA-256 +`cc8b586bb69123be172987b5c974e1f001e9ef5df3f99df2687396a3f7ae7209`. +It covers dielectric and conductor bases, three film thickness ranges, +strength and IOR variation, clearcoat over film, and the combined layered +model at three view cosines. + +The separate angular comparison corpus expands the complete version-4 model +over three view directions and three light directions: + +```shell +cargo run --release \ + --manifest-path tools/bloom-reference/Cargo.toml \ + --bin bloom-brdf-reference -- \ + --angular \ + --out tools/bloom-reference/reference/layered-pbr-angular-v1.json +``` + +Its 63 rows isolate base, specular/IOR, clearcoat, sheen, anisotropy, +iridescence, and combined scenarios. Every row records lobe-separated linear +BRDF output, total `BRDF * NdotL`, PDF, and view/light reciprocity error. The +checked file's SHA-256 is +`110142a66bc22acaf0e7f6664a86f135b194fc62d203f1a92b685fbf2487f309`. +Regeneration is byte-exact at six serialized decimals; the full-precision CPU +component and reciprocity tolerance is `3e-5`. + +The corpus records its comparison boundary rather than hiding known model +differences. It excludes IBL, SSR, exposure, tone mapping, realtime finite +highlight compression, texture filtering, and normal variance. Stochastic +path transport is compared after convergence rather than sample-for-sample, +and iridescence is the bounded Khronos Belcour/Barla Rec.709 approximation, +not spectral conductor Fresnel. + +The hardware cross-path gate loads the exact `v1-l2` records from this file +and renders them through forward MRT and Metal ray-query path tracing with +IBL, screen-space effects, exposure adaptation, bloom, and shadows disabled. +This isolates one white directional light while preserving each production +shader's intentional stability behavior. A 32x32 center region must keep +forward/path mean RGB error at or below 24 display levels, RGB direction +cosine at or above `0.96`, and relative display-luminance error at or below +`0.30`. Responses whose linear-reference length is at least `0.01` must also +track the reference direction with cosine at or above `0.85`; smaller +responses remain bounded to 12 display levels because the forward path's +documented smooth highlight compression can legitimately dominate them. + +On the qualified Apple M1 Max run, the worst display error was `20.4333` +(anisotropy), the minimum forward/path color cosine was `0.985835`, the +maximum relative luminance error was `0.2365`, and the minimum significant +path/reference response cosine was `0.904537`. Bloom does not currently ship +a deferred-shading or visibility-buffer material evaluator: the renderer is +forward MRT by design, and the future visibility path is tracked by issue +#27. Its parity gate must consume this same corpus when that path lands. + +## Transport defect found by the foundation + +The audit found that the former CPU and GPU path-tracing Smith equations +multiplied the alpha term by `NdotV`/`NdotL` inside the square root instead of +using the squared-cosine form. A rough white conductor at `NdotV = 0.1` +returned about `2.06` units in a unit white furnace with that equation. + +Both path tracers now use the corrected correlated-GGX visibility, +energy-normalized Burley diffuse, and reciprocal view/light interface +transmission. The CPU scene tracer imports the named version-1 evaluator +directly rather than maintaining another copy. Its remaining sampler-specific +code uses view-angle Fresnel lobe probabilities to control finite-sample +grazing variance. + +The Metal progressive and moving-camera oracles were reviewed and regenerated +for this intentional broad energy correction. Three identical reruns of each +mode produce bit-exact images, while the BRDF-energy and reprojection fault +controls still fail their respective goldens. The realtime base shader already +used the corrected Smith visibility. + +Scalar clearcoat, dielectric specular/IOR, sheen, anisotropic-GGX, and +iridescent path transport now live in lazy group-2 specializations. They +identify the primary TLAS instance without growing the G-buffer, apply the CPU +reference's reciprocal fixed-IOR clearcoat, KHR_materials_specular F0/F90, +energy-compensated Charlie sheen, Burley anisotropy, and bounded +Belcour/Barla thin-film contracts for direct light, and sample every qualified +lobe at each bounce. Anisotropy reconstructs the retained vertex tangent and +mirrored handedness from the existing geometry megabuffer and committed +object-to-world transform. Texture-free iridescence uses glTF's authored +maximum thickness and spectrally modifies dielectric or conductor Fresnel +without creating a second lobe. Clearcoat composes over the modified base and +sheen with reciprocal attenuation, and pure metals remain independent of +dielectric specular/IOR settings. + +A scene without a qualified record dispatches the original base pipeline +exactly. Clearcoat/specular-only scenes bind one sidecar buffer; scalar +anisotropy and iridescence select separately constant-folded code variants +without adding a resource, and only a scene with scalar sheen creates the +32 KiB directional-albedo LUT. + +Resolved `KHR_materials_specular` factor/color, +`KHR_materials_clearcoat` factor/roughness, and `KHR_materials_sheen` +color/roughness textures, plus `KHR_materials_iridescence` factor/thickness +and `KHR_materials_anisotropy` direction/strength textures, are the first +qualified texture-bearing PT slices. +They use the renderer's existing texture array, reconstruct the committed +triangle UV at both primary and bounce hits, and apply the exact authored +offset/rotation/scale transform. Specular reads factor from alpha and converts +color from sRGB; clearcoat reads factor from red and roughness from green; +sheen converts color from sRGB and reads roughness from alpha; iridescence +reads factor from red and maps thickness texture green across the authored +minimum/maximum range; anisotropy reconstructs its tangent-space direction +from centered red/green and scales strength by blue, matching glTF. Each +factor/color lobe owns a separate, independently lazy 64-byte-per-instance +transform sidecar. Clearcoat normals use a separate 48-byte-per-instance +sidecar, so adding normal transport does not grow the established clearcoat +factor/roughness record. None of the texture paths change the 96-byte scalar +ABI or scalar-only bind groups. + +Resolved UV1 textures additionally use an aligned storage sidecar that is +backfilled from retained or skinned CPU geometry only when a qualified texture +actually selects `TEXCOORD_1`. Missing streams remain explicitly unqualified +instead of receiving an incorrect scalar approximation. On adapters without +PT texture arrays, a clearcoat normal is ignored while its scalar clearcoat +factor/roughness remains active; the entire lobe is never silently dropped. + +## Runtime material-record ABI + +The renderer reserves layered-material identity without growing either +existing material record: + +- the 176-byte global storage record packs an 8-bit version in bits 24–31 of + `header.y` and a 24-bit lobe mask in bits 0–23; +- the 80-byte bound/custom uniform stores the exact `u32` version and mask bit + patterns in `foliage_params.zw` with `bitcast`, not numeric conversion; +- version 1 assigns mask bits 0–5 to clearcoat, sheen, anisotropy, + iridescence, specular/IOR, and transmission respectively; +- every existing material is version 1 with mask zero. + +Version-zero global records are the pre-layered layout. Allocation and update +normalize them to version 1 with an empty mask, so stale data from the old +flags lane cannot activate a future lobe. Tier C still binds the existing ABI +v3 group layout. Foliage setters modify only `foliage_params.xy`, preserving +the metadata lanes. + +The WGSL headers expose named version/mask accessors but no production shader +calls them in the ABI foundation. Consequently the base-only path adds no +shader read, branch, bind-group entry, graph pass, image, allocation, or +material-record byte. Quality telemetry reports the two record sizes, both +default masks, the active-lobe material count, and all four zero-cost +invariants. + +## Imported material contract + +`MaterialLayeredPbr` preserves the complete scalar and source-texture contract +for `KHR_materials_clearcoat`, `KHR_materials_specular`, +`KHR_materials_ior`, `KHR_materials_sheen`, and +`KHR_materials_anisotropy`: + +- authored/default identity, factors, clearcoat roughness, clearcoat normal + scale, specular color, IOR (including zero), sheen color/roughness, and + anisotropy strength/rotation; +- source texture and image indices plus an optional resolved runtime texture; +- `KHR_texture_transform` offset, rotation, scale, and effective UV set for + each texture; +- lazy `TEXCOORD_1` retention only when a contributing physical texture + requests it. + +Plain CPU, staged, and runtime glTF/GLB loaders all produce the same material +metadata. Invalid ranges are rejected with the asset material name rather than +clamped silently. The import record is propagated to `PbrMaterial`; it remains +data-only until the corresponding specialized renderer path is present. + +## Runtime and compatibility boundary + +Clearcoat, specular/IOR, sheen, and anisotropy are live only in lazy scene +specializations. They cover retained and cached opaque geometry, depth- +prepassed opaque geometry, sorted BLEND, TAA-reactive BLEND, weighted OIT, and +combined physical transmission. Scalar, textured UV0/UV1, double-sided, +clustered-light, virtual-shadow, folded scene-input, and native reflection +variants share the same injected source. + +All ten layered textures share one sampler. The sheen directional-albedo LUT +is allocated only after the first contributing sheen material and consumes +32,768 persistent bytes. It adds no render-graph pass or transient image. +Base-only materials retain the exact established shader, bind-group layout, +GPU-driven record, and pipeline selection; they do not load or branch on the +layered ABI and do not allocate the LUT. + +The CPU scene tracer and GPU path tracer now share the version-1 base equations. +GPU PT also has the isolated transport foundation for the remaining lobes: +`InstanceGiData` is unchanged, while the first contributing layered TLAS +instance lazily backfills a parallel 96-byte-per-instance scalar record. +Only a frame that both enables PT and contains one of those records compiles +the group-2 pipeline and allocates/binds its storage buffer. Base-only scenes +retain the established shader source, pipeline, bindings, instance record, and +GPU cost. Qualified specular, clearcoat, sheen, iridescence, and anisotropy +textures independently backfill separate 64-byte texture-transform records +and select independent pipeline/resource bits. Clearcoat normals independently +backfill a 48-byte transform/scale record at group 2 binding 8 and carry a +separate pipeline/resource bit; factor-only clearcoat retains its exact +64-byte record and allocates zero normal bytes. Textured sheen reuses the +scalar path's lazily allocated directional-albedo LUT. UV1 adds a separate +8-byte-per-vertex stream and its own pipeline/layout bit only on +texture-array-capable adapters; no texture record, binding, UV interpolation, +or texture fetch exists in the base pipeline or scalar-only resource layouts. +Array-incapable adapters also skip building the unreachable CPU texture-record +vectors. + +Metal ray-query qualification renders the same seeded scene through base, +clearcoat, specular/IOR, sheen, anisotropy, iridescence, and combined scalar +pipelines. It requires byte-identical output for an unqualified textured lobe, +visible bounded-energy responses for every qualified lobe, a distinct highlight +after a 90-degree anisotropy rotation on explicit vertex tangents, and a +spectral image change between 180 nm and 520 nm films. Telemetry verifies that +sheen, anisotropy, iridescence, and texture code variants remain independently +lazy. The texture corpus additionally requires white UV0 specular, clearcoat, +sheen, and iridescence textures to be byte-identical to their scalar +transport, and the closest UNORM8 +X anisotropy direction to stay within the +documented quantization tolerance. Varying factor/color/roughness/thickness +and anisotropy direction/strength textures must produce visible bounded +responses, 90-degree texture transforms must turn those responses, and UV1 +must select independently retained coordinates on capable adapters. Adapters +without PT texture arrays retain scalar-lobe output and allocate no texture or +UV1 storage. Clearcoat-normal qualification additionally requires a flat +zero-scale normal to be byte-identical to scalar clearcoat, a directional map +to create a bounded visible response, and transformed UV0/UV1 maps to rotate +that response. Telemetry pins the normal record at 48 bytes and proves zero +normal allocation for base, scalar, and factor/roughness-only materials. + +The realtime motion corpus minifies alternating two-texel tangent-space coat +normals across a slowly moving camera. A matching flat-normal run removes +ordinary edge and lighting motion before the texture-specific residual is +measured. On Apple M1 Max / Metal, camera motion measured `2.432037` mean RGB, +the filtered coat response measured `0.205539`, the worst adjacent-frame +residual was `0.532235`, and no sampled channel crossed the coherent-outlier +threshold. The hard gates remain `1.5` mean residual and 2% outlier channels. + +## Public authoring API + +`setSceneNodeMaterial(node, descriptor)` is the single public authoring entry +point for base, emissive, clearcoat, specular/IOR, sheen, anisotropy, and +iridescence factors. Every field is optional. An empty descriptor restores the +same base defaults as a new scene node, while an omitted layered lobe is absent +from the native lobe mask and keeps the allocation-free base-material path. + +```ts +setSceneNodeMaterial(node, { + color: [210, 68, 32], + roughness: 0.22, + metalness: 0, + layered: { + clearcoat: { factor: 0.8, roughness: 0.1 }, + iridescence: { + factor: 0.65, + ior: 1.3, + thicknessMinimum: 120, + thicknessMaximum: 380, + }, + }, +}); +``` + +Base color follows Bloom's engine-wide 0–255 convention. Layered colors are +linear factors, anisotropy rotation is in radians, and iridescence thickness is +in nanometres. Unsafe scalar input is bounded before it reaches a shader, and +reapplying an unchanged descriptor does not rebuild the material. Layer +textures and texture transforms remain glTF-authored in this first API version; +the importer continues to preserve and render their full UV contract. diff --git a/docs/masked-alpha-coverage.md b/docs/masked-alpha-coverage.md new file mode 100644 index 00000000..87bc2e25 --- /dev/null +++ b/docs/masked-alpha-coverage.md @@ -0,0 +1,97 @@ +# Masked alpha coverage + +Imported glTF materials with `alphaMode: MASK` preserve their authored +silhouette as the base-color texture minifies. This is a material-specific +import path: OPAQUE, BLEND, normal-map, and standalone texture mip chains keep +their established bytes and runtime behavior. + +## Import contract + +For each MASK material that has a base-color texture, Bloom computes the +cutoff in texture-alpha space: + +```text +coverage reference = alphaCutoff / baseColorFactor.a +``` + +The importer registers a cutoff-specific texture variant. This matters when +multiple MASK materials share one source image but use different cutoffs. +Level zero remains byte-for-byte identical to the decoded source. Lower levels +store: + +- alpha: the fraction of source texels that survive the effective cutoff; +- RGB: the linear-light, coverage-weighted mean of surviving texels. + +Reduction integrates the exact source footprint, including odd rows and +columns in non-power-of-two images. Visible colors dilate into empty texels +after reduction, so bilinear filtering cannot pull transparent border colors +into foliage and fence silhouettes. References above one intentionally yield +zero lower-mip coverage, matching a base-color factor that can never reach the +material cutoff. + +Bloom also follows glTF's multiplication rule for this path: `COLOR_0` +multiplies `baseColorFactor`; it does not replace it. That keeps imported +level-zero alpha and the factor used to derive the coverage reference in +agreement. + +Only imported MASK base-color textures allocate variants, and only when the +target supports lower mips. An ordinary copy of a shared image remains +available when OPAQUE, BLEND, or another texture semantic uses it. A MASK-only +image aliases its first coverage variant instead of retaining an unreachable +ordinary chain. The storage cost is therefore one chain per unique +`(image, effective cutoff)` pair, minus the ordinary chain it replaces for the +common single-cutoff MASK-only case; it adds no render pass or ordinary frame +allocation. + +## Raster contract + +At magnification and near level zero, scene color, depth prepass, velocity, +and shadow passes retain the exact authored alpha test. At minification they +interpret lower-mip alpha as coverage probability and compare it against the +same deterministic 4×4 Bayer threshold. A short transition between level zero +and the first coverage mip avoids a discrete LOD pop. + +The main and depth-prepass paths use the same texture LOD bias. Shadow cutouts +use the same coverage rule and include vertex alpha, so the caster silhouette +matches the visible geometry. Screen-space stability is handled by the +existing TAA and velocity paths; masked rendering does not introduce a new +history or full-screen pass. + +Bloom's current scene, depth, velocity, and shadow targets are single-sample. +Hardware alpha-to-coverage therefore is not available and is not emulated or +reported as supported. The coverage-mip/Bayer path is the explicit +single-sample fallback. Quality telemetry records: + +- the number of registered coverage-mip variants; +- whether coverage mips are supported on the target; +- render sample count and alpha-to-coverage support; +- the active single-sample fallback name. + +## Platform and authoring boundaries + +Android intentionally retains its established one-mip upload path because +multi-mip color uploads are not yet qualified there. It receives the exact +hard-cutout path and does not claim coverage-mip support. + +For same-binary qualification, `BLOOM_MASK_COVERAGE=0` disables coverage +variant creation at import time and routes MASK materials through the +established ordinary mip chain. This is a diagnostic A/B control, not the +default; `off`, `false`, and `disabled` are accepted aliases. The check runs +only while importing glTF data and adds no normal-frame work. + +Coverage variants bake the imported `baseColorFactor.a` and cutoff. Per-vertex +alpha and runtime draw tint remain exact at level zero but cannot be baked into +the shared lower mip chain. Distant coverage is therefore based on the +imported material and texture, which is the stable authoring contract. Assets +that require independently animated opacity should use BLEND rather than +MASK. + +## Qualification + +The `masked-alpha-coverage` quality case renders 48 imported MASK cards over a +shadow receiver across six projected mip ranges. It keeps fixed-step object +motion, TAA, and cascaded shadows active, captures HDR/depth/cascade +intermediates, and requires masked-path telemetry. A real-GPU negative control +compares the coverage variant with the ordinary box-filtered mip chain and +asserts that the variant retains the authored 75% silhouette area instead of +filling the card. diff --git a/docs/material-texture-indirection.md b/docs/material-texture-indirection.md new file mode 100644 index 00000000..875a2e32 --- /dev/null +++ b/docs/material-texture-indirection.md @@ -0,0 +1,151 @@ +# Capability-tiered material and texture indirection + +Issue #130 adds a stable resource-addressing layer without changing the +existing material pixels. Scene and future GPU-driven passes use typed IDs; +the backend selected from the actual `wgpu::Device` decides how those IDs map +to resources. + +## Tiers + +| Tier | Selection | Resource binding | Draw switching | +|------|-----------|------------------|----------------| +| A | `TEXTURE_BINDING_ARRAY`, non-uniform indexing, and non-zero texture/sampler binding-array limits | One persistent material storage buffer, texture descriptor array, sampler descriptor array, and generation table | No per-material bind-group creation or switch in the GPU-driven opaque path | +| B | At least 16 texture-array layers and 8 sampled textures per stage | Deterministic first-fit pages backed by texture arrays/atlases | Stable-sort by page; switches are bounded by populated page count | +| C | Everything else, or a forced compatibility override | Existing per-material bind groups and ABI v3 | Existing behavior | + +The selection uses `device.features()` and `device.limits()`. It never infers a +tier from an operating system name. Native device-creation paths request Tier +A features only when the adapter advertises the complete feature pair. WebGPU +and downlevel mobile adapters therefore select B or C naturally. + +`BLOOM_MATERIAL_TIER=A|B|C` is a startup diagnostic override. An override can +lower the detected tier but cannot manufacture unsupported features. At +runtime, `setMaterialBindingTierOverride("auto" | "A" | "B" | "C")` follows +the same rule and reports rejection with `false`. + +## Stable IDs and lifetime + +`MaterialId`, `TextureId`, `SamplerId`, `MeshId`, and `BufferViewId` are typed +32-bit values: + +- bits 0–19: one-based descriptor slot; +- bits 20–31: generation; +- zero: diagnostic fallback. + +Retiring a resource immediately makes its ID non-resident, but the owned GPU +resource remains alive until `Queue::on_submitted_work_done` advances the +completion epoch. Only then is the slot reclaimed and its generation bumped. +A stale ID therefore cannot resolve to a later resource that reused the same +slot. + +Tier A mirrors texture and sampler generations in a GPU storage buffer. WGSL +checks the generation before descriptor lookup and redirects a mismatch to +descriptor zero. Record zero is a white, rough, non-metallic, non-emissive +fallback; missing normals return tangent-space +Z. This check is required: +CPU-only generational handles do not protect a shader after a descriptor slot +is reused. + +All allocation failures are deterministic. They return ID zero, increment the +`limit_fallbacks` counter, and emit an actionable diagnostic rather than +sampling an unrelated resource. + +## GPU material record + +`GpuMaterialRecord` is 16-byte aligned and contains: + +- generation, layered-PBR version/lobe mask, and user-parameter range; +- base color, metallic/roughness, emissive, shading-model, and foliage data; +- typed texture IDs for base color, normal, MR, emissive, occlusion, + reflection, and Tier B array/page resources; +- typed sampler IDs. + +The record remains exactly 176 bytes. `header.y` uses its high eight bits for +the layered-PBR material-record version and its low 24 bits for the feature +mask. Version 1 with mask zero is the base-only default; version-zero legacy +records are normalized to that default on allocation/update. The matching +Tier C/custom record remains 80 bytes and keeps exact version/mask bit +patterns in its previously reserved `foliage_params.zw` lanes. See +`docs/layered-pbr.md` for the versioned lobe assignments. + +The storage-buffer record is populated in parallel with the legacy material +state. Tier C continues binding ABI-v3 per-material groups, so introducing the +record has no render-order, shader, or pixel effect. + +The shared GPU-driven header is +`native/shared/shaders/material_indirection.wgsl`. It is embedded by +`shader_library` and intentionally separate from `material_abi.wgsl`; custom +legacy materials do not receive a surprise ABI bump. + +## Color, normal, mip, and sampler rules + +Every resident texture records color space, semantic, mip count, and whether +the view format performs hardware sRGB decoding. + +- sRGB content in a linear view is decoded by the WGSL lookup helper. +- sRGB views are not decoded twice. +- normal and metallic/roughness resources are linear. +- a missing/stale normal returns `(0, 0, 1)`. +- HDR resources are tagged linear. +- texture views keep their full mip chain; sampler IDs select filtering + independently. + +Tier B pages group IDs; they do not reinterpret texels. Existing texture-array +creation remains the upload oracle for sRGB/linear formats, mips, and filtering. + +## Tier B planning contract + +`build_tier_b_dispatch_plan` accepts draws with stable material and texture +IDs plus the adapter page capacity. + +1. Duplicate and fallback texture IDs are removed. +2. Materials are placed by deterministic first fit. +3. A material whose unique set exceeds one page is marked for Tier C fallback. +4. Draws are stable-sorted by `(page, original_submission_index)`. +5. `page_switches <= populated_pages`. + +The stress test covers 4,096 textures and 10,000 visible draws at 256 entries +per page: 16 pages, 16 switches, no fallback, and identical plans across runs. + +## TypeScript diagnostics + +`getMaterialBindingCapabilities()` returns a read-only report containing: + +- detected, selected, and overridden tier; +- required feature booleans; +- raw adapter limits and effective capacities; +- current resident counts; +- stale/limit fallback counters; +- an actionable diagnostic when an override is rejected. + +The same object is embedded under `runtime_paths.material_binding` in quality +telemetry. + +## Integration for #27 and #28 + +GPU-driven consumers should: + +1. Store `MaterialId` in instance/visibility records, not a bind group. +2. Store `TextureId`/`SamplerId` in `GpuMaterialRecord`. +3. Select the dispatch plan from `selected_tier`. +4. Bind Tier A's `global_layout`/`global_bind_group` once, or bind each Tier B + page once per grouped run. +5. Route Tier B overflow and every Tier C draw through the existing material + dispatcher. +6. Never cache a descriptor index without its generation-bearing typed ID. + +The registration and delayed-retirement methods are the streaming hooks; +virtual texture streaming itself remains out of scope. + +## Qualification + +Focused coverage includes: + +- generation churn, double retirement, delayed reclamation, and fallback; +- feature/limit selection and unsupported upward overrides; +- deterministic Tier B paging; +- 4,096-texture/10,000-draw stress; +- a real Tier A GPU bind group with 4,096 resident descriptors; +- sRGB decode and stale-ID rejection after descriptor-slot reuse; +- existing material texture-array GPU tests; +- forced Tier C visual/performance comparison through the standard quality + corpus. diff --git a/docs/perf/009-gpu-driven-rendering.md b/docs/perf/009-gpu-driven-rendering.md index 2f662547..8d620aec 100644 --- a/docs/perf/009-gpu-driven-rendering.md +++ b/docs/perf/009-gpu-driven-rendering.md @@ -1,6 +1,6 @@ # 009 — Indirect multi-draw for scene graph -**Effort:** ~1 week · **Expected gain:** Removes 68 CPU draw calls, enables GPU-side cull · **Status:** deferred +**Effort:** ~1 week · **Expected gain:** Removes CPU draw loops, enables GPU-side cull · **Status:** landed (2026-07-23) ## Problem @@ -15,7 +15,7 @@ per-draw overhead, but we still have 340 `set_bind_group` calls. GPU-driven rendering collapses this to **one `draw_indirect_count` call** — the GPU does the culling and dispatches its own draws. -## Proposed approach +## Landed approach 1. **One shared vertex buffer + one shared index buffer** for all scene geometry. On mesh upload, append vertices/indices into the shared buffers @@ -25,17 +25,47 @@ the culling and dispatches its own draws. vertex_offset }`. Updated from the scene graph in `prepare()`. 3. **GPU cull compute pass**: dispatch one thread per mesh. Each thread tests its mesh's AABB against the frustum (using the same - `extract_frustum_planes` logic we use on the CPU today). Surviving draws - append to an indirect-draw buffer via an atomic counter. -4. **Single `draw_indexed_indirect_count`** call in the scene render pass. - GPU reads the indirect buffer, dispatches each surviving mesh. + `extract_frustum_planes` logic we use on the CPU today). Commands retain + deterministic scene order; culled slots get `instance_count = 0`. +4. **Single indexed multi-draw-indirect call** in each depth/main render + pass. Metal uses fixed-count `multi_draw_indexed_indirect`; adapters with + `MULTI_DRAW_INDIRECT_COUNT` use the count variant. 5. **Material data** lives in a storage buffer indexed by `material_idx`, fetched per-draw in the vertex or fragment shader. -wgpu 24 supports `draw_indexed_indirect` and `draw_indexed_indirect_count` +wgpu 29 supports indexed multi-draw and indirect-count submission via the `Features::INDIRECT_FIRST_INSTANCE` and `MULTI_DRAW_INDIRECT_COUNT` feature flags. Check adapter support at device creation. +The fast path requires Tier-A global material indirection and at least 32 +eligible draws. Smaller scenes keep the lower-overhead CPU loop. Skinned, +active-LOD, and order-sensitive retained alpha scenes also stay on the +compatibility path. The `BLOOM_GPU_DRIVEN=0` environment override remains an +explicit qualification oracle. + +## Qualification result + +Apple M1 Max / Metal, fixed 1,280×720 quality captures: + +- 10,240-draw stress: one indirect call, 10,180 visible and 60 culled; +- stress CPU frame mean: 75.79 ms compatibility → 15.83 ms GPU-driven; +- stress GPU frame mean: 16.31 ms → 10.41 ms; +- stress `main_hdr_pass` CPU: 2.324 ms → 0.182 ms; +- Sponza `main_hdr_pass` CPU: 0.014 ms, below the 0.100 ms target; +- final, HDR, depth, and three shadow captures for the stress case are + byte-identical to the compatibility path; +- full seven-case corpus minimum SSIM is 0.999908 across 42 + final/intermediate comparisons; every shadow capture is exact; +- PBR spheres exercise 25 distinct materials; the 10k stress exercises 12 + shared material records with per-draw tint. +- `cargo check` passes for shared, macOS, Linux, and the actual iOS, tvOS, + visionOS, and WebAssembly targets. Android and Windows cross-checks reach + their platform C dependencies but require the NDK/MSVC SDKs, which are not + installed on the qualification host. + +Run artifacts are written under `tools/quality/out/issue-28-*` and are ignored +by git. + ## References - "GPU-Driven Rendering" (Haar & Aaltonen, SIGGRAPH 2015) — the @@ -46,15 +76,13 @@ feature flags. Check adapter support at device creation. ## Acceptance -- Sponza main_hdr pass CPU time drops from ~700 µs to < 100 µs (measured via - profiler's `main_hdr_pass` CPU phase). -- Frustum culling ratio (surviving draws / total meshes) logged per frame and - reasonable (e.g. 30-70% culled on typical Sponza camera poses). -- Correctness: SSIM ≥ 0.99 vs baseline. -- Doesn't break on meshes that use different materials (material index is - part of the descriptor). -- Graceful fallback when the adapter doesn't support multi-draw-indirect - (write a TODO to handle — M1 Metal supports it). +- [x] Sponza `main_hdr_pass` CPU time is < 100 µs. +- [x] Submitted, compatibility, visible, culled, and cull-ratio telemetry is + emitted in `renderer_paths.gpu_driven`. +- [x] Correctness SSIM is ≥ 0.99 (observed corpus minimum: 0.999908). +- [x] Heterogeneous material IDs are part of each draw descriptor. +- [x] Unsupported adapters and non-profitable/unsafe draw classes fall back + without changing the compatibility renderer. ## Notes for the implementer @@ -73,7 +101,7 @@ feature flags. Check adapter support at device creation. cull compute shader, new render pass using `draw_indexed_indirect_count`. - `native/shared/src/scene.rs` — reworking of per-node GPU resources. -## Deferred — reopen criteria +## Historical deferred rationale Pure CPU-side optimization: removes ~340 CPU draw calls/frame on Sponza. But the perf README's own rule of thumb applies — **Sponza is diff --git a/docs/perf/014-lumen-mesh-sdfs.md b/docs/perf/014-lumen-mesh-sdfs.md index 1f8368d1..7506e65c 100644 --- a/docs/perf/014-lumen-mesh-sdfs.md +++ b/docs/perf/014-lumen-mesh-sdfs.md @@ -196,9 +196,13 @@ access will build cleanly; CI would need host-matching runners. - **GPU jump-flood SDF bake** — V1's brute-force point-triangle works at current mesh sizes; jump-flood would matter if meshes scale up significantly. -- **True octahedral corner wrap (4 corners of each probe)** — V11 - octahedrally wraps edges but keeps corners as edge-extend. Sampler - bilinear weights at corners are small so visual impact is limited. +- ~~**True octahedral corner wrap (4 corners of each probe)**~~ — landed + under #23. Software and hardware WSRC bakes share one mapping for the + padded 10x10 slab; corners double-fold to `(7,7)`, `(7,0)`, `(0,7)`, + and `(0,0)`. A live-GPU readback verifies every corner exactly matches + its wrapped interior texel. The dispatch, ray count, bindings, textures, + and steady-state work are unchanged. On Apple M1 Max / Metal, ten + interleaved 120-bake runs measured 75.678 us before and 69.493 us after. - **Importance-sampled WSRC rebake cadence** — waiting on ticket 016 (importance sampling) which provides the per-direction resolution hints needed to refresh hot octels more often than diff --git a/docs/perf/README.md b/docs/perf/README.md index b8890452..403bfcc5 100644 --- a/docs/perf/README.md +++ b/docs/perf/README.md @@ -111,7 +111,7 @@ Ordered roughly by ROI / effort. | [005](005-depth-prepass.md) | Depth prepass for main HDR pass | ~1 day | main_hdr 17 ms → ~8 ms | landed — the 2026-04-21 prototype failed (Findings in the ticket), then the design was revived as EN-044: alpha-cutout-aware `bloom_depth_prepass` in `renderer/scene_pass.rs` + `scene_pipeline_prepassed` main pass | | [006](006-shadow-pass-culling.md) | Frustum cull casters in shadow pass | ~0.5 day | Shadow pass 14 → 7-10 ms | landed (per-cascade ortho-frustum cull against a world-AABB cached on SceneNode; shadow_pass GPU 3.1 → 2.0 ms on the panning-cache-miss path = **-34%**, CPU 648 → 489 µs = **-24%**. FPS at `--quality 2` 50.3 → 52.8 = +5%. Absolute headroom capped by 95da6af's earlier 2048 → 1024 cascade-map cut — proportional shape matches the ticket's intent. Sun-behind-camera pose (`--yaw π`) diffs within TAA noise floor — shadows of off-screen casters still land on-screen correctly) | | [008](008-visibility-buffer.md) | Visibility buffer replaces 4-MRT G-buffer | ~2 weeks | 75% less bandwidth | deferred — real GPU bandwidth win but invisible behind the vsync cap on Sponza. Reopen when a target scene pushes past 16.7 ms, when integrated / mobile GPUs become the priority, or for overdraw-heavy scenes (foliage, hair, dense transparents). Depth-prepass (005) becomes prerequisite-useful again at that point; ticket 009's unified vertex buffers are a hard prerequisite | -| [009](009-gpu-driven-rendering.md) | Indirect multi-draw for scene graph | ~1 week | Removes CPU draw loop | deferred — pure CPU optimization on a GPU-bound benchmark. Render-total CPU is already ~4 ms against a 16.7 ms vsync budget; shaving another ~600 µs via draw-call collapsing won't move FPS on Sponza. Reopen when a CPU-bound scene arrives (10 000+ meshes), when ticket 008 starts (hard prerequisite for its shading pass), or when bindless texture support lands in wgpu | +| [009](009-gpu-driven-rendering.md) | Indirect multi-draw for scene graph | ~1 week | 10k stress CPU 75.79→15.83 ms; GPU 16.31→10.41 ms; exact pixels | landed | | [010](010-async-compute.md) | Overlap post-FX on compute queue | ~3 days (see ticket) | Hides ~20% of post-FX | deferred — blocked on wgpu 29's lack of a public multi-queue API. Actual paths to land this are (a) wgpu-hal drop for the compute queue (2-3 weeks, loses wgpu-core safety for every post-FX resource), (b) native Metal / DX12 / Vulkan per-platform (3+ weeks, three impls), or (c) wait for wgpu upstream. Estimated gain is ~1.3 ms of the 16.7 ms vsync budget on Sponza; not worth the redesign until a scene pushes past vsync. Parked in the ticket | | [011](011-cross-platform-ffi.md) | Port quality/profiler FFI to iOS/Win/Lin/Android/tvOS/web | ~1 day | Unblocks non-macOS use | landed. Full FFI block (`bloom_set_quality_preset`, `bloom_set_shadows_enabled`, `bloom_set_shadows_always_fresh`, `bloom_set_bloom_enabled`, `bloom_set_ssao_enabled`, `bloom_set_ssr_enabled`, `bloom_set_motion_blur_enabled`, `bloom_set_sss_enabled`, `bloom_set_profiler_enabled`, `bloom_get_profiler_frame_cpu_us`, `bloom_get_profiler_frame_gpu_us`, `bloom_print_profiler_summary`) appended to ios / tvos / windows / linux with matching `#[no_mangle] extern "C"` signatures; web got `#[wasm_bindgen]` variants with `console_log` for the summary printer. Android's prior no-op stubs were replaced with real impls and a `__android_log_print`-backed summary. TIMESTAMP_QUERY conditional feature request mirrored into every platform's `request_device` so the profiler GPU path works wherever the adapter grants it (Metal typically won't, DX12 typically will, Android GPUs mostly won't, web doesn't support it yet). `cargo check` clean on macOS / iOS / tvOS / web from the macOS host; Windows / Linux / Android need native toolchains for their C-dep build scripts (same caveat as 014 V15) | | [012](012-remaining-bind-group-caches.md) | Cache the 5 remaining per-frame `create_bind_group` calls | ~0.5 day | ~15-30 µs CPU | deferred — prototype landed a black-window regression (likely a state-key gap that missed a per-frame input variance); the 15-30 µs CPU savings aren't worth the correctness risk on a GPU-bound benchmark. Re-open if a future scene becomes CPU-bound on bind-group rebuilds. See ticket for prototype notes | diff --git a/docs/pt/PT-6-7-8-skinned-tlas-motion-oracle.md b/docs/pt/PT-6-7-8-skinned-tlas-motion-oracle.md index 18c4ed57..e562f73c 100644 --- a/docs/pt/PT-6-7-8-skinned-tlas-motion-oracle.md +++ b/docs/pt/PT-6-7-8-skinned-tlas-motion-oracle.md @@ -65,9 +65,12 @@ untracked local copies next to the crate, see .gitignore): prev_vp-transpose class that survived three human review rounds) floods the image with unconverged speckle, far past tolerance. -`BLOOM_UPDATE_GOLDEN=1 cargo test golden` regenerates; the strict -outlier gate stays global so looser mean tolerances cannot hide a -broken region. +`BLOOM_UPDATE_GOLDEN=1 cargo test golden` regenerates. PT golden +updates are forbidden while a negative-control fault is active. The +comparison reports mean RGBA/RGB error, maximum channel error, SSIM, +and the fraction of *pixels* (not channels) whose RGB error exceeds +32/255. The 1% structural-outlier gate stays strict so a permissive +mean cannot hide a broken region. **The oracle caught a real bug before it was even committed**: the kernel seeded its RNG from `taa_frame_index`, which freezes when TAA @@ -76,7 +79,177 @@ silently never converged (300 frames = the same image as 1). A player disabling TAA in settings would have hit exactly this in-game. PT now keeps its own rolling `pt_frame_index`. -The bloom-reference RMSE parity run remains a manual protocol (see -PT-5 doc): number-parity on the game scene is now *closer* (skinned -meshes are in the TLAS) but cutout foliage and card-resolution albedo -still make it a human-judged comparison, not a gate. +### Deterministic hardware protocol + +The PT tests make every stochastic and temporal input explicit: + +- seed `0`, sample sequence start `0`, camera frame start `0`; +- TAA/jitter disabled and exposure fixed at `1.0`; +- a new renderer, scene, accumulation history, moments history, and + reservoir history for every repeat; +- one cached `wgpu::Device` per test process, avoiding repeated + headless Metal device teardown without sharing renderer resources. + +Run the complete same-adapter stability gate and both negative controls with +one device kept alive for the entire qualification: + +```shell +cd native/shared +cargo test --release --test golden_render \ + qualify_pt_oracle_hardware -- --ignored --exact --nocapture +``` + +`BLOOM_REQUIRE_RAY_QUERY=1` converts an unsupported/incorrectly +packaged adapter into a test failure. Without it, a genuine lack of a +non-CPU ray-query adapter emits a structured JSON skip. A ray-query +adapter that fails device creation always fails; it never silently +skips. Each repeat uses fresh renderer state while the device remains +alive, and the default repeat count remains one so ordinary test cost +does not increase. + +Expected cross-backend differences are limited to low-amplitude +floating-point intersection, interpolation, texture-sampling, and +denoiser rounding near edges. Black regions, block trails, non-finite +values, uninitialized history, broad energy shifts, and coherent +motion trails are never backend variance. Do not widen tolerances to +accept them. + +On failure, `native/shared/target/golden-artifacts//` contains +`expected.png`, `actual.png`, `absolute-diff.png`, `heatmap.png`, and +`metrics.json`. The JSON records the commit, OS/architecture, adapter, +backend, driver, supported/enabled features, seed, sequence starts, +repeat index, frames/spp, measured render wall time, fault control, and all +comparison metrics. +Set `BLOOM_GOLDEN_DIAGNOSTICS=1` for named captures: + +- progressive: accumulated output, pipeline write probe, depth, + normal, albedo, sun visibility, primary-ray agreement, raw radiance; +- realtime: denoised output, motion, raw radiance, history length, + variance. + +The query-heavy primary-ray probes (debug views 6–19) are +source-stripped from normal production shaders. They create ten extra +inline ray-query objects per thread and are compiled only when those +views or golden diagnostics are explicitly requested. This is +pixel-neutral for production while reducing Metal kernel state. + +#### 2026-07-22/23 Metal root-cause audit and correction + +On the audited dirty worktree based on commit `e498433`, macOS 26.5 and +an Apple M1 Max Metal ray-query adapter reproduced the issue. One +recorded progressive run had mean RGBA error `23.404968262`, mean RGB +error `31.206624349`, `58.0291748%` outlier pixels, maximum error `204`, +and SSIM `0.758563206`. The realtime test showed the same failure class +as localized dark block trails under motion. + +A `git archive` of clean `e498433` was then run against the same adapter. +Its progressive output reproduced the historical PNG byte-for-byte, but +required `167.10 s` for 300 frames. Clean-main realtime still had not +completed after five minutes and was terminated to avoid further GPU +stress, so no clean-main realtime visual pass is claimed. This classifies +the original progressive baseline as visually stable but the Metal query +path as pathologically slow; the broad block corruption depended on the +newer renderer state in the audited worktree rather than on clean main +alone. The backend loop was nevertheless the first bad stage in that +state and a severe performance defect on both clean and dirty trees. + +Named captures located the first divergent stage before accumulation: +the pipeline write, depth, normals, and albedo were clean, while sun +visibility was almost entirely black and raw radiance already contained +workgroup-shaped holes. The cause was not stochastic tolerance, history, +resource lifetime, or the diagnostic-query count. Naga 29.0.1 lowers a +Metal ray query differently from DX12/Vulkan: + +1. `rayQueryInitialize` emits the complete synchronous + `intersector.intersect(...)` and sets `ready = true`. +2. On the non-modern Metal path, `rayQueryProceed` only reads `ready`; it + neither advances the query nor clears the flag. +3. The canonical WGSL `while (rayQueryProceed(...))` loop therefore does + not terminate, even though the committed intersection is already ready. + +All PT, HW-SSGI, and HW-WSRC query loops are now backend-specialized. +Metal compiles a constant-false proceed branch and reads the committed +intersection immediately; DX12/Vulkan compile the original proceed loop. +Generated Metal 2.4 source contains the synchronous intersection behind a +constant-false guard, while generated HLSL shader model 6.0 retains +`RayQuery.Proceed()`. The same WGSL sources validate through Naga for both +variants. This is a strict Metal correctness and performance improvement +and leaves non-Metal query semantics unchanged. + +The first corrected visibility probe exposed a second, independent error: +PT negated the legacy primary-light vector even though the raster shader +and public setter consume it as the vector from the shading point toward +the sun. The PT upload and golden scene now use that convention directly, +matching the documented CPU command (`0.5 1.0 0.3`). The old checked-in +goldens were flat, sunless images; the corrected baselines are brighter and +contain coherent cast shadows whose direction and silhouettes agree with +the 256-spp CPU reference. They were updated only after the raw-radiance, +visibility, accumulated-output, and CPU-reference evidence was inspected. + +Source-stripping debug views 6–19 remains worthwhile: the normal production +kernel has two ray-query objects instead of twelve. It reduces Metal kernel +state and compile cost, but the audit no longer presents it as the root-cause +fix. + +On 2026-07-23 the corrected unified Apple M1 Max / Metal qualification passed all +three progressive and all three realtime repeats with byte-identical output +(`mean/max/outliers = 0`, `SSIM = 1.0`). Across two qualification runs under +different concurrent system load, measured render wall times (including +command submission and the final readback) were `851–3537 ms` for 300 +progressive frames and `187–370 ms` for 48 realtime frames; the complete +six-run plus two-fault qualification took `7.58–23.16 s`. The previous invalid-loop path was +dramatically slower and became visually corrupt with the audited renderer +changes. These wall times are recorded in +failure JSON and pass logs as a local regression signal; issue #128 owns +uncapped per-pass GPU timestamp baselines. A DX12/Vulkan hardware result is +still required before cross-backend qualification is complete. + +The negative controls must fail before a hardware result is accepted: + +```shell +cd native/shared +BLOOM_REQUIRE_RAY_QUERY=1 BLOOM_PT_TEST_FAULT=brdf-energy \ + cargo test --release golden_pt_progressive -- --nocapture +BLOOM_REQUIRE_RAY_QUERY=1 BLOOM_PT_TEST_FAULT=reprojection \ + cargo test --release golden_pt_realtime_motion -- --nocapture +``` + +The first scales path radiance by 25%. The second shifts temporal +reprojection, deliberately bypasses the depth guard, and trusts the +misaddressed history over fresh radiance—the catastrophic cross-surface +history class the motion oracle must reject. Production uses zero offset, +keeps validation enabled, and retains the current sample; those constants +compile away. A successful negative-control test command is therefore an +error: each command above is expected to exit non-zero with golden +artifacts. The unified qualification verifies both failures automatically. + +### CPU sanity oracle + +`bloom-reference` has a procedural `pt-golden` scene with the exact +floor/cube geometry, materials, camera, sun, and deterministic seed: + +```shell +cd tools/bloom-reference +cargo run --release -- \ + --builtin pt-golden \ + --out ../../native/shared/target/golden-artifacts/pt-reference.png \ + --metadata ../../native/shared/target/golden-artifacts/pt-reference.json \ + --width 256 --height 256 --spp 256 --bounces 8 --seed 0 \ + --camera 5 4 7 0 0.5 0 50 \ + --sun-dir 0.5 1 0.3 --sun-intensity 1.2 +``` + +This is an energy/occlusion sanity check, not a whole-frame numeric +golden. Three intentional model/display differences dominate raw +RMSE: the CPU tracer renders the analytic environment on primary +misses while the GPU PT preserves the raster clear/sky; the CPU tracer +uses environment NEE/MIS while the GPU samples its sky on bounce +misses; and the CPU result uses its fixed ACES+sRGB output rather than +the engine HDR/post pipeline. Geometry silhouettes, direct shadows, +occlusion, material ordering, and the direction of indirect energy +must still agree. + +Metal and DX12/Vulkan hardware runners should execute the stability +gate, both negative controls, and upload `target/golden-artifacts` on +failure. Hosted jobs without a ray-query device must not be presented +as coverage; their structured skip is only an applicability result. diff --git a/docs/pt/pt-roadmap.md b/docs/pt/pt-roadmap.md index c3145ffe..6048836b 100644 --- a/docs/pt/pt-roadmap.md +++ b/docs/pt/pt-roadmap.md @@ -34,8 +34,11 @@ fixed exposure) so images are comparable number-to-number. Mode is a runtime setting (`bloom_set_path_tracing`), toggleable per frame. When PT is active, SSGI/SSR/GTAO are skipped (their output would be -overwritten; skipping banks their cost). Shadow cascades still render — mode 2 -reuses them nowhere, but the card-light pass (Tier 1 hit shading) does. +overwritten; skipping banks their cost). PT independently keeps only the +shared TLAS/geometry rebuild and raw-albedo card capture it consumes. SSGI-only +SDF, clipmap, radiance-cache, and card-light passes remain absent when SSGI is +off. Shadow cascades still render because realtime PT can use them for its +noise-free hybrid sun path. ## Tiers @@ -72,6 +75,26 @@ multi-bounce colour bleed. albedo where absent. DX12 with DXC and Vulkan both qualify. - **GGX specular lobe** with lobe selection + MIS, mirroring `bloom-reference/src/tracer.rs` so the two tracers stay comparable. +- **Layered-material sidecar**: a 96-byte scalar record and group-2 pipeline + are created only after PT encounters a qualified layered TLAS instance. + Scalar clearcoat, dielectric specular/IOR, Charlie sheen, anisotropic GGX, + and iridescence direct lighting and bounce sampling match the CPU reference's + reciprocal GGX, F0/F90, diffuse-complement, directional-albedo, + authored-tangent, visible-normal, and bounded spectral thin-film contracts, + including their combined composition. Sheen, anisotropy, and iridescence + select independently lazy, constant-folded code variants; only sheen adds + the 32 KiB LUT. The base kernel, shared GI record, bindings, and cost remain + unchanged. Resolved specular-factor/color, clearcoat-factor/roughness, + sheen-color/roughness, iridescence-factor/thickness, and + anisotropy-direction/strength textures use the existing texture array plus + separate lazy 64-byte transform records, with transformed UV0/UV1 + reconstruction at primary and bounce hits. UV1 is an aligned + 8-byte-per-vertex sidecar retained for static and skinned geometry only when + selected. Neutral scalar-channel textures are byte-identical to the + corresponding scalar path; the centered UNORM8 anisotropy direction uses an + explicit quantization tolerance. Missing UV1 streams, clearcoat normal maps, + and other unqualified texture-bearing lobes continue to use exact + established PT semantics until their complete transport is qualified. - Emissive from material data (VFX/muzzle flashes become real light in PT). ### Tier 3 — PT-3/PT-4: gameplay diff --git a/docs/quality-presets.md b/docs/quality-presets.md new file mode 100644 index 00000000..a8e9b762 --- /dev/null +++ b/docs/quality-presets.md @@ -0,0 +1,59 @@ +# Quality presets + +Bloom keeps its raylib-style individual switches, but a preset is a complete +starting policy rather than a bag of effect toggles. Resolution, temporal +reconstruction, upscale filtering, sharpening, and optional effects move +together: + +| Preset | Render scale | TAA | Upscale | Composite sharpen | Main effects | +|---|---:|---|---|---:|---| +| Off | 0.50 | Off | Bilinear | 0.00 | Base HDR/tonemap only | +| Low | 0.67 | Off | Catmull-Rom | 0.25 | Bloom | +| Medium | 0.75 | On | Catmull-Rom | 0.40 | Shadows, SSAO, bloom | +| High | 0.85 | On | Catmull-Rom | 0.45 | Medium + SSR, SSGI, subtle chromatic aberration | +| Ultra | 1.00 | On | Native | 0.50 | Full effect stack | + +`setQualityPreset()` applies the row as one operation. Call individual setters +afterward to override it: + +```typescript +import { + QualityPreset, + setQualityPreset, + setMotionBlurEnabled, + setRenderScale, +} from "@bloomengine/engine/core"; + +setQualityPreset(QualityPreset.Ultra); +setMotionBlurEnabled(false); + +// Or retain High's effect policy while choosing a custom resolution. +setQualityPreset(QualityPreset.High); +setRenderScale(0.90); +``` + +## First-run default + +A renderer that has not received a preset starts at render scale `0.75`, TAA +on, Catmull-Rom upscale, composite sharpen `0.50`, and no extra CAS pass. The +former `0.50` default shaded only one quarter of the output pixels and read as +broken image quality at ordinary window sizes. The new default shades 56.25% +of output pixels, while Off and Low preserve explicit performance tiers. + +Render resolution and anti-aliasing are independent. `setTaaEnabled()` never +changes `render_scale` and does not rebuild resolution-dependent targets. +Use `setRenderScale()` for resolution, or enable dynamic resolution when a +fixed frame-rate target matters more than a fixed scale. + +## Sharpening + +The preset table uses Bloom's existing composite-pass unsharp mask, so Medium +through Ultra do not add a render pass. The separate contrast-adaptive sharpen +pass remains opt-in through `setCasStrength()` and defaults to zero in every +preset. This avoids paying for two sharpen stages or producing double halos. + +At sub-native scale, texture mip bias, TAA sample weighting, and projection +jitter already follow the selected render extent. Catmull-Rom is used for Low, +and the TAA resolve performs temporal upscaling for Medium and High. Ultra +renders at native resolution and uses TAA only for anti-aliasing and +sub-pixel accumulation. diff --git a/docs/refractive-reflections.md b/docs/refractive-reflections.md new file mode 100644 index 00000000..a9123ac3 --- /dev/null +++ b/docs/refractive-reflections.md @@ -0,0 +1,86 @@ +# Refractive reflection sources + +Native imported physical transmission uses a bounded reflection-source +hierarchy: + +1. an explicit planar probe for glass lying on that probe's plane; +2. a glass-local screen-space ray against the immutable opaque color/depth + snapshots; +3. the prefiltered environment map. + +This path is separate from opaque SSR. The opaque SSR texture was traced from +the opaque surface behind the glass and therefore has the wrong origin and +normal for a dielectric fragment. Reusing it would produce plausible-looking +but geometrically incorrect reflections. + +## Source rules + +The planar tier reuses the first active `createPlanarReflection` capture. A +fragment accepts it only when: + +- its world position is within 0.075 world units of the probe plane; +- its unperturbed normal is aligned to that plane; and +- non-zero probe alpha contributes captured geometry; alpha zero is the + established geometry-miss marker and reveals the prefiltered environment. + +Planar captures have one mip level. Their contribution fades from full weight +at roughness 0.18 to the lower tiers at roughness 0.45, avoiding an +incorrectly sharp rough reflection. + +Glass without an applicable planar probe launches eight quadratically spaced +samples up to eight world units through the existing pre-translucency depth +snapshot. A depth-thickness confidence test rejects crossings, an eight-pixel +boundary fade suppresses screen-edge popping, and the result fades to the +environment by roughness 0.45. Misses, invalid samples, disabled SSR, and rough +surfaces all return the prefiltered environment. + +The hierarchy does not issue a per-fragment ray query. The retained TLAS does +not contain every immediate draw, and shading an arbitrary query hit would +require another material/radiance representation. Adding that work only on +screen misses would create scene-dependent performance cliffs. Hardware ray +query remains available to the bounded transparent-GI path, where its +representation and cost are explicit. + +## Allocation and fallback contract + +The native hierarchy reuses the color/depth snapshots already required by +physical refraction and any explicitly created planar probe. It adds: + +- no graph resource, pass, image, transient slot, or image byte; +- one lazy 160-byte uniform after the first physical-transmission material; +- one dedicated native group-4 layout and bind group for that material path. + +Ordinary scene materials and the folded Web/Android four-group shaders are +unchanged. Folded targets retain their prefiltered-environment reflection. +With no physical-transmission draw, the hierarchy creates no layout, uniform, +bind group, or shader variant. + +Set `BLOOM_REFRACTIVE_REFLECTIONS=0` (also `off`, `false`, or `disabled`) +before renderer creation to compile the exact prior environment-only +reflection expression and skip every hierarchy resource. Runtime +`setSsrEnabled(false)` disables the screen-space tier while leaving a matching +explicit planar probe available. + +Native quality telemetry reports `renderer_paths.refractive_reflections`, +including enable/active state, selected source order, fixed march bounds, +lazy persistent bytes, and zero graph/image cost. + +## Planar depth convention + +Planar captures use an oblique near plane. wgpu/Metal/D3D depth is +`0 <= z <= w`, so the projection's replacement z row is +`plane / dot(plane, far_corner)`. The OpenGL `[-w,w]` formula maps the plane to +`z=-w`; using it on wgpu incorrectly culls and clips valid above-plane +geometry. The projection helper and its regression test pin the native +`z=0` plane invariant. + +## Qualification route + +`examples/quality-transparency/main.ts --reflection-hierarchy` is an +unversioned focused oracle. It places smooth imported transmission on an +explicit horizontal probe and reflects a rotating Damaged Helmet. The default +versioned weighted-transparency corpus is unchanged. + +V1 deliberately selects only the first active planar probe. Glass on another +plane safely falls through to screen space/environment; automatic per-material +multi-probe selection remains future work. diff --git a/docs/renderer-capability-tiers.md b/docs/renderer-capability-tiers.md new file mode 100644 index 00000000..59c3d581 --- /dev/null +++ b/docs/renderer-capability-tiers.md @@ -0,0 +1,50 @@ +# Renderer capability tiers + +Bloom selects renderer paths from granted device features and limits, never from +an operating-system or GPU-name allowlist. `BLOOM_FORCE_RENDER_TIER` accepts +`baseline`, `modern`, or `high-end` and can force a supported lower tier for +qualification. An unsupported upward request is rejected and reported. + +The table below is validated byte-for-byte against the definitions used by the +runtime. Independent optional features remain feature-detected inside a tier: +automatic selection does not turn off GPU-driven submission or ray query merely +because an unrelated optional feature is absent. + + +| Tier | Materials | Geometry | Shadows | GI | Reflections | AA | Textures | Path tracing | Minimum contract | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| baseline | Tier C per-material bind groups | CPU direct draws | Cascaded/VSM raster paths | Software SDF, probes, and SSGI | SSR, planar, and probe fallbacks | TAA/CAS/FXAA | Per-material resident textures | Disabled when this tier is forced | Active platform profile | +| modern | Tier B deterministic paged arrays | CPU direct draws | Cascaded/VSM raster paths | Software SDF, probes, and SSGI | SSR, planar, and probe fallbacks | TAA/CAS/FXAA | Paged texture arrays/atlases | Disabled when this tier is forced | 16 texture-array layers; 8 sampled textures/stage | +| high-end | Tier A descriptor-indexed global tables | GPU indirect when supported; CPU oracle fallback | Cascaded/VSM raster paths | Ray query when supported; software SDF/SSGI fallback | Ray query when supported; SSR/planar/probe fallback | TAA/CAS/FXAA | Descriptor-indexed texture/sampler arrays | Available only with ray query and required limits | Texture-binding arrays + non-uniform indexing; 2 array elements | + + +This table describes the cross-system path contract after device creation. +Platform profiles still own their minimum surface and shader-layout limits. + +## Platform device negotiation + +Every native startup path selects one of these profiles and requests its actual +active shader-layout contract instead of `wgpu::Limits::default()` or the +adapter's entire advertised budget: + +| Profile | Platforms/layout | Bind groups | Color attachments | Sampled textures/stage | Samplers/stage | Storage buffers/stage | Uniform binding | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| `native-full` | macOS, iOS/tvOS/visionOS, Linux, Windows; separate SceneInputs group | 5 | 4 | 19 | 16 | 8 | 64 KiB | +| `folded-mobile` | Android; SceneInputs folded into group 0 | 4 | 4 | 19 | 16 | 4 | 64 KiB | + +Android's lean main pass uses two color attachments, but its platform contract +remains four because other compiled pipelines may use up to four. Path tracing +raises the storage-buffer requirement to 9. Tier A requests only Bloom's +bounded 4,096-texture/64-sampler working set, including fallback entries, +rather than the adapter's maximum descriptor budget. + +Startup first requests supported optional features for the selected tier. If +the backend rejects that request, Bloom retries the same active shader-layout +contract without optional features, selecting the resulting modern/baseline +resource tier. Both success and fallback emit a structured report; the chosen +request and the preferred-request failure cause are also embedded in quality +artifacts under `adapter.device_negotiation`. + +An adapter below the active shader-layout contract receives an explicit error +naming every insufficient limit. It is not reported as a successful lower tier +until Bloom has an implementation of that lower shader layout. diff --git a/docs/rfc/0001-material-render-graph.md b/docs/rfc/0001-material-render-graph.md index c12e87da..07044c2e 100644 --- a/docs/rfc/0001-material-render-graph.md +++ b/docs/rfc/0001-material-render-graph.md @@ -1,6 +1,12 @@ # RFC 0001 — Material system & render graph -**Status:** Implemented — Phases 1–8 + 10 shipped (ABI v3); Phase 3's pool is built but not wired (still Phase 3b); Phase 9 (water) is game-side. See §10 for as-built divergences. +**Status:** Implemented — Phases 1–8 + 10 shipped (ABI v3); the compiled +resource/version/lifetime plan and transient allocator shipped in issue #129; +Phase 9 (water) is game-side. See §10 and +[`docs/compiled-render-graph.md`](../compiled-render-graph.md) for the current +graph contract. Capability-tiered stable material/texture IDs and global +resource lookup shipped in issue #130; see +[`docs/material-texture-indirection.md`](../material-texture-indirection.md). **Author:** Ralph + Claude (pair) **Target version:** 0.x → 0.(x+1), no public breakage for games on the existing `drawModel` / `drawCube` / `loadModel` / `loadModelAnimation` APIs. @@ -299,8 +305,21 @@ struct TranslucentOut { }; ``` -The material descriptor (§3) specifies which profile the shader uses. The -graph routes translucent draws to passes with single-target attachments. +Fast-changing translucent materials may also provide an optional +`fs_reactive` fragment entry returning: + +```wgsl +struct ReactiveTranslucentOut { + @location(0) hdr: vec4, + @location(1) reactive: f32, // authored 0..1 TAA rejection coverage +}; +``` + +`fs_main` remains mandatory and must produce the same HDR result. The material +descriptor (§3) specifies which profile the shader uses. Ordinary translucent +draws keep their single-target attachment. A submitted `fs_reactive` material +lazily selects the two-target sibling, which unions coverage into an R8 target; +no shader-source inference from color or alpha is performed. ### 1.9 Shared header @@ -593,19 +612,21 @@ spec. The version check lives in `renderer/shader_include.rs` + Phase 2b — complete". Node names and the scheduler shape differ from §2 — see §10.) -### Phase 3 — Transient resource pool 🟡 built, not wired +### Phase 3 — Transient resource pool ✅ compiled lifetime plan wired - [x] `renderer/transient.rs`: `TransientDesc`, pool with reference counting (aliasing was dropped — reuse only). -- [ ] Ssao, ssgi, ssr, bloom mip chain, TAA history → all declared as +- [x] Typed graph resources distinguish persistent/temporal imports from + transient allocations and compute first/last use. +- [x] Resize handling for compiled transients goes through a cached physical + allocation plan. +- [x] Conservative aliasing is enabled for compatible, non-overlapping transients. -- [ ] Resize handling goes through the pool; the renderer's member fields - shrink. -**Acceptance:** GPU memory usage reduced (target: 20%+ on 1080p scene), -SELFTEST identical. **Not yet met** — the pool exists with unit tests, but -in the live graph `Transient(u32)` ids are ordering tokens over persistent -renderer fields ("Phase 3b" per `transient.rs`). +**Acceptance:** rendered output remains unchanged. Current live constraints +and measured aliasing results are documented in +[`docs/compiled-render-graph.md`](../compiled-render-graph.md); persistent +post-FX/history targets intentionally do not alias. ### Phase 4 — Translucency buckets + scene snapshot @@ -863,7 +884,7 @@ The shooter is the primary test harness. Water is the forcing function. --- -## 10. As-built divergences (audit 2026-07-16) +## 10. As-built divergences (audits 2026-07-16 and 2026-07-23) The design shipped, but the code moved past this spec in places. The proposal sections above are left as written; this section is the map from @@ -901,8 +922,11 @@ spec to reality. Ground truth: `native/shared/shaders/material_abi.wgsl` sub-graphs `material_pass` and `overlay_2d`. (§2.3's `main_hdr`/`ssao`/ `taa`/`scene_compose`/`tonemap` names never existed as built.) - `TransientDesc` has no `id` field (separate `TransientId` handle), adds - `mips`/`samples`, and the pool ref-counts and reuses but does **not** - alias. + `mips`/`samples`. Issue #129 superseded the original frame-local + declaration with a typed, versioned compiler, cached topology, lifetime + planner, and conservative physical alias allocator. The retained renderer + now binds closures directly to cached pass positions; its legacy scheduler + remains only for unit compatibility tests. **API (§3):** - `loadMaterial` takes a `MaterialDesc` object (`{shader, bucket}`), not a diff --git a/docs/temporal-history.md b/docs/temporal-history.md new file mode 100644 index 00000000..fdf0933e --- /dev/null +++ b/docs/temporal-history.md @@ -0,0 +1,464 @@ +# Temporal history contract + +This document is the authoritative convention for renderer motion and history +work. It starts the common contract required by issue #135; individual effects +may use different storage or filters, but they must not reinterpret motion or +history lifetime. + +## Motion vectors + +- The velocity target stores current minus previous position in UV-sized units: + `(current_ndc.xy - previous_ndc.xy) * 0.5`. +- NDC Y points up while texture UV Y points down. A consumer therefore + reconstructs the previous texture coordinate as + `vec2(current_uv.x - velocity.x, current_uv.y + velocity.y)`. +- The previous projection used for velocity carries the current frame's jitter + on top of the previous unjittered projection. Static geometry consequently + writes exact zero velocity instead of a jitter-phase delta. +- Rigid, skinned, and procedurally displaced draws must evaluate their previous + position with the matching previous transform/deformation state. A deliberate + zero velocity means the consumer must use camera/depth reprojection or reject + history; it must not be treated as proof that the surface was stationary. +- Sky reprojection uses a direction (`w = 0`), not a finite reconstructed + position. + +## History validity + +A temporal history has a separate validity state. A global frame counter is not +a substitute: effects can be enabled, disabled, resized, or suspended at +different times. + +Invalid history must be replaced by the current filtered sample before ordinary +temporal blending begins. A ping-pong index advances only after its producing +pass wrote a valid current history. + +History becomes invalid when any input changes in a way its rejection model +cannot represent, including: + +- target creation or resize/render-scale change; +- effect enable/disable; +- switching between raster ownership and path-traced frame ownership; +- a parameter change that changes the history's radiance domain; +- an explicit camera cut/teleport reset. + +## SSR implementation + +SSR now owns `ssr_history_valid` independently of TAA: + +- invalid history uses temporal alpha `1.0`, replacing stale or zero storage; +- valid history uses alpha `0.1`; +- the established roughness fade keeps wide-lobe surfaces on prefiltered IBL + while preserving SSR ownership for valid smooth reflections; +- march thickness is evaluated against the same explicit-LOD-0 depth sample + used to declare the hit, avoiding a coarse-Hi-Z footprint mismatch; +- accepted hit radiance is unchanged through 8 linear luminance units and + bounded there before it can poison temporal history; +- resize, SSR toggles, SSR-strength changes, and PT-mode transitions invalidate + it; +- frames owned by PT neither write nor advance SSR history; +- the existing two HDR history images, shader, pass count, and steady-state + blend are unchanged. + +Telemetry exposes `temporal_history.ssr_valid` and `ssr_index`. Unit and +headless-GPU tests pin initialization and every transition above. + +The hit bound adds no texture fetch, allocation, pass, draw, or bind group. +It is a luminance dot/select/scale only on accepted hits. A more aggressive +rough-dielectric cutoff was qualification-tested and rejected because it +changed an existing transparent-GI receiver's material response; the +established IBL/SSR ownership curve remains matched on both sides. + +## SSGI implementation + +SSGI now owns `probe_history_valid` independently of TAA: + +- invalid history uses the existing force-refresh route (`alpha = 1.0`); +- force refresh assigns current radiance directly, avoiding the undefined + `invalid_history * 0` side of a nominal `mix(..., alpha = 1)`; +- valid history keeps the existing variance-adaptive four-frame EMA; +- non-finite half-float history, trace input, and trace output are replaced + component-wise with zero; ordinary finite radiance is unchanged; +- degenerate depth-derived normals use finite fallback directions during probe + placement and resolve, preventing NaN normals from rejecting every probe; +- the Hi-Z tracer treats only a mip-zero-confirmed front-depth crossing as a + hit and continues marching after a coarse-footprint false positive; +- the existing temporal workgroup cosine-convolves all 64 directional samples + once per probe, and resolve reads that diffuse result from the probe header + instead of treating one normal-indexed octel as irradiance; +- resize, SSGI toggles, intensity/radius changes, transparent-GI route changes, + and PT ownership invalidate it; +- suppressed frames neither preserve validity nor advance the probe ping-pong; +- the existing two 3D history images, production pass count, and steady-state + filter remain unchanged. + +Telemetry exposes `temporal_history.ssgi_probe_valid` and `ssgi_probe_index`. +The cached diffuse result grows each probe header from 32 to 48 bytes +(16 bytes per probe, 32,640 bytes at a 1920x1080 render size). It adds no +texture, bind group, dispatch, draw, or graph pass, and removes the probe +history texture lookup from each resolve sample. + +## TAA/TSR implementation + +TAA/TSR now separates history validity from its Halton jitter counter: + +- invalid history keeps the established current-frame weight `1.0`; +- valid history keeps the existing four-frame warmup and render-scale-aware + steady blend; +- resize, render-scale changes, and TAA toggles invalidate and reset storage; +- transitions between raster and path-traced scene color invalidate history, + including progressive PT changing ownership without a mode change; +- the ping-pong advances only after the TAA pass wrote its current target. + +Telemetry exposes `temporal_history.taa_valid`, `taa_index`, and +`taa_pt_owned`. + +## Auto-exposure implementation + +Auto-exposure now tracks whether its 1×1 history was written in the current +enable epoch: + +- the first valid frame uses the reserved negative-rate seed signal, replacing + history from the current histogram instead of a stale disabled-era value; +- later frames keep the authored adaptation rate exactly; +- either toggle invalidates and resets the ping-pong; +- the ping-pong advances only after the exposure pass writes, and a skipped + producer falls back to the last valid slot (or manual exposure if none). + +Telemetry exposes `temporal_history.exposure_valid` and `exposure_index`. + +## Path-tracing implementation + +PT history validity remains the path tracer's own sample count, which the +kernel already receives as `size.w`: + +- every off/progressive/realtime mode transition resets the sample count, + ping-pong index, deterministic sequence, and ownership; +- a zero sample count suppresses reprojection and now also suppresses + disocclusion neighbor seeding, so retained buffer bytes cannot resurrect + history after a toggle, camera cut, seed change, or diagnostic reset; +- buffers are retained when compatible and recreated only when trace-grid size + changes, preserving the established steady-state memory and pass cost. + +Telemetry exposes `temporal_history.pt_samples` and `pt_index`. + +## Camera cuts and discontinuities + +Games call `bloom_reset_temporal_history()` before the next 3D camera after a +camera cut, teleport, discontinuous FOV/projection change, or world load. The +same operation is available as `Renderer::reset_temporal_history()`. + +The reset invalidates TAA/TSR, SSAO, SSR, SSGI, PT, and auto-exposure without +reallocating their storage. When the next camera begins, it is pinned as its +own previous camera; material, skin-palette, and retained-scene transform +history are also pinned for that frame. This prevents a cut from creating +full-screen velocity or motion-blur streaks while every temporal filter seeds +from current data. + +Telemetry exposes `camera_cut_pending`, `camera_cut_active`, `ssao_frames`, and +`ssao_index` under `temporal_history`. + +## Cached-model motion + +Ordinary cached-model draws now pair each stable submission slot with its +previous model transform and compose `prev_mvp` from the common +jitter-cancelled previous camera. The model handle is part of the pairing, so +a different model entering a reused slot seeds from its current transform +instead of inheriting a departed object's velocity. Explicit camera cuts clear +the pairing before the next draw. Skinned cached models continue to use their +keyed current/previous joint palettes because those matrices already contain +world locomotion. + +This reuses the existing per-draw uniform and motion target: GPU memory, render +passes, draws, bind groups, readbacks, and shader branches added are all zero. +CPU storage is two grow-on-demand entries of one `u64` handle plus one 4x4 +matrix per live cached instance; its allocated capacity is reported as +`cached_model_motion_cpu_capacity_bytes`. The current entry count, zero GPU +bytes, and zero added passes are reported alongside it. + +## Immediate-primitive motion + +Raylib-style `drawCube`, `drawSphere`, `drawCylinder`, `drawPlane`, wire, +grid, and ray submissions rebuild world-space vertices each frame. Their +stable submission slot now owns the previous vertex positions, so moving, +scaling, or deforming an immediate primitive writes the same true +current-minus-previous velocity as retained geometry. Call order is the +identity contract, matching the cached-model submission-slot API. A new slot, +a different primitive kind in that slot, a changed vertex count, an empty +intervening frame, or an explicit temporal reset seeds previous=current; none +can inherit unrelated motion. + +Previous positions use xyz of the immediate pipeline's otherwise-unused +tangent lane with a marker in w. `Vertex3D` stride and the existing upload are +unchanged, and there is no GPU allocation, bind group, draw, or pass. CPU +history is two grow-on-demand position streams plus compact slot ranges. +Telemetry reports `immediate_motion_entries`, +`immediate_motion_cpu_capacity_bytes`, zero GPU bytes, and zero added passes. + +## Skinned and procedural motion + +Both retained and Raylib-style immediate skinned draws consume current and +previous joint palettes at the same offsets in the existing 1024-matrix GPU +buffers. The legacy immediate vertex shader now reconstructs `prev_clip` from +the previous palette instead of silently using the current pose for both +frames. This adds only the previous-palette matrix operations to weighted +vertices; the buffer, bind group, two palette uploads, velocity attachment, +draw, and pass already existed. + +Animation-handle submissions use the handle as their identity. A palette is +eligible as history only when it was submitted in the immediately preceding +renderer frame and has the same joint count. First sighting, a skipped frame, +a topology change, or an explicit temporal reset seeds previous=current. +This prevents a respawned character from inheriting an old pose or locomotion +vector. + +The unkeyed editor/test API uses stable submission order as its identity, +matching immediate primitives and ordinary cached models. It keeps two +grow-on-demand CPU palette streams and reuses their inner allocations after +warm-up. A missing slot in the preceding frame, changed joint count, or reset +also seeds previous=current. Telemetry reports +`unkeyed_skin_motion_entries`, +`unkeyed_skin_motion_cpu_capacity_bytes`, zero added GPU bytes, and zero added +passes. + +Procedural foliage writes velocity from its actual previous wind time and +previous model transform, rather than treating vertex-shader deformation as +static. Static decals deliberately write zero velocity; spawn and expiry are +qualified through TAA's color, depth, and neighborhood rejection. Procedural +cloud radiance is likewise an explicit zero-velocity producer: field changes +are rejected and converged as radiance changes rather than misrepresented as +screen-space geometry motion. + +The native sequence corpus covers retained rigid transforms, cached and +legacy skinning, keyed and unkeyed palette identity, immediate primitives, +alpha-tested foliage, procedural wind, reactive and ordinary particles, +static decal spawn/expiry, cloud-field changes, emissive switches, +refraction, and camera/reset discontinuities. Each moving geometry route also +captures the velocity target directly; a visually changed negative control +prevents a vacuous pass. + +## Custom translucency and particles + +Custom translucent materials keep `fs_main -> TranslucentOut` as their +baseline contract. Fast-changing particles, force fields, and similar effects +can additionally author `fs_reactive -> ReactiveTranslucentOut`. That entry +must return the same HDR value plus explicit 0..1 coverage; the engine unions +the coverage into TAA's R8 reactive target. Coverage should follow the +effect's visible contribution, normally its final opacity. + +The engine detects the named fragment entry after include expansion. A +submitted opt-in draw activates the reactive frame topology and lazily creates +an attachment-compatible sibling pipeline. Ordinary custom translucency uses +the established single-attachment path when no opt-in draw is visible, so it +adds no image, pass, draw, upload, or per-frame GPU allocation. When active, +coverage costs one R8 render pixel and a second attachment on the existing +translucent pass; it does not add a pass or draw. + +Opting out is explicit. Static and slowly changing custom translucency relies +on the existing depth, color-neighborhood, and history-clamp rejection. The +sequence corpus qualifies that route independently. Rapid particles should +opt in rather than depend on alpha/color guesses, which are ambiguous for +additive and premultiplied materials. + +## Per-pixel TAA/TSR diagnostics + +`captureDebugIntermediates(directory)` now adds four surface-resolution PNGs +without changing the production TAA shader or its output: + +- `taa-rejection-reason.png`: gray = invalid-history seed, red = reprojected + UV outside the prior frame, cyan = reactive coverage, magenta = + disocclusion, yellow = neighborhood clamp, blue = motion weighting, and + green = accepted history; +- `taa-motion.png`: red/green encode signed velocity around 0.5 and blue + encodes magnitude; +- `taa-reprojected-uv.png`: red/green encode the previous-frame UV and blue is + one only when that coordinate is valid; +- `taa-temporal-confidence.png`: red = local luma variance, green = history + clamp magnitude, and blue = the retained-history contribution. + +The maps are produced by one separate four-target pass only for a native debug +capture, after the measured quality window. Normal frames execute no extra +GPU pass, bind group, shader branch, or texture allocation; the CPU only checks +the existing pending-capture flag. The four RGBA8 targets cost exactly +`surface_width * surface_height * 16` bytes during capture; readback cost is +four 256-byte-row-aligned RGBA8 buffers. Both are released after PNG encoding, +so persistent diagnostic memory is zero. These values and the one-pass count +are reported under `temporal_history.diagnostic_*`. + +Native quality captures also include `ssr-raw.png` and `ssr.png` for the +quarter-resolution stochastic march and filtered history. Their accompanying +`*.metrics.json` files retain raw HDR evidence: finite/non-finite counts, +mean/max/p99/p99.9 luminance, alpha hit coverage, and isolated local outliers. +An isolated outlier is over 4 linear luminance and over four times every 3x3 +neighbor. These diagnostics reuse the two production SSR targets and add no +render or diagnostic pass by themselves. + +When SSR is active, the same request also emits +`ssr-rejection-reason.png` and `ssr-temporal-confidence.png` from a +capture-only pass that reuses the production temporal bind group. The reason +palette shares TAA's gray seed, red off-screen, magenta invalid-history, yellow +neighborhood-clamp, and green accepted-history meanings. Confidence RGB shares +the local-variation, clamp-magnitude, and retained-history convention. The two +half-resolution RGBA8 targets and one pass exist only for the capture and are +released after readback; normal frames and production SSR output are +unchanged. Telemetry includes their exact capture texture/readback byte counts, +one diagnostic pass, and zero persistent bytes for the complete SSR capture. + +When SSGI is active, a capture also emits `ssgi-rejection-reason.png` and +`ssgi-temporal-confidence.png`. SSGI history lives in a 3D +probe-X/probe-Y/octahedral-texel domain, so the capture-only compute pass +flattens each probe's 8x8 octahedral slab into a 2D atlas. Gray marks invalid +or freshly seeded probes, magenta marks invalid or adaptively refreshed +radiance, and green marks retained history. Confidence RGB contains local +radiance variation, current-radiance strength, and retained-history +contribution. Screen-space categories such as off-screen reprojection and +motion weighting are intentionally absent because they do not exist in this +representation. + +The two RGBA8 atlases and their readback buffers exist only for the native +capture and are released after encoding. Normal frames add no diagnostic pass, +texture, bind group, or persistent allocation. Telemetry reports +`ssgi_diagnostic_persistent_bytes = 0`, exact capture texture/readback bytes, +one capture-only pass, and whether the temporary resources are live. + +Native captures now include the resolved half-resolution `ssgi.png` plus +`ssgi.metrics.json`. The raw HDR metrics gate finite output and nonzero +indirect radiance after the SDF/card backend has warmed up, independently of +the display tonemap. Marking the existing SSGI target as `COPY_SRC` adds no +normal-frame pass or allocation. + +Realtime path tracing emits `pt-rejection-reason.png`, `pt-motion.png`, +`pt-reprojected-uv.png`, and `pt-temporal-confidence.png` at its trace-grid +resolution. One capture-only compute pass reads the current and previous SVGF +moment buffers, current irradiance/variance, depth, velocity, and the existing +PT matrices. It reproduces the denoiser's bilinear depth validation and +footprint-retention decision without writing production history. + +The PT reason palette uses gray for seed/sky, red for off-screen reprojection, +magenta for invalid or depth-rejected history, cyan for a retained +subpixel-surface flip, blue for accepted motion-vector reprojection, and green +for accepted matrix/static history. Motion and reprojected UV follow the shared +encoding. Confidence RGB stores variance heat, normalized 32-frame history +length, and retained-history contribution. The four RGBA8 textures, bind +group, pipeline, and readbacks exist only for a requested native capture and +are released after encoding. `pt_diagnostic_*` telemetry reports zero +persistent bytes, exact temporary bytes, one capture pass, and resource +lifetime. + +## Temporal sequence gates + +The headless GPU corpus now evaluates a sequence rather than only a final +still. It isolates TAA/TSR from unrelated temporal effects and covers: + +- a camera cut combined with a discontinuous FOV change; +- a 1.2-radian fast rotation followed by 24 stationary recovery frames; +- an eight-frame subpixel pan; +- retained opaque rigid motion; +- retained transparent motion through reactive TAA coverage; +- instanced particle teleport with authored reactive coverage, plus an + ordinary custom-translucency negative control; +- cached skinned locomotion plus joint-palette deformation; +- cached alpha-tested card translation and rotation; +- emissive geometry plus a local light switching both on and off; +- retained opaque physical-transmission translation and rotation; +- a dark interior with an extreme bright opening and a smooth-reflection + negative control; +- a `1.0 -> 0.5` render-scale step; +- a `320x192 -> 256x256` target resize. + +The cut frame must be byte-identical to a fresh-history frame at the same +camera. Fast-rotation recovery is compared with the mean of a settled +16-frame Halton cycle: mean RGB error must contract within four frames, +`>32/255` coherent outliers must cover at most 2% then, and severe +`>64/255` trails must remain below 0.5% after at most four frames. The settled +cycle's mean variation is bounded to 2 RGB levels. Slow-pan pairwise variation +is bounded to 4 RGB levels with at most 3% coherent outliers. + +The rigid, reactive, particle, skinned, alpha-tested, and emissive content +sequences use the same recovery gate and include a negative control requiring +visibly different stable states. A motion trail may cover at most 2% of pixels +after four frames, severe `>64/255` residue must settle below 0.5% within four +frames, and the settled jitter cycle must vary by no more than 2 RGB levels. +The particle sequence uses the production instanced additive route, requires +at least 100 pixels classified as reactive rejection, and then repeats with +the ordinary material contract to prove both its bounded recovery and inactive +reactive topology. +The skinned sequence uses the production cached-model draw, keyed previous +palette, world locomotion, and two-joint deformation paths. The foliage card +uses the production cached alpha-mask/coverage-mip route and additionally +requires at least 250 pixels of captured nonzero object velocity, preventing +depth rejection from hiding a broken motion-vector path. The emissive sequence +checks both dark-to-bright and bright-to-dark radiance convergence on +stationary geometry. + +The physical-refraction sequence requires at least 250 pixels of both captured +nonzero velocity and reactive rejection coverage. Because fully reactive +transmission deliberately consumes the current refracted sample rather than +accumulating it, recovery is compared against the same Halton phase one +16-sample cycle later. Severe residue must remain below 0.5% within four frames, +coherent outliers may cover at most 2% after four frames, and the settled cycle +must still vary by no more than 2 RGB levels. This distinguishes real history +lag from the intended subpixel sample pattern. + +The dark-interior SSR gate requires finite raw march/history values, no +isolated HDR fireflies under the documented local rule, a populated reflection +buffer, and a visible SSR-on/off delta from the smooth-reflection control. + +The SSGI gates cover both production software tracing tiers. A retained +emissive receiver scene warms through the Hi-Z-to-SDF backend transition, then +requires a finite, nonzero resolved HDR target and real retained probe history. +An immediate-only room stays on `hiz-screen` and independently requires +nonzero current trace samples, retained history, finite resolved radiance, and +a nonblack diffuse result. Its optional 1280x720 profile window records all +four probe-pass timestamps for frozen-binary A/B qualification. The tests also +verify the probe-domain reason/confidence dimensions and the +zero-persistent-memory capture contract. + +The realtime PT gate orbits the deterministic retained ray-query scene for 24 +frames, then requires finite nonzero HDR output, accepted history, valid +reprojection, and accumulated SVGF confidence. It verifies all four diagnostic +dimensions and the zero-persistent-memory capture contract in one frame. + +A second realtime PT sequence moves retained rigid geometry between two +visibly distinct poses after 24 settled frames. The transition must write at +least 100 motion texels, retain overlapping history, and classify at least 90% +of moving texels as retained, depth-rejected, or footprint-retained. Severe +trails must settle within four frames, coherent frame-four outliers must stay +below 2%, and stable stochastic flicker must remain below 2 RGB levels. + +A bidirectional PT lighting sequence changes directional intensity from 0.15 +to 2.4 and back without resetting history. Each transition must be visibly +different, contract its settled-reference mean error by at least 35% (plus a +0.25 RGB allowance) within eight frames, leave at most 2% coherent outliers by +frame twelve, and settle below 2 RGB levels of stochastic flicker. The sample +counter must advance continuously across both edits. + +The PT reset gate drains shared asset warm-up, records a deterministic +one-frame seed, accumulates a different camera, and then exercises both the +explicit common reset and PT off/on ownership transition. Each reseed must be +byte-identical to the fresh oracle, restart at one accumulated sample, and +classify every trace texel as gray seed/sky rather than retaining any prior +reason. + +Render-scale changes must produce a first frame byte-identical to a freshly +seeded history at the new scale. Resize changes must have no `>32/255` +outliers against a fresh target-size seed, with mean RGB error at most 0.5 and +maximum channel error at most 32. + +Each run reports temporal SSIM, mean variation, coherent and severe outlier +fractions, ghost-trail duration, and the diagnostic rejection-reason ratio. +These are relative sequence gates, so they do not require a GPU-family-specific +still-image baseline. The capture-only diagnostic test also requires real +off-screen, reactive, and neighborhood-clamp classifications from the +production TAA inputs, preventing an unexercised diagnostic shader from +silently passing. + +## Remaining #135 work + +Run the complete temporal and PT corpus on every required hardware backend, +including the cross-backend goldens owned by #127 and full-corpus hardware +qualification owned by #128. + +The consolidated production-history, reactive-mask, motion-CPU-history, and +capture-only diagnostic cost ledger is checked in at +`docs/evidence/issue-135-temporal-cost-ledger.md`, with machine-readable values +in the adjacent JSON file. diff --git a/docs/temporal-reactive-coverage.md b/docs/temporal-reactive-coverage.md new file mode 100644 index 00000000..dee89d8d --- /dev/null +++ b/docs/temporal-reactive-coverage.md @@ -0,0 +1,100 @@ +# Temporal reactive coverage + +Bloom's TAA history is now coverage-aware for imported glTF `alphaMode: BLEND` +and contributing `KHR_materials_transmission`. Without that signal, a moving +or changing transparent layer can reproject valid opaque velocity while its +color still depends on a different current-frame layer or background sample. +The result is stale color trails around glass, foliage-like BLEND cards, and +refracted silhouettes. + +## Coverage contract + +When TAA and at least one visible imported contributor are active, the compiled +frame graph creates one render-resolution transient: + +- `transparency-reactive`: `r8unorm`, color-attachment plus sampled usage. + +Imported passes union their coverage into it: + +```text +combined = source + destination * (1 - source) +``` + +This is exact front-to-back coverage union and remains bounded in `[0, 1]`. +Sorted BLEND writes its shaded base alpha. Weighted OIT writes resolved opacity +`1 - revealage`. Opaque physical transmission writes its transmission weight; +BLEND+transmission writes base alpha because the complete resolved material +response is mixed by that factor. + +The TAA variant samples coverage at the same unjittered UV as current color and +uses it as a lower bound on current-frame weight: + +```text +history_alpha = max(motion_alpha, disocclusion_alpha, reactive_coverage) +``` + +A 20% transparent contribution therefore rejects at least 20% stale history, +while fully refractive pixels consume the current result immediately. Linear +mask filtering preserves sub-pixel edge coverage at native and upscaled output +resolutions. + +## Lazy-path guarantees + +The mask and every pipeline that writes or reads it are lazy. The established +pipelines remain selected without modification when any of these is true: + +- the frame has no visible imported BLEND or transmission draw; +- TAA is disabled; +- translucency consists only of user-authored custom material buckets; +- weighted OIT is active while TAA is disabled. + +Consequently opaque frames retain the existing TAA bind-group ABI and shader, +perform no extra texture sample, allocate no mask, and compile no reactive +shader. TAA-active imported frames pay one byte per render pixel. A mixed +imported/custom sorted frame remains one globally ordered render pass. Bloom +lazily creates an attachment-compatible custom pipeline sibling whose second +target has an empty write mask, so custom material source/ABI and HDR output +stay unchanged while only imported draws write coverage. The sibling is not +compiled for opaque, custom-only, TAA-off, weighted, or unmixed sorted frames. + +Set `BLOOM_TEMPORAL_REACTIVE=0` before renderer creation for an exact A/B +diagnostic. This restores the previous graph topology and all established +transparency/TAA pipelines; it is not intended as a shipping quality setting. +Qualification telemetry reports `enabled`, `active`, format, and graph +allocation counts under `renderer_paths.temporal_reactive`. + +## Qualification + +The `quality-transparency` fixture supports all three GPU routes without +changing its default weighted corpus: + +```sh +# Existing 96-layer weighted OIT corpus. +./main --quality-run ... + +# Force the sorted imported-BLEND writer. +./main --sorted --quality-run ... + +# Use the physical transmission fixture and refractive writer. +./main --refractive --quality-run ... +``` + +Required regression evidence is: + +- ordinary opaque goldens remain unchanged; +- TAA-off and `BLOOM_TEMPORAL_REACTIVE=0` plans contain no + `transparency-reactive` resource; +- active sorted, weighted, and refractive routes pass backend validation; +- a mixed custom/imported sorted route preserves global depth order with TAA + both off and on, and the custom sibling does not modify the R8 mask; +- changes between reactive on/off are localized to imported transparent + coverage and improve motion-history behavior; +- the extra allocation is exactly one render-resolution byte per pixel and + active-frame CPU/GPU budgets remain satisfied. + +## Current boundary + +Custom `Transparent`, `Refractive`, and `Additive` material buckets do not yet +declare temporal coverage. Their existing ABI is deliberately preserved rather +than silently widening every user material pipeline. A future material API can +add an explicit reactive output without changing the imported glTF contract. diff --git a/docs/tickets.md b/docs/tickets.md index bf80e555..248dcb2b 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -1919,21 +1919,69 @@ adjacent but does not remove the duplication. ## EN-056 — Per-frame upload/allocation tail in the renderer 🟡 *(2026-07-16 audit)* Individually small, collectively the class of waste the frame no longer has -budget for at 4K. All VERIFIED still present: - -- Lighting UBO (~8.7 KB, 256 point-light slots) uploaded whole **8-9x/frame** - with no dirty flag (`mod.rs:10245,11526,11919-11955`; `shadow_pass.rs:73` - is explicitly unconditional). -- Bloom chain creates **9 uniform buffers + 9 bind groups per frame** - (`postfx_chain.rs:66-141` — the per-pass UBOs are argued for in comments; - the bind-group re-creation is not). -- The render graph is rebuilt from scratch **twice per frame** — 17 boxed - closures, fresh HashMap + HashSets, O(n^3) topo sort (`graph.rs:103-224`, - `mod.rs:10650,10824,11067`); composite/post bind groups rebuilt per frame - (`mod.rs:10862,10946`). - -Fix: dirty-flag the lighting UBO, cache bloom + composite bind groups keyed -on the views they wrap, build the graph once and rebuild on topology change. +budget for at 4K. + +- **Lighting UBO fixed in #139:** setters now update one CPU snapshot and the + renderer submits at most three aligned dirty ranges after the frame has + finalized camera and cascade data. Unchanged ranges submit nothing; exact + per-frame write/byte counts live in + `renderer_paths.steady_state_uploads.lighting`. The shader layout and bind + group are unchanged, and the many-light golden hard-gates a steady frame to + at most 512 bytes instead of the former 8-9 full ~8.7 KiB uploads. +- **Bloom pass resources fixed:** per-mip buffers and bind groups are rebuilt + only with the mip chain (initialization/resize); steady frames update only + the first threshold uniform when exposure policy changes. +- **Graph planning fixed in #129:** immutable topology is compiled and cached; + per-frame execution only binds closures to the cached pass slots. Composite + and post-pass bind-group creation remain part of this ticket's audit. +- **Recurring bind-group creation is now measured:** a fixed twelve-counter + array names scene compose, SSR temporal, the post-FX chain, final composite, + and custom post-pass creation in + `renderer_paths.steady_state_resources.bind_group_creations`. Measurement + adds no heap allocation and leaves GPU resources, descriptors, and pass order + untouched. +- **Final composite fixed in #139:** its bind groups are cached for every exact + source/exposure combination (eight source views × two exposure slots). + Resize clears all sixteen entries before recreating their referenced views; + after warmup the real-GPU many-light golden hard-gates + `final_composite: 0`. +- **Scene compose fixed in #139:** three distinct cached groups cover the SSR + fallback and both history slots. SSR toggles and PT ownership select a whole + binding identity, while resize clears all slots before replacing any input + view. The same golden now hard-gates `scene_compose: 0`. +- **SSR temporal fixed in #139:** both alternating previous-history bindings + are cached and shared with the diagnostics pass. Resize invalidates them + before replacing history/raw/velocity views; the steady golden hard-gates + `ssr_temporal: 0`. +- **Ordinary TAA fixed in #139:** both previous-history bindings are cached and + invalidated before resize replaces composed/depth/velocity/history views. + The steady golden hard-gates `taa: 0`. +- **Reactive TAA fixed in #139:** both history slots are additionally keyed by + the compiled plan ID and transient-pool rebuild epoch that own the reactive + coverage view. The retained transparent-motion corpus hard-gates + `taa_reactive: 0` before and after a resize/rebuild cycle. +- **Non-TAA upscale fixed in #139:** its single persistent composed-input + binding is invalidated before resize. A dedicated half-resolution real-GPU + test proves rendered geometry survives and hard-gates `upscale: 0` after + warmup. +- **Optional color-chain passes fixed in #139:** DoF, motion blur, SSS, and CAS + cache lazily against the exact upstream target, including both TAA slots. + Resize invalidates all source arrays before replacing views. A forced + full-chain GPU test renders geometry and hard-gates all four counters to + zero after warmup. +- **Auto exposure fixed in #139:** its cache covers all eight composite sources + crossed with both previous-exposure slots. The same full-chain test enables + adaptation, exercises the ping-pong, and hard-gates `auto_exposure: 0`. +- **Custom post-pass stack fixed in #139:** each pass lazily caches both A/B + input parities and invalidates them before resize replaces LDR/depth views. A + two-pass GPU copy stack preserves geometry and reaches zero churn before and + after resize. +- **Bind-group acceptance is now hard-gated:** official post-warmup quality + telemetry fails unless the sum across all twelve named core sites is zero. + +Remaining: eliminate the measured bind-group hotspots only with complete +resource-generation keys, then instrument and eliminate the other steady-state +upload/allocation sites under #139. ## EN-057 — Hi-Z occlusion runs every frame for zero consumers ✅ *(shipped same day)* diff --git a/docs/transmitted-shadows.md b/docs/transmitted-shadows.md new file mode 100644 index 00000000..e00e407b --- /dev/null +++ b/docs/transmitted-shadows.md @@ -0,0 +1,128 @@ +# Transmitted directional shadows + +Bloom represents directional shadows from imported physical-transmission +materials as a lazy, bounded correction layered over the existing cascaded +shadow map (CSM). The opaque and MASK route remains unchanged and authoritative; +glass never becomes an opaque blocker merely to produce a shadow. + +## Activation and cost contract + +The route activates only when all of the following are true: + +1. imported physical transmission is enabled; +2. directional shadows are enabled; +3. at least one visible retained or cached transmission draw is eligible to + cast a shadow; and +4. `BLOOM_TRANSMITTED_SHADOWS` is not set to `0`, `false`, `off`, or + `disabled`. + +Before activation there is no allocation, pipeline creation, transmitted-shadow +graph resource, or resolve pass. Activation allocates, once: + +| Resource | Cascades | Extent | Format | Bytes | +| --- | ---: | ---: | --- | ---: | +| transmittance | 3 | 1024² | RGBA8Unorm | 12,582,912 | +| nearest depth | 3 | 1024² | Depth16Unorm | 6,291,456 | +| **total** | | | | **18,874,368 (18 MiB)** | + +These are persistent imported graph resources, not frame-transient textures. +They therefore add zero transient slots and cannot alias a live frame target. +The exact size is asserted in a unit test and reported by native quality +telemetry. + +## Caster representation + +Each cascade stores the transmittance and depth of the nearest eligible +transmission surface. Nearest-layer depth deliberately rejects the far face of +a closed volume, preventing the same authored thickness from being absorbed +twice. It also establishes a deterministic upper bound: overlapping or nested +glass does not grow storage or fragment work with layer count. + +The caster evaluates the same material inputs as camera-facing imported +refraction: + +- base color, vertex color, instance tint, and BLEND coverage; +- MASK cutoff; +- metallic suppression of dielectric transmission; +- transmission factor and texture; +- thickness factor and texture; +- attenuation color and distance; +- IOR Fresnel partitioning; and +- model scale for world-space thickness. + +Transmission and thickness textures use the same independent UV0/UV1 +selection and `KHR_texture_transform` contract as camera-facing refraction. +The 8-byte TEXCOORD_1 vertex stream and its caster pipeline are created only +after a usable physical texture requests UV1; UV0 casters retain the original +single-stream pipeline and vertex fetch. + +The established opaque cascade is sampled before writing. A transmission +surface behind a nearer opaque blocker cannot tint that blocker's shadow. +Retained, cached, and cached-skinned draws share this route; the latter reuses +the existing joint palette. + +## Receiver resolve + +The optional `transmitted_shadow_resolve` graph pass runs after path tracing +and before translucent composition. It reconstructs receiver world position +from scene depth, manually bilinearly resolves the transmittance/depth maps, +blends across the established cascade transitions, and reconstructs the +primary directional-light PBR term from the material G-buffer. + +The pass additively writes only the negative correction: + +```text +direct_sun * opaque_visibility * cloud_visibility * (transmittance - 1) +``` + +This avoids multiplying emissive, ambient, image-based, GI, or later +translucent lighting. It also preserves the existing opaque CSM and shared +world-space cloud visibility. Because Bloom has no normal G-buffer, receiver +normal is reconstructed from neighboring world positions; normal-map detail +is not part of this bounded resolve. + +## Caching and invalidation + +A cascade is rerendered only when its light view-projection, deterministic +caster/material/transform signature, or corresponding live opaque-depth +generation changes. Static glass and blockers therefore reuse the persistent +maps. Moving cached-skinned draws invalidate conservatively. + +The caster budget is capped at 1024 submitted draws and diagnoses overflow +once. Casters outside each cascade are rejected by a conservative world-space +AABB/frustum test. + +## Explicit limits + +- Only the nearest transmission layer contributes per light texel. Deep, + order-independent transparent shadow stacks are intentionally outside this + baseline. +- The map resolution is half the opaque CSM linear resolution. +- The resolve reconstructs geometric receiver normals rather than normal-map + detail. +- This route covers the primary directional light; local-light transmission + shadows remain a separate representation problem. + +## Qualification + +The live Metal stress A/B used 96 moving physical-transmission layers at +960×540, with 60 warm-up and 120 measured frames. The feature-on graph compiled +once, reused one cached plan for 178 frames, and retained the existing three +transient slots. The two added profiler regions measured approximately +0.95 ms (`transmitted_shadow_maps`) and 0.69 ms +(`transmitted_shadow_resolve`) mean GPU time in that deliberately saturated +scene. Feature-on/off GPU p95 was within measurement noise. + +The captured on/off images differ only in the localized transmitted tint: +SSIM 0.9999938, luminance RMSE 0.0002146, maximum channel error 0.01961, and +zero pixels above the 0.02 tolerance. A live GPU property test additionally +toggles only `cast_shadow` and verifies both a non-trivial affected region and +the authored RGB attenuation ordering. + +Native telemetry under `renderer_paths.transmitted_shadows` exposes: + +- `enabled` and `active`; +- `representation` (`nearest-layer-rgb-depth`); +- `resolution`; +- `persistent_bytes_when_allocated`; and +- `caster_count`. diff --git a/docs/transparency-composition.md b/docs/transparency-composition.md new file mode 100644 index 00000000..d48e478f --- /dev/null +++ b/docs/transparency-composition.md @@ -0,0 +1,127 @@ +# Transparency composition + +Bloom keeps two conventional imported glTF `alphaMode: BLEND` composition +routes. Neither route writes opaque depth or participates in the opaque shadow +and planar-probe lists. + +## Modes + +Use `setTransparencyCompositionMode()`: + +- `"sorted"` always uses deterministic back-to-front alpha blending. +- `"auto"` is the default. It keeps sorted blending below 64 visible imported + BLEND draws and selects weighted-blended OIT at or above that threshold. +- `"weighted"` forces weighted-blended OIT whenever at least one imported BLEND + draw is visible. Use this for intersecting surfaces whose relative depth + order changes across one primitive or during camera motion. + +`getTransparencyCompositionMode()` reports the configured policy. +`getActiveTransparencyCompositionMode()` reports `"sorted"` or `"weighted"` +for the most recently prepared frame. `BLOOM_TRANSPARENCY=sorted|auto|weighted` +sets the startup policy; the API can change it later. + +The auto threshold is intentionally conservative. Sorted alpha is exact for +simple, non-intersecting layers, while weighted OIT trades exact layer order +for bounded, stable composition when object sorting cannot represent the +per-pixel order. + +## Weighted path + +The accumulation pass uses two render-graph-owned, render-resolution transient +targets: + +- `transparency-accumulation`: `rgba16float`; +- `transparency-revealage`: `r16float`. + +For source radiance `C`, opacity `a`, and normalized fragment depth `z`: + +```text +w = 0.1 + 0.9 * (1 - z)^3 +accum.rgb += C * a * w +accum.a += a * w +reveal *= 1 - a +``` + +The resolve computes: + +```text +opacity = 1 - reveal +color = accum.rgb / max(accum.a, 1e-5) +HDR = color * opacity + HDR * (1 - opacity) +``` + +The bounded weight avoids half-float overflow from the large exponential +weights used by some WBOIT variants. A single layer is algebraically identical +to conventional alpha blending. Accumulation and revealage blending are +order-independent; retained-scene and cached-model imported draws share one +visible draw list and stable ID contract. + +The targets, bind group, shader module, and three pipelines are lazy. Opaque +frames and sorted-only transparency plans do not allocate or compile them. +Bind groups are cached by compiled graph plan plus transient resize generation. + +## Global sorted path + +Conventional imported BLEND draws and user-authored custom `Transparent`, +`Refractive`, and `Additive` commands share one back-to-front dispatcher when +weighted OIT is inactive. The key is: + +1. view depth, far to near; +2. source rank only at exactly equal depth (imported before custom); and +3. the source's stable object/submission ID. + +Retained and cached imported draws keep their existing common stable-ID space. +Custom commands keep submission order at equal depth. Switching source changes +pipeline/bind-group state but not the composition order. + +With TAA reactive coverage active, custom material source/ABI remains +unchanged. On the first frame that actually mixes custom and imported sorted +draws, Bloom lazily compiles an attachment-compatible sibling of each +participating custom pipeline. It preserves the exact HDR blend/depth/shader +contract and declares the R8 reactive target with an empty write mask. Imported +draws can therefore union real coverage while custom draws in the same render +pass leave it untouched. This replaces the former imported-then-custom pass +split without adding a draw, graph pass, or image. + +Set `BLOOM_SORTED_INTERLEAVING=0|false|off|disabled` before renderer creation +to restore the previous imported-list-then-custom-list behavior for exact A/B +diagnosis. With TAA active, that control also restores the former two-render- +pass attachment split. It is a rollback/qualification switch, not a shipping +quality mode. + +## Current boundaries + +- Physical `KHR_materials_transmission` uses the separate imported-refraction + pass and is not folded into weighted OIT. Arbitrarily nested, + order-independent refraction remains a non-goal. +- User-authored custom material `Transparent`, `Refractive`, and `Additive` + buckets are globally interleaved with imported BLEND only in sorted mode. + When imported WBOIT is active, its aggregate resolve occurs before those + custom commands. Interleaving a per-draw custom command inside an + order-independent imported aggregate has no well-defined object order. +- With TAA active, resolved opacity is unioned into the lazy + `r8unorm` temporal-reactive target. See + [Temporal reactive coverage](temporal-reactive-coverage.md). TAA-disabled + weighted frames keep the established resolve and allocate no mask. +- The route uses two full-resolution transient targets only while active: + 10 bytes per render pixel before allocator alignment. TAA-active imported + transparency adds one byte per render pixel for reactive coverage. + +## Qualification contract + +Changes to this path must retain: + +- an order-sensitive sorted negative control; +- an AB/BA intersecting imported-BLEND GPU test with weighted mean RGB + difference at most 0.02/255 and maximum channel difference at most 2/255; +- a simple-BLEND test proving auto mode stays sorted and the graph contains no + weighted targets; +- a mixed imported/custom property test proving moving the custom layer from + behind to in front changes the overlapped result under the global key; +- a TAA-active mixed test proving the lazy custom sibling validates and leaves + imported reactive coverage untouched; +- unchanged opaque goldens and an opaque quality capture with zero weighted + transient allocations; +- a TAA-active run proving the reactive target adds exactly one byte per render + pixel, plus a `BLOOM_TEMPORAL_REACTIVE=0` negative control; +- native, Web/model, and Android/model compile checks. diff --git a/docs/transparent-gi.md b/docs/transparent-gi.md new file mode 100644 index 00000000..f0b24218 --- /dev/null +++ b/docs/transparent-gi.md @@ -0,0 +1,138 @@ +# Transparent global illumination + +Bloom's screen-probe global illumination represents imported physical +transmission with one bounded colored continuation. Glass remains visible to +the normal first-hit query, but radiance behind the nearest glass surface is no +longer discarded or replaced with an opaque card. + +## Activation and zero-cost contract + +The route activates only when all of the following are true: + +1. screen-probe GI is enabled; +2. imported physical transmission is enabled; +3. at least one visible retained scene node or GI-only proxy has both a + Mesh-Card slot and active physical transmission; and +4. `BLOOM_TRANSPARENT_GI` is not set to `0`, `false`, `off`, or `disabled`. + +Scene preparation maintains an O(1) transmission gate. An ordinary opaque +scene does not read the kill switch, scan nodes for transmission, create or +select a specialized pipeline, change its TLAS masks, or execute a +transparent-GI shader branch. The ordinary shaders keep a compile-time-false +switch and preserve their established first-hit code. + +Activation adds no texture, buffer, bind group, graph resource, graph pass, or +transient slot. It reuses spare lanes in the existing per-instance GI record. +The exact additional persistent and transient allocation is therefore zero +bytes. Three specialized pipelines are compiled lazily: hardware probe trace, +software SDF probe trace, and hardware world-space radiance-cache bake. + +## Hardware ray-query representation + +Ordinary instances retain the `0xff` visibility mask. When the route is +eligible, a physical-transmission instance uses bit 1 (`0x02`) while opaque +instances retain bit 0. The normal `0xff` ray query therefore still observes +the nearest surface of either kind. + +Only when that first hit is glass does the specialized kernel issue one +additional query with mask `0x01`. That query skips the complete glass +instance, including its back face, and finds the nearest opaque surface behind +it. Probe tracing and hardware world-space radiance-cache baking share this +rule. The number of continuation queries is capped at one regardless of the +number of overlapping transparent instances. + +## Software SDF representation + +When the software clipmap backend is active, physical-transmission meshes are +excluded from the scene-wide opaque SDF. The established SDF trace therefore +continues to find the nearest opaque surface behind glass. The specialized +kernel separately intersects the ray with retained instance metadata and keeps +only the nearest conservative transmission world AABB before that opaque hit. + +This preserves the existing clipmap resolution, bake schedule, storage, and +probe dispatch dimensions. A route change invalidates an in-flight or live +clipmap, and a staging bake is published only if its scene version and +transmission mode are still current. + +## Transport model + +The existing 144-byte `InstanceGiDataCpu` record carries: + +| Lane | Value | +| --- | --- | +| `card_aabb_min.w` | Beer-Lambert absorption red | +| `card_aabb_max.w` | Beer-Lambert absorption green | +| `world_aabb_min.w` | Beer-Lambert absorption blue | +| `world_aabb_max.w` | BLEND coverage | +| `mat_params.z` | scalar transmission × non-metallic weight | +| `mat_params.w` | dielectric Fresnel pass fraction | + +Absorption uses authored attenuation color and distance, with thickness scaled +by average world model scale. The bounded composition is: + +```text +front_radiance * coverage * (1 - transmission_weight) + + behind_radiance + * mix( + 1, + base_color + * absorption + * transmission_weight + * fresnel_pass, + coverage + ) +``` + +The `mix` preserves the uncovered part of a BLEND surface while tinting only +its covered fraction. The formula avoids adding the opaque front-card result +on top of fully transmitted radiance and conserves the scalar +surface/transmission partition. Fully metallic authored transmission remains +in the opaque TLAS/SDF subset because its dielectric transport weight is zero. + +Changing physical-transmission metadata invalidates the per-instance data, +TLAS mask, and software SDF membership exactly once. Switching the route also +forces one probe-history refresh and invalidates world-space radiance-cache +cascades so the old visibility representation cannot linger temporally. + +## Explicit limits + +- At most one nearest transmission instance contributes to a GI ray. + Arbitrarily deep dielectric stacks are outside this bounded baseline. +- The software fallback uses the instance world AABB, not triangle-accurate + entry depth, for selecting its nearest glass layer. +- GI transport currently uses scalar transmission, thickness, and metallic + factors plus the instance's flat base albedo. Per-hit transmission, + thickness, metallic-roughness, and base-color texture modulation remains + camera/shadow shading work and is not sampled by the continuation query. +- The route follows Bloom's established scene-GI contract: retained nodes and + GI-only proxies enter Mesh-Cards, TLAS, and SDF data. Immediate cached model + draws do not. +- Dynamic skinned path-tracing instances retain the existing opaque GI + metadata until skinned retained-scene GI proxies are introduced. + +## Diagnostics and qualification + +Native quality telemetry under `renderer_paths.transparent_gi` exposes: + +- `enabled` and `active`; +- `representation` (`one-layer-colored-continuation`); +- `additional_persistent_bytes` (`0`); and +- retained `instance_count`. + +Live GPU property tests execute both hardware ray-query and software SDF +specializations. The hardware test changes only a GI-only slab from opaque to +physical transmission and verifies that a cyan/green emitter behind it changes +the indirect result while camera-facing geometry remains identical. Shader +tests parse all ordinary and specialized variants and assert the bounded +transport math. + +A sustained Metal A/B used 96 moving retained physical-transmission panes at +960×540, with 120 warm-up and 240 measured frames. Transmitted shadows were +disabled per node so the comparison isolated transparent GI. The specialized +hardware probe trace averaged 0.254 ms versus 0.195 ms for the opaque control, +an added 0.059 ms in this deliberately saturated route. Feature-on total GPU +p95 was lower within run-to-run noise (90.88 ms versus 93.41 ms), as was CPU +p95 (8.38 ms versus 9.19 ms). Both routes compiled one identical 24-pass graph, +used the same three transient slots and 26,956,800 transient bytes, and +produced a bit-identical camera image in the neutral stress scene. The +separate emitter property test supplies the non-neutral visual assertion. diff --git a/docs/virtualized-geometry.md b/docs/virtualized-geometry.md new file mode 100644 index 00000000..6361540f --- /dev/null +++ b/docs/virtualized-geometry.md @@ -0,0 +1,191 @@ +# Virtualized geometry + +Bloom's virtualized-geometry work is opt-in and staged. The first #131 +milestone establishes a deterministic offline meshlet/page contract. It does +not enable a new runtime renderer, change ordinary glTF loading, or claim +Nanite-equivalent hierarchy/streaming. + +## Cook and inspect + +```shell +cargo run --release --manifest-path tools/bloom-cook/Cargo.toml -- \ + geometry scene.glb scene.bgeo + +cargo run --release --manifest-path tools/bloom-cook/Cargo.toml -- \ + geometry-inspect scene.bgeo +``` + +The default leaf limits are 64 unique vertices and 124 triangles per meshlet. +They are cooker details, not public engine API. Qualification experiments may +override them: + +```shell +bloom-cook geometry scene.glb scene.bgeo \ + --max-vertices 32 --max-triangles 48 --page-bytes 32768 + +# Opt in to the offline coarse hierarchy (up to 16 levels). +bloom-cook geometry scene.glb scene.bgeo --hierarchy-levels 8 +``` + +Vertex limits must be 3–255. Page size must be a power of two between 4 KiB +and 4 MiB. The default is 64 KiB. A meshlet that cannot fit in one page fails +the cook instead of silently exceeding the residency budget. + +Both commands write machine-readable JSON. `geometry` reports source and +payload hashes, eligible triangle/meshlet/page counts, limits, maximum page +size, and every compatibility-routed primitive. `geometry-inspect` validates +the complete artifact before reporting it. + +## Version 1 contract + +`.bgeo` version 1 is little-endian and uses these fixed tables: + +| Record | Size | Purpose | +|---|---:|---| +| Header | 160 B | Magic/version/endian tag, counts, canonical offsets, source and payload SHA-256, page budget | +| Cluster | 128 B | Mesh/primitive/material identity, page and payload ranges, bounds, normal cone, error and hierarchy links | +| Page | 64 B | Contiguous payload/cluster range for one LOD/residency class, independent SHA-256 | +| Compatibility | 16 B | Mesh/primitive, stable reason code, reason detail | + +Cluster payloads contain fixed 72-byte static vertices—position, normal, +tangent, UV0, UV1, and color—followed by three local `u8` indices per +triangle. Clusters do not cross pages. Material boundaries cannot cross +clusters because each glTF primitive is partitioned independently. + +The source hash covers the glTF/GLB bytes and the complete resolved buffer +contents, including external buffers. The payload and every page have separate +SHA-256 hashes. Regenerating the same source and settings is byte-identical. +The hierarchy level count is a cook setting and must therefore be captured by +the future #136 artifact key alongside the source hash and meshlet/page limits. + +The reader rejects before payload access when it sees: + +- unknown magic, format version, or endian tag; +- truncated, overlapping, gapped, non-canonical, or overflowing ranges; +- a file, payload, or page length mismatch; +- a payload or page hash mismatch; +- invalid vertex/index counts, stride, local indices, bounds, cone, hierarchy + links, NaN/Inf values, or page-budget overflow; +- hierarchy roots without the coarse-root flag, missing or out-of-range parent + groups, non-reciprocal group ranges, non-increasing levels, + identity/material crossings, or decreasing accumulated error; +- unknown compatibility reason codes. + +Writers run the strict reader on the in-memory result before atomically +installing it. This prevents a writer regression from putting an unchecked +artifact on disk. + +## Geometry and compatibility behavior + +By default, version 1 builds deterministic leaf meshlets in source triangle +order, exactly as the first milestone did. `--hierarchy-levels` is explicit +opt-in and uses meshoptimizer's deterministic locality-aware leaf builder +before constructing coarse levels. Bounds include object-space AABB/sphere and +a conservative face-normal cone. Double-sided material clusters explicitly +disable backface-cone rejection. Missing normals are regenerated +deterministically with area-weighted triangle normals. Opaque and alpha-masked +triangles are eligible. + +## Atomic coarse hierarchy + +Hierarchy traversal operates on atomic cluster groups. Every child stores the +first cluster and count of its replacement parent group. Every parent sibling +stores the same contiguous child range. A group can therefore simplify into +several ordinary 64-vertex meshlets; a future runtime must select all parents +or all children for that edge. The reader validates both directions before +accepting the archive. + +Each level combines up to eight spatially ordered child groups and targets +half their triangle count. Exact complete-vertex welding restores adjacency +without merging normal, tangent, UV, or color discontinuities. The +attribute-aware simplifier weighs normals, tangents, both UV sets, and color, +and locks the complete group's topological border. Independently selected +neighboring groups therefore retain their shared boundary. A replacement that +does not reduce cluster count is rejected and its children become terminal +roots. + +The simplifier reports absolute object-space error. Each parent group stores +that error plus the maximum error accumulated by its children. Parent +AABB/sphere bounds conservatively cover the complete replaced child group; +normal cones remain per rendered parent meshlet. The cook report exposes root +cluster/payload counts by level so an always-resident budget cannot be hidden +by aggregate totals. + +Before encoding, hierarchy clusters are deterministically remapped into +coarse-first order while every atomic relation range remains contiguous. +Coarse roots occupy a page prefix, and no page may mix root/streamable classes +or LOD levels. `geometry` and `geometry-inspect` report the exact root-page +count and bytes. The reader rejects mixed classes or roots placed after +streamable pages, making the future always-resident upload a bounded prefix +instead of a heuristic scan. + +The cooker integration uses the Rust `meshopt` wrapper and its vendored +meshoptimizer implementation under their permissive MIT/Apache-2.0 terms. + +The following content is recorded for the existing compatibility renderer and +does not produce virtualized meshlets: + +| glTF content | Compatibility reason | +|---|---| +| Points, lines, strips, or fans | `non-triangle-topology` | +| A mesh referenced by a skinned node | `skinned` | +| A primitive with morph targets | `morph-targets` | +| `alphaMode: BLEND` | `alpha-blend` | + +An entirely incompatible asset still produces a valid metadata-only `.bgeo`. +For example, Fox records its skinned primitive and zero pages. This makes the +fallback decision inspectable instead of silently treating unsupported +geometry as static. + +## Runtime and performance boundary + +This milestone is cooker-only: + +- zero production render passes, draws, buffers, bindings, allocations, or + shader branches; +- zero changes to existing immediate-mode or glTF pixels; +- no runtime `.bgeo` selection or silent fallback; +- no new dependency in the engine/runtime crates. + +Runtime integration remains gated on the #131 dependencies: + +- #136 must own the versioned asset database/provenance and asynchronous + artifact lookup; +- #27 must demonstrate a measured net-positive visibility/material path + before virtualized geometry targets it; +- the existing #28 shared geometry arena and indirect submission remain the + compatibility/performance foundation. + +The default version 1 artifact remains byte-identical to the qualified +leaf-only milestone (`parent` and `first_child` absent, both relation counts +zero, level/error zero). Opt-in artifacts populate those formerly reserved +fields without changing the 128-byte cluster record or format version. +Runtime streaming remains disabled until residency, traversal, occlusion, and +fallback milestones are independently qualified. + +## Qualification + +The quick CI quality contract runs release tests and strict Clippy for +`bloom-cook`. The focused tests cover deterministic partitioning, hierarchy +construction and encoding, atomic reciprocal relations, monotonic +error/bounds, locked outer boundaries, limits, normal generation, conservative +bounds/cones, real GLB import, metadata-only compatibility artifacts, repeated +output replacement, and the corruption/range/hash cases above. + +The canonical static smoke asset is +`examples/renderer-test/assets/DamagedHelmet.glb`. With default limits it +currently produces 15,452 triangles in 258 meshlets and 20 pages, with a +maximum page of 63,216 bytes under the 65,536-byte budget. Two independent +cooks are byte-identical. The canonical compatibility control is +`examples/test-gltf-watch/assets/Fox.glb`. + +With `--hierarchy-levels 8`, the same static asset produces 247 leaf clusters, +385 parent clusters, and 100 level-3 coarse roots. The roots reduce the +triangle set by 70.8% and raw payload by 60.2%; two cooks are byte-identical. +Those roots occupy the first eight pages and require exactly 469,360 resident +payload bytes, only 790 bytes of packing overhead beyond their raw cluster +payload. This is structural hierarchy/page qualification, not yet a measured +runtime residency budget. +The hierarchy and page-placement records are +`docs/evidence/issue-131-atomic-hierarchy-v1.{md,json}` and +`docs/evidence/issue-131-coarse-page-prefix-v1.{md,json}`. diff --git a/examples/bistro/main.ts b/examples/bistro/main.ts index f382138d..84f66848 100644 --- a/examples/bistro/main.ts +++ b/examples/bistro/main.ts @@ -20,7 +20,7 @@ // below to open that one instead. import { - initWindow, windowShouldClose, beginDrawing, endDrawing, takeScreenshot, + initWindow, closeWindow, windowShouldClose, beginDrawing, endDrawing, takeScreenshot, setEnvClearFromHdr, setTargetFPS, getDeltaTime, getFPS, isKeyDown, isKeyPressed, getMouseDeltaX, getMouseDeltaY, @@ -28,7 +28,9 @@ import { beginMode3D, endMode3D, setFog, setSunShafts, setVignette, setChromaticAberration, setAutoExposure, setEnvIntensity, setManualExposure, setTaaEnabled, + getCommandLineArgs, resize, } from "bloom/core"; +import { parseQualityRun, QualityRun } from "bloom/quality"; import { Key } from "bloom/core"; import { drawText } from "bloom/text"; import { @@ -47,14 +49,15 @@ const MOVE_SPEED = 5.0; const SPRINT_MULT = 2.5; // Auto-capture args (matches the sponza examples' CLI) -declare const process: { argv: string[] }; -const argv: string[] = process.argv; +const argv: string[] = getCommandLineArgs(); +const qualityConfig = parseQualityRun(argv); let captureFrames = 0; let capturePath = ""; let frameCount = 0; let initYaw = 0.0; +let scenePath = "assets/bistro.gltf"; let taaOverride = -1; // -1 = default, 0 = force off, 1 = force on -for (let i = 2; i < argv.length; i = i + 1) { +for (let i = 1; i < argv.length; i = i + 1) { if (argv[i] === "--capture" && i + 2 < argv.length) { captureFrames = Math.floor(parseFloat(argv[i + 1])); capturePath = argv[i + 2]; @@ -65,11 +68,16 @@ for (let i = 2; i < argv.length; i = i + 1) { if (argv[i] === "--taa" && i + 1 < argv.length) { taaOverride = parseInt(argv[i + 1]); } + if (argv[i] === "--scene" && i + 1 < argv.length) { + scenePath = argv[i + 1]; + } } // ---- Init ---- initWindow(SCREEN_W, SCREEN_H, "Bloom Bistro", 0); +if (qualityConfig !== null) resize(SCREEN_W, SCREEN_H, SCREEN_W, SCREEN_H); setTargetFPS(60); +let qualityRun: QualityRun | null = qualityConfig !== null ? new QualityRun(qualityConfig) : null; setEnvClearFromHdr("assets/outdoor.hdr"); enableShadows(); @@ -95,7 +103,8 @@ setChromaticAberration(0.0005); // ---- Load Bistro into scene graph ---- // `bistro.gltf` = exterior street corner. Swap to `bistrox.gltf` // for the interior wine-bar variant. -const bistro = loadModel("assets/bistro.gltf"); +const bistro = loadModel(scenePath); +console.error("BLOOM_QUALITY_SCENE bistro_meshes=" + bistro.meshCount); const identity = mat4Identity(); for (let i = 0; i < bistro.meshCount; i = i + 1) { const node = createSceneNode(); @@ -117,7 +126,8 @@ let cursorLocked = false; // ---- Main loop ---- while (!windowShouldClose()) { - const dt = getDeltaTime(); + const qualityCapture = qualityRun !== null ? qualityRun.beginFrame() : false; + const dt = qualityRun !== null ? qualityRun.deltaTime() : getDeltaTime(); if (cursorLocked) { camYaw = camYaw - getMouseDeltaX() * MOUSE_SENS; @@ -174,24 +184,30 @@ while (!windowShouldClose()) { endMode3D(); - // HUD - drawText("Bloom Bistro", 10, 10, 20, { r: 255, g: 255, b: 255, a: 255 }); - const fps = getFPS(); - const ms = fps > 0.0 ? 1000.0 / fps : 0.0; - const fpsColor = fps >= 55.0 - ? { r: 120, g: 230, b: 120, a: 255 } - : fps >= 30.0 - ? { r: 230, g: 220, b: 120, a: 255 } - : { r: 230, g: 120, b: 120, a: 255 }; - const fpsText = `FPS ${Math.round(fps)} (${ms.toFixed(1)} ms)`; - drawText(fpsText, 10, 35, 16, fpsColor); - drawText("WASD move / Mouse look / Tab cursor", 10, SCREEN_H - 30, 14, { r: 180, g: 180, b: 180, a: 255 }); - - if (captureFrames > 0) { + if (qualityRun === null) { + drawText("Bloom Bistro", 10, 10, 20, { r: 255, g: 255, b: 255, a: 255 }); + const fps = getFPS(); + const ms = fps > 0.0 ? 1000.0 / fps : 0.0; + const fpsColor = fps >= 55.0 + ? { r: 120, g: 230, b: 120, a: 255 } + : fps >= 30.0 + ? { r: 230, g: 220, b: 120, a: 255 } + : { r: 230, g: 120, b: 120, a: 255 }; + const fpsText = `FPS ${Math.round(fps)} (${ms.toFixed(1)} ms)`; + drawText(fpsText, 10, 35, 16, fpsColor); + drawText("WASD move / Mouse look / Tab cursor", 10, SCREEN_H - 30, 14, { r: 180, g: 180, b: 180, a: 255 }); + } + + if (qualityCapture && qualityRun !== null) { + qualityRun.requestCapture(); + } else if (qualityRun === null && captureFrames > 0) { frameCount = frameCount + 1; if (frameCount === captureFrames) { takeScreenshot(capturePath); } if (frameCount > captureFrames) { endDrawing(); break; } } endDrawing(); + if (qualityRun !== null && qualityRun.endFrame()) break; } + +closeWindow(); diff --git a/examples/bistro/package.json b/examples/bistro/package.json index 60f1a718..e69a687e 100644 --- a/examples/bistro/package.json +++ b/examples/bistro/package.json @@ -1,6 +1,11 @@ { "name": "bloom-bistro", "version": "0.1.0", + "perry": { + "allow": { + "nativeLibrary": ["bloom/*"] + } + }, "dependencies": { "bloom": "file:../../" } diff --git a/examples/dungeon-crawl/package.json b/examples/dungeon-crawl/package.json index 17ce4335..f56811d3 100644 --- a/examples/dungeon-crawl/package.json +++ b/examples/dungeon-crawl/package.json @@ -2,6 +2,11 @@ "name": "dungeon-crawl", "version": "1.0.0", "main": "main.ts", + "perry": { + "allow": { + "nativeLibrary": ["bloom", "bloom/*"] + } + }, "dependencies": { "bloom": "file:../../" } diff --git a/examples/intel-sponza/main.ts b/examples/intel-sponza/main.ts index 402fa50b..e2b19520 100644 --- a/examples/intel-sponza/main.ts +++ b/examples/intel-sponza/main.ts @@ -22,7 +22,7 @@ import { getProfilerFrameCpuUs, getProfilerFrameGpuUs, setQualityPreset, QualityPreset, setShadowsEnabled, setSsaoEnabled, setSsrEnabled, setSsgiEnabled, - setRenderScale, setUpscaleMode, setCasStrength, + setRenderScale, setUpscaleMode, setCasStrength, getCommandLineArgs, } from "bloom/core"; import { Key } from "bloom/core"; import { drawText } from "bloom/text"; @@ -43,8 +43,7 @@ const MOVE_SPEED = 5.0; const SPRINT_MULT = 2.5; // Auto-capture args -declare const process: { argv: string[] }; -const argv: string[] = process.argv; +const argv: string[] = getCommandLineArgs(); let captureFrames = 0; let capturePath = ""; let frameCount = 0; @@ -103,7 +102,7 @@ const sweepUpscaleN: number[] = new Array(sweepCount); sweepUpscaleN[0] = 1; sweepUpscaleN[1] = 1; sweepUpscaleN[2] = 1; sweepUpscaleN[3] = 1; sweepUpscaleN[4] = 0; const sweepCas: number[] = new Array(sweepCount); sweepCas[0] = 0.0; sweepCas[1] = 0.0; sweepCas[2] = 0.4; sweepCas[3] = 0.5; sweepCas[4] = 0.0; -for (let i = 2; i < argv.length; i = i + 1) { +for (let i = 1; i < argv.length; i = i + 1) { if (argv[i] === "--capture" && i + 2 < argv.length) { captureFrames = Math.floor(parseFloat(argv[i + 1])); capturePath = argv[i + 2]; diff --git a/examples/intel-sponza/package.json b/examples/intel-sponza/package.json index 11aa784d..f4a855a0 100644 --- a/examples/intel-sponza/package.json +++ b/examples/intel-sponza/package.json @@ -1,6 +1,11 @@ { "name": "bloom-sponza", "version": "0.1.0", + "perry": { + "allow": { + "nativeLibrary": ["bloom", "bloom/*"] + } + }, "dependencies": { "bloom": "file:../../" } diff --git a/examples/isometric-rpg/package.json b/examples/isometric-rpg/package.json index c7fe7894..97f5dade 100644 --- a/examples/isometric-rpg/package.json +++ b/examples/isometric-rpg/package.json @@ -2,6 +2,11 @@ "name": "isometric-rpg", "version": "1.0.0", "main": "main.ts", + "perry": { + "allow": { + "nativeLibrary": ["bloom", "bloom/*"] + } + }, "dependencies": { "bloom": "file:../../" } diff --git a/examples/kart-racer/package.json b/examples/kart-racer/package.json index 3277440e..3ce90bb3 100644 --- a/examples/kart-racer/package.json +++ b/examples/kart-racer/package.json @@ -2,6 +2,11 @@ "name": "kart-racer", "version": "1.0.0", "main": "main.ts", + "perry": { + "allow": { + "nativeLibrary": ["bloom", "bloom/*"] + } + }, "dependencies": { "bloom": "file:../../" } diff --git a/examples/pbr-spheres/main.ts b/examples/pbr-spheres/main.ts index ce6eb4b0..1110e69f 100644 --- a/examples/pbr-spheres/main.ts +++ b/examples/pbr-spheres/main.ts @@ -15,12 +15,13 @@ // (matches renderer-test's headless interface). import { - initWindow, beginDrawing, endDrawing, takeScreenshot, + initWindow, closeWindow, beginDrawing, endDrawing, takeScreenshot, setEnvClearFromHdr, - setTargetFPS, + setTargetFPS, getCommandLineArgs, resize, windowShouldClose, beginMode3D, endMode3D, } from "bloom/core"; +import { parseQualityRun, QualityRun } from "bloom/quality"; import { genMeshCube, createMeshExplicit } from "bloom/models"; import { createSceneNode, setSceneNodeTransform, @@ -44,7 +45,7 @@ let headlessOutPath = ""; let headlessMode = false; let headlessCamX = 0.0; let headlessCamY = 0.0; -let headlessCamZ = 6.0; +let headlessCamZ = 7.0; let headlessTargetX = 0.0; let headlessTargetY = 0.0; let headlessTargetZ = 0.0; @@ -52,9 +53,9 @@ let headlessFov = 45.0; let headlessResW = 512; let headlessResH = 512; -declare const process: { argv: string[] }; -const argv: string[] = process.argv; -for (let i = 2; i < argv.length; i = i + 1) { +const argv: string[] = getCommandLineArgs(); +const qualityConfig = parseQualityRun(argv); +for (let i = 1; i < argv.length; i = i + 1) { if (argv[i] === "--out" && i + 1 < argv.length) { headlessOutPath = argv[i + 1]; headlessMode = true; @@ -76,8 +77,16 @@ for (let i = 2; i < argv.length; i = i + 1) { // ---- Init ---- initWindow(headlessResW, headlessResH, "Bloom PBR Spheres", 0); +if (qualityConfig !== null) { + // Pin both the swapchain and logical viewport to the manifest resolution; + // otherwise Retina hosts silently capture and benchmark 2x in each axis. + resize(headlessResW, headlessResH, headlessResW, headlessResH); +} setTargetFPS(60); -setEnvClearFromHdr("assets/outdoor.hdr"); +// This example has no private assets directory; use the checked-in canonical +// environment shared with renderer-test. +setEnvClearFromHdr("../renderer-test/assets/outdoor.hdr"); +let qualityRun: QualityRun | null = qualityConfig !== null ? new QualityRun(qualityConfig) : null; // Mirror renderer-test's pattern exactly: declare let-binding for // the handle, populate inside a function (function-local scope @@ -170,7 +179,9 @@ function makeSphere(segs: number, rings: number): { } function initSharedMeshes(): void { - const sphere = makeSphere(24, 16); + // Shared mesh: higher tessellation has no draw-call cost and prevents the + // silhouette faceting that would otherwise dominate the edge metric. + const sphere = makeSphere(64, 48); sphereHandle = createMeshExplicit( sphere.vertices, sphere.vertexCount, sphere.indices, sphere.indexCount, @@ -214,6 +225,7 @@ let headlessFrame = 0; const HEADLESS_WARMUP_FRAMES = 30; while (!windowShouldClose()) { + const qualityCapture = qualityRun !== null ? qualityRun.beginFrame() : false; beginDrawing(); beginMode3D({ @@ -225,14 +237,26 @@ while (!windowShouldClose()) { }); endMode3D(); + if (qualityCapture && qualityRun !== null) { + qualityRun.requestCapture(); + } else if ( + qualityRun === null && headlessMode && headlessOutPath.length > 0 + && headlessFrame + 1 === HEADLESS_WARMUP_FRAMES + ) { + // Screenshot requests must precede endDrawing() so this frame owns the + // readback. The old post-present request captured the following frame. + takeScreenshot(headlessOutPath); + } endDrawing(); - if (headlessMode) { + if (qualityRun !== null) { + if (qualityRun.endFrame()) break; + } else if (headlessMode) { headlessFrame = headlessFrame + 1; - if (headlessFrame === HEADLESS_WARMUP_FRAMES) { - takeScreenshot(headlessOutPath); - } else if (headlessFrame > HEADLESS_WARMUP_FRAMES) { + if (headlessFrame >= HEADLESS_WARMUP_FRAMES) { break; } } } + +closeWindow(); diff --git a/examples/pbr-spheres/package.json b/examples/pbr-spheres/package.json index 9a0f9f47..8aab6559 100644 --- a/examples/pbr-spheres/package.json +++ b/examples/pbr-spheres/package.json @@ -1,6 +1,11 @@ { "name": "bloom-pbr-spheres", "version": "0.1.0", + "perry": { + "allow": { + "nativeLibrary": ["bloom/*"] + } + }, "dependencies": { "bloom": "file:../../" } diff --git a/examples/pong/package.json b/examples/pong/package.json index 3fcb3102..5a036a8a 100644 --- a/examples/pong/package.json +++ b/examples/pong/package.json @@ -2,6 +2,11 @@ "name": "pong", "version": "1.0.0", "main": "main.ts", + "perry": { + "allow": { + "nativeLibrary": ["bloom", "bloom/*"] + } + }, "dependencies": { "bloom": "file:../../" } diff --git a/examples/quality-masked/assets/masked-card.gltf b/examples/quality-masked/assets/masked-card.gltf new file mode 100644 index 00000000..1919ddb5 --- /dev/null +++ b/examples/quality-masked/assets/masked-card.gltf @@ -0,0 +1,120 @@ +{ + "asset": { + "version": "2.0", + "generator": "Bloom masked-alpha coverage qualification fixture" + }, + "scene": 0, + "scenes": [ + { + "nodes": [0] + } + ], + "nodes": [ + { + "mesh": 0 + } + ], + "materials": [ + { + "name": "MASK coverage card", + "pbrMetallicRoughness": { + "baseColorFactor": [1.0, 1.0, 1.0, 1.0], + "baseColorTexture": { + "index": 0 + }, + "metallicFactor": 0.0, + "roughnessFactor": 0.9 + }, + "alphaMode": "MASK", + "alphaCutoff": 0.5, + "doubleSided": true + } + ], + "textures": [ + { + "source": 0 + } + ], + "images": [ + { + "uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAvElEQVR42u3YPQqAMAwG0BzDY3j/wavpIOLgD6it0OZRnDKUfNhnTcyxrmEa57On93raxrd6ZG388AZkPQrBAAYwgAEMYAADGMAABjCAAQxgAAMYwAAGMIABDKi88R77cXVtwF3jV0F0Z8DbEJo34G0AXRjwNYTmDSgZQJMGlAggpQF/HAUGMIAB7gG/XDlTG+Bf4MFXwTzATNBM0EyQAQxgAAMYwAAGMIABDGAAAxjAAAYwgAEMYAADatUXF2sx+EKyYEAAAAAASUVORK5CYII=" + } + ], + "meshes": [ + { + "name": "Masked card", + "primitives": [ + { + "attributes": { + "POSITION": 0, + "NORMAL": 1, + "TEXCOORD_0": 2 + }, + "indices": 3, + "material": 0, + "mode": 4 + } + ] + } + ], + "buffers": [ + { + "byteLength": 140, + "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAACAPwAAgD8AAAAAAACAvwAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgD8AAIA/AACAPwAAgD8AAAAAAAAAAAAAAAAAAAEAAgAAAAIAAwA=" + } + ], + "bufferViews": [ + { + "buffer": 0, + "byteOffset": 0, + "byteLength": 48, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 48, + "byteLength": 48, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 96, + "byteLength": 32, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 128, + "byteLength": 12, + "target": 34963 + } + ], + "accessors": [ + { + "bufferView": 0, + "componentType": 5126, + "count": 4, + "type": "VEC3", + "min": [-1.0, -1.0, 0.0], + "max": [1.0, 1.0, 0.0] + }, + { + "bufferView": 1, + "componentType": 5126, + "count": 4, + "type": "VEC3" + }, + { + "bufferView": 2, + "componentType": 5126, + "count": 4, + "type": "VEC2" + }, + { + "bufferView": 3, + "componentType": 5123, + "count": 6, + "type": "SCALAR" + } + ] +} diff --git a/examples/quality-masked/main.ts b/examples/quality-masked/main.ts new file mode 100644 index 00000000..8801ddc8 --- /dev/null +++ b/examples/quality-masked/main.ts @@ -0,0 +1,109 @@ +// Deterministic masked-alpha qualification scene: coverage-preserving cards +// span multiple projected mip sizes while moving and casting real silhouettes. + +import { + initWindow, closeWindow, windowShouldClose, + beginDrawing, endDrawing, beginMode3D, endMode3D, + setTargetFPS, setAutoExposure, setManualExposure, setTaaEnabled, + setShadowsEnabled, getCommandLineArgs, resize, +} from "bloom/core"; +import { parseQualityRun, QualityRun } from "bloom/quality"; +import { + loadModel, drawModelTransform, drawCube, + setAmbientLight, setDirectionalLight, +} from "bloom/models"; +import { + mat4Identity, mat4Translate, mat4Scale, mat4RotateY, mat4RotateZ, +} from "bloom/math"; + +const argv: string[] = getCommandLineArgs(); +const config = parseQualityRun(argv); +const WIDTH = 960; +const HEIGHT = 540; +const DEPTH_ROWS = 6; +const COLUMNS = 8; + +initWindow(WIDTH, HEIGHT, "Bloom Quality: Masked Alpha Coverage", 0); +if (config !== null) resize(WIDTH, HEIGHT, WIDTH, HEIGHT); +setTargetFPS(60); +setAutoExposure(false); +setManualExposure(1.0); +setTaaEnabled(true); +setShadowsEnabled(true); + +const quality: QualityRun | null = config !== null ? new QualityRun(config) : null; +const card = loadModel("assets/masked-card.gltf"); +let simulationTime = 0.0; + +while (!windowShouldClose()) { + const capture = quality !== null ? quality.beginFrame() : false; + const dt = quality !== null ? quality.deltaTime() : 1 / 60; + simulationTime = simulationTime + dt; + + beginDrawing(); + setAmbientLight({ r: 155, g: 170, b: 190, a: 255 }, 0.55); + setDirectionalLight( + { x: -0.38, y: 0.82, z: 0.42 }, + { r: 255, g: 245, b: 225, a: 255 }, + 1.2, + ); + beginMode3D({ + position: { x: 0, y: 5.0, z: 10.0 }, + target: { x: 0, y: -0.3, z: -4.0 }, + up: { x: 0, y: 1, z: 0 }, + fovy: 52, + projection: "perspective", + }); + + drawCube( + { x: 0, y: -1.25, z: -4.5 }, + 22.0, 0.16, 30.0, + { r: 42, g: 48, b: 58, a: 255 }, + ); + + for (let row = 0; row < DEPTH_ROWS; row = row + 1) { + const z = 3.0 - row * 3.0; + const spread = 8.5 + row * 2.0; + const projectedScale = Math.pow(0.72, row); + for (let column = 0; column < COLUMNS; column = column + 1) { + const phase = row * 0.71 + column * 0.37; + const x = (column / (COLUMNS - 1) - 0.5) * spread; + let transform = mat4Identity(); + transform = mat4Translate(transform, { + x, + y: -0.15 + row * 0.35 + (column % 2) * 0.06, + z, + }); + transform = mat4RotateY( + transform, + Math.sin(simulationTime * 0.42 + phase) * 0.12, + ); + transform = mat4RotateZ( + transform, + Math.sin(simulationTime * 0.63 + phase) * 0.035, + ); + transform = mat4Scale(transform, { + x: 0.55 * projectedScale, + y: 0.82 * projectedScale, + z: 1.0, + }); + drawModelTransform( + card, + transform, + { + r: 150 + (column * 13) % 80, + g: 205 + (row * 7) % 45, + b: 145 + (row * 11 + column * 5) % 75, + a: 255, + }, + ); + } + } + + endMode3D(); + if (capture && quality !== null) quality.requestCapture(); + endDrawing(); + if (quality !== null && quality.endFrame()) break; +} + +closeWindow(); diff --git a/examples/quality-masked/package.json b/examples/quality-masked/package.json new file mode 100644 index 00000000..31df7843 --- /dev/null +++ b/examples/quality-masked/package.json @@ -0,0 +1,13 @@ +{ + "name": "bloom-quality-masked", + "version": "0.1.0", + "private": true, + "perry": { + "allow": { + "nativeLibrary": ["bloom/*"] + } + }, + "dependencies": { + "bloom": "file:../../" + } +} diff --git a/examples/quality-motion/main.ts b/examples/quality-motion/main.ts new file mode 100644 index 00000000..aa71084d --- /dev/null +++ b/examples/quality-motion/main.ts @@ -0,0 +1,102 @@ +// Deterministic skinned-motion + alpha-test qualification scene. +// The Fox supplies skeletal deformation/motion vectors; a single Sponza +// curtain primitive supplies a textured MASK surface and cutout shadow. + +import { + initWindow, closeWindow, windowShouldClose, + beginDrawing, endDrawing, beginMode3D, endMode3D, + setEnvClearFromHdr, setTargetFPS, setAutoExposure, setManualExposure, + setTaaEnabled, setEnvIntensity, getCommandLineArgs, resize, +} from "bloom/core"; +import { parseQualityRun, QualityRun } from "bloom/quality"; +import { + loadModel, loadModelAnimation, updateModelAnimation, drawModel, + setAmbientLight, setDirectionalLight, +} from "bloom/models"; +import { + enableShadows, createSceneNode, attachModelToNode, + setSceneNodeTransform, setSceneNodeCastShadow, setSceneNodeReceiveShadow, +} from "bloom/scene"; +import { mat4Identity, mat4Translate, mat4Scale } from "bloom/math"; + +const argv: string[] = getCommandLineArgs(); +const config = parseQualityRun(argv); + +initWindow(800, 450, "Bloom Quality: Skinned + Alpha Motion", 0); +if (config !== null) resize(800, 450, 800, 450); +setTargetFPS(60); +setEnvClearFromHdr("../renderer-test/assets/outdoor.hdr"); +setEnvIntensity(0.8); +setAutoExposure(false); +setManualExposure(1.0); +setTaaEnabled(true); +enableShadows(); + +const quality: QualityRun | null = config !== null ? new QualityRun(config) : null; +const foxModel = loadModel("../test-gltf-watch/assets/Fox.glb"); +const foxAnimation = loadModelAnimation("../test-gltf-watch/assets/Fox.glb"); + +// Khronos Sponza primitive 0 is MASK foliage. The loader already bakes +// the glTF scene transforms (including its authored unit conversion), so the +// scene node must remain identity-scaled; applying another 0.01 made the +// backdrop disappear from the qualification camera. +const sponza = loadModel("../sponza/assets/Sponza.glb"); +const curtain = createSceneNode(); +attachModelToNode(curtain, sponza.handle, 0); +// Sponza's flattened primitive is centred at (3.962, 1.185, 1.588). +// Compose T(target) * S * T(-centre) so enlarging the authored foliage does +// not scale its baked world-space offset and move it out of frame. +let curtainTransform = mat4Identity(); +curtainTransform = mat4Translate( + curtainTransform, + { x: 4.86, y: 1.20, z: -2.00 }, +); +curtainTransform = mat4Scale( + curtainTransform, + { x: 4.0, y: 4.0, z: 1.0 }, +); +curtainTransform = mat4Translate( + curtainTransform, + { x: -3.962, y: -1.185, z: -1.588 }, +); +setSceneNodeTransform(curtain, curtainTransform); +setSceneNodeCastShadow(curtain, true); +setSceneNodeReceiveShadow(curtain, true); + +let simulationTime = 0.0; +while (!windowShouldClose()) { + const capture = quality !== null ? quality.beginFrame() : false; + const dt = quality !== null ? quality.deltaTime() : 1 / 60; + simulationTime = simulationTime + dt; + + // Fox clip 1 ("Walk") has continuous limb motion and a stable loop. + updateModelAnimation(foxAnimation, 1, simulationTime, 0.02, 4.86, 0.15, -1.25, 0.0); + + beginDrawing(); + setAmbientLight({ r: 120, g: 130, b: 145, a: 255 }, 0.25); + setDirectionalLight( + { x: 0.45, y: 0.85, z: 0.25 }, + { r: 255, g: 244, b: 226, a: 255 }, + 1.8, + ); + beginMode3D({ + position: { x: 4.86, y: 1.45, z: 2.2 }, + target: { x: 4.86, y: 1.2, z: -1.6 }, + up: { x: 0, y: 1, z: 0 }, + fovy: 48, + projection: "perspective", + }); + drawModel( + foxModel, + { x: 4.86, y: 0.15, z: -1.25 }, + 0.02, + { r: 255, g: 255, b: 255, a: 255 }, + ); + endMode3D(); + if (capture && quality !== null) quality.requestCapture(); + endDrawing(); + + if (quality !== null && quality.endFrame()) break; +} + +closeWindow(); diff --git a/examples/quality-motion/package.json b/examples/quality-motion/package.json new file mode 100644 index 00000000..45a724d1 --- /dev/null +++ b/examples/quality-motion/package.json @@ -0,0 +1,13 @@ +{ + "name": "bloom-quality-motion", + "version": "0.1.0", + "private": true, + "perry": { + "allow": { + "nativeLibrary": ["bloom/*"] + } + }, + "dependencies": { + "bloom": "file:../../" + } +} diff --git a/examples/quality-stress/main.ts b/examples/quality-stress/main.ts new file mode 100644 index 00000000..7c1f2de3 --- /dev/null +++ b/examples/quality-stress/main.ts @@ -0,0 +1,103 @@ +// Deterministic 10k-draw + many-light qualification scene. +// Startup/node construction is intentionally outside the measured window. + +import { + initWindow, closeWindow, windowShouldClose, + beginDrawing, endDrawing, beginMode3D, endMode3D, + setEnvClearFromHdr, setTargetFPS, setAutoExposure, setManualExposure, + setTaaEnabled, setEnvIntensity, getCommandLineArgs, resize, +} from "bloom/core"; +import { parseQualityRun, QualityRun } from "bloom/quality"; +import { + genMeshCube, setAmbientLight, setDirectionalLight, +} from "bloom/models"; +import { + enableShadows, addPointLight, createSceneNode, attachModelToNode, + setSceneNodeTransform, setSceneNodeColor, setSceneNodePbr, + setSceneNodeCastShadow, setSceneNodeReceiveShadow, +} from "bloom/scene"; +import { mat4Identity, mat4Translate, mat4Scale } from "bloom/math"; + +const argv: string[] = getCommandLineArgs(); +const config = parseQualityRun(argv); + +const GRID_X = 128; +const GRID_Z = 80; +const DRAW_COUNT = GRID_X * GRID_Z; // 10,240 independently submitted nodes. +const LIGHT_COUNT = 192; + +initWindow(1280, 720, "Bloom Quality: 10k Draws + Many Lights", 0); +if (config !== null) resize(1280, 720, 1280, 720); +setTargetFPS(60); +setEnvClearFromHdr("../renderer-test/assets/outdoor.hdr"); +setEnvIntensity(0.35); +setAutoExposure(false); +setManualExposure(1.0); +setTaaEnabled(true); +enableShadows(); +const quality: QualityRun | null = config !== null ? new QualityRun(config) : null; + +const cube = genMeshCube(1, 1, 1); +for (let i = 0; i < DRAW_COUNT; i = i + 1) { + const xIndex = i % GRID_X; + const zIndex = Math.floor(i / GRID_X); + const x = (xIndex - GRID_X / 2) * 0.72; + const z = (zIndex - GRID_Z / 2) * 0.72; + const y = 0.35 + 0.22 * Math.sin(xIndex * 0.37) * Math.cos(zIndex * 0.29); + const node = createSceneNode(); + attachModelToNode(node, cube.handle, 0); + let transform = mat4Identity(); + transform = mat4Translate(transform, { x: x, y: y, z: z }); + transform = mat4Scale(transform, { x: 0.26, y: 0.55, z: 0.26 }); + setSceneNodeTransform(node, transform); + setSceneNodeColor( + node, + 70 + (xIndex % 7) * 22, + 90 + (zIndex % 5) * 25, + 130 + ((xIndex + zIndex) % 4) * 24, + ); + setSceneNodePbr(node, 0.22 + (xIndex % 6) * 0.12, (zIndex % 9 === 0) ? 0.8 : 0.05); + // Keep the stress focused on main-view draw submission. A 10k-caster + // shadow map would test a different budget and conceal clustered-light cost. + setSceneNodeCastShadow(node, false); + setSceneNodeReceiveShadow(node, true); +} + +while (!windowShouldClose()) { + const capture = quality !== null ? quality.beginFrame() : false; + beginDrawing(); + setAmbientLight({ r: 105, g: 115, b: 135, a: 255 }, 0.25); + setDirectionalLight( + { x: -0.4, y: 0.85, z: 0.3 }, + { r: 255, g: 244, b: 228, a: 255 }, + 1.4, + ); + for (let i = 0; i < LIGHT_COUNT; i = i + 1) { + const col = i % 24; + const row = Math.floor(i / 24); + const hue = i % 3; + addPointLight( + (col - 11.5) * 3.3, + 2.0 + (i % 4) * 0.6, + (row - 3.5) * 6.0, + 5.5, + hue === 0 ? 1.0 : 0.2, + hue === 1 ? 1.0 : 0.25, + hue === 2 ? 1.0 : 0.2, + 3.0, + ); + } + beginMode3D({ + position: { x: 0, y: 38, z: 52 }, + target: { x: 0, y: 0, z: 0 }, + up: { x: 0, y: 1, z: 0 }, + fovy: 58, + projection: "perspective", + }); + endMode3D(); + if (capture && quality !== null) quality.requestCapture(); + endDrawing(); + if (quality !== null && quality.endFrame()) break; +} + +closeWindow(); diff --git a/examples/quality-stress/package.json b/examples/quality-stress/package.json new file mode 100644 index 00000000..3e9e22c9 --- /dev/null +++ b/examples/quality-stress/package.json @@ -0,0 +1,13 @@ +{ + "name": "bloom-quality-stress", + "version": "0.1.0", + "private": true, + "perry": { + "allow": { + "nativeLibrary": ["bloom/*"] + } + }, + "dependencies": { + "bloom": "file:../../" + } +} diff --git a/examples/quality-transparency/assets/transmission-quad.gltf b/examples/quality-transparency/assets/transmission-quad.gltf new file mode 100644 index 00000000..4a9e8ce4 --- /dev/null +++ b/examples/quality-transparency/assets/transmission-quad.gltf @@ -0,0 +1,110 @@ +{ + "asset": { + "version": "2.0", + "generator": "Bloom temporal-reactive transmission qualification fixture" + }, + "extensionsUsed": [ + "KHR_materials_ior", + "KHR_materials_transmission", + "KHR_materials_volume" + ], + "scene": 0, + "scenes": [ + { + "nodes": [0] + } + ], + "nodes": [ + { + "mesh": 0 + } + ], + "materials": [ + { + "name": "Physical transmission qualification layer", + "pbrMetallicRoughness": { + "baseColorFactor": [0.82, 0.92, 1.0, 1.0], + "metallicFactor": 0.0, + "roughnessFactor": 0.12 + }, + "extensions": { + "KHR_materials_ior": { + "ior": 1.5 + }, + "KHR_materials_transmission": { + "transmissionFactor": 0.86 + }, + "KHR_materials_volume": { + "thicknessFactor": 0.08, + "attenuationDistance": 3.0, + "attenuationColor": [0.78, 0.9, 1.0] + } + }, + "doubleSided": true + } + ], + "meshes": [ + { + "name": "Quad", + "primitives": [ + { + "attributes": { + "POSITION": 0, + "NORMAL": 1 + }, + "indices": 2, + "material": 0, + "mode": 4 + } + ] + } + ], + "buffers": [ + { + "byteLength": 108, + "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAACAPwAAgD8AAAAAAACAvwAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAABAAIAAAACAAMA" + } + ], + "bufferViews": [ + { + "buffer": 0, + "byteOffset": 0, + "byteLength": 48, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 48, + "byteLength": 48, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 96, + "byteLength": 12, + "target": 34963 + } + ], + "accessors": [ + { + "bufferView": 0, + "componentType": 5126, + "count": 4, + "type": "VEC3", + "min": [-1.0, -1.0, 0.0], + "max": [1.0, 1.0, 0.0] + }, + { + "bufferView": 1, + "componentType": 5126, + "count": 4, + "type": "VEC3" + }, + { + "bufferView": 2, + "componentType": 5123, + "count": 6, + "type": "SCALAR" + } + ] +} diff --git a/examples/quality-transparency/assets/transparent-quad.gltf b/examples/quality-transparency/assets/transparent-quad.gltf new file mode 100644 index 00000000..a6ab7b60 --- /dev/null +++ b/examples/quality-transparency/assets/transparent-quad.gltf @@ -0,0 +1,93 @@ +{ + "asset": { + "version": "2.0", + "generator": "Bloom weighted-transparency qualification fixture" + }, + "scene": 0, + "scenes": [ + { + "nodes": [0] + } + ], + "nodes": [ + { + "mesh": 0 + } + ], + "materials": [ + { + "name": "BLEND qualification layer", + "pbrMetallicRoughness": { + "baseColorFactor": [1.0, 1.0, 1.0, 0.28], + "metallicFactor": 0.0, + "roughnessFactor": 0.8 + }, + "alphaMode": "BLEND", + "doubleSided": true + } + ], + "meshes": [ + { + "name": "Quad", + "primitives": [ + { + "attributes": { + "POSITION": 0, + "NORMAL": 1 + }, + "indices": 2, + "material": 0, + "mode": 4 + } + ] + } + ], + "buffers": [ + { + "byteLength": 108, + "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAACAPwAAgD8AAAAAAACAvwAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAABAAIAAAACAAMA" + } + ], + "bufferViews": [ + { + "buffer": 0, + "byteOffset": 0, + "byteLength": 48, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 48, + "byteLength": 48, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 96, + "byteLength": 12, + "target": 34963 + } + ], + "accessors": [ + { + "bufferView": 0, + "componentType": 5126, + "count": 4, + "type": "VEC3", + "min": [-1.0, -1.0, 0.0], + "max": [1.0, 1.0, 0.0] + }, + { + "bufferView": 1, + "componentType": 5126, + "count": 4, + "type": "VEC3" + }, + { + "bufferView": 2, + "componentType": 5123, + "count": 6, + "type": "SCALAR" + } + ] +} diff --git a/examples/quality-transparency/main.ts b/examples/quality-transparency/main.ts new file mode 100644 index 00000000..677134f6 --- /dev/null +++ b/examples/quality-transparency/main.ts @@ -0,0 +1,241 @@ +// Deterministic weighted-transparency qualification scene: 96 imported glTF +// BLEND quads arranged as 12 intersecting eight-layer cells. + +import { + initWindow, closeWindow, windowShouldClose, + beginDrawing, endDrawing, beginMode3D, endMode3D, + setTargetFPS, setAutoExposure, setManualExposure, setTaaEnabled, + getCommandLineArgs, resize, setTransparencyCompositionMode, +} from "bloom/core"; +import { parseQualityRun, QualityRun } from "bloom/quality"; +import { + loadModel, drawModelTransform, drawCube, + setAmbientLight, setDirectionalLight, createPlanarReflection, + compileTransparentMaterial, drawMeshWithMaterial, +} from "bloom/models"; +import { + createSceneNode, attachModelToNode, setSceneNodeTransform, + setSceneNodeCastShadow, +} from "bloom/scene"; +import { + mat4Identity, mat4Translate, mat4Scale, mat4RotateX, + mat4RotateY, mat4RotateZ, +} from "bloom/math"; + +const argv: string[] = getCommandLineArgs(); +const config = parseQualityRun(argv); +const sortedInterleavingRoute = argv.includes("--sorted-interleaving"); +const sortedRoute = argv.includes("--sorted") || sortedInterleavingRoute; +// Focused unversioned glass-reflection oracle. One smooth pane reflects +// opaque objects on the camera side; unlike the 96-layer transmission stress +// route, this gives the screen-space reflection tier deliberate valid hits. +const reflectionHierarchyRoute = argv.includes("--reflection-hierarchy"); +const refractiveRoute = argv.includes("--refractive") || reflectionHierarchyRoute; +// Unversioned focused route for physical-transmission GI performance A/B. +// Unlike drawModelTransform(), retained nodes enter Mesh-Cards and the TLAS. +const transparentGiRoute = argv.includes("--transparent-gi"); + +const WIDTH = 960; +const HEIGHT = 540; +const CELLS_X = 4; +const CELLS_Y = 3; +const LAYERS = 8; + +initWindow(WIDTH, HEIGHT, "Bloom Quality: Weighted Transparency", 0); +if (config !== null) resize(WIDTH, HEIGHT, WIDTH, HEIGHT); +setTargetFPS(60); +setAutoExposure(false); +setManualExposure(1.0); +setTaaEnabled(true); +setTransparencyCompositionMode(sortedRoute ? "sorted" : "weighted"); + +const quality: QualityRun | null = config !== null ? new QualityRun(config) : null; +const quad = loadModel( + refractiveRoute || transparentGiRoute + ? "assets/transmission-quad.gltf" + : "assets/transparent-quad.gltf", +); +const customTransparentMaterial = sortedInterleavingRoute + ? compileTransparentMaterial(` +#include "material_abi.wgsl" + +struct VsOut { + @builtin(position) clip_position: vec4, +}; + +@vertex +fn vs_main(in: VertexInput) -> VsOut { + var out: VsOut; + out.clip_position = draw.mvp * vec4(in.position, 1.0); + return out; +} + +@fragment +fn fs_main(_in: VsOut) -> TranslucentOut { + var out: TranslucentOut; + out.hdr = draw.model_tint; + return out; +} +`) + : 0; +let reflectedModel = quad; +if (reflectionHierarchyRoute) { + createPlanarReflection(0.01, 0.0, 1.0, 0.0, 512); + reflectedModel = loadModel("../renderer-test/assets/DamagedHelmet.glb"); +} +const retainedQuads: number[] = []; +if (transparentGiRoute) { + for (let index = 0; index < CELLS_X * CELLS_Y * LAYERS; index = index + 1) { + const node = createSceneNode(); + attachModelToNode(node, quad.handle, 0); + // Isolate transparent-GI timings from the independent transmitted-shadow + // route while preserving camera-facing physical refraction. + setSceneNodeCastShadow(node, false); + retainedQuads.push(node); + } +} +let simulationTime = 0.0; + +while (!windowShouldClose()) { + const capture = quality !== null ? quality.beginFrame() : false; + const dt = quality !== null ? quality.deltaTime() : 1 / 60; + simulationTime = simulationTime + dt; + + beginDrawing(); + setAmbientLight({ r: 190, g: 200, b: 220, a: 255 }, 0.7); + setDirectionalLight( + { x: -0.35, y: 0.8, z: 0.45 }, + { r: 255, g: 246, b: 232, a: 255 }, + 1.0, + ); + beginMode3D({ + position: reflectionHierarchyRoute + ? { x: 0, y: 3.4, z: 8.0 } + : { x: 0, y: 0, z: 9.0 }, + target: { x: 0, y: 0, z: 0 }, + up: { x: 0, y: 1, z: 0 }, + fovy: 48, + projection: "perspective", + }); + + drawCube( + { x: 0, y: 0, z: -1.8 }, + 9.5, 6.5, 0.2, + { r: 24, g: 31, b: 48, a: 255 }, + ); + + if (reflectionHierarchyRoute) { + // Opaque camera-side geometry supplies reflection hits. The tall side + // columns remain directly visible while the smaller center objects sit + // outside the pane's direct view and appear through its reflected rays. + drawCube( + { x: -1.65, y: 1.15, z: 0.1 }, + 0.9, 2.3, 0.9, + { r: 245, g: 58, b: 44, a: 255 }, + ); + drawCube( + { x: 0.0, y: 1.0, z: -1.45 }, + 1.2, 1.2, 1.2, + { r: 36, g: 220, b: 105, a: 255 }, + ); + drawCube( + { x: 1.65, y: 0.9, z: 0.35 }, + 0.85, 1.8, 0.85, + { r: 250, g: 185, b: 36, a: 255 }, + ); + drawCube( + { x: 0.0, y: 2.35, z: -0.7 }, + 1.4, 0.55, 0.55, + { r: 48, g: 105, b: 250, a: 255 }, + ); + let reflectedTransform = mat4Identity(); + reflectedTransform = mat4Translate( + reflectedTransform, + { x: 0.0, y: 1.15, z: -0.6 }, + ); + reflectedTransform = mat4RotateY(reflectedTransform, simulationTime * 0.35); + reflectedTransform = mat4Scale( + reflectedTransform, + { x: 1.35, y: 1.35, z: 1.35 }, + ); + drawModelTransform( + reflectedModel, + reflectedTransform, + { r: 255, g: 255, b: 255, a: 255 }, + ); + + let pane = mat4Identity(); + pane = mat4Translate(pane, { x: 0.0, y: 0.01, z: 0.4 }); + pane = mat4RotateX(pane, -1.57079632679); + pane = mat4Scale(pane, { x: 2.8, y: 2.2, z: 1.0 }); + drawModelTransform( + quad, + pane, + { r: 225, g: 238, b: 255, a: 255 }, + ); + } else for (let cell = 0; cell < CELLS_X * CELLS_Y; cell = cell + 1) { + const cx = (cell % CELLS_X - (CELLS_X - 1) * 0.5) * 2.05; + const cy = (Math.floor(cell / CELLS_X) - (CELLS_Y - 1) * 0.5) * 1.75; + for (let layer = 0; layer < LAYERS; layer = layer + 1) { + const phase = layer * 0.73 + cell * 0.19; + let transform = mat4Identity(); + transform = mat4Translate(transform, { + x: cx, + y: cy, + z: -0.22 + layer * 0.055, + }); + transform = mat4RotateY( + transform, + -0.55 + layer * 0.16 + Math.sin(simulationTime * 0.7 + phase) * 0.09, + ); + transform = mat4RotateX( + transform, + -0.24 + (layer % 4) * 0.16, + ); + transform = mat4RotateZ( + transform, + Math.sin(simulationTime * 0.45 + phase) * 0.12, + ); + transform = mat4Scale(transform, { x: 0.92, y: 0.74, z: 1.0 }); + if (transparentGiRoute) { + setSceneNodeTransform(retainedQuads[cell * LAYERS + layer], transform); + } else { + drawModelTransform( + quad, + transform, + { + r: 70 + (layer * 47 + cell * 11) % 185, + g: 65 + (layer * 29 + cell * 23) % 190, + b: 85 + (layer * 61 + cell * 17) % 170, + a: 255, + }, + ); + if (sortedInterleavingRoute) { + drawMeshWithMaterial( + customTransparentMaterial, + quad, + { + x: cx + 0.13, + y: cy - 0.09, + z: -0.2475 + layer * 0.055, + }, + 0.46, + { + r: 45 + (layer * 31 + cell * 17) % 170, + g: 90 + (layer * 43 + cell * 13) % 150, + b: 55 + (layer * 23 + cell * 29) % 180, + a: 76, + }, + ); + } + } + } + } + + endMode3D(); + if (capture && quality !== null) quality.requestCapture(); + endDrawing(); + if (quality !== null && quality.endFrame()) break; +} + +closeWindow(); diff --git a/examples/quality-transparency/package.json b/examples/quality-transparency/package.json new file mode 100644 index 00000000..d5268297 --- /dev/null +++ b/examples/quality-transparency/package.json @@ -0,0 +1,13 @@ +{ + "name": "bloom-quality-transparency", + "version": "0.1.0", + "private": true, + "perry": { + "allow": { + "nativeLibrary": ["bloom/*"] + } + }, + "dependencies": { + "bloom": "file:../../" + } +} diff --git a/examples/renderer-test/main.ts b/examples/renderer-test/main.ts index 43b95191..5800299b 100644 --- a/examples/renderer-test/main.ts +++ b/examples/renderer-test/main.ts @@ -29,14 +29,18 @@ import { initWindow, closeWindow, windowShouldClose, beginDrawing, endDrawing, takeScreenshot, + captureFrameToPng, clearBackground, setEnvClearFromHdr, setTargetFPS, getDeltaTime, getFPS, getTime, + getCommandLineArgs, resize, isKeyDown, isKeyPressed, getMouseDeltaX, getMouseDeltaY, disableCursor, enableCursor, + setSsrEnabled, beginMode3D, endMode3D, setFog, setChromaticAberration, setVignette, setFilmGrain, setSunShafts, setAutoExposure, setEnvIntensity, setDepthOfField, } from "bloom/core"; +import { parseQualityRun, QualityRun } from "bloom/quality"; import { Key } from "bloom/core"; import { drawText } from "bloom/text"; @@ -51,7 +55,7 @@ import { import { createSceneNode, setSceneNodeTransform, updateSceneNodeGeometry, - setSceneNodeColor, setSceneNodePbr, + setSceneNodeColor, setSceneNodePbr, setSceneNodeMaterial, setSceneNodeCastShadow, setSceneNodeReceiveShadow, enableShadows, dumpShadowMap, addDirectionalLight, addPointLight, @@ -84,6 +88,7 @@ const TWO_PI = 6.28318530; let headlessSpecPath = ""; let headlessOutPath = ""; +let headlessModelPath = "assets/DamagedHelmet.glb"; let headlessMode = false; let headlessCamX = 0.0; let headlessCamY = 0.0; @@ -102,16 +107,20 @@ let interactiveCaptureFrames = 0; let interactiveCapturePath = ""; let headlessShadows = false; let shadowMapDumpPath = ""; +let ssrEnabled = true; -declare const process: { argv: string[] }; -const argv: string[] = process.argv; -for (let i = 2; i < argv.length; i = i + 1) { +const argv: string[] = getCommandLineArgs(); +const qualityConfig = parseQualityRun(argv); +for (let i = 0; i < argv.length; i = i + 1) { if (argv[i] === "--spec" && i + 1 < argv.length) { headlessSpecPath = argv[i + 1]; headlessMode = true; } else if (argv[i] === "--out" && i + 1 < argv.length) { headlessOutPath = argv[i + 1]; - } else if (argv[i] === "--camera" && i + 9 < argv.length) { + } else if (argv[i] === "--model" && i + 1 < argv.length) { + headlessModelPath = argv[i + 1]; + headlessMode = true; + } else if (argv[i] === "--camera" && i + 7 < argv.length) { // --camera px py pz tx ty tz fov // Primary path for headless mode — avoids JSON array access // which has known Perry LLVM backend issues (see Phase 5 notes). @@ -138,6 +147,8 @@ for (let i = 2; i < argv.length; i = i + 1) { headlessShadows = true; } else if (argv[i] === "--dump-shadow-map" && i + 1 < argv.length) { shadowMapDumpPath = argv[i + 1]; + } else if (argv[i] === "--no-ssr") { + ssrEnabled = false; } } @@ -231,8 +242,11 @@ function placeSphere( roughness: number, metalness: number, ): number { const node = placeNode(sphereHandle, 0, px, py, pz, scale, scale, scale); - setSceneNodeColor(node, cr * 255, cg * 255, cb * 255); - setSceneNodePbr(node, roughness, metalness); + setSceneNodeMaterial(node, { + color: [cr * 255, cg * 255, cb * 255], + roughness, + metalness, + }); return node; } @@ -243,8 +257,11 @@ function placeCube( roughness: number, metalness: number, ): number { const node = placeNode(cubeHandle, 0, px, py, pz, sx, sy, sz); - setSceneNodeColor(node, cr * 255, cg * 255, cb * 255); - setSceneNodePbr(node, roughness, metalness); + setSceneNodeMaterial(node, { + color: [cr * 255, cg * 255, cb * 255], + roughness, + metalness, + }); // Thin horizontal slabs (floors) should receive but not cast // shadows — otherwise they fill the shadow map with their own // depth and everything reads as "in shadow of the ground". @@ -507,7 +524,7 @@ function setupGround(): void { let gltfModel = { handle: 0, meshCount: 0, materialCount: 0, transform: [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1] }; function setupGltfModel(): void { - gltfModel = loadModel("assets/DamagedHelmet.glb"); + gltfModel = loadModel(headlessMode ? headlessModelPath : "assets/DamagedHelmet.glb"); // Pedestal (still rendered via scene graph) placeCube(0, 0.1, -30, 6, 0.2, 6, 0.15, 0.15, 0.17, 0.9, 0.0); } @@ -557,7 +574,10 @@ const zoneNames: string[] = [ const winW = headlessResW > 0 ? headlessResW : SCREEN_W; const winH = headlessResH > 0 ? headlessResH : SCREEN_H; initWindow(winW, winH, "Bloom Renderer Test", 0); +if (qualityConfig !== null) resize(winW, winH, winW, winH); setTargetFPS(60); +setSsrEnabled(ssrEnabled); +let qualityRun: QualityRun | null = qualityConfig !== null ? new QualityRun(qualityConfig) : null; if (!headlessMode) { disableCursor(); } @@ -627,14 +647,19 @@ if (headlessShadows) { let headlessFrame = 0; const HEADLESS_WARMUP_FRAMES = 30; +let qualitySimulationTime = 0.0; while (!windowShouldClose()) { - const dt = getDeltaTime(); - const t = getTime(); + const qualityCapture = qualityRun !== null ? qualityRun.beginFrame() : false; + const dt = qualityRun !== null ? qualityRun.deltaTime() : getDeltaTime(); + const t = qualityRun !== null ? qualitySimulationTime : getTime(); + qualitySimulationTime = qualitySimulationTime + dt; // ---- Camera controls ---- - if (headlessMode) { + if (qualityCapture && qualityRun !== null) { + qualityRun.requestCapture(); + } else if (qualityRun === null && headlessMode) { // No input — the spec's camera pose is applied directly below. } else if (cursorLocked) { camYaw = camYaw - getMouseDeltaX() * MOUSE_SENS; @@ -779,10 +804,6 @@ while (!windowShouldClose()) { fovy: headlessFov, projection: "perspective", }); - // TEMP: visual sanity check — a bright cube at origin. If this - // shows but the glTF helmet doesn't, the issue is in the glTF - // model load, not the render pass. - drawCube({ x: 0, y: 0, z: 0 }, 0.8, 0.8, 0.8, { r: 255, g: 100, b: 100, a: 255 }); if (gltfModel.handle !== 0) { drawModel(gltfModel, { x: 0, y: 0, z: 0 }, 1.0, { r: 255, g: 255, b: 255, a: 255 }); } @@ -823,10 +844,10 @@ while (!windowShouldClose()) { // Headless: after warmup frames, capture the frame to --out and // exit. We request the screenshot BEFORE endDrawing() so the // renderer's pending-screenshot path picks it up during present. - if (headlessMode) { + if (headlessMode && qualityRun === null) { headlessFrame = headlessFrame + 1; if (headlessFrame === HEADLESS_WARMUP_FRAMES && headlessOutPath.length > 0) { - takeScreenshot(headlessOutPath); + captureFrameToPng(headlessOutPath); if (shadowMapDumpPath.length > 0) { dumpShadowMap(shadowMapDumpPath); } @@ -835,7 +856,7 @@ while (!windowShouldClose()) { endDrawing(); break; } - } else if (interactiveCaptureFrames > 0) { + } else if (qualityRun === null && interactiveCaptureFrames > 0) { headlessFrame = headlessFrame + 1; if (headlessFrame === interactiveCaptureFrames) { takeScreenshot(interactiveCapturePath); @@ -847,6 +868,9 @@ while (!windowShouldClose()) { } endDrawing(); + if (qualityRun !== null && qualityRun.endFrame()) { + break; + } } // Clean shutdown — headless mode relies on this so the PNG flushes diff --git a/examples/renderer-test/package.json b/examples/renderer-test/package.json index 83ba1ca4..d39399f6 100644 --- a/examples/renderer-test/package.json +++ b/examples/renderer-test/package.json @@ -1,6 +1,11 @@ { "name": "bloom-renderer-test", "version": "0.1.0", + "perry": { + "allow": { + "nativeLibrary": ["bloom/*"] + } + }, "dependencies": { "bloom": "file:../../" } diff --git a/examples/space-blaster/package.json b/examples/space-blaster/package.json index b440d166..33910fbf 100644 --- a/examples/space-blaster/package.json +++ b/examples/space-blaster/package.json @@ -2,6 +2,11 @@ "name": "space-blaster", "version": "1.0.0", "main": "main.ts", + "perry": { + "allow": { + "nativeLibrary": ["bloom", "bloom/*"] + } + }, "dependencies": { "bloom": "file:../../" } diff --git a/examples/sponza/main b/examples/sponza/main index 65da947c..f504a44b 100755 Binary files a/examples/sponza/main and b/examples/sponza/main differ diff --git a/examples/sponza/main.ts b/examples/sponza/main.ts index 90fb9574..f3a6d612 100644 --- a/examples/sponza/main.ts +++ b/examples/sponza/main.ts @@ -7,7 +7,7 @@ // auto-exposure in bright courtyard vs shadowed corridors. import { - initWindow, windowShouldClose, beginDrawing, endDrawing, takeScreenshot, + initWindow, closeWindow, windowShouldClose, beginDrawing, endDrawing, takeScreenshot, setEnvClearFromHdr, setTargetFPS, getDeltaTime, getFPS, isKeyDown, isKeyPressed, getMouseDeltaX, getMouseDeltaY, @@ -15,7 +15,9 @@ import { beginMode3D, endMode3D, setFog, setSunShafts, setVignette, setChromaticAberration, setAutoExposure, setEnvIntensity, setManualExposure, setTaaEnabled, + getCommandLineArgs, resize, } from "bloom/core"; +import { parseQualityRun, QualityRun } from "bloom/quality"; import { Key } from "bloom/core"; import { drawText } from "bloom/text"; import { @@ -35,14 +37,14 @@ const MOVE_SPEED = 5.0; const SPRINT_MULT = 2.5; // Auto-capture args -declare const process: { argv: string[] }; -const argv: string[] = process.argv; +const argv: string[] = getCommandLineArgs(); +const qualityConfig = parseQualityRun(argv); let captureFrames = 0; let capturePath = ""; let frameCount = 0; let initYaw = 0.0; let taaOverride = -1; // -1 = default, 0 = force off, 1 = force on -for (let i = 2; i < argv.length; i = i + 1) { +for (let i = 1; i < argv.length; i = i + 1) { if (argv[i] === "--capture" && i + 2 < argv.length) { captureFrames = Math.floor(parseFloat(argv[i + 1])); capturePath = argv[i + 2]; @@ -57,7 +59,9 @@ for (let i = 2; i < argv.length; i = i + 1) { // ---- Init ---- initWindow(SCREEN_W, SCREEN_H, "Bloom Sponza", 0); +if (qualityConfig !== null) resize(SCREEN_W, SCREEN_H, SCREEN_W, SCREEN_H); setTargetFPS(60); +let qualityRun: QualityRun | null = qualityConfig !== null ? new QualityRun(qualityConfig) : null; setEnvClearFromHdr("assets/outdoor.hdr"); enableShadows(); @@ -94,7 +98,8 @@ let cursorLocked = false; // ---- Main loop ---- while (!windowShouldClose()) { - const dt = getDeltaTime(); + const qualityCapture = qualityRun !== null ? qualityRun.beginFrame() : false; + const dt = qualityRun !== null ? qualityRun.deltaTime() : getDeltaTime(); // Camera controls if (cursorLocked) { @@ -153,27 +158,32 @@ while (!windowShouldClose()) { endMode3D(); - // HUD - drawText("Bloom Sponza", 10, 10, 20, { r: 255, g: 255, b: 255, a: 255 }); - const fps = getFPS(); - const ms = fps > 0.0 ? 1000.0 / fps : 0.0; - // Color the FPS line based on perf bucket so glances give - // instant feedback during stress tests. - const fpsColor = fps >= 55.0 - ? { r: 120, g: 230, b: 120, a: 255 } - : fps >= 30.0 - ? { r: 230, g: 220, b: 120, a: 255 } - : { r: 230, g: 120, b: 120, a: 255 }; - const fpsText = `FPS ${Math.round(fps)} (${ms.toFixed(1)} ms)`; - drawText(fpsText, 10, 35, 16, fpsColor); - drawText("WASD move / Mouse look / Tab cursor", 10, SCREEN_H - 30, 14, { r: 180, g: 180, b: 180, a: 255 }); + // Never bake a wall-clock FPS counter into a deterministic baseline. + if (qualityRun === null) { + drawText("Bloom Sponza", 10, 10, 20, { r: 255, g: 255, b: 255, a: 255 }); + const fps = getFPS(); + const ms = fps > 0.0 ? 1000.0 / fps : 0.0; + const fpsColor = fps >= 55.0 + ? { r: 120, g: 230, b: 120, a: 255 } + : fps >= 30.0 + ? { r: 230, g: 220, b: 120, a: 255 } + : { r: 230, g: 120, b: 120, a: 255 }; + const fpsText = `FPS ${Math.round(fps)} (${ms.toFixed(1)} ms)`; + drawText(fpsText, 10, 35, 16, fpsColor); + drawText("WASD move / Mouse look / Tab cursor", 10, SCREEN_H - 30, 14, { r: 180, g: 180, b: 180, a: 255 }); + } // Auto-capture for automated testing - if (captureFrames > 0) { + if (qualityCapture && qualityRun !== null) { + qualityRun.requestCapture(); + } else if (qualityRun === null && captureFrames > 0) { frameCount = frameCount + 1; if (frameCount === captureFrames) { takeScreenshot(capturePath); } if (frameCount > captureFrames) { endDrawing(); break; } } endDrawing(); + if (qualityRun !== null && qualityRun.endFrame()) break; } + +closeWindow(); diff --git a/examples/sponza/package.json b/examples/sponza/package.json index 11aa784d..f61e7e40 100644 --- a/examples/sponza/package.json +++ b/examples/sponza/package.json @@ -1,6 +1,11 @@ { "name": "bloom-sponza", "version": "0.1.0", + "perry": { + "allow": { + "nativeLibrary": ["bloom/*"] + } + }, "dependencies": { "bloom": "file:../../" } diff --git a/examples/test-gltf-watch/package.json b/examples/test-gltf-watch/package.json index 566b7677..624b9958 100644 --- a/examples/test-gltf-watch/package.json +++ b/examples/test-gltf-watch/package.json @@ -1,5 +1,10 @@ { "name": "bloom-gltf-watch-test", "version": "0.1.0", + "perry": { + "allow": { + "nativeLibrary": ["bloom", "bloom/*"] + } + }, "dependencies": { "bloom": "file:../../" } } diff --git a/examples/test-scene-watch/package.json b/examples/test-scene-watch/package.json index fafcad13..a0c08e32 100644 --- a/examples/test-scene-watch/package.json +++ b/examples/test-scene-watch/package.json @@ -1,6 +1,11 @@ { "name": "bloom-scene-watch-test", "version": "0.1.0", + "perry": { + "allow": { + "nativeLibrary": ["bloom", "bloom/*"] + } + }, "dependencies": { "bloom": "file:../../" } diff --git a/examples/test3d/package.json b/examples/test3d/package.json index 4befaf0b..d4dd10bc 100644 --- a/examples/test3d/package.json +++ b/examples/test3d/package.json @@ -1,6 +1,11 @@ { "name": "bloom-3d-test", "version": "0.1.0", + "perry": { + "allow": { + "nativeLibrary": ["bloom", "bloom/*"] + } + }, "dependencies": { "bloom": "file:../../" } diff --git a/examples/voxel-sandbox/package.json b/examples/voxel-sandbox/package.json index 79c48a39..57ce79ba 100644 --- a/examples/voxel-sandbox/package.json +++ b/examples/voxel-sandbox/package.json @@ -2,6 +2,11 @@ "name": "voxel-sandbox", "version": "1.0.0", "main": "main.ts", + "perry": { + "allow": { + "nativeLibrary": ["bloom", "bloom/*"] + } + }, "dependencies": { "bloom": "file:../../" } diff --git a/native/android/Cargo.lock b/native/android/Cargo.lock index 32e3a16d..3747a895 100644 --- a/native/android/Cargo.lock +++ b/native/android/Cargo.lock @@ -8,12 +8,38 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -90,8 +116,11 @@ dependencies = [ name = "bloom-android" version = "0.1.0" dependencies = [ + "android_logger", "bloom-shared", + "image", "libc", + "log", "ndk", "oboe", "raw-window-handle", @@ -112,8 +141,11 @@ dependencies = [ "image_dds", "lewton", "libc", + "log", "minimp3", "raw-window-handle", + "serde_json", + "web-sys", "wgpu", ] @@ -281,6 +313,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1110,6 +1152,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "renderdoc-sys" version = "1.1.0" diff --git a/native/android/Cargo.toml b/native/android/Cargo.toml index 3283597c..c794bf7e 100644 --- a/native/android/Cargo.toml +++ b/native/android/Cargo.toml @@ -17,8 +17,17 @@ image-extras = ["bloom-shared/image-extras"] [dependencies] bloom-shared = { path = "../shared", default-features = false, features = ["mp3"] } +# The define_core_ffi! macro expands to image::open(...) under models3d / +# image-extras (see shared/src/ffi_core/game_loop.rs), so this crate needs a +# direct `image` dep just like the macos/ios/linux crates. Matches linux's +# codec set: HDR env maps + PNG/JPEG textures. +image = { version = "0.25", default-features = false, features = ["hdr", "png", "jpeg"] } raw-window-handle = "0.6" wgpu = "29" libc = "0.2" ndk = "0.9" oboe = "0.6" +# SH-055 diag — route wgpu/naga `log` output (shader-validation errors) to +# logcat so the Adreno Vulkan material-compile failures are diagnosable. +log = "0.4" +android_logger = "0.14" diff --git a/native/android/src/lib.rs b/native/android/src/lib.rs index 151c6059..458f4dde 100644 --- a/native/android/src/lib.rs +++ b/native/android/src/lib.rs @@ -1,10 +1,10 @@ +use bloom_shared::audio::{parse_mp3, parse_ogg, parse_wav}; use bloom_shared::engine::EngineState; use bloom_shared::renderer::Renderer; -use bloom_shared::string_header::{str_from_header, alloc_perry_string}; -use bloom_shared::audio::{parse_wav, parse_ogg, parse_mp3}; +use bloom_shared::string_header::{alloc_perry_string, str_from_header}; -use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; static mut ENGINE: OnceLock = OnceLock::new(); static mut NATIVE_WINDOW: *mut libc::c_void = std::ptr::null_mut(); @@ -24,7 +24,6 @@ fn bloom_resolve_asset_path(path: &str) -> std::borrow::Cow<'_, str> { // docs for the contract; tools/validate-ffi.js checks parity in CI. bloom_shared::define_core_ffi!(); - /// Resolve relative asset paths to the app's base asset directory. /// On Android, relative paths like "assets/models/tree.glb" won't resolve /// because the working directory isn't the app's data directory. @@ -75,11 +74,13 @@ pub extern "C" fn bloom_android_set_native_window(window: *mut libc::c_void) { } fn pollster_block_on(future: F) -> F::Output { - use std::task::{Context, Poll, Wake, Waker}; use std::pin::Pin; use std::sync::Arc; + use std::task::{Context, Poll, Wake, Waker}; struct NoopWaker; - impl Wake for NoopWaker { fn wake(self: Arc) {} } + impl Wake for NoopWaker { + fn wake(self: Arc) {} + } let waker = Waker::from(Arc::new(NoopWaker)); let mut cx = Context::from_waker(&waker); let mut future = unsafe { Pin::new_unchecked(Box::new(future)) }; @@ -95,16 +96,61 @@ fn pollster_block_on(future: F) -> F::Output { // Window + Renderer init // ============================================================ +/// SH-055 diag — read an Android system property (empty string when unset). +/// Debug levers settable from adb with no rebuild: +/// adb shell setprop debug.bloom.backend gl|vulkan (wgpu backend A/B) +/// adb shell setprop debug.bloom.skipsky 1 (skip the sky draw) +/// then force-stop + relaunch the app. +fn sysprop(name: &str) -> String { + let cname = std::ffi::CString::new(name).unwrap(); + let mut buf = [0u8; 92]; // PROP_VALUE_MAX + let n = unsafe { + libc::__system_property_get(cname.as_ptr(), buf.as_mut_ptr() as *mut libc::c_char) + }; + if n <= 0 { + return String::new(); + } + String::from_utf8_lossy(&buf[..n as usize]) + .trim() + .to_string() +} + +/// SH-055 diag — wgpu backend selection for the A/B experiment. Default keeps +/// the shipped behavior (both allowed, wgpu prefers Vulkan). +fn backend_choice() -> wgpu::Backends { + match sysprop("debug.bloom.backend").as_str() { + "gl" => wgpu::Backends::GL, + "vulkan" => wgpu::Backends::VULKAN, + _ => wgpu::Backends::VULKAN | wgpu::Backends::GL, + } +} + #[no_mangle] -pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u8, _fullscreen: f64) { +pub extern "C" fn bloom_init_window( + width: f64, + height: f64, + title_ptr: *const u8, + _fullscreen: f64, +) { let _title = str_from_header(title_ptr); + // Re-install after perry-runtime's own hook (set during main() init) so the + // upcoming wgpu/naga material-compile panics log their location (SH-055). + install_panic_logger(); + unsafe { - __android_log_print(3, b"BloomEngine\0".as_ptr(), b"bloom_init_window: starting\0".as_ptr()); + __android_log_print( + 3, + b"BloomEngine\0".as_ptr(), + b"bloom_init_window: starting\0".as_ptr(), + ); let window = NATIVE_WINDOW; // If no native window was set, use requested dimensions with a headless surface let (pixel_w, pixel_h) = if !window.is_null() { - (ANativeWindow_getWidth(window) as u32, ANativeWindow_getHeight(window) as u32) + ( + ANativeWindow_getWidth(window) as u32, + ANativeWindow_getHeight(window) as u32, + ) } else { (width as u32, height as u32) }; @@ -116,8 +162,16 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u let logical_w = (pixel_w / 2).max(1); let logical_h = (pixel_h / 2).max(1); + let backends = backend_choice(); + { + let msg = std::ffi::CString::new(format!( + "bloom_init_window: requested backends={backends:?}" + )) + .unwrap(); + __android_log_print(3, b"BloomEngine\0".as_ptr(), b"%s\0".as_ptr(), msg.as_ptr()); + } let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { - backends: wgpu::Backends::VULKAN | wgpu::Backends::GL, + backends, flags: wgpu::InstanceFlags::default(), ..wgpu::InstanceDescriptor::new_without_display_handle() }); @@ -129,17 +183,22 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u return; } - let handle = raw_window_handle::AndroidNdkWindowHandle::new( - std::ptr::NonNull::new(window).unwrap() - ); + let handle = + raw_window_handle::AndroidNdkWindowHandle::new(std::ptr::NonNull::new(window).unwrap()); let raw = raw_window_handle::RawWindowHandle::AndroidNdk(handle); - let surface = instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { - raw_display_handle: Some(raw_window_handle::RawDisplayHandle::Android( - raw_window_handle::AndroidDisplayHandle::new() - )), - raw_window_handle: raw, - }).expect("Failed to create surface"); - __android_log_print(3, b"BloomEngine\0".as_ptr(), b"bloom_init_window: surface created\0".as_ptr()); + let surface = instance + .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: Some(raw_window_handle::RawDisplayHandle::Android( + raw_window_handle::AndroidDisplayHandle::new(), + )), + raw_window_handle: raw, + }) + .expect("Failed to create surface"); + __android_log_print( + 3, + b"BloomEngine\0".as_ptr(), + b"bloom_init_window: surface created\0".as_ptr(), + ); let adapter = pollster_block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { compatible_surface: Some(&surface), @@ -159,82 +218,66 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u } } }; - __android_log_print(3, b"BloomEngine\0".as_ptr(), b"bloom_init_window: adapter found\0".as_ptr()); + __android_log_print( + 3, + b"BloomEngine\0".as_ptr(), + b"bloom_init_window: adapter found\0".as_ptr(), + ); { let info = adapter.get_info(); let msg = std::ffi::CString::new(format!( "adapter: name='{}' backend={:?} device_type={:?} driver='{}' driver_info='{}'", info.name, info.backend, info.device_type, info.driver, info.driver_info - )).unwrap(); + )) + .unwrap(); __android_log_print(3, b"BloomEngine\0".as_ptr(), b"%s\0".as_ptr(), msg.as_ptr()); } - // Ticket 007b: most Android GPUs lack RT, but recent Adreno / - // Mali-Immortalis devices do — request the feature if advertised. - // Limits merge: start from downlevel (required for older Android - // adapters), then layer acceleration-structure minimums on top - // when RT was granted. - let supported = adapter.features(); let force_sw_gi = std::env::var("BLOOM_FORCE_SW_GI") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); - let rt_mask = wgpu::Features::EXPERIMENTAL_RAY_QUERY; - let mut required_features = wgpu::Features::empty(); - // Ticket 011: request TIMESTAMP_QUERY when supported so the profiler - // can record GPU timings. Optional — profiler falls back to CPU-only - // when the adapter doesn't grant it (many Android GPUs won't). - if supported.contains(wgpu::Features::TIMESTAMP_QUERY) { - required_features |= wgpu::Features::TIMESTAMP_QUERY; - } - // Cooked BC7 textures (bloom-cook) upload compressed when the - // adapter has BC support; without it they CPU-decode at load. - if supported.contains(wgpu::Features::TEXTURE_COMPRESSION_BC) { - required_features |= wgpu::Features::TEXTURE_COMPRESSION_BC; - } - if !force_sw_gi && supported.contains(rt_mask) { - required_features |= rt_mask; - } - let experimental_features = if required_features.intersects(rt_mask) { - unsafe { wgpu::ExperimentalFeatures::enabled() } - } else { - wgpu::ExperimentalFeatures::disabled() - }; - let adapter_limits = adapter.limits(); - let mut required_limits = wgpu::Limits::downlevel_defaults() - .using_resolution(adapter_limits.clone()); - // The renderer's `joint_bg` binds a 64KB uniform buffer, but - // downlevel_defaults caps uniform-buffer bindings at 16KB, so - // create_bind_group panics on mobile GPUs (e.g. Adreno) with - // "range 65536 exceeds max_*_buffer_binding_size limit 16384". Raise the - // buffer-binding sizes (and bind-group count) to the adapter's maximum; - // these are guaranteed-supported and match the desktop limits. - required_limits.max_uniform_buffer_binding_size = - adapter_limits.max_uniform_buffer_binding_size; - required_limits.max_storage_buffer_binding_size = - adapter_limits.max_storage_buffer_binding_size; - required_limits.max_bind_groups = - required_limits.max_bind_groups.max(5).min(adapter_limits.max_bind_groups); - if required_features.intersects(rt_mask) { - required_limits = required_limits - .using_minimum_supported_acceleration_structure_values(); - } - let (device, queue) = pollster_block_on(adapter.request_device( - &wgpu::DeviceDescriptor { - label: Some("bloom_device"), - required_features, - required_limits, - experimental_features, - ..Default::default() - }, - )).expect("Failed to create device"); - __android_log_print(3, b"BloomEngine\0".as_ptr(), b"bloom_init_window: device created\0".as_ptr()); + let negotiated = pollster_block_on( + bloom_shared::renderer::device_negotiation::request_device_with_fallback( + &adapter, + bloom_shared::renderer::device_negotiation::DeviceRequestOptions { + allow_ray_query: !force_sw_gi, + profile: bloom_shared::renderer::device_negotiation::DeviceRequestProfile::FoldedMobile, + }, + ), + ) + .unwrap_or_else(|error| panic!("Failed to create renderer device: {error}")); + let negotiation_report = negotiated.report.report_json(); + let msg = std::ffi::CString::new(format!( + "renderer device negotiation = {negotiation_report}" + )) + .unwrap(); + __android_log_print(3, b"BloomEngine\0".as_ptr(), b"%s\0".as_ptr(), msg.as_ptr()); + let device = negotiated.device; + let queue = negotiated.queue; + __android_log_print( + 3, + b"BloomEngine\0".as_ptr(), + b"bloom_init_window: device created\0".as_ptr(), + ); + + // SH-055 diag — capture wgpu validation/device errors with their full + // detail instead of wgpu's default "handle as fatal" panic (which the + // FFI guard swallows as "non-string panic payload"). This is what tells + // us WHY the instanced/from-file material pipelines fail on the Adreno + // Vulkan path. Logs to logcat tag "BloomWGPU". + device.on_uncaptured_error(std::sync::Arc::new(|err| { + log::error!("wgpu uncaptured error: {err}"); + })); let surface_caps = surface.get_capabilities(&adapter); if surface_caps.formats.is_empty() { panic!("Surface reports no supported formats (emulator Vulkan limitation)"); } - let format = surface_caps.formats.iter() - .find(|f| f.is_srgb()).copied() + let format = surface_caps + .formats + .iter() + .find(|f| f.is_srgb()) + .copied() .unwrap_or(surface_caps.formats[0]); let alpha_mode = if surface_caps.alpha_modes.is_empty() { @@ -255,10 +298,20 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u }; surface.configure(&device, &surface_config); - __android_log_print(3, b"BloomEngine\0".as_ptr(), b"bloom_init_window: surface configured\0".as_ptr()); - let renderer = Renderer::new(device, queue, surface, surface_config, logical_w, logical_h); + __android_log_print( + 3, + b"BloomEngine\0".as_ptr(), + b"bloom_init_window: surface configured\0".as_ptr(), + ); + let mut renderer = + Renderer::new(device, queue, surface, surface_config, logical_w, logical_h); + renderer.set_device_negotiation_report(negotiation_report); let _ = ENGINE.set(EngineState::new(renderer)); - __android_log_print(3, b"BloomEngine\0".as_ptr(), b"bloom_init_window: engine initialized\0".as_ptr()); + __android_log_print( + 3, + b"BloomEngine\0".as_ptr(), + b"bloom_init_window: engine initialized\0".as_ptr(), + ); } } @@ -299,7 +352,7 @@ pub extern "C" fn bloom_attach_native(handle: i64, width: f64, height: f64) -> f bloom_shared::attach::attach_engine( target, bloom_shared::attach::AttachParams { - backends: wgpu::Backends::VULKAN | wgpu::Backends::GL, + backends: backend_choice(), logical_w: (width as u32).max(1), logical_h: (height as u32).max(1), physical_w: (width as u32).max(1), @@ -323,7 +376,8 @@ pub extern "C" fn bloom_attach_native(handle: i64, width: f64, height: f64) -> f #[no_mangle] pub extern "C" fn bloom_resize(phys_w: f64, phys_h: f64, log_w: f64, log_h: f64) { if let Some(eng) = unsafe { ENGINE.get_mut() } { - eng.renderer.resize(phys_w as u32, phys_h as u32, log_w as u32, log_h as u32); + eng.renderer + .resize(phys_w as u32, phys_h as u32, log_w as u32, log_h as u32); } } @@ -344,7 +398,11 @@ pub extern "C" fn bloom_close_window() { #[no_mangle] pub extern "C" fn bloom_window_should_close() -> f64 { - if engine().should_close { 1.0 } else { 0.0 } + if engine().should_close { + 1.0 + } else { + 0.0 + } } // ============================================================ @@ -360,7 +418,11 @@ pub extern "C" fn bloom_android_on_touch(action: i32, x: f64, y: f64, pointer_in let sh = eng.screen_height(); let lx = x * 0.5; let ly = y * 0.5; - let msg = std::ffi::CString::new(format!("touch a={} raw=({},{}) scaled=({},{}) sw={} sh={}", action, x, y, lx, ly, sw, sh)).unwrap(); + let msg = std::ffi::CString::new(format!( + "touch a={} raw=({},{}) scaled=({},{}) sw={} sh={}", + action, x, y, lx, ly, sw, sh + )) + .unwrap(); __android_log_print(3, b"BloomTouch\0".as_ptr(), b"%s\0".as_ptr(), msg.as_ptr()); eng.input.set_mouse_position(lx, ly); if action == 1 || action == 3 { @@ -369,9 +431,9 @@ pub extern "C" fn bloom_android_on_touch(action: i32, x: f64, y: f64, pointer_in eng.input.set_touch(pointer_index as usize, lx, ly, true); // DOWN / MOVE } match action { - 0 => eng.input.set_mouse_button_down(0), // ACTION_DOWN - 1 => eng.input.set_mouse_button_up(0), // ACTION_UP - 2 => {} // ACTION_MOVE + 0 => eng.input.set_mouse_button_down(0), // ACTION_DOWN + 1 => eng.input.set_mouse_button_up(0), // ACTION_UP + 2 => {} // ACTION_MOVE _ => {} } } @@ -443,12 +505,18 @@ fn android_audio_thread(renderer: Option) { impl AudioOutputCallback for BloomAudioCallback { type FrameType = (f32, Stereo); - fn on_audio_ready(&mut self, _stream: &mut dyn AudioOutputStreamSafe, frames: &mut [(f32, f32)]) -> DataCallbackResult { + fn on_audio_ready( + &mut self, + _stream: &mut dyn AudioOutputStreamSafe, + frames: &mut [(f32, f32)], + ) -> DataCallbackResult { // Convert frame tuples to interleaved slice let len = frames.len() * 2; let ptr = frames.as_mut_ptr() as *mut f32; let interleaved = unsafe { std::slice::from_raw_parts_mut(ptr, len) }; - for s in interleaved.iter_mut() { *s = 0.0; } + for s in interleaved.iter_mut() { + *s = 0.0; + } if let Some(r) = self.renderer.as_mut() { r.mix(interleaved); } @@ -507,9 +575,13 @@ fn android_audio_thread(renderer: Option) { #[no_mangle] pub extern "C" fn bloom_toggle_fullscreen() {} #[no_mangle] -pub extern "C" fn bloom_set_window_title(title_ptr: *const u8) { let _ = str_from_header(title_ptr); } +pub extern "C" fn bloom_set_window_title(title_ptr: *const u8) { + let _ = str_from_header(title_ptr); +} #[no_mangle] -pub extern "C" fn bloom_set_window_icon(path_ptr: *const u8) { let _ = str_from_header(path_ptr); } +pub extern "C" fn bloom_set_window_icon(path_ptr: *const u8) { + let _ = str_from_header(path_ptr); +} #[no_mangle] pub extern "C" fn bloom_disable_cursor() { @@ -525,15 +597,29 @@ pub extern "C" fn bloom_enable_cursor() { #[no_mangle] pub extern "C" fn bloom_set_clipboard_text(_text_ptr: *const u8) {} #[no_mangle] -pub extern "C" fn bloom_get_clipboard_text() -> *const u8 { std::ptr::null() } +pub extern "C" fn bloom_get_clipboard_text() -> *const u8 { + std::ptr::null() +} // E5b: File dialogs (stub on this platform) #[no_mangle] -pub extern "C" fn bloom_open_file_dialog(_filter_ptr: *const u8, _title_ptr: *const u8) -> *const u8 { std::ptr::null() } +pub extern "C" fn bloom_open_file_dialog( + _filter_ptr: *const u8, + _title_ptr: *const u8, +) -> *const u8 { + std::ptr::null() +} #[no_mangle] -pub extern "C" fn bloom_save_file_dialog(_default_name_ptr: *const u8, _title_ptr: *const u8) -> *const u8 { std::ptr::null() } +pub extern "C" fn bloom_save_file_dialog( + _default_name_ptr: *const u8, + _title_ptr: *const u8, +) -> *const u8 { + std::ptr::null() +} #[no_mangle] -pub extern "C" fn bloom_get_platform() -> f64 { 5.0 } +pub extern "C" fn bloom_get_platform() -> f64 { + 5.0 +} /// Preferred OS language packed as `c0*256+c1` (ISO-639 primary subtag), read /// from the device locale system property via the NDK (no JNI). Tries the @@ -541,7 +627,9 @@ pub extern "C" fn bloom_get_platform() -> f64 { 5.0 } #[no_mangle] pub extern "C" fn bloom_get_language() -> f64 { fn parse(buf: &[u8], n: i32) -> Option { - if n < 2 { return None; } + if n < 2 { + return None; + } let lc = |b: u8| if b.is_ascii_uppercase() { b + 32 } else { b }; let (c0, c1) = (lc(buf[0]), lc(buf[1])); if c0.is_ascii_alphabetic() && c1.is_ascii_alphabetic() { @@ -563,7 +651,9 @@ pub extern "C" fn bloom_get_language() -> f64 { buf.as_mut_ptr() as *mut libc::c_char, ) }; - if let Some(v) = parse(&buf, n) { return v; } + if let Some(v) = parse(&buf, n) { + return v; + } } 25966.0 } @@ -577,17 +667,60 @@ pub extern "C" fn bloom_get_language() -> f64 { // com.bloomengine.game.BloomGameBridge Kotlin class. extern "C" { - fn ANativeWindow_fromSurface(env: *mut libc::c_void, surface: *mut libc::c_void) -> *mut libc::c_void; + fn ANativeWindow_fromSurface( + env: *mut libc::c_void, + surface: *mut libc::c_void, + ) -> *mut libc::c_void; fn mallopt(param: i32, value: i32) -> i32; fn __android_log_print(prio: i32, tag: *const u8, fmt: *const u8, ...) -> i32; fn main() -> i32; } +/// Install a panic hook that logs file:line + payload to logcat (tag +/// "BloomPanic"). The FFI `guard` (native/shared/src/ffi.rs) catches panics at +/// the boundary and only reports a generic "non-string panic payload", +/// discarding the location; this hook fires *before* catch_unwind and records +/// where it happened — the key to diagnosing the wgpu/naga material-compile +/// panics on the Adreno Vulkan path. Called from both JNI_OnLoad and +/// bloom_init_window because perry-runtime overrides the hook when main() runs. +fn install_panic_logger() { + std::panic::set_hook(Box::new(|info| { + let loc = info + .location() + .map(|l| format!("{}:{}", l.file(), l.line())) + .unwrap_or_else(|| "".to_string()); + let payload = if let Some(s) = info.payload().downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = info.payload().downcast_ref::() { + s.clone() + } else { + "".to_string() + }; + let msg = format!("PANIC @ {loc}: {payload}\0"); + unsafe { + __android_log_print(6, b"BloomPanic\0".as_ptr(), b"%s\0".as_ptr(), msg.as_ptr()); + } + })); +} + /// JNI_OnLoad: called when System.loadLibrary() loads this .so. /// Disables MTE heap tagging (required for Perry NaN-boxing) and /// reads the asset base path from BLOOM_ASSET_PATH env var. #[no_mangle] pub extern "C" fn JNI_OnLoad(_vm: *mut libc::c_void, _reserved: *mut libc::c_void) -> i32 { + // SH-055 diag — route the `log` facade (wgpu/naga emit shader-validation + // detail here) to logcat under tag "BloomWGPU". Warn-level keeps it quiet + // in the steady state but surfaces the Adreno Vulkan compile failures. + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Warn) + .with_tag("BloomWGPU"), + ); + + // Perry-runtime installs its own panic hook once `main()` runs, so this one + // gets overridden; `bloom_init_window` re-installs it (see + // `install_panic_logger`) after perry is up and before material compile. + install_panic_logger(); unsafe { // Disable MTE heap tagging for Perry NaN-boxing compatibility. // Perry uses 48-bit pointers; Android's scudo allocator may tag @@ -595,16 +728,38 @@ pub extern "C" fn JNI_OnLoad(_vm: *mut libc::c_void, _reserved: *mut libc::c_voi mallopt(-204, 0); __android_log_print( - 3, b"BloomEngine\0".as_ptr(), + 3, + b"BloomEngine\0".as_ptr(), b"JNI_OnLoad: MTE disabled\0".as_ptr(), ); } + // SH-055 diag — propagate the skip-sky debug prop into an env var the + // platform-neutral renderer can read (see scene_pass.rs). JNI_OnLoad runs + // before main(), so the renderer's OnceLock sees the final value. + if sysprop("debug.bloom.skipsky") == "1" { + std::env::set_var("BLOOM_SKIP_SKY", "1"); + } + // SH-055 diag — frame-graph bisection: comma list of pass-node names to + // skip (see Renderer::dbg_skip). e.g. + // adb shell setprop debug.bloom.skip hdr_scene,translucent + let skip_list = sysprop("debug.bloom.skip"); + if !skip_list.is_empty() { + std::env::set_var("BLOOM_SKIP", skip_list); + } + // SH-055 diag — cap the cached-model draw count (binary search for the + // pathological draw): adb shell setprop debug.bloom.maxmodels N + let max_models = sysprop("debug.bloom.maxmodels"); + if !max_models.is_empty() { + std::env::set_var("BLOOM_MAX_MODELS", max_models); + } + // Read asset base path from environment (set by Activity before loadLibrary) if let Ok(path) = std::env::var("BLOOM_ASSET_PATH") { unsafe { __android_log_print( - 3, b"BloomEngine\0".as_ptr(), + 3, + b"BloomEngine\0".as_ptr(), b"JNI_OnLoad: asset path set\0".as_ptr(), ); ASSET_BASE_PATH = Some(path); @@ -624,7 +779,8 @@ pub unsafe extern "C" fn Java_com_bloomengine_game_BloomGameBridge_nativeSetSurf ) { let window = ANativeWindow_fromSurface(env, surface); __android_log_print( - 3, b"BloomEngine\0".as_ptr(), + 3, + b"BloomEngine\0".as_ptr(), b"nativeSetSurface: ANativeWindow acquired\0".as_ptr(), ); bloom_android_set_native_window(window); @@ -638,12 +794,14 @@ pub unsafe extern "C" fn Java_com_bloomengine_game_BloomGameBridge_nativeMain( _class: *mut libc::c_void, ) { __android_log_print( - 3, b"BloomEngine\0".as_ptr(), + 3, + b"BloomEngine\0".as_ptr(), b"nativeMain: calling main()\0".as_ptr(), ); main(); __android_log_print( - 3, b"BloomEngine\0".as_ptr(), + 3, + b"BloomEngine\0".as_ptr(), b"nativeMain: main() returned\0".as_ptr(), ); } @@ -680,8 +838,6 @@ pub extern "C" fn Java_com_bloomengine_game_BloomGameBridge_nativeOnDestroy( // Thread-safe staging (for async asset loading via Perry threads) // ============================================================ - - // Q6: Multi-hit picking // ============================================================ diff --git a/native/ios/Cargo.lock b/native/ios/Cargo.lock index f4c6a74b..c3c8c0c1 100644 --- a/native/ios/Cargo.lock +++ b/native/ios/Cargo.lock @@ -112,8 +112,11 @@ dependencies = [ "image_dds", "lewton", "libc", + "log", "minimp3", "raw-window-handle", + "serde_json", + "web-sys", "wgpu", ] diff --git a/native/ios/src/lib.rs b/native/ios/src/lib.rs index 02cf25ae..a5952fbc 100644 --- a/native/ios/src/lib.rs +++ b/native/ios/src/lib.rs @@ -31,7 +31,6 @@ static mut SCREEN_SCALE: f64 = 1.0; /// Default: all (0x1E). Set to landscape (0x18) from bloom_init_window when width > height. static mut ORIENTATION_MASK: u64 = 0x1E; // UIInterfaceOrientationMaskAll - /// Resolve a relative asset path to the app bundle path. fn resolve_path(path: &str) -> String { if path.starts_with('/') { @@ -59,14 +58,16 @@ fn bloom_resolve_asset_path(path: &str) -> std::borrow::Cow<'_, str> { // docs for the contract; tools/validate-ffi.js checks parity in CI. bloom_shared::define_core_ffi!(); - // ============================================================ // CG types with objc2 Encode // ============================================================ #[repr(C)] #[derive(Copy, Clone)] -struct CGPoint { x: f64, y: f64 } +struct CGPoint { + x: f64, + y: f64, +} unsafe impl Encode for CGPoint { const ENCODING: Encoding = Encoding::Struct("CGPoint", &[Encoding::Double, Encoding::Double]); @@ -77,7 +78,10 @@ unsafe impl RefEncode for CGPoint { #[repr(C)] #[derive(Copy, Clone)] -struct CGSize { width: f64, height: f64 } +struct CGSize { + width: f64, + height: f64, +} unsafe impl Encode for CGSize { const ENCODING: Encoding = Encoding::Struct("CGSize", &[Encoding::Double, Encoding::Double]); @@ -88,7 +92,10 @@ unsafe impl RefEncode for CGSize { #[repr(C)] #[derive(Copy, Clone)] -struct CGRect { origin: CGPoint, size: CGSize } +struct CGRect { + origin: CGPoint, + size: CGSize, +} unsafe impl Encode for CGRect { const ENCODING: Encoding = Encoding::Struct("CGRect", &[CGPoint::ENCODING, CGSize::ENCODING]); @@ -102,7 +109,11 @@ unsafe impl RefEncode for CGRect { // ============================================================ extern "C" { - fn objc_allocateClassPair(superclass: *const AnyClass, name: *const u8, extra_bytes: usize) -> *mut AnyClass; + fn objc_allocateClassPair( + superclass: *const AnyClass, + name: *const u8, + extra_bytes: usize, + ) -> *mut AnyClass; fn objc_registerClassPair(cls: *mut AnyClass); fn class_addMethod(cls: *mut AnyClass, sel: Sel, imp: *const c_void, types: *const u8) -> bool; @@ -111,7 +122,9 @@ extern "C" { } fn pump_run_loop(seconds: f64) { - unsafe { CFRunLoopRunInMode(kCFRunLoopDefaultMode, seconds, 0); } + unsafe { + CFRunLoopRunInMode(kCFRunLoopDefaultMode, seconds, 0); + } } // ============================================================ @@ -122,26 +135,52 @@ unsafe extern "C" fn bloom_layer_class(_cls: *const c_void, _sel: Sel) -> *const AnyClass::get(c"CAMetalLayer").unwrap() as *const AnyClass as *const c_void } -unsafe extern "C" fn bloom_touches_began(_this: *mut c_void, _sel: Sel, touches: *const AnyObject, _event: *const AnyObject) { +unsafe extern "C" fn bloom_touches_began( + _this: *mut c_void, + _sel: Sel, + touches: *const AnyObject, + _event: *const AnyObject, +) { handle_touches(touches, TouchPhase::Began); } -unsafe extern "C" fn bloom_touches_moved(_this: *mut c_void, _sel: Sel, touches: *const AnyObject, _event: *const AnyObject) { +unsafe extern "C" fn bloom_touches_moved( + _this: *mut c_void, + _sel: Sel, + touches: *const AnyObject, + _event: *const AnyObject, +) { handle_touches(touches, TouchPhase::Moved); } -unsafe extern "C" fn bloom_touches_ended(_this: *mut c_void, _sel: Sel, touches: *const AnyObject, _event: *const AnyObject) { +unsafe extern "C" fn bloom_touches_ended( + _this: *mut c_void, + _sel: Sel, + touches: *const AnyObject, + _event: *const AnyObject, +) { handle_touches(touches, TouchPhase::Ended); } -unsafe extern "C" fn bloom_touches_cancelled(_this: *mut c_void, _sel: Sel, touches: *const AnyObject, _event: *const AnyObject) { +unsafe extern "C" fn bloom_touches_cancelled( + _this: *mut c_void, + _sel: Sel, + touches: *const AnyObject, + _event: *const AnyObject, +) { handle_touches(touches, TouchPhase::Ended); } -enum TouchPhase { Began, Moved, Ended } +enum TouchPhase { + Began, + Moved, + Ended, +} unsafe fn handle_touches(touches: *const AnyObject, phase: TouchPhase) { - if touches.is_null() { return; } + if touches.is_null() { + return; + } let view_ptr: *const AnyObject = match UI_VIEW.as_ref() { Some(v) => Retained::as_ptr(v), @@ -151,7 +190,9 @@ unsafe fn handle_touches(touches: *const AnyObject, phase: TouchPhase) { let enumerator: Retained = msg_send![&*touches, objectEnumerator]; loop { let touch: *const AnyObject = msg_send![&*enumerator, nextObject]; - if touch.is_null() { break; } + if touch.is_null() { + break; + } let touch_id = touch as *const c_void; let loc: CGPoint = msg_send![&*touch, locationInView: view_ptr]; @@ -171,21 +212,17 @@ unsafe fn handle_touches(touches: *const AnyObject, phase: TouchPhase) { None => continue, } } - TouchPhase::Moved => { - match TOUCH_MAP.iter().position(|&p| p == touch_id) { - Some(i) => i, - None => continue, - } - } - TouchPhase::Ended => { - match TOUCH_MAP.iter().position(|&p| p == touch_id) { - Some(i) => { - TOUCH_MAP[i] = std::ptr::null(); - i - } - None => continue, + TouchPhase::Moved => match TOUCH_MAP.iter().position(|&p| p == touch_id) { + Some(i) => i, + None => continue, + }, + TouchPhase::Ended => match TOUCH_MAP.iter().position(|&p| p == touch_id) { + Some(i) => { + TOUCH_MAP[i] = std::ptr::null(); + i } - } + None => continue, + }, }; if let Some(eng) = ENGINE.get_mut() { @@ -208,23 +245,58 @@ unsafe fn handle_touches(touches: *const AnyObject, phase: TouchPhase) { } fn register_metal_view_class() { - if AnyClass::get(c"BloomMetalView").is_some() { return; } + if AnyClass::get(c"BloomMetalView").is_some() { + return; + } unsafe { let superclass = AnyClass::get(c"UIView").unwrap(); - let cls = objc_allocateClassPair(superclass as *const AnyClass, b"BloomMetalView\0".as_ptr(), 0); - if cls.is_null() { return; } + let cls = objc_allocateClassPair( + superclass as *const AnyClass, + b"BloomMetalView\0".as_ptr(), + 0, + ); + if cls.is_null() { + return; + } - extern "C" { fn object_getClass(obj: *const c_void) -> *mut AnyClass; } + extern "C" { + fn object_getClass(obj: *const c_void) -> *mut AnyClass; + } let meta = object_getClass(cls as *const c_void); - class_addMethod(meta, sel!(layerClass), bloom_layer_class as *const c_void, b"#8@0:8\0".as_ptr()); + class_addMethod( + meta, + sel!(layerClass), + bloom_layer_class as *const c_void, + b"#8@0:8\0".as_ptr(), + ); let touch_types = b"v32@0:8@16@24\0".as_ptr(); - class_addMethod(cls, sel!(touchesBegan:withEvent:), bloom_touches_began as *const c_void, touch_types); - class_addMethod(cls, sel!(touchesMoved:withEvent:), bloom_touches_moved as *const c_void, touch_types); - class_addMethod(cls, sel!(touchesEnded:withEvent:), bloom_touches_ended as *const c_void, touch_types); - class_addMethod(cls, sel!(touchesCancelled:withEvent:), bloom_touches_cancelled as *const c_void, touch_types); + class_addMethod( + cls, + sel!(touchesBegan:withEvent:), + bloom_touches_began as *const c_void, + touch_types, + ); + class_addMethod( + cls, + sel!(touchesMoved:withEvent:), + bloom_touches_moved as *const c_void, + touch_types, + ); + class_addMethod( + cls, + sel!(touchesEnded:withEvent:), + bloom_touches_ended as *const c_void, + touch_types, + ); + class_addMethod( + cls, + sel!(touchesCancelled:withEvent:), + bloom_touches_cancelled as *const c_void, + touch_types, + ); objc_registerClassPair(cls); } @@ -239,15 +311,28 @@ unsafe extern "C" fn bloom_vc_supported_orientations(_this: *const c_void, _sel: } fn register_view_controller_class() { - if AnyClass::get(c"BloomViewController").is_some() { return; } + if AnyClass::get(c"BloomViewController").is_some() { + return; + } unsafe { let superclass = AnyClass::get(c"UIViewController").unwrap(); - let cls = objc_allocateClassPair(superclass as *const AnyClass, b"BloomViewController\0".as_ptr(), 0); - if cls.is_null() { return; } + let cls = objc_allocateClassPair( + superclass as *const AnyClass, + b"BloomViewController\0".as_ptr(), + 0, + ); + if cls.is_null() { + return; + } let sel = Sel::register(c"supportedInterfaceOrientations"); - class_addMethod(cls, sel, bloom_vc_supported_orientations as *const c_void, b"Q16@0:8\0".as_ptr()); + class_addMethod( + cls, + sel, + bloom_vc_supported_orientations as *const c_void, + b"Q16@0:8\0".as_ptr(), + ); objc_registerClassPair(cls); } @@ -264,7 +349,9 @@ unsafe extern "C" fn scene_will_connect( _session: *const AnyObject, _options: *const AnyObject, ) { - if scene.is_null() { return; } + if scene.is_null() { + return; + } // Get screen bounds from UIScreen.mainScreen (simpler than scene.screen) let screen_cls = AnyClass::get(c"UIScreen").unwrap(); @@ -298,10 +385,13 @@ unsafe extern "C" fn scene_will_connect( // Configure CAMetalLayer let layer: Retained = msg_send![&*view, layer]; - let drawable_size = CGSize { width: pixel_width as f64, height: pixel_height as f64 }; + let drawable_size = CGSize { + width: pixel_width as f64, + height: pixel_height as f64, + }; let _: () = msg_send![&*layer, setDrawableSize: drawable_size]; let _: () = msg_send![&*layer, setContentsScale: scale]; - let _: () = msg_send![&*layer, setOpaque: Bool::NO]; // Non-opaque so green shows if Metal fails + let _: () = msg_send![&*layer, setOpaque: Bool::NO]; // Non-opaque so green shows if Metal fails // Enable touches let _: () = msg_send![&*view, setUserInteractionEnabled: Bool::YES]; @@ -323,90 +413,34 @@ unsafe extern "C" fn scene_will_connect( }); let view_ptr = Retained::as_ptr(&view) as *mut c_void; - let handle = UiKitWindowHandle::new( - std::ptr::NonNull::new(view_ptr).unwrap(), - ); + let handle = UiKitWindowHandle::new(std::ptr::NonNull::new(view_ptr).unwrap()); let raw = RawWindowHandle::UiKit(handle); - let surface = instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { - raw_display_handle: Some(RawDisplayHandle::UiKit(UiKitDisplayHandle::new())), - raw_window_handle: raw, - }).expect("Failed to create wgpu surface"); + let surface = instance + .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: Some(RawDisplayHandle::UiKit(UiKitDisplayHandle::new())), + raw_window_handle: raw, + }) + .expect("Failed to create wgpu surface"); let adapter = pollster_block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { compatible_surface: Some(&surface), power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() - })).expect("No GPU adapter found"); + })) + .expect("No GPU adapter found"); - // Ticket 007b: opt into HW ray-query on RT-capable Apple Silicon - // devices. `BLOOM_FORCE_SW_GI=1` forces the SW path for bench parity. - let supported = adapter.features(); - let force_sw_gi = std::env::var("BLOOM_FORCE_SW_GI") - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false); - let rt_mask = wgpu::Features::EXPERIMENTAL_RAY_QUERY; - let mut required_features = wgpu::Features::empty(); - // Ticket 011: request TIMESTAMP_QUERY when supported so the profiler - // can record GPU timings. Optional — profiler falls back to CPU-only - // when the adapter doesn't grant it. - if supported.contains(wgpu::Features::TIMESTAMP_QUERY) { - required_features |= wgpu::Features::TIMESTAMP_QUERY; - } - // Cooked BC7 textures (bloom-cook) upload compressed when the - // adapter has BC support; without it they CPU-decode at load. - if supported.contains(wgpu::Features::TEXTURE_COMPRESSION_BC) { - required_features |= wgpu::Features::TEXTURE_COMPRESSION_BC; - } - if !force_sw_gi && supported.contains(rt_mask) { - required_features |= rt_mask; - } - let experimental_features = if required_features.intersects(rt_mask) { - unsafe { wgpu::ExperimentalFeatures::enabled() } - } else { - wgpu::ExperimentalFeatures::disabled() - }; - let mut required_limits = wgpu::Limits::default(); - if required_features.intersects(rt_mask) { - required_limits = required_limits - .using_minimum_supported_acceleration_structure_values(); - } - let (device, queue) = match pollster_block_on(adapter.request_device( - &wgpu::DeviceDescriptor { - label: Some("bloom_device"), - required_features, - required_limits, - experimental_features, - ..Default::default() - }, - )) { - Ok(dq) => dq, - Err(e) => { - // Some real devices (e.g. iPhone 16 Pro / A18) advertise - // EXPERIMENTAL_RAY_QUERY on the adapter but reject it at device - // creation through wgpu's Metal backend, which would otherwise - // abort the app on launch. Retry with a minimal, always-supported - // configuration. The renderer keys off device.features() (see - // renderer/mod.rs), so it transparently falls back to the non-RT - // path when ray-query isn't granted. - eprintln!("[bloom-ios] request_device with preferred features failed ({e:?}); retrying with adapter limits and no ray-query/experimental features"); - // Request exactly the adapter's reported limits (not wgpu's - // Limits::default()): some iOS GPUs cap individual limits — e.g. - // max_inter_stage_shader_variables — below wgpu's defaults, so - // request_device rejects the default-limits request too. Asking for - // adapter.limits() can never exceed what the device supports. - pollster_block_on(adapter.request_device(&wgpu::DeviceDescriptor { - label: Some("bloom_device"), - required_features: wgpu::Features::empty(), - required_limits: adapter.limits(), - experimental_features: wgpu::ExperimentalFeatures::disabled(), - ..Default::default() - })).expect("Failed to create device (minimal fallback)") - } - }; + let negotiated = request_renderer_device(&adapter); + let negotiation_report = negotiated.report.report_json(); + eprintln!("[bloom-ios] renderer device negotiation = {negotiation_report}"); + let device = negotiated.device; + let queue = negotiated.queue; let surface_caps = surface.get_capabilities(&adapter); - let format = surface_caps.formats.iter() - .find(|f| f.is_srgb()).copied() + let format = surface_caps + .formats + .iter() + .find(|f| f.is_srgb()) + .copied() .unwrap_or(surface_caps.formats[0]); let surface_config = wgpu::SurfaceConfiguration { @@ -421,23 +455,41 @@ unsafe extern "C" fn scene_will_connect( }; surface.configure(&device, &surface_config); - let renderer = Renderer::new(device, queue, surface, surface_config, pixel_width, pixel_height); + let mut renderer = Renderer::new( + device, + queue, + surface, + surface_config, + pixel_width, + pixel_height, + ); + renderer.set_device_negotiation_report(negotiation_report); let _ = ENGINE.set(EngineState::new(renderer)); // Write debug info to app container tmp { - let ns_cls = AnyClass::get(c"NSTemporaryDirectory").unwrap_or(AnyClass::get(c"NSString").unwrap()); + let ns_cls = + AnyClass::get(c"NSTemporaryDirectory").unwrap_or(AnyClass::get(c"NSString").unwrap()); // Use NSFileManager to get tmp dir - extern "C" { fn NSTemporaryDirectory() -> *const AnyObject; } + extern "C" { + fn NSTemporaryDirectory() -> *const AnyObject; + } let tmp_dir: *const AnyObject = NSTemporaryDirectory(); if !tmp_dir.is_null() { let utf8: *const u8 = msg_send![&*tmp_dir, UTF8String]; if !utf8.is_null() { let cstr_val = std::ffi::CStr::from_ptr(utf8 as *const i8); if let Ok(tmp_path) = cstr_val.to_str() { - let _ = std::fs::write(format!("{}bloom_debug.txt", tmp_path), - format!("scene_will_connect OK\npixels={}x{} scale={}\nwindow_scene={}\n", - pixel_width, pixel_height, scale, !scene.is_null())); + let _ = std::fs::write( + format!("{}bloom_debug.txt", tmp_path), + format!( + "scene_will_connect OK\npixels={}x{} scale={}\nwindow_scene={}\n", + pixel_width, + pixel_height, + scale, + !scene.is_null() + ), + ); } } } @@ -498,7 +550,10 @@ pub unsafe extern "C" fn perry_scene_will_connect(scene: *const c_void) { // Configure CAMetalLayer let layer: Retained = msg_send![&*view, layer]; - let drawable_size = CGSize { width: pixel_width as f64, height: pixel_height as f64 }; + let drawable_size = CGSize { + width: pixel_width as f64, + height: pixel_height as f64, + }; let _: () = msg_send![&*layer, setDrawableSize: drawable_size]; let _: () = msg_send![&*layer, setContentsScale: scale]; let _: () = msg_send![&*layer, setOpaque: Bool::YES]; @@ -523,90 +578,34 @@ pub unsafe extern "C" fn perry_scene_will_connect(scene: *const c_void) { }); let view_ptr = Retained::as_ptr(&view) as *mut c_void; - let handle = UiKitWindowHandle::new( - std::ptr::NonNull::new(view_ptr).unwrap(), - ); + let handle = UiKitWindowHandle::new(std::ptr::NonNull::new(view_ptr).unwrap()); let raw = RawWindowHandle::UiKit(handle); - let surface = instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { - raw_display_handle: Some(RawDisplayHandle::UiKit(UiKitDisplayHandle::new())), - raw_window_handle: raw, - }).expect("Failed to create wgpu surface"); + let surface = instance + .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: Some(RawDisplayHandle::UiKit(UiKitDisplayHandle::new())), + raw_window_handle: raw, + }) + .expect("Failed to create wgpu surface"); let adapter = pollster_block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { compatible_surface: Some(&surface), power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() - })).expect("No GPU adapter found"); + })) + .expect("No GPU adapter found"); - // Ticket 007b: opt into HW ray-query on RT-capable Apple Silicon - // devices. `BLOOM_FORCE_SW_GI=1` forces the SW path for bench parity. - let supported = adapter.features(); - let force_sw_gi = std::env::var("BLOOM_FORCE_SW_GI") - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false); - let rt_mask = wgpu::Features::EXPERIMENTAL_RAY_QUERY; - let mut required_features = wgpu::Features::empty(); - // Ticket 011: request TIMESTAMP_QUERY when supported so the profiler - // can record GPU timings. Optional — profiler falls back to CPU-only - // when the adapter doesn't grant it. - if supported.contains(wgpu::Features::TIMESTAMP_QUERY) { - required_features |= wgpu::Features::TIMESTAMP_QUERY; - } - // Cooked BC7 textures (bloom-cook) upload compressed when the - // adapter has BC support; without it they CPU-decode at load. - if supported.contains(wgpu::Features::TEXTURE_COMPRESSION_BC) { - required_features |= wgpu::Features::TEXTURE_COMPRESSION_BC; - } - if !force_sw_gi && supported.contains(rt_mask) { - required_features |= rt_mask; - } - let experimental_features = if required_features.intersects(rt_mask) { - unsafe { wgpu::ExperimentalFeatures::enabled() } - } else { - wgpu::ExperimentalFeatures::disabled() - }; - let mut required_limits = wgpu::Limits::default(); - if required_features.intersects(rt_mask) { - required_limits = required_limits - .using_minimum_supported_acceleration_structure_values(); - } - let (device, queue) = match pollster_block_on(adapter.request_device( - &wgpu::DeviceDescriptor { - label: Some("bloom_device"), - required_features, - required_limits, - experimental_features, - ..Default::default() - }, - )) { - Ok(dq) => dq, - Err(e) => { - // Some real devices (e.g. iPhone 16 Pro / A18) advertise - // EXPERIMENTAL_RAY_QUERY on the adapter but reject it at device - // creation through wgpu's Metal backend, which would otherwise - // abort the app on launch. Retry with a minimal, always-supported - // configuration. The renderer keys off device.features() (see - // renderer/mod.rs), so it transparently falls back to the non-RT - // path when ray-query isn't granted. - eprintln!("[bloom-ios] request_device with preferred features failed ({e:?}); retrying with adapter limits and no ray-query/experimental features"); - // Request exactly the adapter's reported limits (not wgpu's - // Limits::default()): some iOS GPUs cap individual limits — e.g. - // max_inter_stage_shader_variables — below wgpu's defaults, so - // request_device rejects the default-limits request too. Asking for - // adapter.limits() can never exceed what the device supports. - pollster_block_on(adapter.request_device(&wgpu::DeviceDescriptor { - label: Some("bloom_device"), - required_features: wgpu::Features::empty(), - required_limits: adapter.limits(), - experimental_features: wgpu::ExperimentalFeatures::disabled(), - ..Default::default() - })).expect("Failed to create device (minimal fallback)") - } - }; + let negotiated = request_renderer_device(&adapter); + let negotiation_report = negotiated.report.report_json(); + eprintln!("[bloom-ios] renderer device negotiation = {negotiation_report}"); + let device = negotiated.device; + let queue = negotiated.queue; let surface_caps = surface.get_capabilities(&adapter); - let format = surface_caps.formats.iter() - .find(|f| f.is_srgb()).copied() + let format = surface_caps + .formats + .iter() + .find(|f| f.is_srgb()) + .copied() .unwrap_or(surface_caps.formats[0]); let surface_config = wgpu::SurfaceConfiguration { @@ -621,17 +620,33 @@ pub unsafe extern "C" fn perry_scene_will_connect(scene: *const c_void) { }; surface.configure(&device, &surface_config); - let renderer = Renderer::new(device, queue, surface, surface_config, pixel_width, pixel_height); + let mut renderer = Renderer::new( + device, + queue, + surface, + surface_config, + pixel_width, + pixel_height, + ); + renderer.set_device_negotiation_report(negotiation_report); let _ = ENGINE.set(EngineState::new(renderer)); } fn register_scene_delegate() { - if AnyClass::get(c"PerrySceneDelegate").is_some() { return; } + if AnyClass::get(c"PerrySceneDelegate").is_some() { + return; + } unsafe { let superclass = AnyClass::get(c"UIResponder").unwrap(); - let cls = objc_allocateClassPair(superclass as *const AnyClass, b"PerrySceneDelegate\0".as_ptr(), 0); - if cls.is_null() { return; } + let cls = objc_allocateClassPair( + superclass as *const AnyClass, + b"PerrySceneDelegate\0".as_ptr(), + 0, + ); + if cls.is_null() { + return; + } // scene:willConnectToSession:connectionOptions: let sel = Sel::register(c"scene:willConnectToSession:connectionOptions:"); @@ -639,10 +654,14 @@ fn register_scene_delegate() { class_addMethod(cls, sel, scene_will_connect as *const c_void, types); // Add UIWindowSceneDelegate protocol - extern "C" { fn objc_getProtocol(name: *const u8) -> *const c_void; } + extern "C" { + fn objc_getProtocol(name: *const u8) -> *const c_void; + } let protocol = objc_getProtocol(b"UIWindowSceneDelegate\0".as_ptr()); if !protocol.is_null() { - extern "C" { fn class_addProtocol(cls: *mut AnyClass, protocol: *const c_void) -> bool; } + extern "C" { + fn class_addProtocol(cls: *mut AnyClass, protocol: *const c_void) -> bool; + } class_addProtocol(cls, protocol); } @@ -655,11 +674,13 @@ fn register_scene_delegate() { // ============================================================ fn pollster_block_on(future: F) -> F::Output { - use std::task::{Context, Poll, Wake, Waker}; use std::pin::Pin; use std::sync::Arc; + use std::task::{Context, Poll, Wake, Waker}; struct NoopWaker; - impl Wake for NoopWaker { fn wake(self: Arc) {} } + impl Wake for NoopWaker { + fn wake(self: Arc) {} + } let waker = Waker::from(Arc::new(NoopWaker)); let mut cx = Context::from_waker(&waker); let mut future = unsafe { Pin::new_unchecked(Box::new(future)) }; @@ -673,18 +694,48 @@ fn pollster_block_on(future: F) -> F::Output { } } +fn request_renderer_device( + adapter: &wgpu::Adapter, +) -> bloom_shared::renderer::device_negotiation::NegotiatedDevice { + // Some iPhones advertise experimental ray query but reject it during Metal + // device creation. The shared planner's featureless retry preserves that + // established startup fallback while keeping both requests bounded to the + // renderer's active full-layout contract. + let force_sw_gi = std::env::var("BLOOM_FORCE_SW_GI") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + pollster_block_on( + bloom_shared::renderer::device_negotiation::request_device_with_fallback( + adapter, + bloom_shared::renderer::device_negotiation::DeviceRequestOptions { + allow_ray_query: !force_sw_gi, + profile: + bloom_shared::renderer::device_negotiation::DeviceRequestProfile::NativeFull, + }, + ), + ) + .unwrap_or_else(|error| panic!("Failed to create renderer device: {error}")) +} + // ============================================================ // FFI entry points // ============================================================ #[no_mangle] -pub extern "C" fn bloom_init_window(_width: f64, _height: f64, title_ptr: *const u8, _fullscreen: f64) { +pub extern "C" fn bloom_init_window( + _width: f64, + _height: f64, + title_ptr: *const u8, + _fullscreen: f64, +) { let _title = str_from_header(title_ptr); // Set orientation mask based on requested dimensions // width > height → landscape, otherwise all orientations if _width > _height { - unsafe { ORIENTATION_MASK = 0x18; } // UIInterfaceOrientationMaskLandscape + unsafe { + ORIENTATION_MASK = 0x18; + } // UIInterfaceOrientationMaskLandscape } // Register ObjC classes for the scene delegate (window/view creation) @@ -694,8 +745,12 @@ pub extern "C" fn bloom_init_window(_width: f64, _height: f64, title_ptr: *const // Signal the main thread that our ObjC classes are ready. // UIApplicationMain (on main thread) waits for this before starting. - extern "C" { fn perry_ios_classes_registered(); } - unsafe { perry_ios_classes_registered(); } + extern "C" { + fn perry_ios_classes_registered(); + } + unsafe { + perry_ios_classes_registered(); + } // Debug: write marker to confirm we reached this point let _ = std::fs::write("/tmp/bloom_checkpoint_1.txt", "classes registered\n"); @@ -722,14 +777,18 @@ pub extern "C" fn bloom_init_window(_width: f64, _height: f64, title_ptr: *const // surface, and ENGINE — all on the main thread. We just wait for it. unsafe { for _ in 0..1000 { - if ENGINE.get().is_some() { break; } + if ENGINE.get().is_some() { + break; + } std::thread::sleep(std::time::Duration::from_millis(10)); } } if unsafe { ENGINE.get().is_none() } { - panic!("[bloom-ios] Engine not initialized after 10s. \ - Compile with: perry compile --target ios-simulator --features ios-game-loop"); + panic!( + "[bloom-ios] Engine not initialized after 10s. \ + Compile with: perry compile --target ios-simulator --features ios-game-loop" + ); } } @@ -788,7 +847,8 @@ pub extern "C" fn bloom_attach_native(handle: i64, width: f64, height: f64) -> f #[no_mangle] pub extern "C" fn bloom_resize(phys_w: f64, phys_h: f64, log_w: f64, log_h: f64) { if let Some(eng) = unsafe { ENGINE.get_mut() } { - eng.renderer.resize(phys_w as u32, phys_h as u32, log_w as u32, log_h as u32); + eng.renderer + .resize(phys_w as u32, phys_h as u32, log_w as u32, log_h as u32); } } @@ -799,12 +859,19 @@ pub extern "C" fn bloom_attach_hwnd(_hwnd_bits: f64, _width: f64, _height: f64) #[no_mangle] pub extern "C" fn bloom_close_window() { - unsafe { UI_VIEW = None; UI_WINDOW = None; } + unsafe { + UI_VIEW = None; + UI_WINDOW = None; + } } #[no_mangle] pub extern "C" fn bloom_window_should_close() -> f64 { - if engine().should_close { 1.0 } else { 0.0 } + if engine().should_close { + 1.0 + } else { + 0.0 + } } /// Poll the first connected game controller (MFi / Xbox / PlayStation) through @@ -825,7 +892,9 @@ fn poll_game_controllers() { ($btn:expr, $eng:expr, $idx:expr) => {{ let b: Retained = $btn; let pressed: Bool = msg_send![&*b, isPressed]; - if pressed.as_bool() { $eng.input.set_gamepad_button_down($idx); } + if pressed.as_bool() { + $eng.input.set_gamepad_button_down($idx); + } }}; } unsafe { @@ -904,7 +973,10 @@ pub extern "C" fn bloom_begin_drawing() { let ph = (view_bounds.size.height * scale) as u32; let eng = engine(); if pw > 0 && ph > 0 && (pw != eng.renderer.width() || ph != eng.renderer.height()) { - let ds = CGSize { width: pw as f64, height: ph as f64 }; + let ds = CGSize { + width: pw as f64, + height: ph as f64, + }; let _: () = msg_send![&*layer, setDrawableSize: ds]; eng.renderer.resize(pw, ph, pw, ph); } else { @@ -1031,7 +1103,10 @@ type AudioComponent = *mut c_void; #[link(name = "AudioToolbox", kind = "framework")] extern "C" { - fn AudioComponentFindNext(component: AudioComponent, desc: *const AudioComponentDescription) -> AudioComponent; + fn AudioComponentFindNext( + component: AudioComponent, + desc: *const AudioComponentDescription, + ) -> AudioComponent; fn AudioComponentInstanceNew(component: AudioComponent, out: *mut AudioUnit) -> OSStatus; fn AudioUnitSetProperty( unit: AudioUnit, @@ -1059,7 +1134,9 @@ const K_AUDIO_FORMAT_LINEAR_PCM: u32 = u32::from_be_bytes(*b"lpcm"); const K_AUDIO_FORMAT_FLAG_IS_FLOAT: u32 = 1; const K_AUDIO_FORMAT_FLAG_IS_PACKED: u32 = 8; -struct AudioUnitInstance { unit: AudioUnit } +struct AudioUnitInstance { + unit: AudioUnit, +} unsafe impl Send for AudioUnitInstance {} unsafe impl Sync for AudioUnitInstance {} @@ -1121,10 +1198,14 @@ pub extern "C" fn bloom_init_audio() { }; let component = AudioComponentFindNext(std::ptr::null_mut(), &desc); - if component.is_null() { return; } + if component.is_null() { + return; + } let mut unit: AudioUnit = std::ptr::null_mut(); - if AudioComponentInstanceNew(component, &mut unit) != 0 { return; } + if AudioComponentInstanceNew(component, &mut unit) != 0 { + return; + } let stream_desc = AudioStreamBasicDescription { sample_rate: 44100.0, @@ -1139,7 +1220,10 @@ pub extern "C" fn bloom_init_audio() { }; AudioUnitSetProperty( - unit, K_AUDIO_UNIT_PROPERTY_STREAM_FORMAT, K_AUDIO_UNIT_SCOPE_INPUT, 0, + unit, + K_AUDIO_UNIT_PROPERTY_STREAM_FORMAT, + K_AUDIO_UNIT_SCOPE_INPUT, + 0, &stream_desc as *const _ as *const c_void, std::mem::size_of::() as u32, ); @@ -1150,7 +1234,10 @@ pub extern "C" fn bloom_init_audio() { }; AudioUnitSetProperty( - unit, K_AUDIO_UNIT_PROPERTY_SET_RENDER_CALLBACK, K_AUDIO_UNIT_SCOPE_INPUT, 0, + unit, + K_AUDIO_UNIT_PROPERTY_SET_RENDER_CALLBACK, + K_AUDIO_UNIT_SCOPE_INPUT, + 0, &callback_struct as *const _ as *const c_void, std::mem::size_of::() as u32, ); @@ -1210,33 +1297,57 @@ pub extern "C" fn bloom_enable_cursor() { #[no_mangle] pub extern "C" fn bloom_set_clipboard_text(_text_ptr: *const u8) {} #[no_mangle] -pub extern "C" fn bloom_get_clipboard_text() -> *const u8 { std::ptr::null() } +pub extern "C" fn bloom_get_clipboard_text() -> *const u8 { + std::ptr::null() +} // E5b: File dialogs (stub on this platform) #[no_mangle] -pub extern "C" fn bloom_open_file_dialog(_filter_ptr: *const u8, _title_ptr: *const u8) -> *const u8 { std::ptr::null() } +pub extern "C" fn bloom_open_file_dialog( + _filter_ptr: *const u8, + _title_ptr: *const u8, +) -> *const u8 { + std::ptr::null() +} #[no_mangle] -pub extern "C" fn bloom_save_file_dialog(_default_name_ptr: *const u8, _title_ptr: *const u8) -> *const u8 { std::ptr::null() } +pub extern "C" fn bloom_save_file_dialog( + _default_name_ptr: *const u8, + _title_ptr: *const u8, +) -> *const u8 { + std::ptr::null() +} // ============================================================ // Input injection + platform detection // ============================================================ #[no_mangle] -pub extern "C" fn bloom_get_platform() -> f64 { 2.0 } +pub extern "C" fn bloom_get_platform() -> f64 { + 2.0 +} /// Preferred OS language packed as `c0*256+c1` (ISO-639 primary subtag). See macos lib for format. #[no_mangle] pub extern "C" fn bloom_get_language() -> f64 { - fn pack(code: &str) -> f64 { let l = code.to_ascii_lowercase(); let b = l.as_bytes(); if b.len() >= 2 { (b[0] as f64) * 256.0 + (b[1] as f64) } else { 25966.0 } } + fn pack(code: &str) -> f64 { + let l = code.to_ascii_lowercase(); + let b = l.as_bytes(); + if b.len() >= 2 { + (b[0] as f64) * 256.0 + (b[1] as f64) + } else { + 25966.0 + } + } let langs = objc2_foundation::NSLocale::preferredLanguages(); - match langs.firstObject() { Some(s) => pack(&s.to_string()), None => 25966.0 } + match langs.firstObject() { + Some(s) => pack(&s.to_string()), + None => 25966.0, + } } // ============================================================ // Thread-safe staging (for async asset loading via Perry threads) // ============================================================ - // Q6: Multi-hit picking // ============================================================ @@ -1264,4 +1375,3 @@ fn bloom_jolt_ffi_physics() -> &'static mut bloom_shared::physics_jolt::JoltPhys #[cfg(feature = "jolt")] bloom_shared::define_physics_ffi!(); - diff --git a/native/linux/Cargo.lock b/native/linux/Cargo.lock index 0aa96a1d..3db4bb69 100644 --- a/native/linux/Cargo.lock +++ b/native/linux/Cargo.lock @@ -357,6 +357,7 @@ dependencies = [ "image_dds", "lewton", "libc", + "log", "minimp3", "raw-window-handle", "web-sys", diff --git a/native/linux/src/lib.rs b/native/linux/src/lib.rs index 46969353..b5e79819 100644 --- a/native/linux/src/lib.rs +++ b/native/linux/src/lib.rs @@ -1,10 +1,10 @@ +use bloom_shared::audio::{parse_mp3, parse_ogg, parse_wav}; use bloom_shared::engine::EngineState; use bloom_shared::renderer::Renderer; -use bloom_shared::string_header::{str_from_header, alloc_perry_string}; -use bloom_shared::audio::{parse_wav, parse_ogg, parse_mp3}; +use bloom_shared::string_header::{alloc_perry_string, str_from_header}; -use std::sync::OnceLock; use std::os::unix::io::RawFd; +use std::sync::OnceLock; static mut ENGINE: OnceLock = OnceLock::new(); static mut GAMEPAD_FD: RawFd = -1; @@ -22,49 +22,48 @@ fn bloom_resolve_asset_path(path: &str) -> std::borrow::Cow<'_, str> { // docs for the contract; tools/validate-ffi.js checks parity in CI. bloom_shared::define_core_ffi!(); - /// Map X11 keysym to Bloom key code. fn map_keycode(keysym: u32) -> usize { match keysym { 0x61..=0x7a => (keysym - 0x61 + 65) as usize, // a-z → A-Z (65-90) - 0x41..=0x5a => keysym as usize, // A-Z direct - 0x30..=0x39 => keysym as usize, // 0-9 - 0xff52 => 256, // XK_Up - 0xff54 => 257, // XK_Down - 0xff51 => 258, // XK_Left - 0xff53 => 259, // XK_Right - 0x20 => 32, // space - 0xff0d => 265, // XK_Return → Bloom ENTER - 0xff1b => 27, // XK_Escape - 0xff09 => 9, // XK_Tab - 0xff08 => 8, // XK_BackSpace - 0xffff => 127, // XK_Delete - 0xff63 => 260, // XK_Insert - 0xff50 => 261, // XK_Home - 0xff57 => 262, // XK_End - 0xff55 => 263, // XK_Page_Up - 0xff56 => 264, // XK_Page_Down - 0xffe1 => 280, // XK_Shift_L - 0xffe2 => 281, // XK_Shift_R - 0xffe3 => 282, // XK_Control_L - 0xffe4 => 283, // XK_Control_R - 0xffe9 => 284, // XK_Alt_L - 0xffea => 285, // XK_Alt_R - 0xffeb => 286, // XK_Super_L - 0xffec => 287, // XK_Super_R + 0x41..=0x5a => keysym as usize, // A-Z direct + 0x30..=0x39 => keysym as usize, // 0-9 + 0xff52 => 256, // XK_Up + 0xff54 => 257, // XK_Down + 0xff51 => 258, // XK_Left + 0xff53 => 259, // XK_Right + 0x20 => 32, // space + 0xff0d => 265, // XK_Return → Bloom ENTER + 0xff1b => 27, // XK_Escape + 0xff09 => 9, // XK_Tab + 0xff08 => 8, // XK_BackSpace + 0xffff => 127, // XK_Delete + 0xff63 => 260, // XK_Insert + 0xff50 => 261, // XK_Home + 0xff57 => 262, // XK_End + 0xff55 => 263, // XK_Page_Up + 0xff56 => 264, // XK_Page_Down + 0xffe1 => 280, // XK_Shift_L + 0xffe2 => 281, // XK_Shift_R + 0xffe3 => 282, // XK_Control_L + 0xffe4 => 283, // XK_Control_R + 0xffe9 => 284, // XK_Alt_L + 0xffea => 285, // XK_Alt_R + 0xffeb => 286, // XK_Super_L + 0xffec => 287, // XK_Super_R // Function keys - 0xffbe => 112, // XK_F1 - 0xffbf => 113, // XK_F2 - 0xffc0 => 114, // XK_F3 - 0xffc1 => 115, // XK_F4 - 0xffc2 => 116, // XK_F5 - 0xffc3 => 117, // XK_F6 - 0xffc4 => 118, // XK_F7 - 0xffc5 => 119, // XK_F8 - 0xffc6 => 120, // XK_F9 - 0xffc7 => 121, // XK_F10 - 0xffc8 => 122, // XK_F11 - 0xffc9 => 123, // XK_F12 + 0xffbe => 112, // XK_F1 + 0xffbf => 113, // XK_F2 + 0xffc0 => 114, // XK_F3 + 0xffc1 => 115, // XK_F4 + 0xffc2 => 116, // XK_F5 + 0xffc3 => 117, // XK_F6 + 0xffc4 => 118, // XK_F7 + 0xffc5 => 119, // XK_F8 + 0xffc6 => 120, // XK_F9 + 0xffc7 => 121, // XK_F10 + 0xffc8 => 122, // XK_F11 + 0xffc9 => 123, // XK_F12 _ => 0, } } @@ -97,22 +96,27 @@ mod x11_impl { static mut WM_PROTOCOLS: x11::xlib::Atom = 0; static mut WM_DELETE_WINDOW: x11::xlib::Atom = 0; - pub fn wm_protocols_atom() -> x11::xlib::Atom { unsafe { WM_PROTOCOLS } } - pub fn wm_delete_window_atom() -> x11::xlib::Atom { unsafe { WM_DELETE_WINDOW } } + pub fn wm_protocols_atom() -> x11::xlib::Atom { + unsafe { WM_PROTOCOLS } + } + pub fn wm_delete_window_atom() -> x11::xlib::Atom { + unsafe { WM_DELETE_WINDOW } + } pub fn set_fullscreen(fullscreen: bool) { unsafe { // BLOOM_NO_FULLSCREEN=1 hard-disables the fullscreen path so // benchmark harnesses on a 4K monitor don't silently 4× their // pixel count when an inherited fullscreen Space leaks in. - if NO_FULLSCREEN && fullscreen { return; } - if DISPLAY.is_null() || X11_WINDOW == 0 { return; } + if NO_FULLSCREEN && fullscreen { + return; + } + if DISPLAY.is_null() || X11_WINDOW == 0 { + return; + } - let wm_state = x11::xlib::XInternAtom( - DISPLAY, - b"_NET_WM_STATE\0".as_ptr() as *const _, - 0, - ); + let wm_state = + x11::xlib::XInternAtom(DISPLAY, b"_NET_WM_STATE\0".as_ptr() as *const _, 0); let wm_fullscreen = x11::xlib::XInternAtom( DISPLAY, b"_NET_WM_STATE_FULLSCREEN\0".as_ptr() as *const _, @@ -145,7 +149,9 @@ mod x11_impl { } pub fn toggle_fullscreen() { - unsafe { set_fullscreen(!IS_FULLSCREEN); } + unsafe { + set_fullscreen(!IS_FULLSCREEN); + } } /// Returns (physical_w, physical_h). Caller's `width`/`height` @@ -177,8 +183,13 @@ mod x11_impl { // XMapWindow, it'll appear far off the visible desktop. let origin_x: i32 = if headless { -20000 } else { 0 }; X11_WINDOW = x11::xlib::XCreateSimpleWindow( - DISPLAY, root, - origin_x, 0, phys_w, phys_h, 0, + DISPLAY, + root, + origin_x, + 0, + phys_w, + phys_h, + 0, x11::xlib::XBlackPixel(DISPLAY, screen), x11::xlib::XWhitePixel(DISPLAY, screen), ); @@ -186,23 +197,29 @@ mod x11_impl { let title_cstr = std::ffi::CString::new(title).unwrap(); x11::xlib::XStoreName(DISPLAY, X11_WINDOW, title_cstr.as_ptr()); - x11::xlib::XSelectInput(DISPLAY, X11_WINDOW, - x11::xlib::ExposureMask | x11::xlib::KeyPressMask | x11::xlib::KeyReleaseMask | - x11::xlib::ButtonPressMask | x11::xlib::ButtonReleaseMask | - x11::xlib::PointerMotionMask | x11::xlib::StructureNotifyMask); + x11::xlib::XSelectInput( + DISPLAY, + X11_WINDOW, + x11::xlib::ExposureMask + | x11::xlib::KeyPressMask + | x11::xlib::KeyReleaseMask + | x11::xlib::ButtonPressMask + | x11::xlib::ButtonReleaseMask + | x11::xlib::PointerMotionMask + | x11::xlib::StructureNotifyMask, + ); // Opt into the WM close protocol before mapping, so the titlebar // ✕ / Alt-F4 arrives as a ClientMessage we can turn into // `windowShouldClose()` instead of the WM dropping our X // connection and Xlib exit(1)-ing the game mid-frame. - WM_PROTOCOLS = x11::xlib::XInternAtom( - DISPLAY, b"WM_PROTOCOLS\0".as_ptr() as *const _, 0); - WM_DELETE_WINDOW = x11::xlib::XInternAtom( - DISPLAY, b"WM_DELETE_WINDOW\0".as_ptr() as *const _, 0); + WM_PROTOCOLS = + x11::xlib::XInternAtom(DISPLAY, b"WM_PROTOCOLS\0".as_ptr() as *const _, 0); + WM_DELETE_WINDOW = + x11::xlib::XInternAtom(DISPLAY, b"WM_DELETE_WINDOW\0".as_ptr() as *const _, 0); if WM_DELETE_WINDOW != 0 { let mut protocols = [WM_DELETE_WINDOW]; - x11::xlib::XSetWMProtocols( - DISPLAY, X11_WINDOW, protocols.as_mut_ptr(), 1); + x11::xlib::XSetWMProtocols(DISPLAY, X11_WINDOW, protocols.as_mut_ptr(), 1); } if !headless { @@ -213,8 +230,14 @@ mod x11_impl { } } - pub fn set_no_fullscreen(no_fs: bool) { unsafe { NO_FULLSCREEN = no_fs; } } - pub fn is_headless() -> bool { unsafe { HEADLESS } } + pub fn set_no_fullscreen(no_fs: bool) { + unsafe { + NO_FULLSCREEN = no_fs; + } + } + pub fn is_headless() -> bool { + unsafe { HEADLESS } + } /// Read the current display's DPI scale factor. Computed from /// physical screen dimensions (pixels / mm). Snapped to the @@ -228,7 +251,9 @@ mod x11_impl { unsafe { let pixels = x11::xlib::XDisplayWidth(display, screen) as f64; let mm = x11::xlib::XDisplayWidthMM(display, screen) as f64; - if mm <= 0.0 || pixels <= 0.0 { return 1.0; } + if mm <= 0.0 || pixels <= 0.0 { + return 1.0; + } let dpi = pixels / (mm / 25.4); // 96 DPI = scale 1.0. Snap to 0.25 steps. let raw = (dpi / 96.0).max(1.0).min(4.0); @@ -236,25 +261,39 @@ mod x11_impl { } } - pub fn display() -> *mut x11::xlib::Display { unsafe { DISPLAY } } - pub fn window() -> x11::xlib::Window { unsafe { X11_WINDOW } } + pub fn display() -> *mut x11::xlib::Display { + unsafe { DISPLAY } + } + pub fn window() -> x11::xlib::Window { + unsafe { X11_WINDOW } + } pub fn set_window_title(title: &str) { unsafe { - if DISPLAY.is_null() || X11_WINDOW == 0 { return; } - let cstr = match std::ffi::CString::new(title) { Ok(c) => c, Err(_) => return }; + if DISPLAY.is_null() || X11_WINDOW == 0 { + return; + } + let cstr = match std::ffi::CString::new(title) { + Ok(c) => c, + Err(_) => return, + }; x11::xlib::XStoreName(DISPLAY, X11_WINDOW, cstr.as_ptr()); // Modern WMs honour _NET_WM_NAME (UTF-8) over the legacy // WM_NAME (Latin-1) set by XStoreName, so write both. - let net_wm_name = x11::xlib::XInternAtom( - DISPLAY, b"_NET_WM_NAME\0".as_ptr() as *const _, 0); - let utf8_string = x11::xlib::XInternAtom( - DISPLAY, b"UTF8_STRING\0".as_ptr() as *const _, 0); + let net_wm_name = + x11::xlib::XInternAtom(DISPLAY, b"_NET_WM_NAME\0".as_ptr() as *const _, 0); + let utf8_string = + x11::xlib::XInternAtom(DISPLAY, b"UTF8_STRING\0".as_ptr() as *const _, 0); if net_wm_name != 0 && utf8_string != 0 { x11::xlib::XChangeProperty( - DISPLAY, X11_WINDOW, net_wm_name, utf8_string, 8, + DISPLAY, + X11_WINDOW, + net_wm_name, + utf8_string, + 8, x11::xlib::PropModeReplace, - title.as_ptr(), title.len() as i32, + title.as_ptr(), + title.len() as i32, ); } x11::xlib::XFlush(DISPLAY); @@ -267,7 +306,9 @@ mod x11_impl { /// premultiplied-alpha packed into the low 32 bits of a long. pub fn set_window_icon(path: &str) { unsafe { - if DISPLAY.is_null() || X11_WINDOW == 0 { return; } + if DISPLAY.is_null() || X11_WINDOW == 0 { + return; + } let img = match image::open(path) { Ok(i) => i.to_rgba8(), Err(_) => return, @@ -284,12 +325,15 @@ mod x11_impl { let argb = (a << 24) | (r << 16) | (g << 8) | b; buf.push(argb as std::os::raw::c_long); } - let net_wm_icon = x11::xlib::XInternAtom( - DISPLAY, b"_NET_WM_ICON\0".as_ptr() as *const _, 0); - let cardinal = x11::xlib::XInternAtom( - DISPLAY, b"CARDINAL\0".as_ptr() as *const _, 0); + let net_wm_icon = + x11::xlib::XInternAtom(DISPLAY, b"_NET_WM_ICON\0".as_ptr() as *const _, 0); + let cardinal = x11::xlib::XInternAtom(DISPLAY, b"CARDINAL\0".as_ptr() as *const _, 0); x11::xlib::XChangeProperty( - DISPLAY, X11_WINDOW, net_wm_icon, cardinal, 32, + DISPLAY, + X11_WINDOW, + net_wm_icon, + cardinal, + 32, x11::xlib::PropModeReplace, buf.as_ptr() as *const u8, buf.len() as i32, @@ -302,11 +346,13 @@ mod x11_impl { /// trick for "hide the cursor". Subsequent calls reuse the cached /// cursor since X11 leaks Cursor handles otherwise. unsafe fn ensure_hidden_cursor() -> x11::xlib::Cursor { - if HIDDEN_CURSOR != 0 { return HIDDEN_CURSOR; } + if HIDDEN_CURSOR != 0 { + return HIDDEN_CURSOR; + } let pixmap = x11::xlib::XCreatePixmap(DISPLAY, X11_WINDOW, 1, 1, 1); let mut color: x11::xlib::XColor = std::mem::zeroed(); - let cursor = x11::xlib::XCreatePixmapCursor( - DISPLAY, pixmap, pixmap, &mut color, &mut color, 0, 0); + let cursor = + x11::xlib::XCreatePixmapCursor(DISPLAY, pixmap, pixmap, &mut color, &mut color, 0, 0); x11::xlib::XFreePixmap(DISPLAY, pixmap); HIDDEN_CURSOR = cursor; cursor @@ -314,7 +360,9 @@ mod x11_impl { pub fn hide_cursor() { unsafe { - if DISPLAY.is_null() || X11_WINDOW == 0 || CURSOR_HIDDEN { return; } + if DISPLAY.is_null() || X11_WINDOW == 0 || CURSOR_HIDDEN { + return; + } let c = ensure_hidden_cursor(); x11::xlib::XDefineCursor(DISPLAY, X11_WINDOW, c); x11::xlib::XFlush(DISPLAY); @@ -324,7 +372,9 @@ mod x11_impl { pub fn show_cursor() { unsafe { - if DISPLAY.is_null() || X11_WINDOW == 0 || !CURSOR_HIDDEN { return; } + if DISPLAY.is_null() || X11_WINDOW == 0 || !CURSOR_HIDDEN { + return; + } x11::xlib::XUndefineCursor(DISPLAY, X11_WINDOW); x11::xlib::XFlush(DISPLAY); CURSOR_HIDDEN = false; @@ -335,7 +385,9 @@ mod x11_impl { /// the motion handler can compute deltas relative to the warp target. pub fn warp_to_center() { unsafe { - if DISPLAY.is_null() || X11_WINDOW == 0 { return; } + if DISPLAY.is_null() || X11_WINDOW == 0 { + return; + } let mut attrs: x11::xlib::XWindowAttributes = std::mem::zeroed(); x11::xlib::XGetWindowAttributes(DISPLAY, X11_WINDOW, &mut attrs); let cx = attrs.width / 2; @@ -362,9 +414,15 @@ mod x11_impl { } } - pub fn is_relative_mode() -> bool { unsafe { RELATIVE_MODE } } - pub fn warp_center_x() -> i32 { unsafe { WARP_CENTER_X } } - pub fn warp_center_y() -> i32 { unsafe { WARP_CENTER_Y } } + pub fn is_relative_mode() -> bool { + unsafe { RELATIVE_MODE } + } + pub fn warp_center_x() -> i32 { + unsafe { WARP_CENTER_X } + } + pub fn warp_center_y() -> i32 { + unsafe { WARP_CENTER_Y } + } /// Apply the requested cursor shape (the same 0..=6 enum macOS uses /// in NSCursor calls). XCreateFontCursor uses cursor-font glyph @@ -372,8 +430,12 @@ mod x11_impl { /// so repeat calls don't leak server-side state. pub fn apply_cursor_shape(shape: u32) { unsafe { - if DISPLAY.is_null() || X11_WINDOW == 0 || CURSOR_HIDDEN { return; } - if shape == LAST_APPLIED_SHAPE { return; } + if DISPLAY.is_null() || X11_WINDOW == 0 || CURSOR_HIDDEN { + return; + } + if shape == LAST_APPLIED_SHAPE { + return; + } // X11 cursor-font glyph indices (from cursorfont.h). // 0 = default arrow → XC_left_ptr (68) // 1 = pointing hand → XC_hand2 (60) @@ -411,10 +473,8 @@ mod x11_impl { match event.type_ { x11::xlib::KeyPress => { - let keysym = x11::xlib::XLookupKeysym( - &mut event.key as *mut _ as *mut _, - 0, - ); + let keysym = + x11::xlib::XLookupKeysym(&mut event.key as *mut _ as *mut _, 0); let bloom_key = map_keycode(keysym as u32); if bloom_key > 0 { engine().input.set_key_down(bloom_key); @@ -442,10 +502,8 @@ mod x11_impl { } } x11::xlib::KeyRelease => { - let keysym = x11::xlib::XLookupKeysym( - &mut event.key as *mut _ as *mut _, - 0, - ); + let keysym = + x11::xlib::XLookupKeysym(&mut event.key as *mut _ as *mut _, 0); let bloom_key = map_keycode(keysym as u32); if bloom_key > 0 { engine().input.set_key_up(bloom_key); @@ -466,7 +524,9 @@ mod x11_impl { warp_to_center(); } } else { - engine().input.set_mouse_position(motion.x as f64, motion.y as f64); + engine() + .input + .set_mouse_position(motion.x as f64, motion.y as f64); } } x11::xlib::ButtonPress => { @@ -538,7 +598,12 @@ mod x11_impl { } #[no_mangle] -pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u8, fullscreen: f64) { +pub extern "C" fn bloom_init_window( + width: f64, + height: f64, + title_ptr: *const u8, + fullscreen: f64, +) { let title = str_from_header(title_ptr); #[cfg(target_os = "linux")] @@ -568,107 +633,49 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u let surface = unsafe { let raw_window = raw_window_handle::RawWindowHandle::Xlib( - raw_window_handle::XlibWindowHandle::new(x11_impl::window()) + raw_window_handle::XlibWindowHandle::new(x11_impl::window()), ); let raw_display = raw_window_handle::RawDisplayHandle::Xlib( raw_window_handle::XlibDisplayHandle::new( std::ptr::NonNull::new(x11_impl::display() as *mut _), 0, - ) + ), ); - instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { - raw_display_handle: Some(raw_display), - raw_window_handle: raw_window, - }).expect("Failed to create surface") + instance + .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: Some(raw_display), + raw_window_handle: raw_window, + }) + .expect("Failed to create surface") }; let adapter = pollster_block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { compatible_surface: Some(&surface), ..Default::default() - })).expect("No adapter found"); + })) + .expect("No adapter found"); - // Ticket 007b: HW ray-query via VK_KHR_ray_query on RT-capable - // desktop Linux GPUs. Older integrated GPUs will fall back to - // the SW path through this gate. - let supported = adapter.features(); let force_sw_gi = std::env::var("BLOOM_FORCE_SW_GI") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); - let rt_mask = wgpu::Features::EXPERIMENTAL_RAY_QUERY; - let mut required_features = wgpu::Features::empty(); - // Ticket 011: request TIMESTAMP_QUERY when supported so the profiler - // can record GPU timings. Optional — profiler falls back to CPU-only - // when the adapter doesn't grant it. - if supported.contains(wgpu::Features::TIMESTAMP_QUERY) { - required_features |= wgpu::Features::TIMESTAMP_QUERY; - } - // Cooked BC7 textures (bloom-cook) upload compressed when the - // adapter has BC support; without it they CPU-decode at load. - if supported.contains(wgpu::Features::TEXTURE_COMPRESSION_BC) { - required_features |= wgpu::Features::TEXTURE_COMPRESSION_BC; - } - if !force_sw_gi && supported.contains(rt_mask) { - required_features |= rt_mask; - } - // PT-2: texture binding array + non-uniform indexing for textured - // path-trace hit shading. Both or neither (the kernel indexes the - // array with a per-thread material id). - let pt_tex_mask = wgpu::Features::TEXTURE_BINDING_ARRAY - | wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING; - if supported.contains(pt_tex_mask) { - required_features |= pt_tex_mask; - } - let experimental_features = if required_features.intersects(rt_mask) { - unsafe { wgpu::ExperimentalFeatures::enabled() } - } else { - wgpu::ExperimentalFeatures::disabled() - }; - let mut required_limits = wgpu::Limits::default(); - // The material ABI declares 5 bind groups (PerFrame, PerView, - // PerMaterial, PerDraw, SceneInputs). wgpu's default limit is - // 4. Vulkan supports at least 7 here, so 5 is universally safe. - required_limits.max_bind_groups = 5; - // The refractive/translucent material profile binds up to 19 - // sampled textures in the fragment stage (5 material maps + env/ - // BRDF/3 shadow cascades/env-diffuse + planar reflection + 3 texture - // arrays + the group-4 scene_color/scene_depth/impulse/motion inputs). - // wgpu's default is 16. Raise to whatever the adapter actually - // supports — every real Vulkan/GL GPU exposes ≥128 — so opaque/ - // transparent materials are unaffected and refractive ones link. - let adapter_limits = adapter.limits(); - required_limits.max_sampled_textures_per_shader_stage = required_limits - .max_sampled_textures_per_shader_stage - .max(adapter_limits.max_sampled_textures_per_shader_stage); - required_limits.max_samplers_per_shader_stage = required_limits - .max_samplers_per_shader_stage - .max(adapter_limits.max_samplers_per_shader_stage); - // PT-2: binding arrays have their own element budget, default 0. - // Take whatever the adapter offers; the renderer checks the - // granted value against its fixed array size before compiling - // the textured kernel variant. - if required_features.contains(pt_tex_mask) { - required_limits.max_binding_array_elements_per_shader_stage = - adapter_limits.max_binding_array_elements_per_shader_stage; - } - // PT-4: the path-trace kernel binds 9 storage buffers (accum + - // moments + reservoir ping-pongs on top of instance/geo data); - // the wgpu default limit is 8. - required_limits.max_storage_buffers_per_shader_stage = required_limits - .max_storage_buffers_per_shader_stage - .max(adapter_limits.max_storage_buffers_per_shader_stage.min(16)); - if required_features.intersects(rt_mask) { - required_limits = required_limits - .using_minimum_supported_acceleration_structure_values(); - } - let (device, queue) = pollster_block_on(adapter.request_device( - &wgpu::DeviceDescriptor { - label: Some("bloom_device"), - required_features, - required_limits, - experimental_features, - ..Default::default() - }, - )).expect("Failed to create device"); + let negotiated = pollster_block_on( + bloom_shared::renderer::device_negotiation::request_device_with_fallback( + &adapter, + bloom_shared::renderer::device_negotiation::DeviceRequestOptions { + allow_ray_query: !force_sw_gi, + profile: + bloom_shared::renderer::device_negotiation::DeviceRequestProfile::NativeFull, + }, + ), + ) + .unwrap_or_else(|error| panic!("Failed to create renderer device: {error}")); + eprintln!( + "bloom: renderer device negotiation = {}", + negotiated.report.report_json() + ); + let negotiation_report = negotiated.report.report_json(); + let device = negotiated.device; + let queue = negotiated.queue; let surface_caps = surface.get_capabilities(&adapter); let format = surface_caps.formats[0]; @@ -680,8 +687,7 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u // COPY_SRC: bloom_take_screenshot reads the swapchain back; // without it the readback copy is a validation error that // aborts the process the first time a game calls it. - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::COPY_SRC, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, format, width: phys_w, height: phys_h, @@ -692,8 +698,18 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u }; surface.configure(&device, &surface_config); - let renderer = Renderer::new(device, queue, surface, surface_config, width as u32, height as u32); - unsafe { let _ = ENGINE.set(EngineState::new(renderer)); } + let mut renderer = Renderer::new( + device, + queue, + surface, + surface_config, + width as u32, + height as u32, + ); + renderer.set_device_negotiation_report(negotiation_report); + unsafe { + let _ = ENGINE.set(EngineState::new(renderer)); + } if fullscreen != 0.0 { x11_impl::set_fullscreen(true); @@ -732,7 +748,8 @@ pub extern "C" fn bloom_attach_native(handle: i64, width: f64, height: f64) -> f #[no_mangle] pub extern "C" fn bloom_resize(phys_w: f64, phys_h: f64, log_w: f64, log_h: f64) { if let Some(eng) = unsafe { ENGINE.get_mut() } { - eng.renderer.resize(phys_w as u32, phys_h as u32, log_w as u32, log_h as u32); + eng.renderer + .resize(phys_w as u32, phys_h as u32, log_w as u32, log_h as u32); } } @@ -750,7 +767,11 @@ pub extern "C" fn bloom_close_window() { #[no_mangle] pub extern "C" fn bloom_window_should_close() -> f64 { - if engine().should_close { 1.0 } else { 0.0 } + if engine().should_close { + 1.0 + } else { + 0.0 + } } #[cfg(target_os = "linux")] @@ -760,13 +781,18 @@ fn poll_linux_gamepad() { if GAMEPAD_FD < 0 { let path = b"/dev/input/js0\0"; // Open non-blocking - GAMEPAD_FD = libc::open(path.as_ptr() as *const libc::c_char, libc::O_RDONLY | libc::O_NONBLOCK); + GAMEPAD_FD = libc::open( + path.as_ptr() as *const libc::c_char, + libc::O_RDONLY | libc::O_NONBLOCK, + ); if GAMEPAD_FD >= 0 { engine().input.gamepad_available = true; engine().input.gamepad_axis_count = 6; } } - if GAMEPAD_FD < 0 { return; } + if GAMEPAD_FD < 0 { + return; + } // Linux joystick event structure #[repr(C)] @@ -784,7 +810,9 @@ fn poll_linux_gamepad() { &mut event as *mut _ as *mut libc::c_void, std::mem::size_of::(), ); - if n != std::mem::size_of::() as isize { break; } + if n != std::mem::size_of::() as isize { + break; + } let type_masked = event.event_type & 0x7f; // strip JS_EVENT_INIT if type_masked == 1 { @@ -823,8 +851,12 @@ pub extern "C" fn bloom_begin_drawing() { pub extern "C" fn bloom_end_drawing() { // Pump geisterhand BEFORE end_frame. Mirrors macOS — the screenshot // function re-renders inline using the captured VP + vertex buffers. - extern "C" { fn perry_geisterhand_pump(); } - unsafe { perry_geisterhand_pump(); } + extern "C" { + fn perry_geisterhand_pump(); + } + unsafe { + perry_geisterhand_pump(); + } engine().end_frame(); } @@ -884,7 +916,9 @@ fn alsa_audio_thread(mut renderer: Option) { let mut mix_buf = vec![0.0f32; frames * channels as usize]; while AUDIO_RUNNING.load(Ordering::SeqCst) { - for s in mix_buf.iter_mut() { *s = 0.0; } + for s in mix_buf.iter_mut() { + *s = 0.0; + } // Renderer is moved into this thread at spawn — no shared // engine state is touched from here (see audio/mod.rs contract). @@ -994,7 +1028,10 @@ pub extern "C" fn bloom_open_file_dialog(filter_ptr: *const u8, title_ptr: *cons } } #[no_mangle] -pub extern "C" fn bloom_save_file_dialog(default_name_ptr: *const u8, title_ptr: *const u8) -> *const u8 { +pub extern "C" fn bloom_save_file_dialog( + default_name_ptr: *const u8, + title_ptr: *const u8, +) -> *const u8 { let default_name = str_from_header(default_name_ptr); let title = str_from_header(title_ptr); let dialog = rfd::FileDialog::new() @@ -1006,14 +1043,31 @@ pub extern "C" fn bloom_save_file_dialog(default_name_ptr: *const u8, title_ptr: } } #[no_mangle] -pub extern "C" fn bloom_get_platform() -> f64 { 4.0 } +pub extern "C" fn bloom_get_platform() -> f64 { + 4.0 +} /// Preferred OS language packed as `c0*256+c1` (ISO-639 primary subtag), from $LANG/$LC_*. #[no_mangle] pub extern "C" fn bloom_get_language() -> f64 { - fn pack(code: &str) -> f64 { let l = code.to_ascii_lowercase(); let b = l.as_bytes(); if b.len() >= 2 { (b[0] as f64) * 256.0 + (b[1] as f64) } else { 25966.0 } } - let v = std::env::var("LANG").or_else(|_| std::env::var("LC_ALL")).or_else(|_| std::env::var("LC_MESSAGES")).unwrap_or_default(); - if v.len() >= 2 && !v.starts_with('C') && !v.starts_with("POSIX") { pack(&v) } else { 25966.0 } + fn pack(code: &str) -> f64 { + let l = code.to_ascii_lowercase(); + let b = l.as_bytes(); + if b.len() >= 2 { + (b[0] as f64) * 256.0 + (b[1] as f64) + } else { + 25966.0 + } + } + let v = std::env::var("LANG") + .or_else(|_| std::env::var("LC_ALL")) + .or_else(|_| std::env::var("LC_MESSAGES")) + .unwrap_or_default(); + if v.len() >= 2 && !v.starts_with('C') && !v.starts_with("POSIX") { + pack(&v) + } else { + 25966.0 + } } // ============================================================ @@ -1044,22 +1098,22 @@ pub extern "C" fn bloom_get_language() -> f64 { // 3D→2D Projection (for UI overlays positioned in 3D space) // ============================================================ - // ============================================================ // Scene picking (raycasting) // ============================================================ - // ============================================================ // Thread-safe staging (for async asset loading via Perry threads) // ============================================================ fn pollster_block_on(future: F) -> F::Output { - use std::task::{Context, Poll, Wake, Waker}; use std::pin::Pin; use std::sync::Arc; + use std::task::{Context, Poll, Wake, Waker}; struct NoopWaker; - impl Wake for NoopWaker { fn wake(self: Arc) {} } + impl Wake for NoopWaker { + fn wake(self: Arc) {} + } let waker = Waker::from(Arc::new(NoopWaker)); let mut cx = Context::from_waker(&waker); let mut future = unsafe { Pin::new_unchecked(Box::new(future)) }; @@ -1071,8 +1125,6 @@ fn pollster_block_on(future: F) -> F::Output { } } - - // Q6: Multi-hit picking // ============================================================ @@ -1090,7 +1142,6 @@ fn pollster_block_on(future: F) -> F::Output { // underlying renderer methods, so these wrappers are platform-agnostic. // ============================================================ - // ============================================================ // Profiler — CPU phase timings (always available) + GPU timestamps // (when the adapter supports TIMESTAMP_QUERY). Disabled by default. @@ -1106,9 +1157,7 @@ fn pollster_block_on(future: F) -> F::Output { /// surface bloom is already drawing into. fn bloom_register_geisterhand_screenshot() { extern "C" { - fn perry_geisterhand_register_screenshot_capture( - f: extern "C" fn(*mut usize) -> *mut u8, - ); + fn perry_geisterhand_register_screenshot_capture(f: extern "C" fn(*mut usize) -> *mut u8); } unsafe { perry_geisterhand_register_screenshot_capture(bloom_screenshot_capture); @@ -1124,48 +1173,45 @@ extern "C" fn bloom_screenshot_capture(out_len: *mut usize) -> *mut u8 { let eng = engine(); eng.renderer.screenshot_requested = true; - eng.scene.prepare( - &eng.renderer.device, - &eng.renderer.queue, - &eng.renderer.vp_matrix(), - &eng.renderer.prev_vp_matrix, - eng.renderer.uniform_3d_layout(), - // Screenshot capture renders everything the camera might see — - // never occlusion-cull a one-shot capture. - None, - ); - eng.scene.prepare_materials(&eng.renderer); + // Screenshot capture renders everything the camera might see — + // never occlusion-cull a one-shot capture. + eng.renderer.prepare_scene_graph(&mut eng.scene, false); { let t = eng.get_time() as f32; let dt = eng.delta_time as f32; eng.renderer.material_system_begin_frame(t, dt); } - eng.renderer.end_frame_with_scene(&mut eng.scene, &mut eng.profiler); + eng.renderer + .end_frame_with_scene(&mut eng.scene, &mut eng.profiler); match eng.renderer.screenshot_data.take() { - Some((width, height, rgba)) => { - match encode_png(width, height, &rgba) { - Some(png_data) => { - let len = png_data.len(); - let ptr = unsafe { libc::malloc(len) as *mut u8 }; - if ptr.is_null() { - unsafe { *out_len = 0; } - return std::ptr::null_mut(); - } + Some((width, height, rgba)) => match encode_png(width, height, &rgba) { + Some(png_data) => { + let len = png_data.len(); + let ptr = unsafe { libc::malloc(len) as *mut u8 }; + if ptr.is_null() { unsafe { - std::ptr::copy_nonoverlapping(png_data.as_ptr(), ptr, len); - *out_len = len; + *out_len = 0; } - ptr + return std::ptr::null_mut(); } - None => { - unsafe { *out_len = 0; } - std::ptr::null_mut() + unsafe { + std::ptr::copy_nonoverlapping(png_data.as_ptr(), ptr, len); + *out_len = len; } + ptr } - } + None => { + unsafe { + *out_len = 0; + } + std::ptr::null_mut() + } + }, None => { - unsafe { *out_len = 0; } + unsafe { + *out_len = 0; + } std::ptr::null_mut() } } diff --git a/native/macos/Cargo.lock b/native/macos/Cargo.lock index ecd6ee55..f3e6b9b5 100644 --- a/native/macos/Cargo.lock +++ b/native/macos/Cargo.lock @@ -337,8 +337,11 @@ dependencies = [ "image_dds", "lewton", "libc", + "log", "minimp3", "raw-window-handle", + "serde_json", + "web-sys", "wgpu", ] diff --git a/native/macos/src/lib.rs b/native/macos/src/lib.rs index fa39afc6..1899bac7 100644 --- a/native/macos/src/lib.rs +++ b/native/macos/src/lib.rs @@ -7,14 +7,17 @@ #![allow(static_mut_refs)] use bloom_shared::engine::EngineState; -use bloom_shared::string_header::{str_from_header, alloc_perry_string}; +use bloom_shared::string_header::{alloc_perry_string, str_from_header}; use objc2::rc::Retained; use objc2::{msg_send, MainThreadMarker, MainThreadOnly}; -use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSEventMask, NSEventType, NSWindow, NSWindowStyleMask}; +use objc2_app_kit::{ + NSApplication, NSApplicationActivationPolicy, NSEventMask, NSEventType, NSWindow, + NSWindowStyleMask, +}; use objc2_foundation::{NSDate, NSDefaultRunLoopMode, NSPoint, NSRect, NSSize, NSString}; -use raw_window_handle::{RawWindowHandle, AppKitWindowHandle}; +use raw_window_handle::{AppKitWindowHandle, RawWindowHandle}; use std::sync::OnceLock; static mut ENGINE: OnceLock = OnceLock::new(); @@ -24,6 +27,10 @@ static mut WINDOW: Option> = None; // 'closed', just invisible) so headless --capture can run to // completion. static mut HEADLESS: bool = false; +// Qualification captures name exact physical pixel dimensions. Retina's +// backing scale must not silently turn a 512x512 case into a 1024x1024 +// render (and a 4x fill-rate benchmark) when this opt-in is enabled. +static mut HEADLESS_PIXEL_EXACT: bool = false; static mut AUDIO_UNIT: Option = None; // Render half of the audio system. Moved here from EngineState by // bloom_init_audio (AudioMixer::take_renderer) BEFORE the CoreAudio @@ -45,7 +52,6 @@ fn bloom_resolve_asset_path(path: &str) -> std::borrow::Cow<'_, str> { // docs for the contract; tools/validate-ffi.js checks parity in CI. bloom_shared::define_core_ffi!(); - /// Map macOS virtual key code to Bloom key code. fn map_keycode(keycode: u16) -> usize { match keycode { @@ -120,14 +126,14 @@ fn map_keycode(keycode: u16) -> usize { 103 => 122, // F11 111 => 123, // F12 // Modifiers - 56 => 280, // Left Shift - 60 => 281, // Right Shift - 59 => 282, // Left Control - 62 => 283, // Right Control - 58 => 284, // Left Alt/Option - 61 => 285, // Right Alt/Option - 55 => 286, // Left Command - 54 => 287, // Right Command + 56 => 280, // Left Shift + 60 => 281, // Right Shift + 59 => 282, // Left Control + 62 => 283, // Right Control + 58 => 284, // Left Alt/Option + 61 => 285, // Right Alt/Option + 55 => 286, // Left Command + 54 => 287, // Right Command _ => 0, } } @@ -198,7 +204,10 @@ type AudioComponent = *mut std::ffi::c_void; #[link(name = "AudioToolbox", kind = "framework")] extern "C" { - fn AudioComponentFindNext(component: AudioComponent, desc: *const AudioComponentDescription) -> AudioComponent; + fn AudioComponentFindNext( + component: AudioComponent, + desc: *const AudioComponentDescription, + ) -> AudioComponent; fn AudioComponentInstanceNew(component: AudioComponent, out: *mut AudioUnit) -> OSStatus; fn AudioUnitSetProperty( unit: AudioUnit, @@ -245,10 +254,7 @@ unsafe extern "C" fn audio_render_callback( let buffer_list = &mut *io_data; let buffer = &mut buffer_list.buffers[0]; let num_samples = in_number_frames as usize * 2; // stereo - let output = std::slice::from_raw_parts_mut( - buffer.data as *mut f32, - num_samples, - ); + let output = std::slice::from_raw_parts_mut(buffer.data as *mut f32, num_samples); match AUDIO_RENDERER.as_mut() { Some(r) => r.mix(output), @@ -263,7 +269,12 @@ unsafe extern "C" fn audio_render_callback( // ============================================================ #[no_mangle] -pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u8, fullscreen: f64) { +pub extern "C" fn bloom_init_window( + width: f64, + height: f64, + title_ptr: *const u8, + fullscreen: f64, +) { let title = str_from_header(title_ptr); let mtm = MainThreadMarker::from(unsafe { MainThreadMarker::new_unchecked() }); @@ -275,7 +286,14 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u let headless = std::env::var("BLOOM_HEADLESS") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); - unsafe { HEADLESS = headless; } + let headless_pixel_exact = headless + && std::env::var("BLOOM_HEADLESS_PIXEL_EXACT") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + unsafe { + HEADLESS = headless; + HEADLESS_PIXEL_EXACT = headless_pixel_exact; + } let app = NSApplication::sharedApplication(mtm); if headless { @@ -311,7 +329,9 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u // NSWindow restoration was resurrecting a prior fullscreen toggle on // the 4K display, which silently rendered benchmarks at 4× the // requested pixel count. - unsafe { let _: () = msg_send![&window, setRestorable: false]; } + unsafe { + let _: () = msg_send![&window, setRestorable: false]; + } // BLOOM_NO_FULLSCREEN=1 hard-disables fullscreen capability: the // window cannot be entered into fullscreen via the green button, @@ -359,7 +379,13 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u // upscales a low-res image. `backingScaleFactor` is 2.0 on Retina, // 1.0 otherwise (tracks the window's current screen). let scale: f64 = unsafe { msg_send![&*window, backingScaleFactor] }; - let scale = if scale > 0.0 { scale } else { 1.0 }; + let scale = if headless_pixel_exact { + 1.0 + } else if scale > 0.0 { + scale + } else { + 1.0 + }; let target = { let view_ptr = Retained::as_ptr(&content_view) as *mut std::ffi::c_void; @@ -371,19 +397,28 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u raw_window_handle: RawWindowHandle::AppKit(handle), } }; - let engine_state = unsafe { - bloom_shared::attach::attach_engine( - target, - bloom_shared::attach::AttachParams { - backends: wgpu::Backends::METAL, - logical_w: width as u32, - logical_h: height as u32, - physical_w: ((width * scale) as u32).max(1), - physical_h: ((height * scale) as u32).max(1), - format: bloom_shared::attach::FormatPreference::Srgb, - }, + let engine_state = if headless_pixel_exact { + bloom_shared::attach::attach_headless_engine( + wgpu::Backends::METAL, + width as u32, + height as u32, ) - .expect("Failed to attach engine") + .expect("Failed to attach headless engine") + } else { + unsafe { + bloom_shared::attach::attach_engine( + target, + bloom_shared::attach::AttachParams { + backends: wgpu::Backends::METAL, + logical_w: width as u32, + logical_h: height as u32, + physical_w: ((width * scale) as u32).max(1), + physical_h: ((height * scale) as u32).max(1), + format: bloom_shared::attach::FormatPreference::Srgb, + }, + ) + .expect("Failed to attach engine") + } }; unsafe { @@ -445,7 +480,11 @@ pub extern "C" fn bloom_attach_native(handle: i64, width: f64, height: f64) -> f 1.0 } }; - if s > 0.0 { s } else { 1.0 } + if s > 0.0 { + s + } else { + 1.0 + } }; let target = { @@ -488,7 +527,8 @@ pub extern "C" fn bloom_attach_native(handle: i64, width: f64, height: f64) -> f #[no_mangle] pub extern "C" fn bloom_resize(phys_w: f64, phys_h: f64, log_w: f64, log_h: f64) { if let Some(eng) = unsafe { ENGINE.get_mut() } { - eng.renderer.resize(phys_w as u32, phys_h as u32, log_w as u32, log_h as u32); + eng.renderer + .resize(phys_w as u32, phys_h as u32, log_w as u32, log_h as u32); } } @@ -509,7 +549,11 @@ pub extern "C" fn bloom_close_window() { #[no_mangle] pub extern "C" fn bloom_window_should_close() -> f64 { - if engine().should_close { 1.0 } else { 0.0 } + if engine().should_close { + 1.0 + } else { + 0.0 + } } /// Poll the first connected game controller (MFi / Xbox / PlayStation) through @@ -533,7 +577,9 @@ fn poll_game_controllers() { ($btn:expr, $eng:expr, $idx:expr) => {{ let b: Retained = $btn; let pressed: Bool = msg_send![&*b, isPressed]; - if pressed.as_bool() { $eng.input.set_gamepad_button_down($idx); } + if pressed.as_bool() { + $eng.input.set_gamepad_button_down($idx); + } }}; } unsafe { @@ -646,7 +692,9 @@ pub extern "C" fn bloom_begin_drawing() { engine().input.set_key_up(bloom_key); } } - NSEventType::MouseMoved | NSEventType::LeftMouseDragged | NSEventType::RightMouseDragged => { + NSEventType::MouseMoved + | NSEventType::LeftMouseDragged + | NSEventType::RightMouseDragged => { if engine().input.cursor_disabled { // In disabled-cursor mode, use raw deltas from NSEvent let dx: f64 = unsafe { msg_send![&*event, deltaX] }; @@ -654,8 +702,13 @@ pub extern "C" fn bloom_begin_drawing() { engine().input.accumulate_mouse_delta(dx, dy); } else if let Some(window) = unsafe { &WINDOW } { let loc = event.locationInWindow(); - let frame = window.contentView().map(|v| v.frame()).unwrap_or(NSRect::ZERO); - engine().input.set_mouse_position(loc.x, frame.size.height - loc.y); + let frame = window + .contentView() + .map(|v| v.frame()) + .unwrap_or(NSRect::ZERO); + engine() + .input + .set_mouse_position(loc.x, frame.size.height - loc.y); } } NSEventType::LeftMouseDown => { @@ -695,23 +748,27 @@ pub extern "C" fn bloom_begin_drawing() { // Handle window resize — track physical (backing) size for the // swapchain while keeping the logical (points) size for user code. - if let Some(window) = unsafe { &WINDOW } { - if let Some(content_view) = window.contentView() { - let frame = content_view.frame(); - let logical_w = frame.size.width as u32; - let logical_h = frame.size.height as u32; - let scale: f64 = unsafe { msg_send![&*window, backingScaleFactor] }; - let scale = if scale > 0.0 { scale } else { 1.0 }; - let physical_w = ((frame.size.width * scale) as u32).max(1); - let physical_h = ((frame.size.height * scale) as u32).max(1); - let eng = engine(); - if logical_w > 0 && logical_h > 0 - && (physical_w != eng.renderer.physical_width() - || physical_h != eng.renderer.physical_height() - || logical_w != eng.renderer.width() - || logical_h != eng.renderer.height()) - { - eng.renderer.resize(physical_w, physical_h, logical_w, logical_h); + if !unsafe { HEADLESS_PIXEL_EXACT } { + if let Some(window) = unsafe { &WINDOW } { + if let Some(content_view) = window.contentView() { + let frame = content_view.frame(); + let logical_w = frame.size.width as u32; + let logical_h = frame.size.height as u32; + let scale: f64 = unsafe { msg_send![&*window, backingScaleFactor] }; + let scale = if scale > 0.0 { scale } else { 1.0 }; + let physical_w = ((frame.size.width * scale) as u32).max(1); + let physical_h = ((frame.size.height * scale) as u32).max(1); + let eng = engine(); + if logical_w > 0 + && logical_h > 0 + && (physical_w != eng.renderer.physical_width() + || physical_h != eng.renderer.physical_height() + || logical_w != eng.renderer.width() + || logical_h != eng.renderer.height()) + { + eng.renderer + .resize(physical_w, physical_h, logical_w, logical_h); + } } } } @@ -729,7 +786,7 @@ pub extern "C" fn bloom_begin_drawing() { 4 => objc2_app_kit::NSCursor::resizeLeftRightCursor().set(), 5 => objc2_app_kit::NSCursor::resizeUpDownCursor().set(), 6 => objc2_app_kit::NSCursor::crosshairCursor().set(), - _ => {}, + _ => {} } // Poll a connected game controller (no-op if none) before begin_frame @@ -743,8 +800,12 @@ pub extern "C" fn bloom_begin_drawing() { pub extern "C" fn bloom_end_drawing() { // Pump geisterhand BEFORE end_frame. // Screenshot function re-renders inline with captured VP + vertices. - extern "C" { fn perry_geisterhand_pump(); } - unsafe { perry_geisterhand_pump(); } + extern "C" { + fn perry_geisterhand_pump(); + } + unsafe { + perry_geisterhand_pump(); + } engine().end_frame(); } @@ -935,12 +996,15 @@ pub extern "C" fn bloom_set_window_icon(path_ptr: *const u8) { unsafe { let ns_path = NSString::from_str(path); let image_cls = objc2::runtime::AnyClass::get(c"NSImage").unwrap(); - let image: *mut objc2::runtime::AnyObject = - msg_send![image_cls, alloc]; - if image.is_null() { return; } + let image: *mut objc2::runtime::AnyObject = msg_send![image_cls, alloc]; + if image.is_null() { + return; + } let image: *mut objc2::runtime::AnyObject = msg_send![image, initWithContentsOfFile: &*ns_path]; - if image.is_null() { return; } + if image.is_null() { + return; + } let app = NSApplication::sharedApplication(MainThreadMarker::new_unchecked()); let _: () = msg_send![&*app, setApplicationIconImage: image]; } @@ -1008,7 +1072,10 @@ pub extern "C" fn bloom_open_file_dialog(filter_ptr: *const u8, title_ptr: *cons } #[no_mangle] -pub extern "C" fn bloom_save_file_dialog(default_name_ptr: *const u8, title_ptr: *const u8) -> *const u8 { +pub extern "C" fn bloom_save_file_dialog( + default_name_ptr: *const u8, + title_ptr: *const u8, +) -> *const u8 { let default_name = str_from_header(default_name_ptr); let title = str_from_header(title_ptr); let dialog = rfd::FileDialog::new() @@ -1024,7 +1091,9 @@ pub extern "C" fn bloom_save_file_dialog(default_name_ptr: *const u8, title_ptr: // Input injection + platform detection // ============================================================ #[no_mangle] -pub extern "C" fn bloom_get_platform() -> f64 { 1.0 } +pub extern "C" fn bloom_get_platform() -> f64 { + 1.0 +} /// Return the user's preferred OS language as a packed 2-letter code: /// `c0 * 256 + c1`, where c0/c1 are the ASCII bytes of the lowercased @@ -1036,7 +1105,11 @@ pub extern "C" fn bloom_get_language() -> f64 { fn pack(code: &str) -> f64 { let lower = code.to_ascii_lowercase(); let b = lower.as_bytes(); - if b.len() >= 2 { (b[0] as f64) * 256.0 + (b[1] as f64) } else { 101.0 * 256.0 + 110.0 } + if b.len() >= 2 { + (b[0] as f64) * 256.0 + (b[1] as f64) + } else { + 101.0 * 256.0 + 110.0 + } } let langs = objc2_foundation::NSLocale::preferredLanguages(); match langs.firstObject() { @@ -1098,9 +1171,7 @@ pub extern "C" fn bloom_get_language() -> f64 { fn bloom_register_geisterhand_screenshot() { // Try to register with geisterhand if it's linked (weak symbol) extern "C" { - fn perry_geisterhand_register_screenshot_capture( - f: extern "C" fn(*mut usize) -> *mut u8, - ); + fn perry_geisterhand_register_screenshot_capture(f: extern "C" fn(*mut usize) -> *mut u8); } unsafe { perry_geisterhand_register_screenshot_capture(bloom_screenshot_capture); @@ -1117,17 +1188,9 @@ extern "C" fn bloom_screenshot_capture(out_len: *mut usize) -> *mut u8 { // Set capture flag and render inline eng.renderer.screenshot_requested = true; - eng.scene.prepare( - &eng.renderer.device, - &eng.renderer.queue, - &eng.renderer.vp_matrix(), - &eng.renderer.prev_vp_matrix, - eng.renderer.uniform_3d_layout(), - // Screenshot capture renders everything the camera might see — - // never occlusion-cull a one-shot capture. - None, - ); - eng.scene.prepare_materials(&eng.renderer); + // Screenshot capture renders everything the camera might see — + // never occlusion-cull a one-shot capture. + eng.renderer.prepare_scene_graph(&mut eng.scene, false); // Phase 1c: sync material PerFrame + PerView UBOs with the // current engine clock before the main HDR pass dispatches any // material draws that were submitted during this frame. @@ -1136,7 +1199,8 @@ extern "C" fn bloom_screenshot_capture(out_len: *mut usize) -> *mut u8 { let dt = eng.delta_time as f32; eng.renderer.material_system_begin_frame(t, dt); } - eng.renderer.end_frame_with_scene(&mut eng.scene, &mut eng.profiler); + eng.renderer + .end_frame_with_scene(&mut eng.scene, &mut eng.profiler); match eng.renderer.screenshot_data.take() { Some((width, height, rgba)) => { @@ -1147,7 +1211,9 @@ extern "C" fn bloom_screenshot_capture(out_len: *mut usize) -> *mut u8 { // Allocate with libc::malloc (caller will free with libc::free) let ptr = unsafe { libc::malloc(len) as *mut u8 }; if ptr.is_null() { - unsafe { *out_len = 0; } + unsafe { + *out_len = 0; + } return std::ptr::null_mut(); } unsafe { @@ -1157,13 +1223,17 @@ extern "C" fn bloom_screenshot_capture(out_len: *mut usize) -> *mut u8 { ptr } None => { - unsafe { *out_len = 0; } + unsafe { + *out_len = 0; + } std::ptr::null_mut() } } } None => { - unsafe { *out_len = 0; } + unsafe { + *out_len = 0; + } std::ptr::null_mut() } } @@ -1202,7 +1272,7 @@ fn encode_png(width: u32, height: u32, rgba: &[u8]) -> Option> { raw.push(rgba[idx + 2]); // R (was at offset 2 in BGRA) raw.push(rgba[idx + 1]); // G (same position) raw.push(rgba[idx + 0]); // B (was at offset 0 in BGRA) - raw.push(255); // A (force opaque — alpha from sRGB surface is unreliable) + raw.push(255); // A (force opaque — alpha from sRGB surface is unreliable) } } @@ -1284,7 +1354,6 @@ fn adler32(data: &[u8]) -> u32 { // Scene picking (raycasting) // ============================================================ - // Q6: Multi-hit picking — returns all hits sorted by distance. // ============================================================ @@ -1299,4 +1368,3 @@ fn bloom_jolt_ffi_physics() -> &'static mut bloom_shared::physics_jolt::JoltPhys #[cfg(feature = "jolt")] bloom_shared::define_physics_ffi!(); - diff --git a/native/shared/Cargo.lock b/native/shared/Cargo.lock index cf4a300b..5d8a8179 100644 --- a/native/shared/Cargo.lock +++ b/native/shared/Cargo.lock @@ -106,10 +106,12 @@ dependencies = [ "image_dds", "lewton", "libc", + "log", "minimp3", "notify", "pollster", "raw-window-handle", + "serde_json", "web-sys", "web-time", "wgpu", diff --git a/native/shared/Cargo.toml b/native/shared/Cargo.toml index 691ca007..ca75d0a2 100644 --- a/native/shared/Cargo.toml +++ b/native/shared/Cargo.toml @@ -17,7 +17,7 @@ web = ["dep:web-time"] # games disable this via Perry's `[native-library.""]` feature # forwarding to drop gltf/gltf_json/image_dds from the binary entirely. # Default-on so existing 3D games are unaffected. -models3d = ["dep:gltf", "dep:image_dds"] +models3d = ["dep:gltf", "dep:image_dds", "dep:serde_json"] # EN-014 — image codecs beyond PNG. `image::load_from_memory` is # format-agnostic, so disabling this only changes which file formats # decode at runtime; PNG always works. @@ -31,6 +31,7 @@ cmake = "0.1" [dependencies] wgpu = "29" +log = "0.4" raw-window-handle = "0.6" fontdue = "0.9" libc = "0.2" @@ -38,7 +39,16 @@ bytemuck = { version = "1", features = ["derive"] } half = "2" image = { version = "0.25", default-features = false, features = ["png"] } image_dds = { version = "0.7", default-features = false, features = ["ddsfile", "image"], optional = true } -gltf = { version = "1", features = ["KHR_materials_pbrSpecularGlossiness", "KHR_materials_transmission"], optional = true } +gltf = { version = "1", features = [ + "extensions", + "KHR_materials_ior", + "KHR_materials_pbrSpecularGlossiness", + "KHR_materials_specular", + "KHR_materials_transmission", + "KHR_materials_volume", + "KHR_texture_transform", +], optional = true } +serde_json = { version = "1", optional = true } lewton = "0.10" minimp3 = { version = "0.5", optional = true } earcutr = "0.4" @@ -60,3 +70,6 @@ web-sys = { version = "0.3", features = ["console"] } [dev-dependencies] pollster = "0.4" +# Live GPU-object counters are enabled only while compiling tests. Production +# builds keep wgpu's counter atomics disabled. +wgpu = { version = "29", features = ["counters"] } diff --git a/native/shared/build.rs b/native/shared/build.rs index eae91387..b047397e 100644 --- a/native/shared/build.rs +++ b/native/shared/build.rs @@ -9,6 +9,56 @@ fn main() { println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-env-changed=CARGO_FEATURE_JOLT"); + // SH-055 — `fold_scene_inputs` cfg. GPUs that cap `maxBindGroups` at 4 — + // WebGPU/wasm (EN-063) and Android (Adreno et al.) — can't host the group-4 + // SceneInputs bind group, so the renderer folds those seven bindings into + // group 0. Both platforms share the identical fold; emit one cfg so every + // fold site reads as a single intent instead of scattering `any(wasm32, + // android)`. (Must run before the jolt-feature early return below.) + println!("cargo:rustc-check-cfg=cfg(fold_scene_inputs)"); + let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_arch == "wasm32" || target_os == "android" { + println!("cargo:rustc-cfg=fold_scene_inputs"); + } + + // SH-055 — `lean_mrt` cfg (Android only). Adreno GPUs are tile-based with a + // small on-chip cache (GMEM); main_hdr_pass's 4 simultaneous color targets + // (hdr+material+velocity+albedo, ~20 bytes/pixel) measured as a + // content-independent GPU cost on a Pixel 4a (Adreno 618) — a classic GMEM- + // overflow signature. `material` only feeds SSR (off on Android) and + // hardware-ray-traced path tracing (unavailable on Adreno 618); `albedo` + // only feeds SSGI-modulation and SSAO-alpha-weighting (both off on + // Android) in the scene-compose shader. Both are genuinely unread when + // Android's render profile runs, so main_hdr_pass drops them to `None` + // targets (see `RenderPassContext::check_compatible` in wgpu-core — a + // pipeline target and its render-pass attachment must both be `None` at + // the same index; the material's WGSL keeps writing `out.material`/ + // `out.albedo` unchanged, those writes are just discarded with no backing + // attachment). `velocity` stays — Android keeps TAA on, which reprojects + // via motion vectors. wasm is NOT included: no Adreno-specific GMEM data + // for WebGPU/browser GPUs, so this stays narrower than `fold_scene_inputs`. + println!("cargo:rustc-check-cfg=cfg(lean_mrt)"); + if target_os == "android" { + println!("cargo:rustc-cfg=lean_mrt"); + } + + // SH-055 — `mobile_lights` cfg (Android only). The froxel *clustered* + // point-light path reads the per-froxel light list from storage buffers + // once per fragment. On Adreno 618 those SSBO reads are cache-hostile and, + // multiplied by foliage overdraw, measured as ~230ms/frame — the single + // largest cost in the whole frame (SurfaceFlinger-measured 3.1→10.1 fps + // just from switching it off). The plain UBO-array loop it replaces is the + // exact semantic reference the clustered path is built to match (enforced + // by the many_point_lights golden), so for scenes within the UBO's + // point-light capacity there is no visible difference. Android therefore + // defaults to the plain loop; `BLOOM_SKIP=clustered_on` forces the froxel + // path back on for A/B measurement. + println!("cargo:rustc-check-cfg=cfg(mobile_lights)"); + if target_os == "android" { + println!("cargo:rustc-cfg=mobile_lights"); + } + if std::env::var_os("CARGO_FEATURE_JOLT").is_none() { return; } @@ -52,9 +102,7 @@ fn build_jolt() { // when prebuilts are present, useful for hacking on the C++ shim. let from_source = std::env::var_os("BLOOM_JOLT_FROM_SOURCE").is_some(); if !from_source { - if let Some(prebuilt_dir) = - find_prebuilt_dir(&manifest_dir, &target_os, &target_arch) - { + if let Some(prebuilt_dir) = find_prebuilt_dir(&manifest_dir, &target_os, &target_arch) { link_prebuilt(&prebuilt_dir, &target_os); emit_cxx_runtime_link(&target_os); return; @@ -68,9 +116,18 @@ fn build_jolt() { ); } - println!("cargo:rerun-if-changed={}", shim_dir.join("CMakeLists.txt").display()); - println!("cargo:rerun-if-changed={}", shim_dir.join("include/bloom_jolt.h").display()); - println!("cargo:rerun-if-changed={}", shim_dir.join("src/bloom_jolt.cpp").display()); + println!( + "cargo:rerun-if-changed={}", + shim_dir.join("CMakeLists.txt").display() + ); + println!( + "cargo:rerun-if-changed={}", + shim_dir.join("include/bloom_jolt.h").display() + ); + println!( + "cargo:rerun-if-changed={}", + shim_dir.join("src/bloom_jolt.cpp").display() + ); // Build into a stable, short path next to the shim itself rather than the // per-build OUT_DIR. Two reasons: @@ -114,7 +171,10 @@ fn build_jolt() { let _ = cfg.build(); } - println!("cargo:rustc-link-search=native={}", dst.join("lib").display()); + println!( + "cargo:rustc-link-search=native={}", + dst.join("lib").display() + ); println!("cargo:rustc-link-lib=static=bloom_jolt"); println!("cargo:rustc-link-lib=static=Jolt"); @@ -229,7 +289,8 @@ fn link_prebuilt(dir: &std::path::Path, target_os: &str) { ); println!( "cargo:rerun-if-changed={}", - dir.join(format!("{}Jolt.{}", lib_prefix, lib_ext)).display() + dir.join(format!("{}Jolt.{}", lib_prefix, lib_ext)) + .display() ); println!("cargo:rustc-link-search=native={}", dir.display()); diff --git a/native/shared/examples/validate_layered_gltf.rs b/native/shared/examples/validate_layered_gltf.rs new file mode 100644 index 00000000..dc006fca --- /dev/null +++ b/native/shared/examples/validate_layered_gltf.rs @@ -0,0 +1,218 @@ +//! Import-only validator for canonical layered-material glTF assets. +//! +//! Usage: +//! `cargo run --example validate_layered_gltf --features models3d -- \ +//! sheen=/path/to/CompareSheen.glb \ +//! anisotropy=/path/to/AnisotropyStrengthTest.glb \ +//! iridescence=/path/to/CompareIridescence.glb` + +use bloom_shared::models::load_gltf_staged; +use std::path::Path; +use std::process::ExitCode; + +fn has_sheen(material: bloom_shared::models::MaterialLayeredPbr) -> bool { + material.sheen_authored + && material + .sheen_color_factor + .iter() + .any(|value| value.is_finite() && *value > 0.0) +} + +fn has_anisotropy(material: bloom_shared::models::MaterialLayeredPbr) -> bool { + material.anisotropy_authored + && material.anisotropy_strength.is_finite() + && material.anisotropy_strength > 0.0 +} + +fn has_iridescence(material: bloom_shared::models::MaterialLayeredPbr) -> bool { + material.iridescence_authored + && material.iridescence_factor.is_finite() + && material.iridescence_factor > 0.0 + && material.iridescence_ior.is_finite() + && material.iridescence_ior >= 1.0 + && (material.iridescence_thickness_maximum > 0.0 + || (material.iridescence_thickness_texture.is_some() + && material.iridescence_thickness_minimum > 0.0)) +} + +fn main() -> ExitCode { + let mut arguments = std::env::args().skip(1).peekable(); + if arguments.peek().is_none() { + eprintln!("expected one or more KIND=PATH arguments"); + return ExitCode::from(2); + } + + let mut failed = false; + for argument in arguments { + let Some((kind, path)) = argument.split_once('=') else { + eprintln!("{argument}: expected KIND=PATH"); + failed = true; + continue; + }; + if !matches!(kind, "sheen" | "anisotropy" | "iridescence") { + eprintln!("{argument}: KIND must be sheen, anisotropy, or iridescence"); + failed = true; + continue; + } + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) => { + eprintln!("{}: {error}", Path::new(path).display()); + failed = true; + continue; + } + }; + let Some(staged) = load_gltf_staged(&bytes) else { + eprintln!("{}: import failed", Path::new(path).display()); + failed = true; + continue; + }; + let model = &staged.model; + + let sheen_meshes = model + .meshes + .iter() + .filter(|mesh| has_sheen(mesh.layered_pbr)) + .count(); + let anisotropy_meshes = model + .meshes + .iter() + .filter(|mesh| has_anisotropy(mesh.layered_pbr)) + .count(); + let iridescence_meshes = model + .meshes + .iter() + .filter(|mesh| has_iridescence(mesh.layered_pbr)) + .count(); + let authored_tangents = model + .meshes + .iter() + .flat_map(|mesh| &mesh.vertices) + .filter(|vertex| { + let tangent = vertex.tangent; + tangent[0] * tangent[0] + tangent[1] * tangent[1] + tangent[2] * tangent[2] > 1e-6 + && tangent[3].abs() > 0.5 + }) + .count(); + let expected_count = match kind { + "sheen" => sheen_meshes, + "anisotropy" => anisotropy_meshes, + "iridescence" => iridescence_meshes, + _ => unreachable!(), + }; + if expected_count == 0 { + eprintln!( + "{}: imported {} meshes but found no active {kind} material", + Path::new(path).display(), + model.meshes.len(), + ); + failed = true; + continue; + } + println!( + "{}: meshes={} sheen={} anisotropy={} iridescence={} \ + authored_tangent_vertices={} bounds={:?}..{:?}", + Path::new(path).display(), + model.meshes.len(), + sheen_meshes, + anisotropy_meshes, + iridescence_meshes, + authored_tangents, + model.bbox_min, + model.bbox_max, + ); + for (texture_index, texture) in staged.textures.iter().enumerate() { + let mut minimum = [u8::MAX; 4]; + let mut maximum = [u8::MIN; 4]; + let mut sum = [0u64; 4]; + for texel in texture.data.chunks_exact(4) { + for channel in 0..4 { + minimum[channel] = minimum[channel].min(texel[channel]); + maximum[channel] = maximum[channel].max(texel[channel]); + sum[channel] += u64::from(texel[channel]); + } + } + let texel_count = u64::from(texture.width) * u64::from(texture.height); + let mean = sum.map(|value| value as f64 / texel_count as f64); + println!( + " texture[{}]: {}x{} rgba_min={minimum:?} rgba_max={maximum:?} \ + rgba_mean={mean:?} normal={} coverage_reference={:?}", + texture_index + 1, + texture.width, + texture.height, + texture.is_normal, + texture.alpha_coverage_reference, + ); + } + for (mesh_index, mesh) in model.meshes.iter().enumerate() { + let material = mesh.layered_pbr; + let mut normal_minimum = [f32::INFINITY; 3]; + let mut normal_maximum = [f32::NEG_INFINITY; 3]; + let mut position_minimum = [f32::INFINITY; 3]; + let mut position_maximum = [f32::NEG_INFINITY; 3]; + for vertex in &mesh.vertices { + for channel in 0..3 { + normal_minimum[channel] = normal_minimum[channel].min(vertex.normal[channel]); + normal_maximum[channel] = normal_maximum[channel].max(vertex.normal[channel]); + position_minimum[channel] = + position_minimum[channel].min(vertex.position[channel]); + position_maximum[channel] = + position_maximum[channel].max(vertex.position[channel]); + } + } + let center = [ + (position_minimum[0] + position_maximum[0]) * 0.5, + (position_minimum[1] + position_maximum[1]) * 0.5, + (position_minimum[2] + position_maximum[2]) * 0.5, + ]; + let mut radial_normal_dot = [f32::INFINITY, f32::NEG_INFINITY, 0.0]; + for vertex in &mesh.vertices { + let radial = [ + vertex.position[0] - center[0], + vertex.position[1] - center[1], + vertex.position[2] - center[2], + ]; + let radial_length = + (radial[0] * radial[0] + radial[1] * radial[1] + radial[2] * radial[2]).sqrt(); + if radial_length > 1e-6 { + let dot = (radial[0] * vertex.normal[0] + + radial[1] * vertex.normal[1] + + radial[2] * vertex.normal[2]) + / radial_length; + radial_normal_dot[0] = radial_normal_dot[0].min(dot); + radial_normal_dot[1] = radial_normal_dot[1].max(dot); + radial_normal_dot[2] += dot; + } + } + radial_normal_dot[2] /= mesh.vertices.len().max(1) as f32; + println!( + " mesh[{mesh_index}]: metallic={:.5} roughness={:.5} \ + base_texture={:?} mr_texture={:?} iridescence_active={} \ + position_range={position_minimum:?}..{position_maximum:?} \ + normal_range={normal_minimum:?}..{normal_maximum:?} \ + radial_normal_dot[min,max,mean]={radial_normal_dot:?} \ + iridescence={{authored={}, factor={:.5}, ior={:.5}, \ + thickness_nm={:.5}..{:.5}, factor_texture={:?}, \ + thickness_texture={:?}}}", + mesh.metallic_factor, + mesh.roughness_factor, + mesh.texture_idx, + mesh.metallic_roughness_texture_idx, + has_iridescence(material), + material.iridescence_authored, + material.iridescence_factor, + material.iridescence_ior, + material.iridescence_thickness_minimum, + material.iridescence_thickness_maximum, + material.iridescence_texture, + material.iridescence_thickness_texture, + ); + } + } + + if failed { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + } +} diff --git a/native/shared/shaders/layered_pbr_v3.wgsl b/native/shared/shaders/layered_pbr_v3.wgsl new file mode 100644 index 00000000..853bf2e1 --- /dev/null +++ b/native/shared/shaders/layered_pbr_v3.wgsl @@ -0,0 +1,1025 @@ +// Bloom layered-PBR reference contract, realtime version 4. +// +// This source is injected only into the lazy layered scene specialization. +// Ordinary scene shaders do not declare, load, or branch on these values. + +struct LayeredPbrFactors { + // x = clearcoat factor, y = perceptual roughness, + // z = clearcoat normal scale, w = dielectric IOR (zero is glTF compat). + clearcoat_ior: vec4, + // rgb = specular color factor, w = scalar specular factor. + specular: vec4, + // rgb = linear sheen color factor, w = sheen perceptual roughness. + sheen: vec4, + // x = anisotropy strength, yz = cos/sin rotation, w = reserved. + anisotropy: vec4, + // x = factor, y = film IOR, z/w = thickness min/max in nanometers. + iridescence: vec4, + // For each texture: uv.xy = offset, uv.zw = scale; + // rotation.xy = cos/sin, rotation.z = usable texture, rotation.w = UV set. + clearcoat_factor_uv: vec4, + clearcoat_factor_rotation: vec4, + clearcoat_roughness_uv: vec4, + clearcoat_roughness_rotation: vec4, + clearcoat_normal_uv: vec4, + clearcoat_normal_rotation: vec4, + specular_factor_uv: vec4, + specular_factor_rotation: vec4, + specular_color_uv: vec4, + specular_color_rotation: vec4, + sheen_color_uv: vec4, + sheen_color_rotation: vec4, + sheen_roughness_uv: vec4, + sheen_roughness_rotation: vec4, + anisotropy_uv: vec4, + anisotropy_texture_rotation: vec4, + iridescence_factor_uv: vec4, + iridescence_factor_rotation: vec4, + iridescence_thickness_uv: vec4, + iridescence_thickness_rotation: vec4, +}; + +struct LayeredSurface { + dielectric_f0: vec3, + dielectric_f90: f32, + clearcoat_normal: vec3, + clearcoat_factor: f32, + clearcoat_roughness: f32, + sheen_color: vec3, + sheen_roughness: f32, + anisotropic_tangent: vec3, + anisotropy_strength: f32, + iridescence_factor: f32, + iridescence_ior: f32, + iridescence_thickness_nm: f32, +}; + +fn layered_transform_uv( + primary_uv: vec2, + secondary_uv: vec2, + params: vec4, + rotation: vec4, +) -> vec2 { + let source_uv = select(primary_uv, secondary_uv, rotation.w > 0.5); + let scaled = source_uv * params.zw; + let rotated = vec2( + rotation.x * scaled.x - rotation.y * scaled.y, + rotation.y * scaled.x + rotation.x * scaled.y, + ); + return rotated + params.xy; +} + +fn layered_ior_f0(ior: f32) -> f32 { + if (ior == 0.0) { + return 1.0; + } + let safe_ior = max(ior, 1.0); + let ratio = (safe_ior - 1.0) / (safe_ior + 1.0); + return ratio * ratio; +} + +fn layered_fresnel_schlick_f90( + cos_theta: f32, + f0: vec3, + f90: vec3, +) -> vec3 { + let m = clamp(1.0 - cos_theta, 0.0, 1.0); + let m2 = m * m; + let m5 = m2 * m2 * m; + return f0 + (f90 - f0) * m5; +} + +fn layered_fresnel0_to_ior(f0: vec3) -> vec3 { + let root = sqrt(clamp(f0, vec3(0.0), vec3(0.9999))); + return (vec3(1.0) + root) / (vec3(1.0) - root); +} + +fn layered_ior_to_fresnel0( + transmitted_ior: vec3, + incident_ior: f32, +) -> vec3 { + let incident = vec3(incident_ior); + let ratio = + (transmitted_ior - incident) / (transmitted_ior + incident); + return ratio * ratio; +} + +fn layered_iridescence_sensitivity( + optical_path_difference_nm: f32, + shift: vec3, +) -> vec3 { + let phase = 2.0 * PI * optical_path_difference_nm * 1e-9; + let phase_squared = phase * phase; + let value = vec3(5.4856e-13, 4.4201e-13, 5.2481e-13); + let position = vec3(1.6810e6, 1.7953e6, 2.2084e6); + let variance = vec3(4.3278e9, 9.3046e9, 6.6121e9); + var xyz = value * sqrt(vec3(2.0 * PI) * variance) + * cos(position * phase + shift) + * exp(-vec3(phase_squared) * variance); + xyz.x += 9.7470e-14 * sqrt(2.0 * PI * 4.5282e9) + * cos(2.2399e6 * phase + shift.x) + * exp(-4.5282e9 * phase_squared); + xyz /= 1.0685e-7; + return vec3( + 3.2404542 * xyz.x - 0.9692660 * xyz.y + 0.0556434 * xyz.z, + -1.5371385 * xyz.x + 1.8760108 * xyz.y - 0.2040259 * xyz.z, + -0.4985314 * xyz.x + 0.0415560 * xyz.y + 1.0572252 * xyz.z, + ); +} + +fn layered_eval_iridescence( + outside_ior: f32, + authored_film_ior: f32, + cos_theta_1: f32, + authored_thickness_nm: f32, + base_f0: vec3, +) -> vec3 { + let safe_outside_ior = max(outside_ior, 1e-4); + let thickness_nm = max(authored_thickness_nm, 0.0); + let film_ior = mix( + safe_outside_ior, + max(authored_film_ior, 1.0), + smoothstep(0.0, 0.03, thickness_nm), + ); + let cosine_1 = clamp(cos_theta_1, 0.0, 1.0); + let sin_theta_2_squared = pow(safe_outside_ior / film_ior, 2.0) + * (1.0 - cosine_1 * cosine_1); + let cos_theta_2_squared = 1.0 - sin_theta_2_squared; + if (cos_theta_2_squared < 0.0) { + return vec3(1.0); + } + let cosine_2 = sqrt(cos_theta_2_squared); + + let r0 = layered_ior_f0(film_ior / safe_outside_ior); + let r12 = layered_fresnel_schlick_f90( + cosine_1, + vec3(r0), + vec3(1.0), + ).x; + let t121 = 1.0 - r12; + let phi12 = select(0.0, PI, film_ior < safe_outside_ior); + let phi21 = PI - phi12; + + let base_ior = layered_fresnel0_to_ior(base_f0); + let r1 = layered_ior_to_fresnel0(base_ior, film_ior); + let r23 = layered_fresnel_schlick_f90( + cosine_2, + r1, + vec3(1.0), + ); + let phi23 = vec3( + select(0.0, PI, base_ior.x < film_ior), + select(0.0, PI, base_ior.y < film_ior), + select(0.0, PI, base_ior.z < film_ior), + ); + let optical_path_difference = + 2.0 * film_ior * thickness_nm * cosine_2; + let phase_shift = vec3(phi21) + phi23; + let r123 = clamp( + vec3(r12) * r23, + vec3(1e-5), + vec3(0.9999), + ); + let reflected_series = + vec3(t121 * t121) * r23 / (vec3(1.0) - r123); + var result = vec3(r12) + reflected_series; + var coefficient = reflected_series - vec3(t121); + let amplitude = sqrt(r123); + for (var order = 1; order <= 2; order += 1) { + coefficient *= amplitude; + result += coefficient * 2.0 * layered_iridescence_sensitivity( + f32(order) * optical_path_difference, + f32(order) * phase_shift, + ); + } + return clamp(result, vec3(0.0), vec3(1.0)); +} + +fn layered_raw_iridescence_base_fresnel( + surface: LayeredSurface, + cos_theta: f32, + base_color: vec3, + metallic: f32, +) -> vec3 { + let dielectric = layered_eval_iridescence( + 1.0, + surface.iridescence_ior, + cos_theta, + surface.iridescence_thickness_nm, + surface.dielectric_f0, + ); + let conductor = layered_eval_iridescence( + 1.0, + surface.iridescence_ior, + cos_theta, + surface.iridescence_thickness_nm, + base_color, + ); + return mix(dielectric, conductor, metallic); +} + +fn layered_base_fresnel( + surface: LayeredSurface, + cos_theta: f32, + base_color: vec3, + metallic: f32, +) -> vec3 { + let dielectric = layered_fresnel_schlick_f90( + cos_theta, + surface.dielectric_f0, + vec3(surface.dielectric_f90), + ); + let conductor = f_schlick(cos_theta, base_color); + let base = mix(dielectric, conductor, metallic); + if (surface.iridescence_factor <= 0.0) { + return base; + } + return mix( + base, + layered_raw_iridescence_base_fresnel( + surface, + cos_theta, + base_color, + metallic, + ), + surface.iridescence_factor, + ); +} + +fn layered_dielectric_fresnel( + surface: LayeredSurface, + cos_theta: f32, +) -> vec3 { + let base = layered_fresnel_schlick_f90( + cos_theta, + surface.dielectric_f0, + vec3(surface.dielectric_f90), + ); + if (surface.iridescence_factor <= 0.0) { + return base; + } + let thin_film = layered_eval_iridescence( + 1.0, + surface.iridescence_ior, + cos_theta, + surface.iridescence_thickness_nm, + surface.dielectric_f0, + ); + return mix(base, thin_film, surface.iridescence_factor); +} + +fn layered_dielectric_transmission(surface: LayeredSurface, cos_theta: f32) -> f32 { + let fresnel = layered_dielectric_fresnel(surface, cos_theta); + return max(1.0 - max(fresnel.r, max(fresnel.g, fresnel.b)), 0.0); +} + +fn layered_base_fresnel_roughness( + surface: LayeredSurface, + n_dot_v: f32, + base_color: vec3, + metallic: f32, + roughness: f32, +) -> vec3 { + let grazing = max( + vec3(surface.dielectric_f90 * (1.0 - roughness)), + surface.dielectric_f0, + ); + let m = clamp(1.0 - n_dot_v, 0.0, 1.0); + let m2 = m * m; + let dielectric = surface.dielectric_f0 + (grazing - surface.dielectric_f0) * m2 * m2 * m; + let conductor_f0 = base_color; + let conductor_grazing = max(vec3(1.0 - roughness), conductor_f0); + let conductor = conductor_f0 + + (conductor_grazing - conductor_f0) * m2 * m2 * m; + let base = mix(dielectric, conductor, metallic); + if (surface.iridescence_factor <= 0.0) { + return base; + } + return mix( + base, + layered_raw_iridescence_base_fresnel( + surface, + n_dot_v, + base_color, + metallic, + ), + surface.iridescence_factor, + ); +} + +fn layered_ibl_f0( + surface: LayeredSurface, + n_dot_v: f32, + base_color: vec3, + metallic: f32, +) -> vec3 { + let base_f0 = mix(surface.dielectric_f0, base_color, metallic); + if (surface.iridescence_factor <= 0.0) { + return base_f0; + } + let f90 = mix( + vec3(surface.dielectric_f90), + vec3(1.0), + metallic, + ); + let m = clamp(1.0 - n_dot_v, 0.0, 1.0); + let m2 = m * m; + let m5 = min(m2 * m2 * m, 0.9999); + let thin_film_f0 = clamp( + ( + layered_raw_iridescence_base_fresnel( + surface, + n_dot_v, + base_color, + metallic, + ) - f90 * m5 + ) / (1.0 - m5), + vec3(0.0), + vec3(1.0), + ); + return mix(base_f0, thin_film_f0, surface.iridescence_factor); +} + +fn layered_clearcoat_fresnel(surface: LayeredSurface, cos_theta: f32) -> f32 { + let m = clamp(1.0 - cos_theta, 0.0, 1.0); + let m2 = m * m; + return surface.clearcoat_factor * (0.04 + 0.96 * m2 * m2 * m); +} + +fn layered_clearcoat_transmission(surface: LayeredSurface, cos_theta: f32) -> f32 { + return max(1.0 - layered_clearcoat_fresnel(surface, cos_theta), 0.0); +} + +fn layered_clearcoat_ibl_attenuation( + surface: LayeredSurface, + view: vec3, +) -> f32 { + let n_dot_v = max(dot(surface.clearcoat_normal, view), 0.0); + let transmission = layered_clearcoat_transmission(surface, n_dot_v); + return transmission * transmission; +} + +fn layered_sheen_lambda_helper(x: f32, alpha_g: f32) -> f32 { + let one_minus_alpha_sq = (1.0 - alpha_g) * (1.0 - alpha_g); + let a = mix(21.5473, 25.3245, one_minus_alpha_sq); + let b = mix(3.82987, 3.32435, one_minus_alpha_sq); + let c = mix(0.19823, 0.16801, one_minus_alpha_sq); + let d = mix(-1.97760, -1.27393, one_minus_alpha_sq); + let e = mix(-4.32054, -4.85967, one_minus_alpha_sq); + return a / (1.0 + b * pow(max(x, 0.0), c)) + d * x + e; +} + +fn layered_sheen_lambda(cos_theta: f32, alpha_g: f32) -> f32 { + let cosine = clamp(abs(cos_theta), 0.0, 1.0); + if (cosine < 0.5) { + return exp(layered_sheen_lambda_helper(cosine, alpha_g)); + } + return exp( + 2.0 * layered_sheen_lambda_helper(0.5, alpha_g) + - layered_sheen_lambda_helper(1.0 - cosine, alpha_g), + ); +} + +fn layered_sheen_distribution(n_dot_h: f32, perceptual_roughness: f32) -> f32 { + let alpha_g = max(perceptual_roughness * perceptual_roughness, 1e-6); + let inverse_alpha = 1.0 / alpha_g; + let sin2_h = max(1.0 - n_dot_h * n_dot_h, 0.0); + return (2.0 + inverse_alpha) * pow(sin2_h, 0.5 * inverse_alpha) + / (2.0 * PI); +} + +fn layered_sheen_visibility( + n_dot_l: f32, + n_dot_v: f32, + perceptual_roughness: f32, +) -> f32 { + let alpha_g = max(perceptual_roughness * perceptual_roughness, 1e-6); + let denominator = ( + 1.0 + + layered_sheen_lambda(n_dot_v, alpha_g) + + layered_sheen_lambda(n_dot_l, alpha_g) + ) * (4.0 * n_dot_v * n_dot_l); + return 1.0 / max(denominator, 1e-6); +} + +fn layered_sheen_directional_albedo(n_dot: f32, roughness: f32) -> f32 { + return textureSample( + layered_sheen_albedo_tex, + layered_sampler, + vec2(clamp(n_dot, 0.0, 1.0), clamp(roughness, 0.0, 1.0)), + ).r; +} + +fn layered_sheen_scale( + surface: LayeredSurface, + n_dot_v: f32, + n_dot_l: f32, +) -> f32 { + let maximum_color = max( + surface.sheen_color.r, + max(surface.sheen_color.g, surface.sheen_color.b), + ); + if (maximum_color <= 0.0) { + return 1.0; + } + let view_albedo = + layered_sheen_directional_albedo(n_dot_v, surface.sheen_roughness); + let light_albedo = + layered_sheen_directional_albedo(n_dot_l, surface.sheen_roughness); + return clamp(1.0 - maximum_color * max(view_albedo, light_albedo), 0.0, 1.0); +} + +fn layered_sheen_ibl_scale(surface: LayeredSurface, n_dot_v: f32) -> f32 { + let maximum_color = max( + surface.sheen_color.r, + max(surface.sheen_color.g, surface.sheen_color.b), + ); + if (maximum_color <= 0.0) { + return 1.0; + } + let albedo = + layered_sheen_directional_albedo(n_dot_v, surface.sheen_roughness); + return clamp(1.0 - maximum_color * albedo, 0.0, 1.0); +} + +fn layered_d_ggx_anisotropic( + n_dot_h: f32, + t_dot_h: f32, + b_dot_h: f32, + at: f32, + ab: f32, +) -> f32 { + let a2 = at * ab; + let f = vec3(ab * t_dot_h, at * b_dot_h, a2 * n_dot_h); + let w2 = a2 / max(dot(f, f), 1e-8); + return a2 * w2 * w2 / PI; +} + +fn layered_v_ggx_anisotropic( + n_dot_l: f32, + n_dot_v: f32, + t_dot_v: f32, + b_dot_v: f32, + t_dot_l: f32, + b_dot_l: f32, + at: f32, + ab: f32, +) -> f32 { + let ggx_v = n_dot_l * length(vec3(at * t_dot_v, ab * b_dot_v, n_dot_v)); + let ggx_l = n_dot_v * length(vec3(at * t_dot_l, ab * b_dot_l, n_dot_l)); + return clamp(0.5 / max(ggx_v + ggx_l, 1e-5), 0.0, 1.0); +} + +fn layered_base_distribution( + surface: LayeredSurface, + n: vec3, + h: vec3, + n_dot_h: f32, + alpha: f32, +) -> f32 { + if (surface.anisotropy_strength <= 0.0) { + return d_ggx(n_dot_h, alpha * alpha); + } + let tangent = surface.anisotropic_tangent; + let bitangent = normalize(cross(n, tangent)); + let at = mix(alpha, 1.0, surface.anisotropy_strength * surface.anisotropy_strength); + return layered_d_ggx_anisotropic( + n_dot_h, + dot(tangent, h), + dot(bitangent, h), + at, + alpha, + ); +} + +fn layered_base_visibility( + surface: LayeredSurface, + n: vec3, + v: vec3, + l: vec3, + n_dot_l: f32, + n_dot_v: f32, + alpha: f32, +) -> f32 { + if (surface.anisotropy_strength <= 0.0) { + return v_smith_ggx_correlated(n_dot_l, n_dot_v, alpha * alpha); + } + let tangent = surface.anisotropic_tangent; + let bitangent = normalize(cross(n, tangent)); + let at = mix(alpha, 1.0, surface.anisotropy_strength * surface.anisotropy_strength); + return layered_v_ggx_anisotropic( + n_dot_l, + n_dot_v, + dot(tangent, v), + dot(bitangent, v), + dot(tangent, l), + dot(bitangent, l), + at, + alpha, + ); +} + +fn layered_ibl_reflection( + surface: LayeredSurface, + n: vec3, + v: vec3, + roughness: f32, +) -> vec3 { + if (surface.anisotropy_strength <= 0.0) { + return reflect(-v, n); + } + let anisotropic_bitangent = normalize(cross(n, surface.anisotropic_tangent)); + let anisotropic_tangent = cross(anisotropic_bitangent, v); + let anisotropic_normal_raw = + cross(anisotropic_tangent, anisotropic_bitangent); + let anisotropic_normal_len2 = + dot(anisotropic_normal_raw, anisotropic_normal_raw); + let anisotropic_normal = select( + n, + anisotropic_normal_raw + * inverseSqrt(max(anisotropic_normal_len2, 1e-8)), + anisotropic_normal_len2 > 1e-8, + ); + let bend = 1.0 - surface.anisotropy_strength * (1.0 - roughness); + let bend2 = bend * bend; + let bent_normal_raw = mix(anisotropic_normal, n, bend2 * bend2); + let bent_normal = normalize(bent_normal_raw); + return normalize(reflect(-v, bent_normal)); +} + +fn layered_sheen_ibl( + surface: LayeredSurface, + n: vec3, + v: vec3, + max_spec_mip: f32, + occlusion: f32, +) -> vec3 { + let maximum_color = max( + surface.sheen_color.r, + max(surface.sheen_color.g, surface.sheen_color.b), + ); + if (maximum_color <= 0.0) { + return vec3(0.0); + } + let n_dot_v = max(dot(n, v), 0.0); + let reflection = reflect(-v, n); + let prefiltered = env_sample_lod( + reflection, + surface.sheen_roughness * max_spec_mip, + ); + let albedo = layered_sheen_directional_albedo( + n_dot_v, + surface.sheen_roughness, + ); + return prefiltered * surface.sheen_color * albedo * occlusion; +} + +fn layered_safe_tangent(normal: vec3, candidate: vec3) -> vec3 { + let projected = candidate - normal * dot(normal, candidate); + let projected_len2 = dot(projected, projected); + if (projected_len2 > 1e-8) { + return projected * inverseSqrt(projected_len2); + } + let fallback_axis = select( + vec3(1.0, 0.0, 0.0), + vec3(0.0, 1.0, 0.0), + abs(normal.x) > 0.9, + ); + return normalize(cross(normal, fallback_axis)); +} + +fn evaluate_layered_surface( + in: VertexOutputScene, + base_normal: vec3, + lod_bias: f32, +) -> LayeredSurface { + let secondary_uv = layered_secondary_uv(in); + + var specular_factor = clamp(layered_material.specular.w, 0.0, 1.0); + if (layered_material.specular_factor_rotation.z > 0.5) { + let uv = layered_transform_uv( + in.uv, + secondary_uv, + layered_material.specular_factor_uv, + layered_material.specular_factor_rotation, + ); + specular_factor *= textureSampleBias( + layered_specular_factor_tex, + layered_sampler, + uv, + lod_bias, + ).a; + } + + var specular_color = max(layered_material.specular.rgb, vec3(0.0)); + if (layered_material.specular_color_rotation.z > 0.5) { + let uv = layered_transform_uv( + in.uv, + secondary_uv, + layered_material.specular_color_uv, + layered_material.specular_color_rotation, + ); + let sampled = textureSampleBias( + layered_specular_color_tex, + layered_sampler, + uv, + lod_bias, + ).rgb; + specular_color *= srgb_to_linear_v(sampled); + } + let dielectric_f0 = min( + vec3(layered_ior_f0(layered_material.clearcoat_ior.w)) * specular_color, + vec3(1.0), + ) * specular_factor; + + var clearcoat_factor = clamp(layered_material.clearcoat_ior.x, 0.0, 1.0); + if (layered_material.clearcoat_factor_rotation.z > 0.5) { + let uv = layered_transform_uv( + in.uv, + secondary_uv, + layered_material.clearcoat_factor_uv, + layered_material.clearcoat_factor_rotation, + ); + clearcoat_factor *= textureSampleBias( + layered_clearcoat_factor_tex, + layered_sampler, + uv, + lod_bias, + ).r; + } + + var clearcoat_roughness = clamp(layered_material.clearcoat_ior.y, 0.04, 1.0); + if (clearcoat_factor > 0.0 + && layered_material.clearcoat_roughness_rotation.z > 0.5) { + let uv = layered_transform_uv( + in.uv, + secondary_uv, + layered_material.clearcoat_roughness_uv, + layered_material.clearcoat_roughness_rotation, + ); + clearcoat_roughness = clamp( + clearcoat_roughness * textureSampleBias( + layered_clearcoat_roughness_tex, + layered_sampler, + uv, + lod_bias, + ).g, + 0.04, + 1.0, + ); + } + + var clearcoat_normal = base_normal; + if (clearcoat_factor > 0.0 + && layered_material.clearcoat_normal_rotation.z > 0.5) { + let source_uv = select( + in.uv, + secondary_uv, + layered_material.clearcoat_normal_rotation.w > 0.5, + ); + let uv = layered_transform_uv( + in.uv, + secondary_uv, + layered_material.clearcoat_normal_uv, + layered_material.clearcoat_normal_rotation, + ); + let sampled = textureSampleBias( + layered_clearcoat_normal_tex, + layered_sampler, + uv, + 1.0 + lod_bias, + ); + var tangent_normal = sampled.xyz * 2.0 - 1.0; + tangent_normal.x *= layered_material.clearcoat_ior.z; + tangent_normal.y *= layered_material.clearcoat_ior.z; + let normal_len2 = clamp(dot(tangent_normal, tangent_normal), 0.01, 1.0); + tangent_normal *= inverseSqrt(normal_len2); + + var tbn = compute_tbn( + dpdx(in.world_pos), + dpdy(in.world_pos), + dpdx(source_uv), + dpdy(source_uv), + base_normal, + ); + let tangent_ortho = + in.tangent.xyz - base_normal * dot(base_normal, in.tangent.xyz); + if (dot(tangent_ortho, tangent_ortho) > 1e-4) { + let mesh_tangent = normalize(tangent_ortho); + let mesh_bitangent = + cross(base_normal, mesh_tangent) * in.tangent.w; + tbn = mat3x3(mesh_tangent, mesh_bitangent, base_normal); + } + let mapped_raw = tbn * tangent_normal; + let mapped_len2 = dot(mapped_raw, mapped_raw); + let mapped = select( + base_normal, + mapped_raw * inverseSqrt(max(mapped_len2, 1e-8)), + mapped_len2 > 1e-8, + ); + // Keep the independently-authored coat on the geometric hemisphere. + // This prevents invalid maps or degenerate UV derivatives from turning + // the top interface through the base surface. + let hemisphere = dot(mapped, base_normal); + clearcoat_normal = normalize( + mapped + base_normal * max(0.05 - hemisphere, 0.0), + ); + + // The same LEADR/Toksvig and screen-space variance treatment as the + // base lobe keeps the sharper coat from reintroducing temporal sparkle. + let sigma2_toksvig = (1.0 - normal_len2) / normal_len2; + let baked_variance = clamp(sampled.a, 0.0, 0.999); + let sigma2_baked = baked_variance / max(1.0 - baked_variance, 0.001); + let normal_dx = dpdx(clearcoat_normal); + let normal_dy = dpdy(clearcoat_normal); + let curvature_sq = dot(normal_dx, normal_dx) + dot(normal_dy, normal_dy); + let kernel_alpha = min(2.0 * curvature_sq, 0.9); + let roughness2 = min( + clearcoat_roughness * clearcoat_roughness + + sigma2_toksvig + + sigma2_baked + + kernel_alpha, + 1.0, + ); + clearcoat_roughness = sqrt(roughness2); + } + + var iridescence_factor = clamp(layered_material.iridescence.x, 0.0, 1.0); + if (iridescence_factor > 0.0 + && layered_material.iridescence_factor_rotation.z > 0.5) { + let uv = layered_transform_uv( + in.uv, + secondary_uv, + layered_material.iridescence_factor_uv, + layered_material.iridescence_factor_rotation, + ); + iridescence_factor *= textureSampleBias( + layered_iridescence_factor_tex, + layered_sampler, + uv, + lod_bias, + ).r; + } + var iridescence_thickness_nm = + max(layered_material.iridescence.w, 0.0); + if (iridescence_factor > 0.0 + && layered_material.iridescence_thickness_rotation.z > 0.5) { + let uv = layered_transform_uv( + in.uv, + secondary_uv, + layered_material.iridescence_thickness_uv, + layered_material.iridescence_thickness_rotation, + ); + let sampled = textureSampleBias( + layered_iridescence_thickness_tex, + layered_sampler, + uv, + lod_bias, + ).g; + iridescence_thickness_nm = mix( + max(layered_material.iridescence.z, 0.0), + max(layered_material.iridescence.w, 0.0), + sampled, + ); + } + if (iridescence_thickness_nm <= 0.0) { + iridescence_factor = 0.0; + } + + var sheen_color = max(layered_material.sheen.rgb, vec3(0.0)); + var anisotropy_strength = clamp(layered_material.anisotropy.x, 0.0, 1.0); + let sheen_factor_max = max( + sheen_color.r, + max(sheen_color.g, sheen_color.b), + ); + if (sheen_factor_max <= 0.0 && anisotropy_strength <= 0.0) { + // Version-2 clearcoat/specular/IOR materials keep a short path: no + // sheen/anisotropy texture reads, UV transforms, derivatives, or LUT + // work. The tangent is unused while anisotropy strength is zero. + return LayeredSurface( + dielectric_f0, + specular_factor, + clearcoat_normal, + clearcoat_factor, + clearcoat_roughness, + vec3(0.0), + 0.04, + base_normal, + 0.0, + iridescence_factor, + max(layered_material.iridescence.y, 1.0), + iridescence_thickness_nm, + ); + } + if (layered_material.sheen_color_rotation.z > 0.5) { + let uv = layered_transform_uv( + in.uv, + secondary_uv, + layered_material.sheen_color_uv, + layered_material.sheen_color_rotation, + ); + sheen_color *= srgb_to_linear_v(textureSampleBias( + layered_sheen_color_tex, + layered_sampler, + uv, + lod_bias, + ).rgb); + } + var sheen_roughness = clamp(layered_material.sheen.w, 0.04, 1.0); + if (max(sheen_color.r, max(sheen_color.g, sheen_color.b)) > 0.0 + && layered_material.sheen_roughness_rotation.z > 0.5) { + let uv = layered_transform_uv( + in.uv, + secondary_uv, + layered_material.sheen_roughness_uv, + layered_material.sheen_roughness_rotation, + ); + sheen_roughness = clamp( + sheen_roughness * textureSampleBias( + layered_sheen_roughness_tex, + layered_sampler, + uv, + lod_bias, + ).a, + 0.04, + 1.0, + ); + } + + var anisotropic_tangent = base_normal; + if (anisotropy_strength > 0.0) { + var anisotropy_direction = vec2(1.0, 0.0); + let anisotropy_source_uv = select( + in.uv, + secondary_uv, + layered_material.anisotropy_texture_rotation.w > 0.5, + ); + let anisotropy_tbn = compute_tbn( + dpdx(in.world_pos), + dpdy(in.world_pos), + dpdx(anisotropy_source_uv), + dpdy(anisotropy_source_uv), + base_normal, + ); + if (layered_material.anisotropy_texture_rotation.z > 0.5) { + let uv = layered_transform_uv( + in.uv, + secondary_uv, + layered_material.anisotropy_uv, + layered_material.anisotropy_texture_rotation, + ); + let sampled = textureSampleBias( + layered_anisotropy_tex, + layered_sampler, + uv, + lod_bias, + ).rgb; + anisotropy_direction = sampled.rg * 2.0 - vec2(1.0); + if (dot(anisotropy_direction, anisotropy_direction) <= 1e-6) { + anisotropy_direction = vec2(1.0, 0.0); + } else { + anisotropy_direction = normalize(anisotropy_direction); + } + anisotropy_strength *= sampled.b; + } + let rotated_direction = vec2( + layered_material.anisotropy.y * anisotropy_direction.x + - layered_material.anisotropy.z * anisotropy_direction.y, + layered_material.anisotropy.z * anisotropy_direction.x + + layered_material.anisotropy.y * anisotropy_direction.y, + ); + anisotropic_tangent = layered_safe_tangent( + base_normal, + anisotropy_tbn * vec3(rotated_direction, 0.0), + ); + let tangent_ortho = + in.tangent.xyz - base_normal * dot(base_normal, in.tangent.xyz); + if (dot(tangent_ortho, tangent_ortho) > 1e-4) { + let mesh_tangent = normalize(tangent_ortho); + let mesh_bitangent = cross(base_normal, mesh_tangent) * in.tangent.w; + anisotropic_tangent = layered_safe_tangent( + base_normal, + mesh_tangent * rotated_direction.x + mesh_bitangent * rotated_direction.y, + ); + } + // The distribution squares tangent/bitangent projections, but + // retaining the authored mirrored tangent sign here is still + // necessary for textured rotations and bent-normal IBL. + anisotropic_tangent = + layered_safe_tangent(base_normal, anisotropic_tangent); + } + + return LayeredSurface( + dielectric_f0, + specular_factor, + clearcoat_normal, + clearcoat_factor, + clearcoat_roughness, + sheen_color, + sheen_roughness, + anisotropic_tangent, + anisotropy_strength, + iridescence_factor, + max(layered_material.iridescence.y, 1.0), + iridescence_thickness_nm, + ); +} + +fn shade_layered_pbr( + surface: LayeredSurface, + n: vec3, + v: vec3, + l_dir: vec3, + light_color: vec3, + intensity: f32, + base_color: vec3, + metallic: f32, + roughness: f32, +) -> vec3 { + let base = shade_layered_base_pbr( + surface, + n, + v, + l_dir, + light_color, + intensity, + base_color, + metallic, + roughness, + ); + var undercoat = base; + let sheen_max = max( + surface.sheen_color.r, + max(surface.sheen_color.g, surface.sheen_color.b), + ); + if (sheen_max > 0.0 && intensity > 0.0) { + let sheen_n_dot_l = max(dot(n, l_dir), 0.0); + let sheen_n_dot_v = max(dot(n, v), 1e-4); + if (sheen_n_dot_l > 0.0) { + undercoat = base * layered_sheen_scale( + surface, + sheen_n_dot_v, + sheen_n_dot_l, + ); + let half_raw = l_dir + v; + let half_len2 = dot(half_raw, half_raw); + if (half_len2 > 1e-12) { + let half_vector = half_raw * inverseSqrt(half_len2); + let n_dot_h = clamp(dot(n, half_vector), 0.0, 1.0); + let distribution = + layered_sheen_distribution(n_dot_h, surface.sheen_roughness); + let visibility = layered_sheen_visibility( + sheen_n_dot_l, + sheen_n_dot_v, + surface.sheen_roughness, + ); + let sheen_raw = surface.sheen_color * distribution * visibility; + let sheen_luma = dot( + sheen_raw, + vec3(0.2126, 0.7152, 0.0722), + ); + let sheen_cap = 1.0 / (1.0 + sheen_luma / 0.3); + let sheen = sheen_raw * sheen_cap * light_color + * intensity * sheen_n_dot_l; + undercoat += sheen; + } + } + } + if (surface.clearcoat_factor <= 0.0 || intensity <= 0.0) { + return undercoat; + } + + let coat_n = surface.clearcoat_normal; + let n_dot_l = max(dot(coat_n, l_dir), 0.0); + let n_dot_v = max(dot(coat_n, v), 1e-4); + let base_attenuation = + layered_clearcoat_transmission(surface, n_dot_v) + * layered_clearcoat_transmission(surface, n_dot_l); + if (n_dot_l <= 0.0) { + return undercoat * base_attenuation; + } + let half_raw = l_dir + v; + let half_len2 = dot(half_raw, half_raw); + if (half_len2 <= 1e-12) { + return undercoat * base_attenuation; + } + let half_vector = half_raw * inverseSqrt(half_len2); + let n_dot_h = clamp(dot(coat_n, half_vector), 0.0, 1.0); + let v_dot_h = clamp(dot(v, half_vector), 0.0, 1.0); + let alpha = max( + surface.clearcoat_roughness * surface.clearcoat_roughness, + 0.001, + ); + let alpha2 = alpha * alpha; + let distribution = d_ggx(n_dot_h, alpha2); + let visibility = v_smith_ggx_correlated(n_dot_l, n_dot_v, alpha2); + let fresnel = layered_clearcoat_fresnel(surface, v_dot_h); + let coat_raw = vec3(fresnel * distribution * visibility); + + // Smooth finite compression preserves a visible varnish highlight without + // allowing a point-sun GGX peak to become a temporal firefly. + let coat_luma = dot(coat_raw, vec3(0.2126, 0.7152, 0.0722)); + let coat_cap = 1.0 / (1.0 + coat_luma / 0.3); + let coat = coat_raw * coat_cap * light_color * intensity * n_dot_l; + return undercoat * base_attenuation + coat; +} diff --git a/native/shared/shaders/material_abi.wgsl b/native/shared/shaders/material_abi.wgsl index 7428ec78..fae2c867 100644 --- a/native/shared/shaders/material_abi.wgsl +++ b/native/shared/shaders/material_abi.wgsl @@ -134,7 +134,9 @@ struct MaterialFactors { // y = wrap_factor (0..1, how much wrap-lambert wraps the diffuse // term — 0 is standard lambert, 1 is light fully // wrapping around to the back face), - // zw = reserved. + // z = layered-PBR material-record version, w = lobe feature mask. + // Both are exact u32 bit patterns stored in f32 lanes so this UBO + // remains 80 bytes. Use the accessors below; do not numeric-cast. foliage_params: vec4, }; @@ -150,6 +152,16 @@ struct MaterialFactors { @group(2) @binding(9) var occ_samp: sampler; @group(2) @binding(10) var material: MaterialFactors; +const BLOOM_BOUND_MATERIAL_RECORD_VERSION: u32 = 1u; + +fn bloom_bound_material_record_version() -> u32 { + return bitcast(material.foliage_params.z); +} + +fn bloom_bound_material_lobe_mask() -> u32 { + return bitcast(material.foliage_params.w); +} + // Binding 11 is reserved for per-material user params. Shaders that // declare user params do so via: // @@ -289,6 +301,17 @@ struct TranslucentOut { @location(0) hdr: vec4, }; +// Optional responsive-translucency profile. A custom translucent material may +// expose a second `@fragment fn fs_reactive(...) -> ReactiveTranslucentOut` +// entry alongside its ordinary `fs_main`. The engine selects that entry only +// while TAA has allocated the lazy R8 reactive attachment. `reactive` is +// current-frame coverage in [0,1]: use opacity for smoke/glass and 1 for +// rapidly changing additive sparks that must consume current color. +struct ReactiveTranslucentOut { + @location(0) hdr: vec4, + @location(1) reactive: f32, +}; + // ===================================================================== // Standard helpers // ===================================================================== diff --git a/native/shared/shaders/material_indirection.wgsl b/native/shared/shaders/material_indirection.wgsl new file mode 100644 index 00000000..da729d59 --- /dev/null +++ b/native/shared/shaders/material_indirection.wgsl @@ -0,0 +1,237 @@ +// Bloom global material indirection ABI — version 1. +// +// This header is consumed by GPU-driven opaque passes. The legacy/custom +// material ABI remains version 3 and unchanged: Tier C therefore stays a +// pixel-identical compatibility path. + +const BLOOM_RESOURCE_SLOT_MASK: u32 = 0x000fffffu; +const BLOOM_RESOURCE_GENERATION_SHIFT: u32 = 20u; +const BLOOM_GLOBAL_MATERIAL_RECORD_VERSION: u32 = 1u; +const BLOOM_GLOBAL_MATERIAL_VERSION_SHIFT: u32 = 24u; +const BLOOM_GLOBAL_MATERIAL_LOBE_MASK: u32 = 0x00ffffffu; + +struct GlobalMaterialRecord { + // x=generation, y=high-8-bit layered-PBR version + low-24-bit lobe mask, + // z=user-param byte offset, w=user-param byte size. + header: vec4, + base_color: vec4, + metal_rough: vec4, + emissive: vec4, + shading_model: vec4, + foliage_params: vec4, + texture_ids_0: vec4, + texture_ids_1: vec4, + texture_ids_2: vec4, + sampler_ids_0: vec4, + sampler_ids_1: vec4, +}; + +struct GlobalMaterialTable { + records: array, +}; + +struct GlobalResourceGenerationTable { + // x=texture generation, y=sampler generation, z=texture flags, + // w=texture semantic. flags: bit0=sRGB content, bit1=hardware decode, + // bit2=HDR-linear. + entries: array>, +}; + +// The pipeline using this header supplies this persistent global layout as one +// persistent global group. No per-material bind group is created or switched. +@group(2) @binding(0) var global_materials: GlobalMaterialTable; +@group(2) @binding(1) var global_textures: binding_array>; +@group(2) @binding(2) var global_samplers: binding_array; +@group(2) @binding(3) var global_resource_generations: GlobalResourceGenerationTable; + +fn bloom_resource_slot(id: u32) -> u32 { + return id & BLOOM_RESOURCE_SLOT_MASK; +} + +fn bloom_resource_generation(id: u32) -> u32 { + return id >> BLOOM_RESOURCE_GENERATION_SHIFT; +} + +// Record zero is the diagnostic fallback. It is white, rough, non-metallic, +// non-emissive, and carries fallback texture/sampler ID zero. +fn bloom_material_record(id: u32) -> GlobalMaterialRecord { + let slot = bloom_resource_slot(id); + if (slot == 0u || slot >= arrayLength(&global_materials.records)) { + return global_materials.records[0u]; + } + let candidate = global_materials.records[slot]; + if (candidate.header.x != bloom_resource_generation(id)) { + return global_materials.records[0u]; + } + return candidate; +} + +fn bloom_global_material_record_version(material_record: GlobalMaterialRecord) -> u32 { + return material_record.header.y >> BLOOM_GLOBAL_MATERIAL_VERSION_SHIFT; +} + +fn bloom_global_material_lobe_mask(material_record: GlobalMaterialRecord) -> u32 { + return material_record.header.y & BLOOM_GLOBAL_MATERIAL_LOBE_MASK; +} + +fn bloom_texture_slot(id: u32) -> u32 { + let slot = bloom_resource_slot(id); + if (slot == 0u || slot >= arrayLength(&global_resource_generations.entries)) { + return 0u; + } + if (global_resource_generations.entries[slot].x != bloom_resource_generation(id)) { + return 0u; + } + return slot; +} + +fn bloom_sampler_slot(id: u32) -> u32 { + let slot = bloom_resource_slot(id); + if (slot == 0u || slot >= arrayLength(&global_resource_generations.entries)) { + return 0u; + } + if (global_resource_generations.entries[slot].y != bloom_resource_generation(id)) { + return 0u; + } + return slot; +} + +fn bloom_srgb_channel_to_linear(v: f32) -> f32 { + if (v <= 0.04045) { + return v / 12.92; + } + return pow((v + 0.055) / 1.055, 2.4); +} + +fn bloom_decode_registered_color(texture_slot: u32, value: vec4) -> vec4 { + let flags = global_resource_generations.entries[texture_slot].z; + let srgb_content = (flags & 1u) != 0u; + let hardware_decoded = (flags & 2u) != 0u; + if (!srgb_content || hardware_decoded) { + return value; + } + return vec4( + bloom_srgb_channel_to_linear(value.r), + bloom_srgb_channel_to_linear(value.g), + bloom_srgb_channel_to_linear(value.b), + value.a + ); +} + +fn bloom_sample_base_color(material_record: GlobalMaterialRecord, uv: vec2) -> vec4 { + let texture_slot = bloom_texture_slot(material_record.texture_ids_0.x); + let sampler_slot = bloom_sampler_slot(material_record.sampler_ids_0.x); + let sampled = textureSample( + global_textures[texture_slot], + global_samplers[sampler_slot], + uv + ); + return bloom_decode_registered_color(texture_slot, sampled) * material_record.base_color; +} + +fn bloom_sample_raw( + texture_id: u32, + sampler_id: u32, + uv: vec2 +) -> vec4 { + let texture_slot = bloom_texture_slot(texture_id); + let sampler_slot = bloom_sampler_slot(sampler_id); + return textureSample( + global_textures[texture_slot], + global_samplers[sampler_slot], + uv + ); +} + +fn bloom_sample_raw_bias( + texture_id: u32, + sampler_id: u32, + uv: vec2, + bias: f32 +) -> vec4 { + let texture_slot = bloom_texture_slot(texture_id); + let sampler_slot = bloom_sampler_slot(sampler_id); + return textureSampleBias( + global_textures[texture_slot], + global_samplers[sampler_slot], + uv, + bias + ); +} + +fn bloom_sample_raw_level( + texture_id: u32, + sampler_id: u32, + uv: vec2, + level: f32 +) -> vec4 { + let texture_slot = bloom_texture_slot(texture_id); + let sampler_slot = bloom_sampler_slot(sampler_id); + return textureSampleLevel( + global_textures[texture_slot], + global_samplers[sampler_slot], + uv, + level + ); +} + +fn bloom_base_color_dimensions(material_record: GlobalMaterialRecord) -> vec2 { + let texture_slot = bloom_texture_slot(material_record.texture_ids_0.x); + return textureDimensions(global_textures[texture_slot]); +} + +fn bloom_sample_registered_color_bias( + texture_id: u32, + sampler_id: u32, + uv: vec2, + bias: f32 +) -> vec4 { + let texture_slot = bloom_texture_slot(texture_id); + let sampler_slot = bloom_sampler_slot(sampler_id); + let sampled = textureSampleBias( + global_textures[texture_slot], + global_samplers[sampler_slot], + uv, + bias + ); + return bloom_decode_registered_color(texture_slot, sampled); +} + +// Slot zero is the white diagnostic texture. For a missing normal map the +// semantic fallback must reproduce the legacy RGBA8 default-normal texel +// exactly: (128, 128, 255, 255). The 128/255 quantization and alpha=1 are +// observable in the existing Toksvig path, so idealized (0.5, 0.5, 1, 0) +// would subtly change every untextured material. +fn bloom_sample_normal_raw_bias( + material_record: GlobalMaterialRecord, + uv: vec2, + bias: f32 +) -> vec4 { + let texture_slot = bloom_texture_slot(material_record.texture_ids_0.y); + if (texture_slot == 0u) { + return vec4(128.0 / 255.0, 128.0 / 255.0, 1.0, 1.0); + } + let sampler_slot = bloom_sampler_slot(material_record.sampler_ids_0.y); + return textureSampleBias( + global_textures[texture_slot], + global_samplers[sampler_slot], + uv, + bias + ); +} + +fn bloom_sample_normal(material_record: GlobalMaterialRecord, uv: vec2) -> vec3 { + let texture_slot = bloom_texture_slot(material_record.texture_ids_0.y); + if (texture_slot == 0u) { + return vec3(0.0, 0.0, 1.0); + } + let sampler_slot = bloom_sampler_slot(material_record.sampler_ids_0.y); + // Normal resources are required to register a linear view. Renormalization + // keeps filtered mip values on the unit hemisphere. + let encoded = textureSample( + global_textures[texture_slot], + global_samplers[sampler_slot], + uv + ).xyz; + return normalize(encoded * 2.0 - 1.0); +} diff --git a/native/shared/shaders/sheen_albedo_lut_r16f.bin b/native/shared/shaders/sheen_albedo_lut_r16f.bin new file mode 100644 index 00000000..40314fc1 Binary files /dev/null and b/native/shared/shaders/sheen_albedo_lut_r16f.bin differ diff --git a/native/shared/src/anim_mixer.rs b/native/shared/src/anim_mixer.rs index 137ae80e..6b8d6e82 100644 --- a/native/shared/src/anim_mixer.rs +++ b/native/shared/src/anim_mixer.rs @@ -49,13 +49,26 @@ pub struct AnimMixer { impl Default for AnimMixer { fn default() -> Self { Self { - cur_clip: 0, cur_time: 0.0, cur_speed: 1.0, cur_loop: true, - prev_clip: 0, prev_time: 0.0, prev_speed: 1.0, prev_loop: true, - fade_t: 0.0, fade_dur: 0.0, - layer_clip: -1, layer_time: 0.0, layer_speed: 1.0, layer_loop: false, - layer_weight: 0.0, layer_mask_root: -1, - root_motion: false, root_delta: [0.0; 3], - finished: false, started: false, + cur_clip: 0, + cur_time: 0.0, + cur_speed: 1.0, + cur_loop: true, + prev_clip: 0, + prev_time: 0.0, + prev_speed: 1.0, + prev_loop: true, + fade_t: 0.0, + fade_dur: 0.0, + layer_clip: -1, + layer_time: 0.0, + layer_speed: 1.0, + layer_loop: false, + layer_weight: 0.0, + layer_mask_root: -1, + root_motion: false, + root_delta: [0.0; 3], + finished: false, + started: false, } } } diff --git a/native/shared/src/attach.rs b/native/shared/src/attach.rs index eb39f5fb..4a8a2e70 100644 --- a/native/shared/src/attach.rs +++ b/native/shared/src/attach.rs @@ -74,6 +74,70 @@ pub struct AttachParams { pub physical_h: u32, pub format: FormatPreference, } +fn request_device( + instance: &wgpu::Instance, + compatible_surface: Option<&wgpu::Surface<'_>>, +) -> Result< + ( + wgpu::Adapter, + wgpu::Device, + wgpu::Queue, + crate::renderer::device_negotiation::DeviceNegotiationReport, + ), + String, +> { + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + compatible_surface, + power_preference: wgpu::PowerPreference::HighPerformance, + ..Default::default() + })) + .map_err(|e| format!("no compatible adapter: {e}"))?; + + let force_sw_gi = std::env::var("BLOOM_FORCE_SW_GI") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + let negotiated = block_on( + crate::renderer::device_negotiation::request_device_with_fallback( + &adapter, + crate::renderer::device_negotiation::DeviceRequestOptions { + allow_ray_query: !force_sw_gi, + profile: if cfg!(target_os = "android") { + crate::renderer::device_negotiation::DeviceRequestProfile::FoldedMobile + } else { + crate::renderer::device_negotiation::DeviceRequestProfile::NativeFull + }, + }, + ), + )?; + eprintln!( + "bloom: renderer device negotiation = {}", + negotiated.report.report_json() + ); + Ok(( + adapter, + negotiated.device, + negotiated.queue, + negotiated.report, + )) +} + +/// Build the same production renderer without a presentation surface. +/// Used only by explicit batch/headless hosts, which need exact pixels and +/// must not depend on window-system DPI or drawable availability. +pub fn attach_headless_engine( + backends: wgpu::Backends, + width: u32, + height: u32, +) -> Result { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends, + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + let (_adapter, device, queue, negotiation) = request_device(&instance, None)?; + let mut renderer = Renderer::new_headless(device, queue, width.max(1), height.max(1)); + renderer.set_device_negotiation_report(negotiation.report_json()); + Ok(EngineState::new(renderer)) +} /// Build a fully-configured [`EngineState`] that renders into a /// host-owned surface. This is the GPU half of `bloom_init_window` with @@ -101,121 +165,7 @@ pub unsafe fn attach_engine( .create_surface_unsafe(target) .map_err(|e| format!("create_surface failed: {e}"))?; - let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { - compatible_surface: Some(&surface), - power_preference: wgpu::PowerPreference::HighPerformance, - ..Default::default() - })) - .map_err(|e| format!("no compatible adapter: {e}"))?; - - // Optional device features, requested only when the adapter offers - // them (mirrors bloom_init_window): GPU-timestamp profiling, BC - // texture compression, and HW ray query for the GI probe path. - let supported = adapter.features(); - let mut required_features = wgpu::Features::empty(); - if supported.contains(wgpu::Features::TIMESTAMP_QUERY) { - required_features |= wgpu::Features::TIMESTAMP_QUERY; - } - if supported.contains(wgpu::Features::TEXTURE_COMPRESSION_BC) { - required_features |= wgpu::Features::TEXTURE_COMPRESSION_BC; - } - let force_sw_gi = std::env::var("BLOOM_FORCE_SW_GI") - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false); - let rt_mask = wgpu::Features::EXPERIMENTAL_RAY_QUERY; - if !force_sw_gi && supported.contains(rt_mask) { - required_features |= rt_mask; - } - // PT-2: texture binding array + non-uniform indexing for textured - // path-trace hit shading (mirrors bloom_init_window). - let pt_tex_mask = wgpu::Features::TEXTURE_BINDING_ARRAY - | wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING; - if supported.contains(pt_tex_mask) { - required_features |= pt_tex_mask; - } - let experimental_features = if required_features.intersects(rt_mask) { - // wgpu 29 requires this explicit opt-in token for EXPERIMENTAL_* - // features. Apple-Silicon Metal ray query has been stable since - // wgpu v25, so the documented UB risk is acceptable here. - unsafe { wgpu::ExperimentalFeatures::enabled() } - } else { - wgpu::ExperimentalFeatures::disabled() - }; - - // The material ABI declares 5 bind groups; wgpu defaults to 4. Every - // real backend supports >= 7. - let adapter_limits = adapter.limits(); - let mut required_limits = wgpu::Limits::default(); - required_limits.max_bind_groups = 5; - - // A user material's fragment stage binds more sampled textures than - // wgpu's default permits — 19 against a default cap of 16, once the - // scene/GI inputs sit alongside the material's own maps. - // - // Only the fallback below ever picked up the adapter's real limits, and - // it runs solely when request_device *fails*. On macOS the default - // request succeeds at 16, so the shortfall surfaced much later, as an - // abort inside create_pipeline_layout('user_material') — the shooter - // could not open on macOS at all. (iOS escaped it by luck: its first - // request fails on an unrelated limit, so it always retried with the - // adapter's limits and got the headroom as a side effect.) - // - // Ask for what the adapter actually offers on the limits the material - // system leans on, never dropping below wgpu's defaults. - required_limits.max_sampled_textures_per_shader_stage = required_limits - .max_sampled_textures_per_shader_stage - .max(adapter_limits.max_sampled_textures_per_shader_stage); - required_limits.max_samplers_per_shader_stage = required_limits - .max_samplers_per_shader_stage - .max(adapter_limits.max_samplers_per_shader_stage); - // PT-2: binding arrays have their own element budget, default 0. - if required_features.contains(pt_tex_mask) { - required_limits.max_binding_array_elements_per_shader_stage = - adapter_limits.max_binding_array_elements_per_shader_stage; - } - // PT-4: the path-trace kernel binds 9 storage buffers (accum + - // moments + reservoir ping-pongs on top of instance/geo data); - // the wgpu default limit is 8. - required_limits.max_storage_buffers_per_shader_stage = required_limits - .max_storage_buffers_per_shader_stage - .max(adapter_limits.max_storage_buffers_per_shader_stage.min(16)); - - if required_features.intersects(rt_mask) { - required_limits = - required_limits.using_minimum_supported_acceleration_structure_values(); - } - - let device_desc = wgpu::DeviceDescriptor { - label: Some("bloom_device"), - required_features, - required_limits: required_limits.clone(), - experimental_features, - ..Default::default() - }; - - // Some constrained mobile GPUs (e.g. A18) report a feature/limit set - // they then refuse at device-create time. Retry once with the - // adapter's own reported limits + no optional features before giving - // up — matches the iOS init path's fallback. - let (device, queue) = match block_on(adapter.request_device(&device_desc)) { - Ok(pair) => pair, - Err(first) => { - let fallback = wgpu::DeviceDescriptor { - label: Some("bloom_device_fallback"), - required_features: wgpu::Features::empty(), - required_limits: { - let mut l = adapter.limits(); - l.max_bind_groups = l.max_bind_groups.max(5); - l - }, - experimental_features: wgpu::ExperimentalFeatures::disabled(), - ..Default::default() - }; - block_on(adapter.request_device(&fallback)).map_err(|second| { - format!("request_device failed: {first}; fallback: {second}") - })? - } - }; + let (adapter, device, queue, negotiation) = request_device(&instance, Some(&surface))?; let surface_caps = surface.get_capabilities(&adapter); if surface_caps.formats.is_empty() { @@ -251,7 +201,7 @@ pub unsafe fn attach_engine( }; surface.configure(&device, &surface_config); - let renderer = Renderer::new( + let mut renderer = Renderer::new( device, queue, surface, @@ -259,5 +209,6 @@ pub unsafe fn attach_engine( params.logical_w.max(1), params.logical_h.max(1), ); + renderer.set_device_negotiation_report(negotiation.report_json()); Ok(EngineState::new(renderer)) } diff --git a/native/shared/src/audio/decode.rs b/native/shared/src/audio/decode.rs index 966cb3b4..11671725 100644 --- a/native/shared/src/audio/decode.rs +++ b/native/shared/src/audio/decode.rs @@ -6,8 +6,12 @@ use super::SoundData; /// Parse a WAV file into SoundData. pub fn parse_wav(data: &[u8]) -> Option { - if data.len() < 44 { return None; } - if &data[0..4] != b"RIFF" || &data[8..12] != b"WAVE" { return None; } + if data.len() < 44 { + return None; + } + if &data[0..4] != b"RIFF" || &data[8..12] != b"WAVE" { + return None; + } let channels = u16::from_le_bytes([data[22], data[23]]); let sample_rate = u32::from_le_bytes([data[24], data[25], data[26], data[27]]); @@ -17,21 +21,30 @@ pub fn parse_wav(data: &[u8]) -> Option { while offset + 8 < data.len() { let chunk_id = &data[offset..offset + 4]; let chunk_size = u32::from_le_bytes([ - data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7], + data[offset + 4], + data[offset + 5], + data[offset + 6], + data[offset + 7], ]) as usize; if chunk_id == b"data" { let pcm_data = &data[offset + 8..std::cmp::min(offset + 8 + chunk_size, data.len())]; let samples = match bits_per_sample { - 16 => pcm_data.chunks_exact(2) + 16 => pcm_data + .chunks_exact(2) .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]) as f32 / 32768.0) .collect(), - 8 => pcm_data.iter() + 8 => pcm_data + .iter() .map(|&b| (b as f32 - 128.0) / 128.0) .collect(), _ => return None, }; - return Some(SoundData { samples, sample_rate, channels }); + return Some(SoundData { + samples, + sample_rate, + channels, + }); } offset += 8 + chunk_size; } @@ -62,8 +75,14 @@ pub fn parse_mp3(data: &[u8]) -> Option { } } - if samples.is_empty() { return None; } - Some(SoundData { samples, sample_rate, channels }) + if samples.is_empty() { + return None; + } + Some(SoundData { + samples, + sample_rate, + channels, + }) } /// Parse an OGG Vorbis file into SoundData. @@ -80,8 +99,14 @@ pub fn parse_ogg(data: &[u8]) -> Option { } } - if samples.is_empty() { return None; } - Some(SoundData { samples, sample_rate, channels }) + if samples.is_empty() { + return None; + } + Some(SoundData { + samples, + sample_rate, + channels, + }) } /// Decode an audio file by extension, falling back to format sniffing. diff --git a/native/shared/src/audio/mod.rs b/native/shared/src/audio/mod.rs index bbcff3d1..2ddd4499 100644 --- a/native/shared/src/audio/mod.rs +++ b/native/shared/src/audio/mod.rs @@ -31,9 +31,9 @@ mod render; mod spsc; pub mod stream; -pub use decode::{decode_audio, parse_ogg, parse_wav}; #[cfg(feature = "mp3")] pub use decode::parse_mp3; +pub use decode::{decode_audio, parse_ogg, parse_wav}; pub use render::AudioRenderer; use crate::handles::HandleRegistry; @@ -150,10 +150,17 @@ impl AudioMixer { /// `ref_dist`/`rolloff` of 1 with a huge `max_dist` is exactly the /// pre-EN-062 1/d behaviour, which is what the plain play calls use. fn send_play( - &mut self, handle: f64, spatial: Option<[f32; 3]>, looping: bool, - ref_dist: f32, max_dist: f32, rolloff: f32, + &mut self, + handle: f64, + spatial: Option<[f32; 3]>, + looping: bool, + ref_dist: f32, + max_dist: f32, + rolloff: f32, ) -> f64 { - let Some(data) = self.sounds.get(handle).cloned() else { return 0.0 }; + let Some(data) = self.sounds.get(handle).cloned() else { + return 0.0; + }; let volume = self.get_sound_volume(handle); let (bus, send, lowpass) = self.routing(handle); self.next_voice += 1; @@ -169,7 +176,9 @@ impl AudioMixer { max_dist, rolloff, pitch: 1.0, - bus, send, lowpass, + bus, + send, + lowpass, }); voice_id as f64 } @@ -194,11 +203,20 @@ impl AudioMixer { /// volume, `rolloff` how hard the level falls past it, `max_dist` where /// the mixer culls entirely. Returns the voice id (0.0 = unknown sound). pub fn play_sound_3d_ex( - &mut self, handle: f64, x: f32, y: f32, z: f32, - looping: bool, ref_dist: f32, max_dist: f32, rolloff: f32, + &mut self, + handle: f64, + x: f32, + y: f32, + z: f32, + looping: bool, + ref_dist: f32, + max_dist: f32, + rolloff: f32, ) -> f64 { self.send_play( - handle, Some([x, y, z]), looping, + handle, + Some([x, y, z]), + looping, ref_dist.max(1e-3), if max_dist > 0.0 { max_dist } else { 1.0e9 }, rolloff.max(0.0), @@ -206,29 +224,43 @@ impl AudioMixer { } pub fn set_voice_position(&mut self, voice: f64, x: f32, y: f32, z: f32) { - self.send(Cmd::SetVoicePosition { voice_id: voice as u64, pos: [x, y, z] }); + self.send(Cmd::SetVoicePosition { + voice_id: voice as u64, + pos: [x, y, z], + }); } /// Fades the voice out over one mix block (~10 ms) and removes it — a /// hard cut mid-waveform on a looping bed is an audible click. pub fn stop_voice(&mut self, voice: f64) { - self.send(Cmd::StopVoice { voice_id: voice as u64 }); + self.send(Cmd::StopVoice { + voice_id: voice as u64, + }); } pub fn set_voice_volume(&mut self, voice: f64, volume: f32) { - self.send(Cmd::SetVoiceVolume { voice_id: voice as u64, volume }); + self.send(Cmd::SetVoiceVolume { + voice_id: voice as u64, + volume, + }); } /// Playback-rate multiplier, clamped 0.25..4. Doppler multiplies on top. pub fn set_voice_pitch(&mut self, voice: f64, pitch: f32) { - self.send(Cmd::SetVoicePitch { voice_id: voice as u64, pitch }); + self.send(Cmd::SetVoicePitch { + voice_id: voice as u64, + pitch, + }); } /// Per-voice occlusion low-pass (Hz; 0 = bypass). Unlike /// [`Self::set_sound_lowpass`] this muffles ONE emitter, not every voice /// sharing the asset. pub fn set_voice_lowpass(&mut self, voice: f64, cutoff: f32) { - self.send(Cmd::SetVoiceLowpass { voice_id: voice as u64, cutoff }); + self.send(Cmd::SetVoiceLowpass { + voice_id: voice as u64, + cutoff, + }); } // ---- EN-029 routing ------------------------------------------------ @@ -246,26 +278,41 @@ impl AudioMixer { /// Assign a sound to a mix bus (see `render::bus`). pub fn set_sound_bus(&mut self, handle: f64, bus: u8) { - let e = self.routes.entry(handle.to_bits()).or_insert((render::bus::SFX, 0.0, 0.0)); + let e = self + .routes + .entry(handle.to_bits()) + .or_insert((render::bus::SFX, 0.0, 0.0)); e.0 = bus; } /// Reverb send for this sound, 0..1. This is what gives a gunshot its tail. pub fn set_sound_reverb_send(&mut self, handle: f64, send: f32) { let send = send.clamp(0.0, 1.0); - let e = self.routes.entry(handle.to_bits()).or_insert((render::bus::SFX, 0.0, 0.0)); + let e = self + .routes + .entry(handle.to_bits()) + .or_insert((render::bus::SFX, 0.0, 0.0)); e.1 = send; // Also steer voices already in flight, so a zone change is audible on // the tail that is sounding right now rather than only the next one. - self.send(Cmd::SetSoundSend { sound_id: handle.to_bits(), send }); + self.send(Cmd::SetSoundSend { + sound_id: handle.to_bits(), + send, + }); } /// Low-pass cutoff in Hz for this sound; 0 = bypass. The occlusion knob. pub fn set_sound_lowpass(&mut self, handle: f64, cutoff: f32) { let cutoff = cutoff.max(0.0); - let e = self.routes.entry(handle.to_bits()).or_insert((render::bus::SFX, 0.0, 0.0)); + let e = self + .routes + .entry(handle.to_bits()) + .or_insert((render::bus::SFX, 0.0, 0.0)); e.2 = cutoff; - self.send(Cmd::SetSoundLowpass { sound_id: handle.to_bits(), cutoff }); + self.send(Cmd::SetSoundLowpass { + sound_id: handle.to_bits(), + cutoff, + }); } pub fn set_bus_gain(&mut self, bus: u8, gain: f32) { @@ -273,7 +320,13 @@ impl AudioMixer { } pub fn duck_bus(&mut self, bus: u8, amount: f32, attack: f32, release: f32, hold: f32) { - self.send(Cmd::DuckBus { bus, amount, attack, release, hold }); + self.send(Cmd::DuckBus { + bus, + amount, + attack, + release, + hold, + }); } pub fn set_reverb(&mut self, size: f32, damp: f32, wet: f32) { @@ -281,19 +334,27 @@ impl AudioMixer { } pub fn stop_sound(&mut self, handle: f64) { - self.send(Cmd::StopSound { sound_id: handle.to_bits() }); + self.send(Cmd::StopSound { + sound_id: handle.to_bits(), + }); } pub fn set_sound_volume(&mut self, handle: f64, volume: f32) { for entry in &mut self.sound_volumes { if entry.0 == handle { entry.1 = volume; - self.send(Cmd::SetSoundVolume { sound_id: handle.to_bits(), volume }); + self.send(Cmd::SetSoundVolume { + sound_id: handle.to_bits(), + volume, + }); return; } } self.sound_volumes.push((handle, volume)); - self.send(Cmd::SetSoundVolume { sound_id: handle.to_bits(), volume }); + self.send(Cmd::SetSoundVolume { + sound_id: handle.to_bits(), + volume, + }); } fn get_sound_volume(&self, handle: f64) -> f32 { @@ -367,7 +428,9 @@ impl AudioMixer { } pub fn play_music(&mut self, handle: f64) { - let Some(m) = self.music.get(handle) else { return }; + let Some(m) = self.music.get(handle) else { + return; + }; // Optimistically flip the flag so is_music_playing is true the // moment play_music returns (the render thread confirms on its // next callback). @@ -375,7 +438,11 @@ impl AudioMixer { let payload = match &m.source { MusicSource::Full(data) => render::MusicPayload::Full(data.clone()), #[cfg(not(target_arch = "wasm32"))] - MusicSource::Streamed { kind, bytes, channels } => render::MusicPayload::Stream { + MusicSource::Streamed { + kind, + bytes, + channels, + } => render::MusicPayload::Stream { consumer: stream::start(*kind, bytes.clone(), m.looping), channels: *channels, }, @@ -394,14 +461,19 @@ impl AudioMixer { if let Some(m) = self.music.get(handle) { m.shared.playing.store(false, Ordering::Relaxed); } - self.send(Cmd::StopMusic { music_id: handle.to_bits() }); + self.send(Cmd::StopMusic { + music_id: handle.to_bits(), + }); } pub fn set_music_volume(&mut self, handle: f64, volume: f32) { if let Some(m) = self.music.get_mut(handle) { m.volume = volume; } - self.send(Cmd::SetMusicVolume { music_id: handle.to_bits(), volume }); + self.send(Cmd::SetMusicVolume { + music_id: handle.to_bits(), + volume, + }); } pub fn is_music_playing(&self, handle: f64) -> bool { @@ -434,7 +506,10 @@ impl AudioMixer { } else { [0.0, 0.0, -1.0] }; - self.send(Cmd::SetListener { pos: [x, y, z], forward }); + self.send(Cmd::SetListener { + pos: [x, y, z], + forward, + }); } /// Inline mixing for single-threaded targets (web) and tests: mixes @@ -458,7 +533,9 @@ mod tests { fn tone(len: usize) -> SoundData { SoundData { - samples: (0..len).map(|i| if i % 2 == 0 { 0.5 } else { -0.5 }).collect(), + samples: (0..len) + .map(|i| if i % 2 == 0 { 0.5 } else { -0.5 }) + .collect(), sample_rate: 44_100, channels: 1, } @@ -495,8 +572,12 @@ mod tests { let mut quiet = [0.0f32; 256]; b.mix_output(&mut quiet); - assert!(peak(&quiet) < peak(&loud) * 0.5, - "SFX bus gain did not attenuate: {} vs {}", peak(&quiet), peak(&loud)); + assert!( + peak(&quiet) < peak(&loud) * 0.5, + "SFX bus gain did not attenuate: {} vs {}", + peak(&quiet), + peak(&loud) + ); // A sound on a *different* bus must be untouched by that gain. let mut c = AudioMixer::new(); @@ -527,8 +608,12 @@ mod tests { a.mix_output(&mut ducked); ducked = [0.0f32; 512]; a.mix_output(&mut ducked); - assert!(peak(&ducked) < dry * 0.5, - "duck had no effect: {} vs {}", peak(&ducked), dry); + assert!( + peak(&ducked) < dry * 0.5, + "duck had no effect: {} vs {}", + peak(&ducked), + dry + ); } #[test] @@ -541,8 +626,11 @@ mod tests { a.play_sound(h); let mut out = [0.0f32; 512]; a.mix_output(&mut out); - assert!(peak(&out) < 0.1, - "low-pass did not attenuate a Nyquist tone: peak {}", peak(&out)); + assert!( + peak(&out) < 0.1, + "low-pass did not attenuate a Nyquist tone: peak {}", + peak(&out) + ); } /// Mix `blocks` × 256-sample blocks, returning the peak seen *after* the @@ -589,7 +677,7 @@ mod tests { fn music_playing_flag_round_trip() { let mut a = AudioMixer::new(); let h = a.load_music(tone(16)); // tiny, non-looping after we set it - // default looping=true → flip via the public surface used by FFI + // default looping=true → flip via the public surface used by FFI a.music.get_mut(h).unwrap().looping = false; assert!(!a.is_music_playing(h)); a.play_music(h); @@ -613,7 +701,10 @@ mod tests { out }); let out = worker.join().unwrap(); - assert!(out.iter().any(|&s| s != 0.0), "handed-off renderer produced no output"); + assert!( + out.iter().any(|&s| s != 0.0), + "handed-off renderer produced no output" + ); // Control side mixing is now inert (no double-mixing race). let mut silent = [1.0f32; 8]; a.mix_output(&mut silent); @@ -635,7 +726,11 @@ mod tests { /// All-0.5 mono signal: peaks read gains directly. fn flat(len: usize) -> SoundData { - SoundData { samples: vec![0.5; len], sample_rate: 44_100, channels: 1 } + SoundData { + samples: vec![0.5; len], + sample_rate: 44_100, + channels: 1, + } } fn sine(freq: f32, len: usize) -> SoundData { @@ -664,7 +759,7 @@ mod tests { fn looping_voice_persists_until_stop_voice() { let mut a = AudioMixer::new(); let h = a.load_sound(flat(64)); // 64 frames — far shorter than a block - // Listener at origin looking down -Z; source dead ahead at 1 m. + // Listener at origin looking down -Z; source dead ahead at 1 m. let v = a.play_sound_3d_ex(h, 0.0, 0.0, -1.0, true, 1.0, 0.0, 1.0); assert!(v > 0.0, "no voice id returned"); let mut out = [0.0f32; 512]; @@ -691,7 +786,12 @@ mod tests { let mut out = [0.0f32; 512]; a.mix_output(&mut out); let (l1, r1) = peak_lr(&out); - assert!(l1 > r1 * 2.0, "left source not left-dominant: L={} R={}", l1, r1); + assert!( + l1 > r1 * 2.0, + "left source not left-dominant: L={} R={}", + l1, + r1 + ); a.set_voice_position(v, 10.0, 0.0, 0.0); // One block to ramp, one to settle. @@ -700,7 +800,12 @@ mod tests { a.mix_output(&mut out); } let (l2, r2) = peak_lr(&out); - assert!(r2 > l2 * 2.0, "moved source not right-dominant: L={} R={}", l2, r2); + assert!( + r2 > l2 * 2.0, + "moved source not right-dominant: L={} R={}", + l2, + r2 + ); } #[test] @@ -718,8 +823,11 @@ mod tests { let near = run(1.0); let far = run(10.0); let ratio = far / near; - assert!((ratio - 0.1).abs() < 0.02, - "distance curve changed: 10m/1m = {} (want ~0.1)", ratio); + assert!( + (ratio - 0.1).abs() < 0.02, + "distance curve changed: 10m/1m = {} (want ~0.1)", + ratio + ); } #[test] @@ -731,8 +839,17 @@ mod tests { a.mix_output(&mut out); let (l, r) = peak_lr(&out); // 0.5 sample × cos(π/4) ≈ 0.3536 per channel (linear pan gave 0.25). - assert!((l - r).abs() < 0.01, "center source unbalanced: L={} R={}", l, r); - assert!(l > 0.32 && l < 0.39, "not equal-power: L={} (want ~0.354)", l); + assert!( + (l - r).abs() < 0.01, + "center source unbalanced: L={} R={}", + l, + r + ); + assert!( + l > 0.32 && l < 0.39, + "not equal-power: L={} (want ~0.354)", + l + ); } #[test] @@ -750,8 +867,12 @@ mod tests { }; let near = run(2.0); let far = run(150.0); - assert!(far < near * 0.7, - "no air absorption: near(norm)={} far(norm)={}", near, far); + assert!( + far < near * 0.7, + "no air absorption: near(norm)={} far(norm)={}", + near, + far + ); } #[test] @@ -766,8 +887,12 @@ mod tests { }; let front = run(-5.0); let behind = run(5.0); - assert!(behind < front * 0.6, - "rear cue missing: front={} behind={}", front, behind); + assert!( + behind < front * 0.6, + "rear cue missing: front={} behind={}", + front, + behind + ); } #[test] @@ -806,12 +931,16 @@ mod tests { } let mut out = [0.0f32; 512]; a.mix_output(&mut out); - if block < 10 { continue; } // let the smoothed rate settle + if block < 10 { + continue; + } // let the smoothed rate settle let mut i = 0; while i < out.len() { let s = out[i]; // left channel if s != 0.0 { - if last != 0.0 && (s > 0.0) != (last > 0.0) { n += 1; } + if last != 0.0 && (s > 0.0) != (last > 0.0) { + n += 1; + } last = s; } i += 2; @@ -821,8 +950,12 @@ mod tests { }; let moving = crossings(true); let still = crossings(false); - assert!(moving as f32 > still as f32 * 1.05, - "no doppler: approaching={} static={}", moving, still); + assert!( + moving as f32 > still as f32 * 1.05, + "no doppler: approaching={} static={}", + moving, + still + ); } #[test] @@ -857,7 +990,11 @@ mod tests { b.play_sound_3d_ex(h2, -2.0, 0.0, -2.0, true, 1.0, 0.0, 1.0); let mut open = [0.0f32; 2048]; b.mix_output(&mut open); - assert!(muffled_peak < peak(&open) * 0.3, - "voice lowpass had no effect: {} vs {}", muffled_peak, peak(&open)); + assert!( + muffled_peak < peak(&open) * 0.3, + "voice lowpass had no effect: {} vs {}", + muffled_peak, + peak(&open) + ); } } diff --git a/native/shared/src/audio/render.rs b/native/shared/src/audio/render.rs index 08e23758..027db171 100644 --- a/native/shared/src/audio/render.rs +++ b/native/shared/src/audio/render.rs @@ -79,8 +79,13 @@ pub enum Cmd { send: f32, lowpass: f32, }, - StopSound { sound_id: u64 }, - SetSoundVolume { sound_id: u64, volume: f32 }, + StopSound { + sound_id: u64, + }, + SetSoundVolume { + sound_id: u64, + volume: f32, + }, PlayMusic { music_id: u64, payload: MusicPayload, @@ -88,32 +93,73 @@ pub enum Cmd { volume: f32, looping: bool, }, - StopMusic { music_id: u64 }, - SetMusicVolume { music_id: u64, volume: f32 }, + StopMusic { + music_id: u64, + }, + SetMusicVolume { + music_id: u64, + volume: f32, + }, SetMaster(f32), - SetListener { pos: [f32; 3], forward: [f32; 3] }, + SetListener { + pos: [f32; 3], + forward: [f32; 3], + }, // ---- EN-029 ------------------------------------------------------- - SetBusGain { bus: u8, gain: f32 }, + SetBusGain { + bus: u8, + gain: f32, + }, /// Momentary sidechain-style duck: pull `bus` down by `amount` (linear, /// 0..1) over `attack` seconds, hold, then release over `release`. - DuckBus { bus: u8, amount: f32, attack: f32, release: f32, hold: f32 }, - SetReverbParams { size: f32, damp: f32, wet: f32 }, + DuckBus { + bus: u8, + amount: f32, + attack: f32, + release: f32, + hold: f32, + }, + SetReverbParams { + size: f32, + damp: f32, + wet: f32, + }, /// Per-playing-voice sends — this is the occlusion primitive. The game /// raycasts and decides; the mixer just filters. - SetSoundLowpass { sound_id: u64, cutoff: f32 }, - SetSoundSend { sound_id: u64, send: f32 }, + SetSoundLowpass { + sound_id: u64, + cutoff: f32, + }, + SetSoundSend { + sound_id: u64, + send: f32, + }, // ---- EN-062: live-voice control ------------------------------------ - SetVoicePosition { voice_id: u64, pos: [f32; 3] }, + SetVoicePosition { + voice_id: u64, + pos: [f32; 3], + }, /// Fades out over ~1 block and removes — a hard cut on a looping bed /// mid-waveform is an audible click. - StopVoice { voice_id: u64 }, - SetVoiceVolume { voice_id: u64, volume: f32 }, - SetVoicePitch { voice_id: u64, pitch: f32 }, + StopVoice { + voice_id: u64, + }, + SetVoiceVolume { + voice_id: u64, + volume: f32, + }, + SetVoicePitch { + voice_id: u64, + pitch: f32, + }, /// Per-VOICE occlusion; SetSoundLowpass muffles every voice of a sound, /// which is wrong the moment two emitters share an asset. - SetVoiceLowpass { voice_id: u64, cutoff: f32 }, + SetVoiceLowpass { + voice_id: u64, + cutoff: f32, + }, } /// Mix buses. Kept tiny and fixed: a general submix graph is a lot of @@ -142,7 +188,14 @@ struct BusState { impl Default for BusState { fn default() -> Self { - Self { gain: 1.0, duck: 0.0, duck_target: 0.0, attack: 0.01, release: 0.3, hold_left: 0.0 } + Self { + gain: 1.0, + duck: 0.0, + duck_target: 0.0, + attack: 0.01, + release: 0.3, + hold_left: 0.0, + } } } @@ -162,7 +215,11 @@ impl BusState { } else { 0.0 }; - let rate = if target > self.duck { self.attack } else { self.release }; + let rate = if target > self.duck { + self.attack + } else { + self.release + }; if rate <= 0.0 { self.duck = target; } else { @@ -173,7 +230,9 @@ impl BusState { } } - fn current(&self) -> f32 { (self.gain * (1.0 - self.duck)).clamp(0.0, 4.0) } + fn current(&self) -> f32 { + (self.gain * (1.0 - self.duck)).clamp(0.0, 4.0) + } } /// Speed of sound, m/s — the doppler constant. @@ -238,11 +297,17 @@ pub enum MusicPayload { /// Chunks arrive from a background decode worker; the worker handles /// looping internally (End only arrives for finished non-loop tracks). #[cfg(not(target_arch = "wasm32"))] - Stream { consumer: StreamConsumer, channels: u16 }, + Stream { + consumer: StreamConsumer, + channels: u16, + }, } enum MusicSamples { - Full { data: Arc, position: usize }, + Full { + data: Arc, + position: usize, + }, #[cfg(not(target_arch = "wasm32"))] Stream { consumer: StreamConsumer, @@ -380,24 +445,41 @@ impl AudioRenderer { } pub fn set_sample_rate(&mut self, sr: f32) { - if sr > 1000.0 { self.sample_rate = sr; } + if sr > 1000.0 { + self.sample_rate = sr; + } } fn apply(&mut self, cmd: Cmd) { match cmd { Cmd::PlaySound { - sound_id, voice_id, data, volume, spatial, looping, - ref_dist, max_dist, rolloff, pitch, bus, send, lowpass, + sound_id, + voice_id, + data, + volume, + spatial, + looping, + ref_dist, + max_dist, + rolloff, + pitch, + bus, + send, + lowpass, } => { if self.voices.len() >= MAX_VOICES { // Voice steal: drop the quietest non-stopping voice (least // audible) so the push below can never reallocate. O(n) over // <= MAX_VOICES, no allocation. `swap_remove` is O(1) and // voice order does not affect the mix. - let victim = self.voices.iter().enumerate() + let victim = self + .voices + .iter() + .enumerate() .filter(|(_, v)| !v.stopping) .min_by(|(_, a), (_, b)| { - a.volume.partial_cmp(&b.volume) + a.volume + .partial_cmp(&b.volume) .unwrap_or(std::cmp::Ordering::Equal) }) .map(|(i, _)| i) @@ -405,7 +487,12 @@ impl AudioRenderer { self.voices.swap_remove(victim); } self.voices.push(Voice { - sound_id, voice_id, data, frame_pos: 0.0, volume, spatial, + sound_id, + voice_id, + data, + frame_pos: 0.0, + volume, + spatial, looping, ref_dist: ref_dist.max(1e-3), max_dist: max_dist.max(0.0), @@ -413,8 +500,14 @@ impl AudioRenderer { pitch: pitch.clamp(0.25, 4.0), doppler: 1.0, prev_dist: -1.0, - g_l: 0.0, g_r: 0.0, seeded: false, stopping: false, - bus, send, lowpass, lp_z: [0.0; 2], + g_l: 0.0, + g_r: 0.0, + seeded: false, + stopping: false, + bus, + send, + lowpass, + lp_z: [0.0; 2], }); } Cmd::StopSound { sound_id } => { @@ -427,7 +520,13 @@ impl AudioRenderer { } } } - Cmd::PlayMusic { music_id, payload, shared, volume, looping } => { + Cmd::PlayMusic { + music_id, + payload, + shared, + volume, + looping, + } => { // Restart-from-zero semantics (matches the old mixer). self.music.retain(|m| m.music_id != music_id); shared.playing.store(true, Ordering::Relaxed); @@ -450,7 +549,14 @@ impl AudioRenderer { let old = self.music.remove(0); old.shared.playing.store(false, Ordering::Relaxed); } - self.music.push(MusicVoice { music_id, samples, shared, volume, looping, consumed: 0 }); + self.music.push(MusicVoice { + music_id, + samples, + shared, + volume, + looping, + consumed: 0, + }); } Cmd::StopMusic { music_id } => { if let Some(m) = self.music.iter().position(|m| m.music_id == music_id) { @@ -476,7 +582,13 @@ impl AudioRenderer { self.buses[bus as usize].gain = gain.max(0.0); } } - Cmd::DuckBus { bus, amount, attack, release, hold } => { + Cmd::DuckBus { + bus, + amount, + attack, + release, + hold, + } => { if (bus as usize) < bus::COUNT { self.buses[bus as usize].set_duck(amount, attack, release, hold); } @@ -490,37 +602,51 @@ impl AudioRenderer { } Cmd::SetSoundLowpass { sound_id, cutoff } => { for v in &mut self.voices { - if v.sound_id == sound_id { v.lowpass = cutoff; } + if v.sound_id == sound_id { + v.lowpass = cutoff; + } } } Cmd::SetSoundSend { sound_id, send } => { for v in &mut self.voices { - if v.sound_id == sound_id { v.send = send.clamp(0.0, 1.0); } + if v.sound_id == sound_id { + v.send = send.clamp(0.0, 1.0); + } } } Cmd::SetVoicePosition { voice_id, pos } => { for v in &mut self.voices { - if v.voice_id == voice_id { v.spatial = Some(pos); } + if v.voice_id == voice_id { + v.spatial = Some(pos); + } } } Cmd::StopVoice { voice_id } => { for v in &mut self.voices { - if v.voice_id == voice_id { v.stopping = true; } + if v.voice_id == voice_id { + v.stopping = true; + } } } Cmd::SetVoiceVolume { voice_id, volume } => { for v in &mut self.voices { - if v.voice_id == voice_id { v.volume = volume.max(0.0); } + if v.voice_id == voice_id { + v.volume = volume.max(0.0); + } } } Cmd::SetVoicePitch { voice_id, pitch } => { for v in &mut self.voices { - if v.voice_id == voice_id { v.pitch = pitch.clamp(0.25, 4.0); } + if v.voice_id == voice_id { + v.pitch = pitch.clamp(0.25, 4.0); + } } } Cmd::SetVoiceLowpass { voice_id, cutoff } => { for v in &mut self.voices { - if v.voice_id == voice_id { v.lowpass = cutoff.max(0.0); } + if v.voice_id == voice_id { + v.lowpass = cutoff.max(0.0); + } } } } @@ -559,16 +685,18 @@ impl AudioRenderer { } let reverb_active = self.reverb_l.wet > 0.0; if reverb_active { - for s in self.send_buf[..output.len()].iter_mut() { *s = 0.0; } + for s in self.send_buf[..output.len()].iter_mut() { + *s = 0.0; + } } // Spatial audio: listener-relative parameters, computed once. let [lx, ly, lz] = self.listener_pos; let [lfx, _lfy, lfz] = self.listener_forward; // "right" math projects out Y - // Listener right = cross(forward, up=[0,1,0]) = (-fz, 0, fx) — the SAME - // vector mat4_look_at calls `s` (screen-right). EN-062 flipped the sign - // here: this used to be (fz, -fx), which is screen-LEFT, so the whole - // stereo field was mirrored — a shriek on screen-left panned right. + // Listener right = cross(forward, up=[0,1,0]) = (-fz, 0, fx) — the SAME + // vector mat4_look_at calls `s` (screen-right). EN-062 flipped the sign + // here: this used to be (fz, -fx), which is screen-LEFT, so the whole + // stereo field was mirrored — a shriek on screen-left panned right. let lrx = -lfz; let lrz = lfx; let lr_len = (lrx * lrx + lrz * lrz).sqrt().max(0.001); @@ -578,13 +706,22 @@ impl AudioRenderer { // Split the borrow: the voice loop needs `voices` and `send_buf` // mutably at once, and the compiler can only see they're disjoint if // we name them separately. - let Self { voices, send_buf, reverb_l, reverb_r, music, .. } = self; + let Self { + voices, + send_buf, + reverb_l, + reverb_r, + music, + .. + } = self; // Sound effects voices.retain_mut(|v| { let channels = v.data.channels.max(1) as usize; let frames = v.data.samples.len() / channels; - if frames == 0 { return false; } + if frames == 0 { + return false; + } // ---- per-block spatial targets -------------------------------- // Manual (occlusion) low-pass; spatial cues can only lower it. @@ -662,7 +799,9 @@ impl AudioRenderer { // Fully silent (culled by distance, or a zero-volume ambient bed): // advance the head arithmetically and skip the per-sample work. if t_l < 1e-5 && t_r < 1e-5 && v.g_l < 1e-5 && v.g_r < 1e-5 { - if v.stopping { return false; } + if v.stopping { + return false; + } v.g_l = t_l; v.g_r = t_r; v.frame_pos += step * out_frames as f64; @@ -694,10 +833,19 @@ impl AudioRenderer { let mut f = 0usize; while f < out_frames { let idx = v.frame_pos as usize; - if idx >= frames { ended = !v.looping; break; } + if idx >= frames { + ended = !v.looping; + break; + } let frac = (v.frame_pos - idx as f64) as f32; let (s0l, s0r) = v.frame(idx); - let nidx = if idx + 1 < frames { idx + 1 } else if v.looping { 0 } else { idx }; + let nidx = if idx + 1 < frames { + idx + 1 + } else if v.looping { + 0 + } else { + idx + }; let (s1l, s1r) = v.frame(nidx); let mut sl = s0l + (s1l - s0l) * frac; let mut sr = s0r + (s1r - s0r) * frac; @@ -713,11 +861,15 @@ impl AudioRenderer { let ol = sl * v.g_l; let or = sr * v.g_r; output[i] += ol; - if i + 1 < output.len() { output[i + 1] += or; } + if i + 1 < output.len() { + output[i + 1] += or; + } if reverb_active && send > 0.0 { send_buf[i] += ol * send; - if i + 1 < output.len() { send_buf[i + 1] += or * send; } + if i + 1 < output.len() { + send_buf[i + 1] += or * send; + } } v.g_l += dg_l; @@ -791,7 +943,13 @@ impl AudioRenderer { m.consumed = *position; } #[cfg(not(target_arch = "wasm32"))] - MusicSamples::Stream { consumer, channels, current, offset, ended } => { + MusicSamples::Stream { + consumer, + channels, + current, + offset, + ended, + } => { let mono = *channels == 1; while i < output.len() { if *offset >= current.len() { @@ -849,15 +1007,28 @@ mod tests { use crate::audio::spsc; fn dummy_sound() -> Arc { - Arc::new(SoundData { samples: vec![0.0; 16], sample_rate: 44_100, channels: 1 }) + Arc::new(SoundData { + samples: vec![0.0; 16], + sample_rate: 44_100, + channels: 1, + }) } fn play(id: u64, volume: f32) -> Cmd { Cmd::PlaySound { - sound_id: id, voice_id: id, data: dummy_sound(), volume, - spatial: None, looping: false, - ref_dist: 1.0, max_dist: 0.0, rolloff: 1.0, pitch: 1.0, - bus: bus::SFX, send: 0.0, lowpass: 0.0, + sound_id: id, + voice_id: id, + data: dummy_sound(), + volume, + spatial: None, + looping: false, + ref_dist: 1.0, + max_dist: 0.0, + rolloff: 1.0, + pitch: 1.0, + bus: bus::SFX, + send: 0.0, + lowpass: 0.0, } } @@ -873,7 +1044,11 @@ mod tests { assert!(r.voices.len() <= MAX_VOICES); } assert_eq!(r.voices.len(), MAX_VOICES); - assert_eq!(r.voices.capacity(), cap0, "voices reallocated on the audio thread"); + assert_eq!( + r.voices.capacity(), + cap0, + "voices reallocated on the audio thread" + ); } // At the cap, a new play steals the QUIETEST voice, not an arbitrary one. @@ -881,12 +1056,16 @@ mod tests { fn voice_steal_drops_the_quietest() { let (_tx, rx) = spsc::channel::(8); let mut r = AudioRenderer::new(rx); - for i in 0..(MAX_VOICES as u64 - 1) { r.apply(play(i, 1.0)); } + for i in 0..(MAX_VOICES as u64 - 1) { + r.apply(play(i, 1.0)); + } r.apply(play(9999, 0.01)); // quiet marker, brings us to the cap assert_eq!(r.voices.len(), MAX_VOICES); r.apply(play(10_000, 1.0)); // must evict the quiet marker - assert!(!r.voices.iter().any(|v| v.sound_id == 9999), - "the quietest voice should have been stolen"); + assert!( + !r.voices.iter().any(|v| v.sound_id == 9999), + "the quietest voice should have been stolen" + ); assert_eq!(r.voices.len(), MAX_VOICES); } } diff --git a/native/shared/src/audio/spsc.rs b/native/shared/src/audio/spsc.rs index 102a9987..090f4524 100644 --- a/native/shared/src/audio/spsc.rs +++ b/native/shared/src/audio/spsc.rs @@ -92,7 +92,8 @@ impl Consumer { return None; // empty } let item = unsafe { (*ring.buf[head].get()).assume_init_read() }; - ring.head.store((head + 1) % ring.buf.len(), Ordering::Release); + ring.head + .store((head + 1) % ring.buf.len(), Ordering::Release); Some(item) } } diff --git a/native/shared/src/decals.rs b/native/shared/src/decals.rs index d119afdc..e5b59b1e 100644 --- a/native/shared/src/decals.rs +++ b/native/shared/src/decals.rs @@ -49,7 +49,12 @@ pub struct DecalStyle { impl Default for DecalStyle { fn default() -> Self { - Self { frame: 0.0, color: [1.0; 4], life: 0.0, fade: 0.0 } + Self { + frame: 0.0, + color: [1.0; 4], + life: 0.0, + fade: 0.0, + } } } @@ -107,9 +112,15 @@ impl DecalManager { life: f32, fade: f32, ) { - if self.capacity == 0 { return; } + if self.capacity == 0 { + return; + } let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); - let nn = if len > 1e-5 { [n[0] / len, n[1] / len, n[2] / len] } else { [0.0, 1.0, 0.0] }; + let nn = if len > 1e-5 { + [n[0] / len, n[1] / len, n[2] / len] + } else { + [0.0, 1.0, 0.0] + }; // Spherical encode. el is the angle off +Y; az is the heading in XZ. let el = nn[1].clamp(-1.0, 1.0).acos(); let az = nn[2].atan2(nn[0]); @@ -123,7 +134,9 @@ impl DecalManager { pos[1] + nn[1] * 0.002, pos[2] + nn[2] * 0.002, ], - az, el, roll, + az, + el, + roll, size, color, frame, @@ -149,7 +162,9 @@ impl DecalManager { self.decals.swap_remove(i); // The ring cursor indexes into a Vec that just shrank; clamp it // or the next spawn writes out of bounds. - if self.next >= self.decals.len().max(1) { self.next = 0; } + if self.next >= self.decals.len().max(1) { + self.next = 0; + } continue; } i += 1; @@ -160,26 +175,30 @@ impl DecalManager { let alpha = if d.fade > 0.0 && d.life != f32::MAX { let remaining = d.life - d.age; (remaining / d.fade).clamp(0.0, 1.0) - } else { 1.0 }; + } else { + 1.0 + }; let o = i * 12; - self.packed[o] = d.pos[0]; - self.packed[o + 1] = d.pos[1]; - self.packed[o + 2] = d.pos[2]; - self.packed[o + 3] = d.roll; - self.packed[o + 4] = d.size; - self.packed[o + 5] = d.color[0]; - self.packed[o + 6] = d.color[1]; - self.packed[o + 7] = d.color[2]; - self.packed[o + 8] = d.color[3] * alpha; - self.packed[o + 9] = d.frame; // extra.x - self.packed[o + 10] = d.az; // extra.y - self.packed[o + 11] = d.el; // extra.z + self.packed[o] = d.pos[0]; + self.packed[o + 1] = d.pos[1]; + self.packed[o + 2] = d.pos[2]; + self.packed[o + 3] = d.roll; + self.packed[o + 4] = d.size; + self.packed[o + 5] = d.color[0]; + self.packed[o + 6] = d.color[1]; + self.packed[o + 7] = d.color[2]; + self.packed[o + 8] = d.color[3] * alpha; + self.packed[o + 9] = d.frame; // extra.x + self.packed[o + 10] = d.az; // extra.y + self.packed[o + 11] = d.el; // extra.z } self.live = self.decals.len() as u32; self.live } - pub fn packed(&self) -> &[f32] { &self.packed } + pub fn packed(&self) -> &[f32] { + &self.packed + } pub fn clear(&mut self) { self.decals.clear(); @@ -189,7 +208,9 @@ impl DecalManager { } impl Default for DecalManager { - fn default() -> Self { Self::new() } + fn default() -> Self { + Self::new() + } } #[cfg(test)] @@ -201,7 +222,16 @@ mod tests { let mut m = DecalManager::new(); m.init(4, 1); for i in 0..10 { - m.spawn([i as f32, 0.0, 0.0], [0.0, 1.0, 0.0], 0.2, 0.0, 0.0, [1.0; 4], 0.0, 0.0); + m.spawn( + [i as f32, 0.0, 0.0], + [0.0, 1.0, 0.0], + 0.2, + 0.0, + 0.0, + [1.0; 4], + 0.0, + 0.0, + ); } assert_eq!(m.update(0.016), 4); } @@ -220,7 +250,9 @@ mod tests { let mut m = DecalManager::new(); m.init(8, 1); m.spawn([0.0; 3], [0.0, 1.0, 0.0], 0.2, 0.0, 0.0, [1.0; 4], 0.0, 0.0); - for _ in 0..100 { m.update(1.0); } + for _ in 0..100 { + m.update(1.0); + } assert_eq!(m.live, 1); } diff --git a/native/shared/src/drs.rs b/native/shared/src/drs.rs index 20844432..097da18c 100644 --- a/native/shared/src/drs.rs +++ b/native/shared/src/drs.rs @@ -106,11 +106,15 @@ impl DrsController { } } - pub fn current_scale(&self) -> f32 { STEPS[self.idx] } + pub fn current_scale(&self) -> f32 { + STEPS[self.idx] + } } impl Default for DrsController { - fn default() -> Self { Self::new() } + fn default() -> Self { + Self::new() + } } fn closest_step_idx(scale: f32) -> usize { @@ -139,7 +143,7 @@ mod tests { assert_eq!(closest_step_idx(0.70), 2); // closer to 0.67 (Δ0.03) than 0.75 (Δ0.05) assert_eq!(closest_step_idx(1.00), 5); assert_eq!(closest_step_idx(2.00), 5); // clamped at top - assert_eq!(closest_step_idx(0.0), 0); // clamped at bottom + assert_eq!(closest_step_idx(0.0), 0); // clamped at bottom } #[test] @@ -183,13 +187,21 @@ mod tests { let mut cooldown = 0u32; let advance = |ms: f32, ema: &mut f32, idx: &mut usize, cooldown: &mut u32| { - if *ema == 0.0 { *ema = ms; } else { *ema += EMA_ALPHA * (ms - *ema); } + if *ema == 0.0 { + *ema = ms; + } else { + *ema += EMA_ALPHA * (ms - *ema); + } *cooldown = cooldown.saturating_add(1); - if *cooldown < COOLDOWN_FRAMES { return; } + if *cooldown < COOLDOWN_FRAMES { + return; + } if *ema > target * HYSTERESIS_DOWN && *idx > 0 { - *idx -= 1; *cooldown = 0; + *idx -= 1; + *cooldown = 0; } else if *ema < target * HYSTERESIS_UP && *idx + 1 < STEPS.len() { - *idx += 1; *cooldown = 0; + *idx += 1; + *cooldown = 0; } }; diff --git a/native/shared/src/engine.rs b/native/shared/src/engine.rs index e72a6921..0608e3b2 100644 --- a/native/shared/src/engine.rs +++ b/native/shared/src/engine.rs @@ -1,22 +1,22 @@ use crate::audio::AudioMixer; +use crate::drs::DrsController; +use crate::frame_callbacks::FrameCallbackSystem; use crate::input::InputState; -use crate::renderer::Renderer; -use crate::text_renderer::TextRenderer; -use crate::textures::TextureManager; #[cfg(feature = "models3d")] use crate::models::ModelManager; -use crate::scene::SceneGraph; -use crate::frame_callbacks::FrameCallbackSystem; -use crate::postfx::PostFxPipeline; -use crate::profiler::Profiler; -use crate::drs::DrsController; #[cfg(all(feature = "jolt", not(target_arch = "wasm32")))] use crate::physics_jolt::JoltPhysics; +use crate::postfx::PostFxPipeline; +use crate::profiler::Profiler; +use crate::renderer::Renderer; +use crate::scene::SceneGraph; +use crate::text_renderer::TextRenderer; +use crate::textures::TextureManager; -#[cfg(feature = "web")] -use web_time::Instant; #[cfg(not(feature = "web"))] use std::time::Instant; +#[cfg(feature = "web")] +use web_time::Instant; pub struct EngineState { pub renderer: Renderer, @@ -180,20 +180,12 @@ impl EngineState { // draw; the shooter's 267 of them paid this every frame for // nothing). The Hi-Z pyramid itself stays: SSAO consumes it. self.renderer.occlusion.set_has_consumers( - self.scene.nodes.iter().any(|(_, n)| n.visible && !n.gi_only)); - self.scene.prepare( - &self.renderer.device, - &self.renderer.queue, - &self.renderer.vp_matrix(), - // EN-022 fix: the velocity reference (prev unjittered VP - // + current jitter) — NOT the raw jittered prev VP — so - // static scene nodes get true zero velocity instead of - // jitter-delta noise that flickers TAA history. - &self.renderer.velocity_ref_vp, - self.renderer.uniform_3d_layout(), - Some(&self.renderer.occlusion), + self.scene + .nodes + .iter() + .any(|(_, n)| n.visible && !n.gi_only), ); - self.scene.prepare_materials(&self.renderer); + self.renderer.prepare_scene_graph(&mut self.scene, true); self.profiler.end("scene_prepare"); // Phase 6 — drain hot-reload events and rebuild any @@ -206,12 +198,13 @@ impl EngineState { // material draws. Without this, group 1 (view/proj/camera/ // lights/shadow) is zero and shaders that read e.g. // `view.view_proj` produce offscreen geometry. - let t = self.get_time() as f32; + let t = self.get_time() as f32; let dt = self.delta_time as f32; self.renderer.material_system_begin_frame(t, dt); self.profiler.begin("render_total"); - self.renderer.end_frame_with_scene(&mut self.scene, &mut self.profiler); + self.renderer + .end_frame_with_scene(&mut self.scene, &mut self.profiler); self.profiler.end("render_total"); } @@ -232,8 +225,16 @@ impl EngineState { } } - pub fn get_fps(&self) -> f64 { self.current_fps } - pub fn get_time(&self) -> f64 { self.start_time.elapsed().as_secs_f64() } - pub fn screen_width(&self) -> f64 { self.renderer.width() as f64 } - pub fn screen_height(&self) -> f64 { self.renderer.height() as f64 } + pub fn get_fps(&self) -> f64 { + self.current_fps + } + pub fn get_time(&self) -> f64 { + self.start_time.elapsed().as_secs_f64() + } + pub fn screen_width(&self) -> f64 { + self.renderer.width() as f64 + } + pub fn screen_height(&self) -> f64 { + self.renderer.height() as f64 + } } diff --git a/native/shared/src/ffi.rs b/native/shared/src/ffi.rs index 1163e03b..097f8b89 100644 --- a/native/shared/src/ffi.rs +++ b/native/shared/src/ffi.rs @@ -62,6 +62,18 @@ pub fn guard(name: &'static str, f: impl FnOnce() -> T) -> T { } } +/// Run a mutating FFI call and return Perry's numeric boolean status. +/// +/// Successful execution returns `1.0`; a caught panic returns the existing +/// `f64` default (`0.0`). Platform stubs use the same return type and report +/// `0.0`, so public setters never need a second capability query. +pub fn guard_applied(name: &'static str, f: impl FnOnce()) -> f64 { + guard(name, || { + f(); + 1.0 + }) +} + /// Log `make_msg()` the first time `key` is seen, silently no-op after. fn warn_once(key: &'static str, make_msg: impl FnOnce() -> String) { use std::sync::Mutex; @@ -88,6 +100,20 @@ pub fn feature_off_warn_once(name: &'static str, feature: &'static str) { }); } +#[cfg(test)] +mod tests { + use super::guard_applied; + + #[test] + fn applied_status_distinguishes_success_from_caught_failure() { + assert_eq!(guard_applied("ffi_status_success_test", || {}), 1.0); + assert_eq!( + guard_applied("ffi_status_failure_test", || panic!("expected test panic")), + 0.0 + ); + } +} + /// Platform-appropriate error log: logcat on Android, stderr elsewhere. pub fn log_error(msg: &str) { #[cfg(target_os = "android")] diff --git a/native/shared/src/ffi_core/assets.rs b/native/shared/src/ffi_core/assets.rs index 1c292afe..daead06c 100644 --- a/native/shared/src/ffi_core/assets.rs +++ b/native/shared/src/ffi_core/assets.rs @@ -9,7 +9,6 @@ #[macro_export] macro_rules! __bloom_ffi_assets { () => { - // bloom_take_screenshot [source: macos] #[no_mangle] pub extern "C" fn bloom_take_screenshot(path_ptr: *const u8) { @@ -19,7 +18,63 @@ macro_rules! __bloom_ffi_assets { let eng = engine(); eng.renderer.screenshot_requested = true; eng.renderer.pending_screenshot_path = Some(path); - }) + }) + } + + // Status-returning capture entry point for deterministic tooling. + // This distinct ABI avoids Perry's legacy void-import lowering for + // bloom_take_screenshot while preserving that public API unchanged. + #[no_mangle] + pub extern "C" fn bloom_capture_frame_to_png(path_ptr: *const u8) -> f64 { + $crate::ffi::guard("bloom_capture_frame_to_png", move || { + let path = match $crate::string_header::try_str_from_header(path_ptr) { + Some(path) if !path.is_empty() => path.to_string(), + _ => return 0.0, + }; + eprintln!("bloom: deterministic capture requested -> '{}'", path); + let eng = engine(); + eng.renderer.screenshot_requested = true; + eng.renderer.pending_screenshot_path = Some(path); + // Native fallback for Perry builds that lose the sibling + // debug-capture call while lowering a class method. The + // qualification runner sets this variable explicitly. + if eng.renderer.pending_quality_capture_dir.is_none() { + if let Ok(directory) = std::env::var("BLOOM_QUALITY_INTERMEDIATES") { + if !directory.is_empty() { + eng.renderer.pending_quality_capture_dir = Some(directory); + } + } + } + 1.0 + }) + } + + /// Queue the standard named render-graph diagnostics alongside the + /// next frame capture. This path is qualification-only and executes + /// after the measured window. + #[no_mangle] + pub extern "C" fn bloom_capture_debug_intermediates(path_ptr: *const u8) -> f64 { + $crate::ffi::guard("bloom_capture_debug_intermediates", move || { + let path = match $crate::string_header::try_str_from_header(path_ptr) { + Some(path) if !path.is_empty() => path.to_string(), + _ => return 0.0, + }; + engine().renderer.pending_quality_capture_dir = Some(path); + 1.0 + }) + } + + #[no_mangle] + pub extern "C" fn bloom_capture_frame_ready() -> f64 { + let renderer = &engine().renderer; + if renderer.screenshot_requested + || renderer.pending_screenshot_path.is_some() + || renderer.pending_quality_capture_dir.is_some() + { + 0.0 + } else { + 1.0 + } } // bloom_load_font [source: macos] @@ -32,7 +87,7 @@ macro_rules! __bloom_ffi_assets { Ok(data) => engine().text.load_font(&data) as f64, Err(_) => 0.0, } - }) + }) } // bloom_unload_font [source: macos] @@ -40,7 +95,7 @@ macro_rules! __bloom_ffi_assets { pub extern "C" fn bloom_unload_font(font_handle: f64) { $crate::ffi::guard("bloom_unload_font", move || { engine().text.unload_font(font_handle as usize); - }) + }) } // bloom_load_sound [source: curated] @@ -56,7 +111,7 @@ macro_rules! __bloom_ffi_assets { }, Err(_) => 0.0, } - }) + }) } // bloom_load_texture [source: curated] @@ -68,12 +123,16 @@ macro_rules! __bloom_ffi_assets { match std::fs::read(path) { Ok(data) => { let eng = engine(); - let $crate::engine::EngineState { ref mut textures, ref mut renderer, .. } = *eng; + let $crate::engine::EngineState { + ref mut textures, + ref mut renderer, + .. + } = *eng; textures.load_texture(renderer, &data) } Err(_) => 0.0, } - }) + }) } // bloom_unload_texture [source: curated] @@ -81,9 +140,13 @@ macro_rules! __bloom_ffi_assets { pub extern "C" fn bloom_unload_texture(handle: f64) { $crate::ffi::guard("bloom_unload_texture", move || { let eng = engine(); - let $crate::engine::EngineState { ref mut textures, ref mut renderer, .. } = *eng; + let $crate::engine::EngineState { + ref mut textures, + ref mut renderer, + .. + } = *eng; textures.unload_texture(handle, renderer); - }) + }) } // bloom_get_texture_width [source: macos] @@ -91,8 +154,11 @@ macro_rules! __bloom_ffi_assets { pub extern "C" fn bloom_get_texture_width(handle: f64) -> f64 { $crate::ffi::guard("bloom_get_texture_width", move || { let eng = engine(); - eng.textures.get(handle).map(|t| t.width as f64).unwrap_or(0.0) - }) + eng.textures + .get(handle) + .map(|t| t.width as f64) + .unwrap_or(0.0) + }) } // bloom_get_texture_height [source: macos] @@ -100,8 +166,11 @@ macro_rules! __bloom_ffi_assets { pub extern "C" fn bloom_get_texture_height(handle: f64) -> f64 { $crate::ffi::guard("bloom_get_texture_height", move || { let eng = engine(); - eng.textures.get(handle).map(|t| t.height as f64).unwrap_or(0.0) - }) + eng.textures + .get(handle) + .map(|t| t.height as f64) + .unwrap_or(0.0) + }) } // bloom_load_image [source: macos] @@ -114,7 +183,7 @@ macro_rules! __bloom_ffi_assets { Ok(data) => engine().textures.load_image(&data), Err(_) => 0.0, } - }) + }) } // bloom_image_resize [source: macos] @@ -122,15 +191,17 @@ macro_rules! __bloom_ffi_assets { pub extern "C" fn bloom_image_resize(handle: f64, w: f64, h: f64) { $crate::ffi::guard("bloom_image_resize", move || { engine().textures.image_resize(handle, w as u32, h as u32); - }) + }) } // bloom_image_crop [source: macos] #[no_mangle] pub extern "C" fn bloom_image_crop(handle: f64, x: f64, y: f64, w: f64, h: f64) { $crate::ffi::guard("bloom_image_crop", move || { - engine().textures.image_crop(handle, x as u32, y as u32, w as u32, h as u32); - }) + engine() + .textures + .image_crop(handle, x as u32, y as u32, w as u32, h as u32); + }) } // bloom_image_flip_h [source: macos] @@ -138,7 +209,7 @@ macro_rules! __bloom_ffi_assets { pub extern "C" fn bloom_image_flip_h(handle: f64) { $crate::ffi::guard("bloom_image_flip_h", move || { engine().textures.image_flip_h(handle); - }) + }) } // bloom_image_flip_v [source: macos] @@ -146,7 +217,7 @@ macro_rules! __bloom_ffi_assets { pub extern "C" fn bloom_image_flip_v(handle: f64) { $crate::ffi::guard("bloom_image_flip_v", move || { engine().textures.image_flip_v(handle); - }) + }) } // bloom_load_texture_from_image [source: curated] @@ -154,9 +225,13 @@ macro_rules! __bloom_ffi_assets { pub extern "C" fn bloom_load_texture_from_image(handle: f64) -> f64 { $crate::ffi::guard("bloom_load_texture_from_image", move || { let eng = engine(); - let $crate::engine::EngineState { ref mut textures, ref mut renderer, .. } = *eng; + let $crate::engine::EngineState { + ref mut textures, + ref mut renderer, + .. + } = *eng; textures.load_texture_from_image(handle, renderer) - }) + }) } // bloom_gen_texture_mipmaps [source: macos] @@ -165,7 +240,7 @@ macro_rules! __bloom_ffi_assets { $crate::ffi::guard("bloom_gen_texture_mipmaps", move || { // Mipmap generation is handled by the GPU texture creation pipeline // This is a no-op for now as wgpu handles mipmaps internally - }) + }) } // bloom_load_shader [source: macos] @@ -174,7 +249,7 @@ macro_rules! __bloom_ffi_assets { $crate::ffi::guard("bloom_load_shader", move || { let source = $crate::string_header::str_from_header(source_ptr); engine().renderer.load_custom_shader(source) as f64 - }) + }) } // bloom_load_music [source: curated] @@ -189,7 +264,7 @@ macro_rules! __bloom_ffi_assets { Ok(data) => engine().audio.load_music_bytes(path, data), Err(_) => 0.0, } - }) + }) } // bloom_write_file [source: macos] @@ -210,7 +285,7 @@ macro_rules! __bloom_ffi_assets { Ok(_) => 1.0, Err(_) => 0.0, } - }) + }) } // bloom_launch_process [EN-048] @@ -228,11 +303,15 @@ macro_rules! __bloom_ffi_assets { // there is no shell here, which is also why there is nothing to inject into. #[no_mangle] pub extern "C" fn bloom_launch_process( - cmd_ptr: *const u8, args_ptr: *const u8, cwd_ptr: *const u8, + cmd_ptr: *const u8, + args_ptr: *const u8, + cwd_ptr: *const u8, ) -> f64 { $crate::ffi::guard("bloom_launch_process", move || { let cmd = $crate::string_header::str_from_header(cmd_ptr); - if cmd.is_empty() { return 0.0; } + if cmd.is_empty() { + return 0.0; + } let args = $crate::string_header::str_from_header(args_ptr); let cwd = $crate::string_header::str_from_header(cwd_ptr); @@ -243,7 +322,9 @@ macro_rules! __bloom_ffi_assets { // parent's context. So launching "main.exe" with cwd "" // fails with "program not found" even though main.exe is sitting // right there in . Which is exactly what it did. - let bare = !cmd.chars().any(|ch| ch == '/' || ch == std::path::MAIN_SEPARATOR); + let bare = !cmd + .chars() + .any(|ch| ch == '/' || ch == std::path::MAIN_SEPARATOR); let resolved: std::path::PathBuf = if !cwd.is_empty() && bare { std::path::Path::new(cwd).join(cmd) } else { @@ -251,19 +332,40 @@ macro_rules! __bloom_ffi_assets { }; let mut c = std::process::Command::new(&resolved); for a in args.split('\n') { - if !a.is_empty() { c.arg(a); } + if !a.is_empty() { + c.arg(a); + } + } + if !cwd.is_empty() { + c.current_dir(cwd); } - if !cwd.is_empty() { c.current_dir(cwd); } // Detach: we never wait on it, and we do not want its output in ours. c.stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); let r = c.spawn(); match r { Ok(child) => child.id() as f64, Err(_) => 0.0, } - }) + }) + } + + // Perry native executables do not expose Node's `process` global. + // Keep argv access in the engine ABI so command-line tools work on + // every native host without runtime-specific globals. + #[no_mangle] + pub extern "C" fn bloom_command_line_arg_count() -> f64 { + std::env::args_os().count() as f64 + } + + #[no_mangle] + pub extern "C" fn bloom_command_line_arg(index: f64) -> *const u8 { + let value = std::env::args_os() + .nth(index.max(0.0) as usize) + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_default(); + $crate::string_header::alloc_perry_string(&value) } // bloom_file_exists [source: macos] @@ -272,8 +374,12 @@ macro_rules! __bloom_ffi_assets { $crate::ffi::guard("bloom_file_exists", move || { let path = $crate::string_header::str_from_header(path_ptr); let path: &str = &bloom_resolve_asset_path(path); - if std::path::Path::new(path).exists() { 1.0 } else { 0.0 } - }) + if std::path::Path::new(path).exists() { + 1.0 + } else { + 0.0 + } + }) } // bloom_read_file [source: curated] @@ -286,7 +392,7 @@ macro_rules! __bloom_ffi_assets { Ok(contents) => $crate::string_header::alloc_perry_string(&contents), Err(_) => $crate::string_header::alloc_perry_string(""), } - }) + }) } // bloom_load_render_texture [source: macos] @@ -303,12 +409,15 @@ macro_rules! __bloom_ffi_assets { // Register as a texture handle so drawTexture can sample it. let tex_handle = eng.textures.textures.alloc($crate::textures::TextureData { - bind_group_idx, width: w, height: h, + bind_group_idx, + width: w, + height: h, }); - eng.textures.set_render_texture_handle(rt_handle, tex_handle); + eng.textures + .set_render_texture_handle(rt_handle, tex_handle); rt_handle - }) + }) } // bloom_unload_render_texture [source: macos] @@ -316,7 +425,7 @@ macro_rules! __bloom_ffi_assets { pub extern "C" fn bloom_unload_render_texture(handle: f64) { $crate::ffi::guard("bloom_unload_render_texture", move || { engine().textures.unload_render_texture(handle); - }) + }) } // bloom_stage_texture [source: macos] @@ -329,7 +438,7 @@ macro_rules! __bloom_ffi_assets { Ok(data) => $crate::staging::decode_and_stage_texture(&data), Err(_) => 0.0, } - }) + }) } // bloom_stage_sound [source: curated] @@ -338,12 +447,15 @@ macro_rules! __bloom_ffi_assets { $crate::ffi::guard("bloom_stage_sound", move || { let path = $crate::string_header::str_from_header(path_ptr); let path: &str = &bloom_resolve_asset_path(path); - let data = match std::fs::read(path) { Ok(d) => d, Err(_) => return 0.0 }; + let data = match std::fs::read(path) { + Ok(d) => d, + Err(_) => return 0.0, + }; match $crate::audio::decode_audio(path, &data) { Some(s) => $crate::staging::stage_sound(s), None => 0.0, } - }) + }) } // bloom_commit_texture [source: macos] @@ -355,34 +467,43 @@ macro_rules! __bloom_ffi_assets { None => return 0.0, }; let eng = engine(); - let bind_group_idx = eng.renderer.register_texture(staged.width, staged.height, &staged.data); + let bind_group_idx = eng.renderer.register_texture_kind_with_alpha_coverage( + staged.width, + staged.height, + &staged.data, + staged.is_normal, + staged.alpha_coverage_reference, + ); eng.textures.textures.alloc($crate::textures::TextureData { - bind_group_idx, width: staged.width, height: staged.height, + bind_group_idx, + width: staged.width, + height: staged.height, }) - }) + }) } // bloom_commit_sound [source: macos] #[no_mangle] pub extern "C" fn bloom_commit_sound(staging_handle: f64) -> f64 { - $crate::ffi::guard("bloom_commit_sound", move || { - match $crate::staging::take_sound(staging_handle) { + $crate::ffi::guard( + "bloom_commit_sound", + move || match $crate::staging::take_sound(staging_handle) { Some(sd) => engine().audio.load_sound(sd), None => 0.0, - } - }) + }, + ) } // bloom_commit_music [source: macos] #[no_mangle] pub extern "C" fn bloom_commit_music(staging_handle: f64) -> f64 { - $crate::ffi::guard("bloom_commit_music", move || { - match $crate::staging::take_sound(staging_handle) { + $crate::ffi::guard( + "bloom_commit_music", + move || match $crate::staging::take_sound(staging_handle) { Some(sd) => engine().audio.load_music(sd), None => 0.0, - } - }) + }, + ) } - }; } diff --git a/native/shared/src/ffi_core/audio_ffi.rs b/native/shared/src/ffi_core/audio_ffi.rs index d9db48ec..c805b6d2 100644 --- a/native/shared/src/ffi_core/audio_ffi.rs +++ b/native/shared/src/ffi_core/audio_ffi.rs @@ -9,13 +9,12 @@ #[macro_export] macro_rules! __bloom_ffi_audio_ffi { () => { - // bloom_play_sound [source: macos] #[no_mangle] pub extern "C" fn bloom_play_sound(handle: f64) { $crate::ffi::guard("bloom_play_sound", move || { engine().audio.play_sound(handle); - }) + }) } // bloom_stop_sound [source: macos] @@ -23,15 +22,17 @@ macro_rules! __bloom_ffi_audio_ffi { pub extern "C" fn bloom_stop_sound(handle: f64) { $crate::ffi::guard("bloom_stop_sound", move || { engine().audio.stop_sound(handle); - }) + }) } // bloom_play_sound_3d [source: macos] #[no_mangle] pub extern "C" fn bloom_play_sound_3d(handle: f64, x: f64, y: f64, z: f64) { $crate::ffi::guard("bloom_play_sound_3d", move || { - engine().audio.play_sound_3d(handle, x as f32, y as f32, z as f32); - }) + engine() + .audio + .play_sound_3d(handle, x as f32, y as f32, z as f32); + }) } // bloom_play_music [source: macos] @@ -39,7 +40,7 @@ macro_rules! __bloom_ffi_audio_ffi { pub extern "C" fn bloom_play_music(handle: f64) { $crate::ffi::guard("bloom_play_music", move || { engine().audio.play_music(handle); - }) + }) } // bloom_stop_music [source: macos] @@ -47,15 +48,19 @@ macro_rules! __bloom_ffi_audio_ffi { pub extern "C" fn bloom_stop_music(handle: f64) { $crate::ffi::guard("bloom_stop_music", move || { engine().audio.stop_music(handle); - }) + }) } // bloom_is_music_playing [source: macos] #[no_mangle] pub extern "C" fn bloom_is_music_playing(handle: f64) -> f64 { $crate::ffi::guard("bloom_is_music_playing", move || { - if engine().audio.is_music_playing(handle) { 1.0 } else { 0.0 } - }) + if engine().audio.is_music_playing(handle) { + 1.0 + } else { + 0.0 + } + }) } // ---- EN-029: buses, reverb send, occlusion low-pass ------------- @@ -70,7 +75,7 @@ macro_rules! __bloom_ffi_audio_ffi { pub extern "C" fn bloom_set_sound_bus(handle: f64, bus: f64) { $crate::ffi::guard("bloom_set_sound_bus", move || { engine().audio.set_sound_bus(handle, bus as u8); - }) + }) } // bloom_set_sound_reverb_send — 0..1. @@ -78,7 +83,7 @@ macro_rules! __bloom_ffi_audio_ffi { pub extern "C" fn bloom_set_sound_reverb_send(handle: f64, send: f64) { $crate::ffi::guard("bloom_set_sound_reverb_send", move || { engine().audio.set_sound_reverb_send(handle, send as f32); - }) + }) } // bloom_set_sound_lowpass — cutoff Hz; 0 = bypass. @@ -86,7 +91,7 @@ macro_rules! __bloom_ffi_audio_ffi { pub extern "C" fn bloom_set_sound_lowpass(handle: f64, cutoff: f64) { $crate::ffi::guard("bloom_set_sound_lowpass", move || { engine().audio.set_sound_lowpass(handle, cutoff as f32); - }) + }) } // bloom_set_bus_gain @@ -94,16 +99,27 @@ macro_rules! __bloom_ffi_audio_ffi { pub extern "C" fn bloom_set_bus_gain(bus: f64, gain: f64) { $crate::ffi::guard("bloom_set_bus_gain", move || { engine().audio.set_bus_gain(bus as u8, gain as f32); - }) + }) } // bloom_duck_bus — momentary attenuation with attack/hold/release. #[no_mangle] - pub extern "C" fn bloom_duck_bus(bus: f64, amount: f64, attack: f64, release: f64, hold: f64) { + pub extern "C" fn bloom_duck_bus( + bus: f64, + amount: f64, + attack: f64, + release: f64, + hold: f64, + ) { $crate::ffi::guard("bloom_duck_bus", move || { engine().audio.duck_bus( - bus as u8, amount as f32, attack as f32, release as f32, hold as f32); - }) + bus as u8, + amount as f32, + attack as f32, + release as f32, + hold as f32, + ); + }) } // bloom_set_reverb — size / damp / wet, all 0..1. wet = 0 bypasses the @@ -111,8 +127,10 @@ macro_rules! __bloom_ffi_audio_ffi { #[no_mangle] pub extern "C" fn bloom_set_reverb(size: f64, damp: f64, wet: f64) { $crate::ffi::guard("bloom_set_reverb", move || { - engine().audio.set_reverb(size as f32, damp as f32, wet as f32); - }) + engine() + .audio + .set_reverb(size as f32, damp as f32, wet as f32); + }) } // ---- EN-062: live spatial voices --------------------------------- @@ -129,14 +147,27 @@ macro_rules! __bloom_ffi_audio_ffi { // max_dist = cull range (<= 0 = never cull). #[no_mangle] pub extern "C" fn bloom_play_sound_3d_ex( - handle: f64, x: f64, y: f64, z: f64, - looping: f64, ref_dist: f64, max_dist: f64, rolloff: f64, + handle: f64, + x: f64, + y: f64, + z: f64, + looping: f64, + ref_dist: f64, + max_dist: f64, + rolloff: f64, ) -> f64 { $crate::ffi::guard("bloom_play_sound_3d_ex", move || { engine().audio.play_sound_3d_ex( - handle, x as f32, y as f32, z as f32, - looping != 0.0, ref_dist as f32, max_dist as f32, rolloff as f32) - }) + handle, + x as f32, + y as f32, + z as f32, + looping != 0.0, + ref_dist as f32, + max_dist as f32, + rolloff as f32, + ) + }) } // bloom_voice_set_position — move a live voice (doppler falls out of @@ -144,8 +175,10 @@ macro_rules! __bloom_ffi_audio_ffi { #[no_mangle] pub extern "C" fn bloom_voice_set_position(voice: f64, x: f64, y: f64, z: f64) { $crate::ffi::guard("bloom_voice_set_position", move || { - engine().audio.set_voice_position(voice, x as f32, y as f32, z as f32); - }) + engine() + .audio + .set_voice_position(voice, x as f32, y as f32, z as f32); + }) } // bloom_voice_stop — click-free: fades over one mix block, then drops. @@ -153,7 +186,7 @@ macro_rules! __bloom_ffi_audio_ffi { pub extern "C" fn bloom_voice_stop(voice: f64) { $crate::ffi::guard("bloom_voice_stop", move || { engine().audio.stop_voice(voice); - }) + }) } // bloom_voice_set_volume @@ -161,7 +194,7 @@ macro_rules! __bloom_ffi_audio_ffi { pub extern "C" fn bloom_voice_set_volume(voice: f64, volume: f64) { $crate::ffi::guard("bloom_voice_set_volume", move || { engine().audio.set_voice_volume(voice, volume as f32); - }) + }) } // bloom_voice_set_pitch — 0.25..4 playback-rate multiplier. @@ -169,7 +202,7 @@ macro_rules! __bloom_ffi_audio_ffi { pub extern "C" fn bloom_voice_set_pitch(voice: f64, pitch: f64) { $crate::ffi::guard("bloom_voice_set_pitch", move || { engine().audio.set_voice_pitch(voice, pitch as f32); - }) + }) } // bloom_voice_set_lowpass — per-VOICE occlusion (Hz; 0 = bypass). @@ -177,8 +210,7 @@ macro_rules! __bloom_ffi_audio_ffi { pub extern "C" fn bloom_voice_set_lowpass(voice: f64, cutoff: f64) { $crate::ffi::guard("bloom_voice_set_lowpass", move || { engine().audio.set_voice_lowpass(voice, cutoff as f32); - }) + }) } - }; } diff --git a/native/shared/src/ffi_core/draw.rs b/native/shared/src/ffi_core/draw.rs index 78bec7fb..6d462a85 100644 --- a/native/shared/src/ffi_core/draw.rs +++ b/native/shared/src/ffi_core/draw.rs @@ -9,82 +9,167 @@ #[macro_export] macro_rules! __bloom_ffi_draw { () => { - // bloom_clear_background [source: macos] #[no_mangle] pub extern "C" fn bloom_clear_background(r: f64, g: f64, b: f64, a: f64) { $crate::ffi::guard("bloom_clear_background", move || { engine().renderer.set_clear_color(r, g, b, a); - }) + }) } // bloom_draw_line [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_line(x1: f64, y1: f64, x2: f64, y2: f64, thickness: f64, r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_draw_line( + x1: f64, + y1: f64, + x2: f64, + y2: f64, + thickness: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { $crate::ffi::guard("bloom_draw_line", move || { - engine().renderer.draw_line(x1, y1, x2, y2, thickness, r, g, b, a); - }) + engine() + .renderer + .draw_line(x1, y1, x2, y2, thickness, r, g, b, a); + }) } // bloom_draw_rect [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_rect(x: f64, y: f64, w: f64, h: f64, r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_draw_rect( + x: f64, + y: f64, + w: f64, + h: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { $crate::ffi::guard("bloom_draw_rect", move || { engine().renderer.draw_rect(x, y, w, h, r, g, b, a); - }) + }) } // bloom_draw_rect_lines [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_rect_lines(x: f64, y: f64, w: f64, h: f64, thickness: f64, r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_draw_rect_lines( + x: f64, + y: f64, + w: f64, + h: f64, + thickness: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { $crate::ffi::guard("bloom_draw_rect_lines", move || { - engine().renderer.draw_rect_lines(x, y, w, h, thickness, r, g, b, a); - }) + engine() + .renderer + .draw_rect_lines(x, y, w, h, thickness, r, g, b, a); + }) } // bloom_draw_circle [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_circle(cx: f64, cy: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_draw_circle( + cx: f64, + cy: f64, + radius: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { $crate::ffi::guard("bloom_draw_circle", move || { engine().renderer.draw_circle(cx, cy, radius, r, g, b, a); - }) + }) } // bloom_draw_circle_lines [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_circle_lines(cx: f64, cy: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_draw_circle_lines( + cx: f64, + cy: f64, + radius: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { $crate::ffi::guard("bloom_draw_circle_lines", move || { - engine().renderer.draw_circle_lines(cx, cy, radius, r, g, b, a); - }) + engine() + .renderer + .draw_circle_lines(cx, cy, radius, r, g, b, a); + }) } // bloom_draw_triangle [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_triangle(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64, r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_draw_triangle( + x1: f64, + y1: f64, + x2: f64, + y2: f64, + x3: f64, + y3: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { $crate::ffi::guard("bloom_draw_triangle", move || { - engine().renderer.draw_triangle(x1, y1, x2, y2, x3, y3, r, g, b, a); - }) + engine() + .renderer + .draw_triangle(x1, y1, x2, y2, x3, y3, r, g, b, a); + }) } // bloom_draw_poly [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_poly(cx: f64, cy: f64, sides: f64, radius: f64, rotation: f64, r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_draw_poly( + cx: f64, + cy: f64, + sides: f64, + radius: f64, + rotation: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { $crate::ffi::guard("bloom_draw_poly", move || { - engine().renderer.draw_poly(cx, cy, sides, radius, rotation, r, g, b, a); - }) + engine() + .renderer + .draw_poly(cx, cy, sides, radius, rotation, r, g, b, a); + }) } // bloom_draw_text [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_text(text_ptr: *const u8, x: f64, y: f64, size: f64, r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_draw_text( + text_ptr: *const u8, + x: f64, + y: f64, + size: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { $crate::ffi::guard("bloom_draw_text", move || { let text = $crate::string_header::str_from_header(text_ptr); let eng = engine(); // Need to split borrow: take text out temporarily - let mut text_renderer = std::mem::replace(&mut eng.text, $crate::text_renderer::TextRenderer::empty()); + let mut text_renderer = + std::mem::replace(&mut eng.text, $crate::text_renderer::TextRenderer::empty()); text_renderer.draw_text(&mut eng.renderer, text, x, y, size as u32, r, g, b, a); eng.text = text_renderer; - }) + }) } // bloom_measure_text [source: macos] @@ -93,49 +178,99 @@ macro_rules! __bloom_ffi_draw { $crate::ffi::guard("bloom_measure_text", move || { let text = $crate::string_header::str_from_header(text_ptr); engine().text.measure_text(text, size as u32) - }) + }) } // bloom_draw_text_ex [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_text_ex(font_handle: f64, text_ptr: *const u8, x: f64, y: f64, size: f64, spacing: f64, r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_draw_text_ex( + font_handle: f64, + text_ptr: *const u8, + x: f64, + y: f64, + size: f64, + spacing: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { $crate::ffi::guard("bloom_draw_text_ex", move || { let text = $crate::string_header::str_from_header(text_ptr); let eng = engine(); - let mut text_renderer = std::mem::replace(&mut eng.text, $crate::text_renderer::TextRenderer::empty()); - text_renderer.draw_text_ex(&mut eng.renderer, font_handle as usize, text, x, y, size as u32, spacing as f32, r, g, b, a); + let mut text_renderer = + std::mem::replace(&mut eng.text, $crate::text_renderer::TextRenderer::empty()); + text_renderer.draw_text_ex( + &mut eng.renderer, + font_handle as usize, + text, + x, + y, + size as u32, + spacing as f32, + r, + g, + b, + a, + ); eng.text = text_renderer; - }) + }) } // bloom_measure_text_ex [source: macos] #[no_mangle] - pub extern "C" fn bloom_measure_text_ex(font_handle: f64, text_ptr: *const u8, size: f64, spacing: f64) -> f64 { + pub extern "C" fn bloom_measure_text_ex( + font_handle: f64, + text_ptr: *const u8, + size: f64, + spacing: f64, + ) -> f64 { $crate::ffi::guard("bloom_measure_text_ex", move || { let text = $crate::string_header::str_from_header(text_ptr); - engine().text.measure_text_ex(font_handle as usize, text, size as u32, spacing as f32) - }) + engine().text.measure_text_ex( + font_handle as usize, + text, + size as u32, + spacing as f32, + ) + }) } // bloom_draw_texture [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_texture(handle: f64, x: f64, y: f64, tint_r: f64, tint_g: f64, tint_b: f64, tint_a: f64) { + pub extern "C" fn bloom_draw_texture( + handle: f64, + x: f64, + y: f64, + tint_r: f64, + tint_g: f64, + tint_b: f64, + tint_a: f64, + ) { $crate::ffi::guard("bloom_draw_texture", move || { let eng = engine(); if let Some(tex) = eng.textures.get(handle) { let bind_group_idx = tex.bind_group_idx; - eng.renderer.draw_texture(bind_group_idx, x, y, tint_r, tint_g, tint_b, tint_a); + eng.renderer + .draw_texture(bind_group_idx, x, y, tint_r, tint_g, tint_b, tint_a); } - }) + }) } // bloom_draw_texture_rec [source: macos] #[no_mangle] pub extern "C" fn bloom_draw_texture_rec( handle: f64, - src_x: f64, src_y: f64, src_w: f64, src_h: f64, - dst_x: f64, dst_y: f64, - tint_r: f64, tint_g: f64, tint_b: f64, tint_a: f64, + src_x: f64, + src_y: f64, + src_w: f64, + src_h: f64, + dst_x: f64, + dst_y: f64, + tint_r: f64, + tint_g: f64, + tint_b: f64, + tint_a: f64, ) { $crate::ffi::guard("bloom_draw_texture_rec", move || { let eng = engine(); @@ -143,22 +278,40 @@ macro_rules! __bloom_ffi_draw { let bind_group_idx = tex.bind_group_idx; eng.renderer.draw_texture_rec( bind_group_idx, - src_x, src_y, src_w, src_h, - dst_x, dst_y, - tint_r, tint_g, tint_b, tint_a, + src_x, + src_y, + src_w, + src_h, + dst_x, + dst_y, + tint_r, + tint_g, + tint_b, + tint_a, ); } - }) + }) } // bloom_draw_texture_pro [source: macos] #[no_mangle] pub extern "C" fn bloom_draw_texture_pro( handle: f64, - src_x: f64, src_y: f64, src_w: f64, src_h: f64, - dst_x: f64, dst_y: f64, dst_w: f64, dst_h: f64, - origin_x: f64, origin_y: f64, rotation: f64, - tint_r: f64, tint_g: f64, tint_b: f64, tint_a: f64, + src_x: f64, + src_y: f64, + src_w: f64, + src_h: f64, + dst_x: f64, + dst_y: f64, + dst_w: f64, + dst_h: f64, + origin_x: f64, + origin_y: f64, + rotation: f64, + tint_r: f64, + tint_g: f64, + tint_b: f64, + tint_a: f64, ) { $crate::ffi::guard("bloom_draw_texture_pro", move || { let eng = engine(); @@ -166,25 +319,46 @@ macro_rules! __bloom_ffi_draw { let bind_group_idx = tex.bind_group_idx; eng.renderer.draw_texture_pro( bind_group_idx, - src_x, src_y, src_w, src_h, - dst_x, dst_y, dst_w, dst_h, - origin_x, origin_y, rotation, - tint_r, tint_g, tint_b, tint_a, + src_x, + src_y, + src_w, + src_h, + dst_x, + dst_y, + dst_w, + dst_h, + origin_x, + origin_y, + rotation, + tint_r, + tint_g, + tint_b, + tint_a, ); } - }) + }) } // bloom_begin_mode_2d [source: macos] #[no_mangle] - pub extern "C" fn bloom_begin_mode_2d(offset_x: f64, offset_y: f64, target_x: f64, target_y: f64, rotation: f64, zoom: f64) { + pub extern "C" fn bloom_begin_mode_2d( + offset_x: f64, + offset_y: f64, + target_x: f64, + target_y: f64, + rotation: f64, + zoom: f64, + ) { $crate::ffi::guard("bloom_begin_mode_2d", move || { engine().renderer.begin_mode_2d( - offset_x as f32, offset_y as f32, - target_x as f32, target_y as f32, - rotation as f32, zoom as f32, + offset_x as f32, + offset_y as f32, + target_x as f32, + target_y as f32, + rotation as f32, + zoom as f32, ); - }) + }) } // bloom_end_mode_2d [source: macos] @@ -192,25 +366,39 @@ macro_rules! __bloom_ffi_draw { pub extern "C" fn bloom_end_mode_2d() { $crate::ffi::guard("bloom_end_mode_2d", move || { engine().renderer.end_mode_2d(); - }) + }) } // bloom_begin_mode_3d [source: macos] #[no_mangle] pub extern "C" fn bloom_begin_mode_3d( - pos_x: f64, pos_y: f64, pos_z: f64, - target_x: f64, target_y: f64, target_z: f64, - up_x: f64, up_y: f64, up_z: f64, - fovy: f64, projection: f64, + pos_x: f64, + pos_y: f64, + pos_z: f64, + target_x: f64, + target_y: f64, + target_z: f64, + up_x: f64, + up_y: f64, + up_z: f64, + fovy: f64, + projection: f64, ) { $crate::ffi::guard("bloom_begin_mode_3d", move || { engine().renderer.begin_mode_3d( - pos_x as f32, pos_y as f32, pos_z as f32, - target_x as f32, target_y as f32, target_z as f32, - up_x as f32, up_y as f32, up_z as f32, - fovy as f32, projection as f32, + pos_x as f32, + pos_y as f32, + pos_z as f32, + target_x as f32, + target_y as f32, + target_z as f32, + up_x as f32, + up_y as f32, + up_z as f32, + fovy as f32, + projection as f32, ); - }) + }) } // bloom_end_mode_3d [source: macos] @@ -218,55 +406,133 @@ macro_rules! __bloom_ffi_draw { pub extern "C" fn bloom_end_mode_3d() { $crate::ffi::guard("bloom_end_mode_3d", move || { engine().renderer.end_mode_3d(); - }) + }) } // bloom_draw_cube [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_cube(x: f64, y: f64, z: f64, w: f64, h: f64, d: f64, r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_draw_cube( + x: f64, + y: f64, + z: f64, + w: f64, + h: f64, + d: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { $crate::ffi::guard("bloom_draw_cube", move || { engine().renderer.draw_cube(x, y, z, w, h, d, r, g, b, a); - }) + }) } // bloom_draw_cube_wires [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_cube_wires(x: f64, y: f64, z: f64, w: f64, h: f64, d: f64, r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_draw_cube_wires( + x: f64, + y: f64, + z: f64, + w: f64, + h: f64, + d: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { $crate::ffi::guard("bloom_draw_cube_wires", move || { - engine().renderer.draw_cube_wires(x, y, z, w, h, d, r, g, b, a); - }) + engine() + .renderer + .draw_cube_wires(x, y, z, w, h, d, r, g, b, a); + }) } // bloom_draw_sphere [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_sphere(cx: f64, cy: f64, cz: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_draw_sphere( + cx: f64, + cy: f64, + cz: f64, + radius: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { $crate::ffi::guard("bloom_draw_sphere", move || { - engine().renderer.draw_sphere(cx, cy, cz, radius, r, g, b, a); - }) + engine() + .renderer + .draw_sphere(cx, cy, cz, radius, r, g, b, a); + }) } // bloom_draw_sphere_wires [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_sphere_wires(cx: f64, cy: f64, cz: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_draw_sphere_wires( + cx: f64, + cy: f64, + cz: f64, + radius: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { $crate::ffi::guard("bloom_draw_sphere_wires", move || { - engine().renderer.draw_sphere_wires(cx, cy, cz, radius, r, g, b, a); - }) + engine() + .renderer + .draw_sphere_wires(cx, cy, cz, radius, r, g, b, a); + }) } // bloom_draw_cylinder [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_cylinder(x: f64, y: f64, z: f64, radius_top: f64, radius_bottom: f64, height: f64, r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_draw_cylinder( + x: f64, + y: f64, + z: f64, + radius_top: f64, + radius_bottom: f64, + height: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { $crate::ffi::guard("bloom_draw_cylinder", move || { - engine().renderer.draw_cylinder(x, y, z, radius_top, radius_bottom, height, r, g, b, a); - }) + engine().renderer.draw_cylinder( + x, + y, + z, + radius_top, + radius_bottom, + height, + r, + g, + b, + a, + ); + }) } // bloom_draw_plane [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_plane(cx: f64, cy: f64, cz: f64, w: f64, d: f64, r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_draw_plane( + cx: f64, + cy: f64, + cz: f64, + w: f64, + d: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { $crate::ffi::guard("bloom_draw_plane", move || { engine().renderer.draw_plane(cx, cy, cz, w, d, r, g, b, a); - }) + }) } // bloom_draw_grid [source: macos] @@ -274,15 +540,28 @@ macro_rules! __bloom_ffi_draw { pub extern "C" fn bloom_draw_grid(slices: f64, spacing: f64) { $crate::ffi::guard("bloom_draw_grid", move || { engine().renderer.draw_grid(slices as i32, spacing); - }) + }) } // bloom_draw_ray [source: macos] #[no_mangle] - pub extern "C" fn bloom_draw_ray(origin_x: f64, origin_y: f64, origin_z: f64, dir_x: f64, dir_y: f64, dir_z: f64, r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_draw_ray( + origin_x: f64, + origin_y: f64, + origin_z: f64, + dir_x: f64, + dir_y: f64, + dir_z: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { $crate::ffi::guard("bloom_draw_ray", move || { - engine().renderer.draw_ray(origin_x, origin_y, origin_z, dir_x, dir_y, dir_z, r, g, b, a); - }) + engine().renderer.draw_ray( + origin_x, origin_y, origin_z, dir_x, dir_y, dir_z, r, g, b, a, + ); + }) } // bloom_begin_texture_mode [source: macos] @@ -306,12 +585,23 @@ macro_rules! __bloom_ffi_draw { // data we need first, then call the mutable method. let color_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); // Create depth texture for this RT. - let depth_tex = eng.renderer.device.create_texture(&wgpu::TextureDescriptor { - label: Some("rt_depth"), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, - mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Depth32Float, usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - view_formats: &[], - }); + let depth_tex = eng + .renderer + .device + .create_texture(&wgpu::TextureDescriptor { + label: Some("rt_depth"), + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth32Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); let depth_view = depth_tex.create_view(&wgpu::TextureViewDescriptor::default()); eng.renderer.rt_color_view = Some(color_view); eng.renderer.rt_depth_view = Some(depth_view); @@ -319,7 +609,7 @@ macro_rules! __bloom_ffi_draw { eng.renderer.rt_width = w; eng.renderer.rt_height = h; } - }) + }) } // bloom_end_texture_mode [source: macos] @@ -327,8 +617,7 @@ macro_rules! __bloom_ffi_draw { pub extern "C" fn bloom_end_texture_mode() { $crate::ffi::guard("bloom_end_texture_mode", move || { engine().renderer.end_texture_mode(); - }) + }) } - }; } diff --git a/native/shared/src/ffi_core/game_loop.rs b/native/shared/src/ffi_core/game_loop.rs index e6f433ad..5aac171d 100644 --- a/native/shared/src/ffi_core/game_loop.rs +++ b/native/shared/src/ffi_core/game_loop.rs @@ -9,7 +9,6 @@ #[macro_export] macro_rules! __bloom_ffi_game_loop { () => { - // --- game loop hooks -------------------------------------------- // No-op on native: the TypeScript runGame() helper drives the @@ -18,7 +17,10 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_run_game(_callback: extern "C" fn(f64)) {} #[no_mangle] - pub extern "C" fn bloom_register_frame_callback(priority: f64, callback: extern "C" fn(f64)) -> f64 { + pub extern "C" fn bloom_register_frame_callback( + priority: f64, + callback: extern "C" fn(f64), + ) -> f64 { $crate::ffi::guard("bloom_register_frame_callback", move || { engine().frame_callbacks.register(priority as i32, callback) as f64 }) @@ -27,48 +29,43 @@ macro_rules! __bloom_ffi_game_loop { // bloom_get_delta_time [source: macos] #[no_mangle] pub extern "C" fn bloom_get_delta_time() -> f64 { - $crate::ffi::guard("bloom_get_delta_time", move || { - engine().delta_time - }) + $crate::ffi::guard("bloom_get_delta_time", move || engine().delta_time) } // bloom_get_fps [source: macos] #[no_mangle] pub extern "C" fn bloom_get_fps() -> f64 { - $crate::ffi::guard("bloom_get_fps", move || { - engine().get_fps() - }) + $crate::ffi::guard("bloom_get_fps", move || engine().get_fps()) } // bloom_get_screen_width [source: macos] #[no_mangle] pub extern "C" fn bloom_get_screen_width() -> f64 { - $crate::ffi::guard("bloom_get_screen_width", move || { - engine().screen_width() - }) + $crate::ffi::guard("bloom_get_screen_width", move || engine().screen_width()) } // bloom_get_screen_height [source: macos] #[no_mangle] pub extern "C" fn bloom_get_screen_height() -> f64 { - $crate::ffi::guard("bloom_get_screen_height", move || { - engine().screen_height() - }) + $crate::ffi::guard("bloom_get_screen_height", move || engine().screen_height()) } // bloom_create_instance_buffer [source: macos] #[no_mangle] pub extern "C" fn bloom_create_instance_buffer( - data_ptr: *const f64, instance_count: f64, + data_ptr: *const f64, + instance_count: f64, ) -> f64 { $crate::ffi::guard("bloom_create_instance_buffer", move || { - if data_ptr.is_null() || instance_count <= 0.0 { return 0.0; } + if data_ptr.is_null() || instance_count <= 0.0 { + return 0.0; + } let count = instance_count as u32; let slot_count = (count as usize) * 9; let raw_f64 = unsafe { std::slice::from_raw_parts(data_ptr, slot_count) }; let raw_f32: Vec = raw_f64.iter().map(|&v| v as f32).collect(); engine().renderer.create_instance_buffer(&raw_f32, count) as f64 - }) + }) } // bloom_create_instance_buffer_scratch — instance data arrives via @@ -82,11 +79,13 @@ macro_rules! __bloom_ffi_game_loop { let eng = engine(); let count = instance_count as u32; let need = (count as usize) * 9; - if count == 0 || eng.models.scratch_f32.len() < need { return 0.0; } + if count == 0 || eng.models.scratch_f32.len() < need { + return 0.0; + } let data: Vec = eng.models.scratch_f32[..need].to_vec(); eng.models.mesh_scratch_reset(); eng.renderer.create_instance_buffer(&data, count) as f64 - }) + }) } // bloom_destroy_instance_buffer [source: macos] @@ -94,13 +93,17 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_destroy_instance_buffer(handle: f64) { $crate::ffi::guard("bloom_destroy_instance_buffer", move || { engine().renderer.destroy_instance_buffer(handle as u32); - }) + }) } // bloom_create_planar_reflection [source: macos] #[no_mangle] pub extern "C" fn bloom_create_planar_reflection( - plane_y: f64, nx: f64, ny: f64, nz: f64, resolution: f64, + plane_y: f64, + nx: f64, + ny: f64, + nz: f64, + resolution: f64, ) -> f64 { $crate::ffi::guard("bloom_create_planar_reflection", move || { engine().renderer.create_planar_reflection( @@ -108,57 +111,75 @@ macro_rules! __bloom_ffi_game_loop { [nx as f32, ny as f32, nz as f32], resolution as u32, ) as f64 - }) + }) } // bloom_create_texture_array [source: macos] #[no_mangle] pub extern "C" fn bloom_create_texture_array( - data_ptr: *const u8, - data_len: f64, - width: f64, - height: f64, + data_ptr: *const u8, + data_len: f64, + width: f64, + height: f64, layer_count: f64, ) -> f64 { $crate::ffi::guard("bloom_create_texture_array", move || { // EN-014 V2 — V1 stays callable; forwards to _ex with default // format = sRGB (0) and mip_levels = 1 (no mips). - bloom_create_texture_array_ex(data_ptr, data_len, width, height, layer_count, 0.0, 1.0) - }) + bloom_create_texture_array_ex( + data_ptr, + data_len, + width, + height, + layer_count, + 0.0, + 1.0, + ) + }) } // bloom_create_texture_array_ex [source: macos] #[no_mangle] pub extern "C" fn bloom_create_texture_array_ex( - data_ptr: *const u8, - data_len: f64, - width: f64, - height: f64, + data_ptr: *const u8, + data_len: f64, + width: f64, + height: f64, layer_count: f64, - format: f64, - mip_levels: f64, + format: f64, + mip_levels: f64, ) -> f64 { $crate::ffi::guard("bloom_create_texture_array_ex", move || { - if data_ptr.is_null() || data_len <= 0.0 { return 0.0; } + if data_ptr.is_null() || data_len <= 0.0 { + return 0.0; + } let w = width as u32; let h = height as u32; - if w == 0 || h == 0 { return 0.0; } + if w == 0 || h == 0 { + return 0.0; + } let layers_count = (layer_count as u32) .min($crate::renderer::material_system::MAX_TEXTURE_ARRAY_LAYERS); - if layers_count == 0 { return 0.0; } + if layers_count == 0 { + return 0.0; + } let layer_size = (w as usize) * (h as usize) * 4; - let total_bytes = (data_len as usize) - .min(layers_count as usize * layer_size); + let total_bytes = (data_len as usize).min(layers_count as usize * layer_size); let bytes = unsafe { std::slice::from_raw_parts(data_ptr, total_bytes) }; let mut layers: Vec<(&[u8], u32, u32)> = Vec::with_capacity(layers_count as usize); for i in 0..(layers_count as usize) { let start = i * layer_size; - let end = start + layer_size; - if end > bytes.len() { break; } + let end = start + layer_size; + if end > bytes.len() { + break; + } layers.push((&bytes[start..end], w, h)); } - engine().renderer.create_texture_array_ex(&layers, format as u32, mip_levels as u32) as f64 - }) + engine() + .renderer + .create_texture_array_ex(&layers, format as u32, mip_levels as u32) + as f64 + }) } // bloom_create_texture_array_scratch [EN-049] @@ -178,19 +199,23 @@ macro_rules! __bloom_ffi_game_loop { // is 16,384 pushes rather than 65,536. #[no_mangle] pub extern "C" fn bloom_create_texture_array_scratch( - width: f64, - height: f64, + width: f64, + height: f64, layer_count: f64, - format: f64, - mip_levels: f64, + format: f64, + mip_levels: f64, ) -> f64 { $crate::ffi::guard("bloom_create_texture_array_scratch", move || { let w = width as u32; let h = height as u32; - if w == 0 || h == 0 { return 0.0; } + if w == 0 || h == 0 { + return 0.0; + } let layers_count = (layer_count as u32) .min($crate::renderer::material_system::MAX_TEXTURE_ARRAY_LAYERS); - if layers_count == 0 { return 0.0; } + if layers_count == 0 { + return 0.0; + } let texels = (w as usize) * (h as usize) * (layers_count as usize); // Scoped so the scratch borrow ends before the renderer borrow. @@ -211,12 +236,17 @@ macro_rules! __bloom_ffi_game_loop { let mut layers: Vec<(&[u8], u32, u32)> = Vec::with_capacity(layers_count as usize); for i in 0..(layers_count as usize) { let start = i * layer_size; - let end = start + layer_size; - if end > bytes.len() { break; } + let end = start + layer_size; + if end > bytes.len() { + break; + } layers.push((&bytes[start..end], w, h)); } - engine().renderer.create_texture_array_ex(&layers, format as u32, mip_levels as u32) as f64 - }) + engine() + .renderer + .create_texture_array_ex(&layers, format as u32, mip_levels as u32) + as f64 + }) } // bloom_create_texture_array_from_files [EN-014 V3] @@ -232,14 +262,18 @@ macro_rules! __bloom_ffi_game_loop { // the first file's size wins and any mismatch is skipped with a warning. #[no_mangle] pub extern "C" fn bloom_create_texture_array_from_files( - paths_ptr: *const u8, format: f64, mip_levels: f64, + paths_ptr: *const u8, + format: f64, + mip_levels: f64, ) -> f64 { $crate::ffi::guard("bloom_create_texture_array_from_files", move || { let list = $crate::string_header::str_from_header(paths_ptr); let mut decoded: Vec<(Vec, u32, u32)> = Vec::new(); for p in list.split(',') { let p = p.trim(); - if p.is_empty() { continue; } + if p.is_empty() { + continue; + } let resolved = bloom_resolve_asset_path(p); match image::open(resolved.as_ref()) { Ok(img) => { @@ -252,28 +286,37 @@ macro_rules! __bloom_ffi_game_loop { } } } - if decoded.is_empty() { return 0.0; } + if decoded.is_empty() { + return 0.0; + } let (w, h) = (decoded[0].1, decoded[0].2); let mut layers: Vec<(&[u8], u32, u32)> = Vec::with_capacity(decoded.len()); for (bytes, lw, lh) in decoded.iter() { if *lw != w || *lh != h { - eprintln!("[texarray] layer size {}x{} != {}x{}; skipped", lw, lh, w, h); + eprintln!( + "[texarray] layer size {}x{} != {}x{}; skipped", + lw, lh, w, h + ); continue; } layers.push((bytes.as_slice(), w, h)); } - if layers.is_empty() { return 0.0; } - engine().renderer.create_texture_array_ex( - &layers, format as u32, mip_levels as u32) as f64 - }) + if layers.is_empty() { + return 0.0; + } + engine() + .renderer + .create_texture_array_ex(&layers, format as u32, mip_levels as u32) + as f64 + }) } // bloom_clear_post_pass [source: macos] #[no_mangle] - pub extern "C" fn bloom_clear_post_pass() { - $crate::ffi::guard("bloom_clear_post_pass", move || { + pub extern "C" fn bloom_clear_post_pass() -> f64 { + $crate::ffi::guard_applied("bloom_clear_post_pass", move || { engine().renderer.clear_post_pass(); - }) + }) } // bloom_add_post_pass [source: macos] @@ -283,17 +326,20 @@ macro_rules! __bloom_ffi_game_loop { let source = $crate::string_header::str_from_header(source_ptr); match engine().renderer.add_post_pass(source) { Ok(h) => h as f64, - Err(e) => { eprintln!("[post_pass] compile failed: {:?}", e); 0.0 } + Err(e) => { + eprintln!("[post_pass] compile failed: {:?}", e); + 0.0 + } } - }) + }) } // bloom_clear_all_post_passes [source: macos] #[no_mangle] - pub extern "C" fn bloom_clear_all_post_passes() { - $crate::ffi::guard("bloom_clear_all_post_passes", move || { + pub extern "C" fn bloom_clear_all_post_passes() -> f64 { + $crate::ffi::guard_applied("bloom_clear_all_post_passes", move || { engine().renderer.clear_all_post_passes(); - }) + }) } // bloom_get_render_scale [source: macos] @@ -301,7 +347,7 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_get_render_scale() -> f64 { $crate::ffi::guard("bloom_get_render_scale", move || { engine().renderer.render_scale() as f64 - }) + }) } // bloom_get_physical_width [source: macos] @@ -309,7 +355,7 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_get_physical_width() -> f64 { $crate::ffi::guard("bloom_get_physical_width", move || { engine().renderer.physical_width() as f64 - }) + }) } // bloom_get_physical_height [source: macos] @@ -317,7 +363,7 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_get_physical_height() -> f64 { $crate::ffi::guard("bloom_get_physical_height", move || { engine().renderer.physical_height() as f64 - }) + }) } // bloom_get_profiler_frame_cpu_us [source: macos] @@ -325,7 +371,7 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_get_profiler_frame_cpu_us() -> f64 { $crate::ffi::guard("bloom_get_profiler_frame_cpu_us", move || { engine().profiler.avg_frame_cpu_us() - }) + }) } // bloom_get_profiler_frame_gpu_us [source: macos] @@ -333,7 +379,43 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_get_profiler_frame_gpu_us() -> f64 { $crate::ffi::guard("bloom_get_profiler_frame_gpu_us", move || { engine().profiler.avg_frame_gpu_us() - }) + }) + } + + // bloom_write_quality_telemetry [quality qualification] + #[no_mangle] + pub extern "C" fn bloom_write_quality_telemetry( + path_ptr: *const u8, + warmup_frames: f64, + measured_frames: f64, + fixed_timestep: f64, + quality_preset: f64, + render_scale: f64, + measurement_wall_ms: f64, + ) -> f64 { + $crate::ffi::guard("bloom_write_quality_telemetry", move || { + let path = $crate::string_header::str_from_header(path_ptr); + let eng = engine(); + let present_mode = eng.renderer.present_mode_code(); + let adapter = eng.renderer.quality_adapter_json(); + let runtime_paths = eng.renderer.quality_runtime_paths_json(); + let report = eng.profiler.quality_report_json( + present_mode, + warmup_frames.max(0.0) as u32, + measured_frames.max(1.0) as u32, + fixed_timestep, + quality_preset.max(0.0) as u32, + render_scale, + measurement_wall_ms, + &adapter, + &runtime_paths, + ); + if std::fs::write(path, report).is_ok() { + 1.0 + } else { + 0.0 + } + }) } // bloom_print_profiler_summary [source: macos] @@ -341,7 +423,7 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_print_profiler_summary() { $crate::ffi::guard("bloom_print_profiler_summary", move || { print!("{}", engine().profiler.summary()); - }) + }) } // bloom_inject_key_down [source: macos] @@ -349,7 +431,7 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_inject_key_down(key: f64) { $crate::ffi::guard("bloom_inject_key_down", move || { engine().input.inject_key_down(key as usize); - }) + }) } // bloom_inject_key_up [source: macos] @@ -357,7 +439,7 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_inject_key_up(key: f64) { $crate::ffi::guard("bloom_inject_key_up", move || { engine().input.inject_key_up(key as usize); - }) + }) } // bloom_inject_gamepad_axis [source: macos] @@ -365,7 +447,7 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_inject_gamepad_axis(axis: f64, value: f64) { $crate::ffi::guard("bloom_inject_gamepad_axis", move || { engine().input.set_gamepad_axis(axis as usize, value as f32); - }) + }) } // bloom_inject_gamepad_button_down [source: macos] @@ -373,7 +455,7 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_inject_gamepad_button_down(button: f64) { $crate::ffi::guard("bloom_inject_gamepad_button_down", move || { engine().input.set_gamepad_button_down(button as usize); - }) + }) } // bloom_inject_gamepad_button_up [source: macos] @@ -381,15 +463,19 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_inject_gamepad_button_up(button: f64) { $crate::ffi::guard("bloom_inject_gamepad_button_up", move || { engine().input.set_gamepad_button_up(button as usize); - }) + }) } // bloom_is_any_input_pressed [source: macos] #[no_mangle] pub extern "C" fn bloom_is_any_input_pressed() -> f64 { $crate::ffi::guard("bloom_is_any_input_pressed", move || { - if engine().input.is_any_input_pressed() { 1.0 } else { 0.0 } - }) + if engine().input.is_any_input_pressed() { + 1.0 + } else { + 0.0 + } + }) } // bloom_update_music_stream [source: macos] @@ -397,15 +483,13 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_update_music_stream(handle: f64) { $crate::ffi::guard("bloom_update_music_stream", move || { engine().audio.update_music_stream(handle); - }) + }) } // bloom_get_time [source: macos] #[no_mangle] pub extern "C" fn bloom_get_time() -> f64 { - $crate::ffi::guard("bloom_get_time", move || { - engine().get_time() - }) + $crate::ffi::guard("bloom_get_time", move || engine().get_time()) } // bloom_unregister_frame_callback [source: macos] @@ -413,7 +497,7 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_unregister_frame_callback(id: f64) { $crate::ffi::guard("bloom_unregister_frame_callback", move || { engine().frame_callbacks.unregister(id as u64); - }) + }) } // bloom_get_render_texture_texture [source: macos] @@ -421,12 +505,12 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_get_render_texture_texture(handle: f64) -> f64 { $crate::ffi::guard("bloom_get_render_texture_texture", move || { engine().textures.get_render_texture_texture(handle) - }) + }) } // bloom_postfx_set_selected [source: macos] #[no_mangle] - pub extern "C" fn bloom_postfx_set_selected(handle: f64) { + pub extern "C" fn bloom_postfx_set_selected(handle: f64) -> f64 { $crate::ffi::guard("bloom_postfx_set_selected", move || { if let Some(pfx) = &mut engine().postfx { if handle == 0.0 { @@ -434,38 +518,50 @@ macro_rules! __bloom_ffi_game_loop { } else { pfx.set_selected(vec![handle]); } + 1.0 + } else { + 0.0 } - }) + }) } // bloom_postfx_set_hovered [source: macos] #[no_mangle] - pub extern "C" fn bloom_postfx_set_hovered(handle: f64) { + pub extern "C" fn bloom_postfx_set_hovered(handle: f64) -> f64 { $crate::ffi::guard("bloom_postfx_set_hovered", move || { if let Some(pfx) = &mut engine().postfx { pfx.set_hovered(handle); + 1.0 + } else { + 0.0 } - }) + }) } // bloom_postfx_set_outline_color [source: macos] #[no_mangle] - pub extern "C" fn bloom_postfx_set_outline_color(r: f64, g: f64, b: f64, a: f64) { + pub extern "C" fn bloom_postfx_set_outline_color(r: f64, g: f64, b: f64, a: f64) -> f64 { $crate::ffi::guard("bloom_postfx_set_outline_color", move || { if let Some(pfx) = &mut engine().postfx { pfx.outline_params.color_selected = [r as f32, g as f32, b as f32, a as f32]; + 1.0 + } else { + 0.0 } - }) + }) } // bloom_postfx_set_outline_thickness [source: macos] #[no_mangle] - pub extern "C" fn bloom_postfx_set_outline_thickness(thickness: f64) { + pub extern "C" fn bloom_postfx_set_outline_thickness(thickness: f64) -> f64 { $crate::ffi::guard("bloom_postfx_set_outline_thickness", move || { if let Some(pfx) = &mut engine().postfx { pfx.outline_params.thickness[0] = thickness as f32; + 1.0 + } else { + 0.0 } - }) + }) } // bloom_profiler_frame_history [source: macos] @@ -478,7 +574,7 @@ macro_rules! __bloom_ffi_game_loop { s.push_str(&format!("{:.2}|{:.2}\n", cpu, gpu)); } $crate::string_header::alloc_perry_string(&s) - }) + }) } // bloom_profiler_overlay_text [source: macos] @@ -494,12 +590,12 @@ macro_rules! __bloom_ffi_game_loop { s.push('|'); match gpu { Some(g) => s.push_str(&format!("{:.2}", g)), - None => s.push_str("-1"), + None => s.push_str("-1"), } s.push('\n'); } $crate::string_header::alloc_perry_string(&s) - }) + }) } // EN-020 — numeric profiler ABI. The packed-text FFIs above stay @@ -515,7 +611,7 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_profiler_row_count() -> f64 { $crate::ffi::guard("bloom_profiler_row_count", move || { engine().profiler.snapshot().len() as f64 - }) + }) } // bloom_profiler_row_label [source: windows] @@ -527,15 +623,20 @@ macro_rules! __bloom_ffi_game_loop { Some((label, _, _)) => $crate::string_header::alloc_perry_string(label), None => $crate::string_header::alloc_perry_string(""), } - }) + }) } // bloom_profiler_row_cpu_us [source: windows] #[no_mangle] pub extern "C" fn bloom_profiler_row_cpu_us(i: f64) -> f64 { $crate::ffi::guard("bloom_profiler_row_cpu_us", move || { - engine().profiler.snapshot().get(i as usize).map(|r| r.1).unwrap_or(0.0) - }) + engine() + .profiler + .snapshot() + .get(i as usize) + .map(|r| r.1) + .unwrap_or(0.0) + }) } // bloom_profiler_row_gpu_us [source: windows] @@ -546,7 +647,7 @@ macro_rules! __bloom_ffi_game_loop { Some((_, _, Some(g))) => *g, _ => -1.0, } - }) + }) } // bloom_profiler_hist_count [source: windows] @@ -554,24 +655,33 @@ macro_rules! __bloom_ffi_game_loop { pub extern "C" fn bloom_profiler_hist_count() -> f64 { $crate::ffi::guard("bloom_profiler_hist_count", move || { engine().profiler.frame_history().len() as f64 - }) + }) } // bloom_profiler_hist_cpu_us [source: windows] #[no_mangle] pub extern "C" fn bloom_profiler_hist_cpu_us(i: f64) -> f64 { $crate::ffi::guard("bloom_profiler_hist_cpu_us", move || { - engine().profiler.frame_history().get(i as usize).map(|h| h.0).unwrap_or(0.0) - }) + engine() + .profiler + .frame_history() + .get(i as usize) + .map(|h| h.0) + .unwrap_or(0.0) + }) } // bloom_profiler_hist_gpu_us [source: windows] #[no_mangle] pub extern "C" fn bloom_profiler_hist_gpu_us(i: f64) -> f64 { $crate::ffi::guard("bloom_profiler_hist_gpu_us", move || { - engine().profiler.frame_history().get(i as usize).map(|h| h.1).unwrap_or(0.0) - }) + engine() + .profiler + .frame_history() + .get(i as usize) + .map(|h| h.1) + .unwrap_or(0.0) + }) } - }; } diff --git a/native/shared/src/ffi_core/input.rs b/native/shared/src/ffi_core/input.rs index dcf6fffb..2bc94736 100644 --- a/native/shared/src/ffi_core/input.rs +++ b/native/shared/src/ffi_core/input.rs @@ -9,29 +9,40 @@ #[macro_export] macro_rules! __bloom_ffi_input { () => { - // bloom_is_key_pressed [source: macos] #[no_mangle] pub extern "C" fn bloom_is_key_pressed(key: f64) -> f64 { $crate::ffi::guard("bloom_is_key_pressed", move || { - if engine().input.is_key_pressed(key as usize) { 1.0 } else { 0.0 } - }) + if engine().input.is_key_pressed(key as usize) { + 1.0 + } else { + 0.0 + } + }) } // bloom_is_key_down [source: macos] #[no_mangle] pub extern "C" fn bloom_is_key_down(key: f64) -> f64 { $crate::ffi::guard("bloom_is_key_down", move || { - if engine().input.is_key_down(key as usize) { 1.0 } else { 0.0 } - }) + if engine().input.is_key_down(key as usize) { + 1.0 + } else { + 0.0 + } + }) } // bloom_is_key_released [source: macos] #[no_mangle] pub extern "C" fn bloom_is_key_released(key: f64) -> f64 { $crate::ffi::guard("bloom_is_key_released", move || { - if engine().input.is_key_released(key as usize) { 1.0 } else { 0.0 } - }) + if engine().input.is_key_released(key as usize) { + 1.0 + } else { + 0.0 + } + }) } // bloom_is_key_repeated — OS auto-repeat as its own edge. isKeyPressed @@ -40,48 +51,60 @@ macro_rules! __bloom_ffi_input { #[no_mangle] pub extern "C" fn bloom_is_key_repeated(key: f64) -> f64 { $crate::ffi::guard("bloom_is_key_repeated", move || { - if engine().input.is_key_repeated(key as usize) { 1.0 } else { 0.0 } - }) + if engine().input.is_key_repeated(key as usize) { + 1.0 + } else { + 0.0 + } + }) } // bloom_get_mouse_x [source: macos] #[no_mangle] pub extern "C" fn bloom_get_mouse_x() -> f64 { - $crate::ffi::guard("bloom_get_mouse_x", move || { - engine().input.mouse_x - }) + $crate::ffi::guard("bloom_get_mouse_x", move || engine().input.mouse_x) } // bloom_get_mouse_y [source: macos] #[no_mangle] pub extern "C" fn bloom_get_mouse_y() -> f64 { - $crate::ffi::guard("bloom_get_mouse_y", move || { - engine().input.mouse_y - }) + $crate::ffi::guard("bloom_get_mouse_y", move || engine().input.mouse_y) } // bloom_is_mouse_button_pressed [source: macos] #[no_mangle] pub extern "C" fn bloom_is_mouse_button_pressed(btn: f64) -> f64 { $crate::ffi::guard("bloom_is_mouse_button_pressed", move || { - if engine().input.is_mouse_button_pressed(btn as usize) { 1.0 } else { 0.0 } - }) + if engine().input.is_mouse_button_pressed(btn as usize) { + 1.0 + } else { + 0.0 + } + }) } // bloom_is_mouse_button_down [source: macos] #[no_mangle] pub extern "C" fn bloom_is_mouse_button_down(btn: f64) -> f64 { $crate::ffi::guard("bloom_is_mouse_button_down", move || { - if engine().input.is_mouse_button_down(btn as usize) { 1.0 } else { 0.0 } - }) + if engine().input.is_mouse_button_down(btn as usize) { + 1.0 + } else { + 0.0 + } + }) } // bloom_is_mouse_button_released [source: macos] #[no_mangle] pub extern "C" fn bloom_is_mouse_button_released(btn: f64) -> f64 { $crate::ffi::guard("bloom_is_mouse_button_released", move || { - if engine().input.is_mouse_button_released(btn as usize) { 1.0 } else { 0.0 } - }) + if engine().input.is_mouse_button_released(btn as usize) { + 1.0 + } else { + 0.0 + } + }) } // bloom_get_crown_rotation [source: macos] @@ -89,15 +112,19 @@ macro_rules! __bloom_ffi_input { pub extern "C" fn bloom_get_crown_rotation() -> f64 { $crate::ffi::guard("bloom_get_crown_rotation", move || { engine().input.consume_crown_rotation() - }) + }) } // bloom_is_gamepad_available [source: macos] #[no_mangle] pub extern "C" fn bloom_is_gamepad_available() -> f64 { $crate::ffi::guard("bloom_is_gamepad_available", move || { - if engine().input.is_gamepad_available() { 1.0 } else { 0.0 } - }) + if engine().input.is_gamepad_available() { + 1.0 + } else { + 0.0 + } + }) } // bloom_get_gamepad_axis [source: macos] @@ -105,7 +132,7 @@ macro_rules! __bloom_ffi_input { pub extern "C" fn bloom_get_gamepad_axis(axis: f64) -> f64 { $crate::ffi::guard("bloom_get_gamepad_axis", move || { engine().input.get_gamepad_axis(axis as usize) as f64 - }) + }) } // bloom_gamepad_rumble [EN-031] @@ -123,31 +150,43 @@ macro_rules! __bloom_ffi_input { (high as f32).clamp(0.0, 1.0), (seconds as f32).clamp(0.0, 10.0), ]; - }) + }) } // bloom_is_gamepad_button_pressed [source: macos] #[no_mangle] pub extern "C" fn bloom_is_gamepad_button_pressed(btn: f64) -> f64 { $crate::ffi::guard("bloom_is_gamepad_button_pressed", move || { - if engine().input.is_gamepad_button_pressed(btn as usize) { 1.0 } else { 0.0 } - }) + if engine().input.is_gamepad_button_pressed(btn as usize) { + 1.0 + } else { + 0.0 + } + }) } // bloom_is_gamepad_button_down [source: macos] #[no_mangle] pub extern "C" fn bloom_is_gamepad_button_down(btn: f64) -> f64 { $crate::ffi::guard("bloom_is_gamepad_button_down", move || { - if engine().input.is_gamepad_button_down(btn as usize) { 1.0 } else { 0.0 } - }) + if engine().input.is_gamepad_button_down(btn as usize) { + 1.0 + } else { + 0.0 + } + }) } // bloom_is_gamepad_button_released [source: macos] #[no_mangle] pub extern "C" fn bloom_is_gamepad_button_released(btn: f64) -> f64 { $crate::ffi::guard("bloom_is_gamepad_button_released", move || { - if engine().input.is_gamepad_button_released(btn as usize) { 1.0 } else { 0.0 } - }) + if engine().input.is_gamepad_button_released(btn as usize) { + 1.0 + } else { + 0.0 + } + }) } // bloom_get_gamepad_axis_count [source: macos] @@ -155,7 +194,7 @@ macro_rules! __bloom_ffi_input { pub extern "C" fn bloom_get_gamepad_axis_count() -> f64 { $crate::ffi::guard("bloom_get_gamepad_axis_count", move || { engine().input.get_gamepad_axis_count() as f64 - }) + }) } // bloom_get_mouse_delta_x [source: macos] @@ -163,7 +202,7 @@ macro_rules! __bloom_ffi_input { pub extern "C" fn bloom_get_mouse_delta_x() -> f64 { $crate::ffi::guard("bloom_get_mouse_delta_x", move || { engine().input.mouse_delta_x - }) + }) } // bloom_get_mouse_delta_y [source: macos] @@ -171,7 +210,7 @@ macro_rules! __bloom_ffi_input { pub extern "C" fn bloom_get_mouse_delta_y() -> f64 { $crate::ffi::guard("bloom_get_mouse_delta_y", move || { engine().input.mouse_delta_y - }) + }) } // bloom_get_mouse_wheel [source: macos] @@ -179,7 +218,7 @@ macro_rules! __bloom_ffi_input { pub extern "C" fn bloom_get_mouse_wheel() -> f64 { $crate::ffi::guard("bloom_get_mouse_wheel", move || { engine().input.consume_mouse_wheel() - }) + }) } // bloom_get_char_pressed [source: macos] @@ -187,7 +226,7 @@ macro_rules! __bloom_ffi_input { pub extern "C" fn bloom_get_char_pressed() -> f64 { $crate::ffi::guard("bloom_get_char_pressed", move || { engine().input.pop_char() as f64 - }) + }) } // bloom_get_touch_x [source: macos] @@ -195,7 +234,7 @@ macro_rules! __bloom_ffi_input { pub extern "C" fn bloom_get_touch_x(index: f64) -> f64 { $crate::ffi::guard("bloom_get_touch_x", move || { engine().input.get_touch_x(index as usize) - }) + }) } // bloom_get_touch_y [source: macos] @@ -203,7 +242,7 @@ macro_rules! __bloom_ffi_input { pub extern "C" fn bloom_get_touch_y(index: f64) -> f64 { $crate::ffi::guard("bloom_get_touch_y", move || { engine().input.get_touch_y(index as usize) - }) + }) } // bloom_get_touch_count [source: macos] @@ -211,15 +250,19 @@ macro_rules! __bloom_ffi_input { pub extern "C" fn bloom_get_touch_count() -> f64 { $crate::ffi::guard("bloom_get_touch_count", move || { engine().input.get_touch_count() as f64 - }) + }) } // bloom_is_touch_active [source: macos] #[no_mangle] pub extern "C" fn bloom_is_touch_active(index: f64) -> f64 { $crate::ffi::guard("bloom_is_touch_active", move || { - if engine().input.is_touch_active(index as usize) { 1.0 } else { 0.0 } - }) + if engine().input.is_touch_active(index as usize) { + 1.0 + } else { + 0.0 + } + }) } // bloom_get_max_touch_points [source: macos] @@ -227,8 +270,7 @@ macro_rules! __bloom_ffi_input { pub extern "C" fn bloom_get_max_touch_points() -> f64 { $crate::ffi::guard("bloom_get_max_touch_points", move || { engine().input.max_touch_points() as f64 - }) + }) } - }; } diff --git a/native/shared/src/ffi_core/mod.rs b/native/shared/src/ffi_core/mod.rs index 98570e26..20270dc9 100644 --- a/native/shared/src/ffi_core/mod.rs +++ b/native/shared/src/ffi_core/mod.rs @@ -58,16 +58,16 @@ //! (`crate::string_header`); array params are pointers to inline f64 //! data (the compiler skips the 8-byte ArrayHeader at the callsite). -mod game_loop; -mod input; -mod draw; mod assets; mod audio_ffi; +mod draw; +mod game_loop; +mod input; mod models; +mod ragdoll_ffi; mod scene; -mod visual; mod vfx; -mod ragdoll_ffi; +mod visual; /// Expand the full shared (non-physics) FFI surface. Composed from the /// per-subsystem section macros in this directory; platform crates invoke @@ -90,7 +90,6 @@ macro_rules! define_core_ffi { }; } - // Compile-coverage for the macro body: expand it against mock hooks so // `cargo test -p bloom-shared` catches breakage without building any // platform crate. Nothing here runs — the hooks panic if called. diff --git a/native/shared/src/ffi_core/models.rs b/native/shared/src/ffi_core/models.rs index 43f44b71..fde65f44 100644 --- a/native/shared/src/ffi_core/models.rs +++ b/native/shared/src/ffi_core/models.rs @@ -16,12 +16,21 @@ macro_rules! __bloom_ffi_models { pub extern "C" fn bloom_load_model(path_ptr: *const u8) -> f64 { $crate::ffi::guard("bloom_load_model", move || { let path = $crate::string_header::str_from_header(path_ptr); - let path: &str = &bloom_resolve_asset_path(path); - match std::fs::read(path) { + let resolved =bloom_resolve_asset_path(path); + let resolved_path = std::path::Path::new(resolved.as_ref()); + match std::fs::read(resolved_path) { Ok(data) => { let eng = engine(); let $crate::engine::EngineState { ref mut models, ref mut renderer, .. } = *eng; - models.load_model_with_textures(&data, renderer) + // Passing the source directory is harmless for GLB + // and required for loose glTF buffers/images. The old + // FFI path always passed None, so valid external + // scenes such as Bistro silently loaded zero meshes. + models.load_model_with_textures_from_source_path( + &data, + resolved_path, + renderer, + ) } Err(_) => 0.0, } @@ -834,8 +843,15 @@ macro_rules! __bloom_ffi_models { // visibly flatter than the same GLB through loadModel. let mut tex_map: Vec = Vec::with_capacity(staged.textures.len()); for tex in &staged.textures { - tex_map.push(eng.renderer.register_texture_kind( - tex.width, tex.height, &tex.data, tex.is_normal)); + tex_map.push( + eng.renderer.register_texture_kind_with_alpha_coverage( + tex.width, + tex.height, + &tex.data, + tex.is_normal, + tex.alpha_coverage_reference, + ), + ); } let mut model = staged.model; // Remap EVERY texture slot, not just the base colour — this @@ -859,6 +875,12 @@ macro_rules! __bloom_ffi_models { remap(&mut mesh.metallic_roughness_texture_idx); remap(&mut mesh.emissive_texture_idx); remap(&mut mesh.occlusion_texture_idx); + if let Some(binding) = &mut mesh.transmission.texture { + remap(&mut binding.runtime_texture_idx); + } + if let Some(binding) = &mut mesh.transmission.thickness_texture { + remap(&mut binding.runtime_texture_idx); + } } eng.models.models.alloc(model) }) @@ -912,15 +934,15 @@ macro_rules! __bloom_ffi_models { // Same idiom as bloom_create_instance_buffer_scratch. ≤ 64 floats. #[cfg(feature = "models3d")] #[no_mangle] - pub extern "C" fn bloom_set_material_params_scratch(handle: f64, param_count: f64) { + pub extern "C" fn bloom_set_material_params_scratch(handle: f64, param_count: f64) -> f64 { $crate::ffi::guard("bloom_set_material_params_scratch", move || { let eng = engine(); let count = param_count as usize; if count > 64 { eprintln!("[material] set_material_params_scratch: param_count {} > 64 (256-byte UBO cap)", count); - return; + return 0.0; } - if eng.models.scratch_f32.len() < count { return; } + if eng.models.scratch_f32.len() < count { return 0.0; } let mut bytes = vec![0u8; count * 4]; for i in 0..count { bytes[i*4..i*4+4].copy_from_slice(&eng.models.scratch_f32[i].to_le_bytes()); @@ -931,13 +953,16 @@ macro_rules! __bloom_ffi_models { handle as u32, &bytes, ) { eprintln!("[material] set_material_params_scratch failed: {}", e); + return 0.0; } + 1.0 }) } #[cfg(not(feature = "models3d"))] #[no_mangle] - pub extern "C" fn bloom_set_material_params_scratch(_handle: f64, _param_count: f64) { + pub extern "C" fn bloom_set_material_params_scratch(_handle: f64, _param_count: f64) -> f64 { $crate::ffi::feature_off_warn_once("bloom_set_material_params_scratch", "models3d"); + 0.0 } // bloom_set_material_params [source: linux; gated: models3d] diff --git a/native/shared/src/ffi_core/ragdoll_ffi.rs b/native/shared/src/ffi_core/ragdoll_ffi.rs index 2c9fc8c6..2f918dc9 100644 --- a/native/shared/src/ffi_core/ragdoll_ffi.rs +++ b/native/shared/src/ffi_core/ragdoll_ffi.rs @@ -17,18 +17,19 @@ #[macro_export] macro_rules! __bloom_ffi_ragdoll { () => { - // bloom_ragdoll_create — allocate a slot. Returns a 1-based handle. #[cfg(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32")))] #[no_mangle] pub extern "C" fn bloom_ragdoll_create() -> f64 { $crate::ffi::guard("bloom_ragdoll_create", move || { engine().ragdolls.create() as f64 - }) + }) } #[cfg(not(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32"))))] #[no_mangle] - pub extern "C" fn bloom_ragdoll_create() -> f64 { 0.0 } + pub extern "C" fn bloom_ragdoll_create() -> f64 { + 0.0 + } // bloom_ragdoll_activate [EN-025] // @@ -44,58 +45,86 @@ macro_rules! __bloom_ffi_ragdoll { #[cfg(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32")))] #[no_mangle] pub extern "C" fn bloom_ragdoll_activate( - rag: f64, anim: f64, world: f64, - scale: f64, px: f64, py: f64, pz: f64, rot_y: f64, + rag: f64, + anim: f64, + world: f64, + scale: f64, + px: f64, + py: f64, + pz: f64, + rot_y: f64, ) -> f64 { $crate::ffi::guard("bloom_ragdoll_activate", move || { let eng = engine(); let (builds, layer) = { - let Some(a) = eng.models.get_animation(anim) else { return 0.0 }; + let Some(a) = eng.models.get_animation(anim) else { + return 0.0; + }; // 12 bodies is the sweet spot for these skeletons: spine + // limbs. Past that you start buying fingers, which cost // solver time and buy jitter. let builds = $crate::ragdoll::plan( - a, scale as f32, + a, + scale as f32, [px as f32, py as f32, pz as f32], rot_y as f32, - 12, // max bodies + 12, // max bodies // Chunkier capsules. Thin ones interpenetrate and let // the corpse fold flat through itself; a limb should // have some volume to rest ON. - 0.38, // capsule radius as a fraction of bone length + 0.38, // capsule radius as a fraction of bone length ); - (builds, 1u32) // MOVING layer + (builds, 1u32) // MOVING layer }; - if builds.is_empty() { return 0.0; } + if builds.is_empty() { + return 0.0; + } // --- bodies let mut bodies: Vec = Vec::with_capacity(builds.len()); for b in builds.iter() { let shape = eng.jolt.create_capsule_shape(b.half_height, b.radius); - if shape == 0.0 { bodies.push(0.0); continue; } + if shape == 0.0 { + bodies.push(0.0); + continue; + } // Quaternion from the capsule's world basis. let q = $crate::ragdoll::quat_from_mat(&b.world); let body = eng.jolt.create_body( - world, shape, - 2, // DYNAMIC - b.world[3][0], b.world[3][1], b.world[3][2], - q[0], q[1], q[2], q[3], - 0.0, 0.0, 0.0, // linear velocity - 0.0, 0.0, 0.0, // angular velocity + world, + shape, + 2, // DYNAMIC + b.world[3][0], + b.world[3][1], + b.world[3][2], + q[0], + q[1], + q[2], + q[3], + 0.0, + 0.0, + 0.0, // linear velocity + 0.0, + 0.0, + 0.0, // angular velocity layer, - false, // sensor - true, // allow sleeping — corpses settle - false, // ccd: not worth it here - true, // start awake - 0.8, // friction: corpses do not skate - 0.02, // restitution: they do not bounce + false, // sensor + true, // allow sleeping — corpses settle + false, // ccd: not worth it here + true, // start awake + 0.8, // friction: corpses do not skate + 0.02, // restitution: they do not bounce // Angular damping does most of the work of making this // read as a body rather than a rag: without it the limbs // keep windmilling long after the thing has landed. - 0.20, 0.75, // lin / ang damping - 1.0, // gravity factor - 0.0, 0.0, 0.0, 0.0, // mass override + inertia + 0.20, + 0.75, // lin / ang damping + 1.0, // gravity factor + 0.0, + 0.0, + 0.0, + 0.0, // mass override + inertia 0, ); bodies.push(body); @@ -109,36 +138,69 @@ macro_rules! __bloom_ffi_ragdoll { // Twist (y) is held hardest, because an over-twisted limb is the // single most obviously WRONG thing a ragdoll can do. let rot_limits: [f32; 6] = [ - -0.8, 0.8, // x — bend + -0.8, 0.8, // x — bend -0.25, 0.25, // y — twist - -0.8, 0.8, // z — bend + -0.8, 0.8, // z — bend ]; let mut constraints: Vec = Vec::new(); for (i, b) in builds.iter().enumerate() { - if b.parent_bone == usize::MAX { continue; } + if b.parent_bone == usize::MAX { + continue; + } let pa = bodies.get(b.parent_bone).copied().unwrap_or(0.0); let pb = bodies.get(i).copied().unwrap_or(0.0); - if pa == 0.0 || pb == 0.0 { continue; } + if pa == 0.0 || pb == 0.0 { + continue; + } let c = eng.jolt.constraint_six_dof_locked_translation( - pa, pb, - b.anchor[0], b.anchor[1], b.anchor[2], - b.anchor[0], b.anchor[1], b.anchor[2], + pa, + pb, + b.anchor[0], + b.anchor[1], + b.anchor[2], + b.anchor[0], + b.anchor[1], + b.anchor[2], rot_limits, - true, // anchors given in world space + true, // anchors given in world space ); - if c != 0.0 { constraints.push(c); } + if c != 0.0 { + constraints.push(c); + } } - let Some(a) = eng.models.get_animation(anim) else { return 0.0 }; - let Some(r) = eng.ragdolls.get_mut(rag as u32) else { return 0.0 }; - r.attach(&builds, &bodies, constraints, a, - scale as f32, [px as f32, py as f32, pz as f32], rot_y as f32); + let Some(a) = eng.models.get_animation(anim) else { + return 0.0; + }; + let Some(r) = eng.ragdolls.get_mut(rag as u32) else { + return 0.0; + }; + r.attach( + &builds, + &bodies, + constraints, + a, + scale as f32, + [px as f32, py as f32, pz as f32], + rot_y as f32, + ); 1.0 - }) + }) } #[cfg(not(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32"))))] #[no_mangle] - pub extern "C" fn bloom_ragdoll_activate(_r: f64, _a: f64, _w: f64, _s: f64, _x: f64, _y: f64, _z: f64, _ry: f64) -> f64 { 0.0 } + pub extern "C" fn bloom_ragdoll_activate( + _r: f64, + _a: f64, + _w: f64, + _s: f64, + _x: f64, + _y: f64, + _z: f64, + _ry: f64, + ) -> f64 { + 0.0 + } // bloom_ragdoll_push — the killing blow. Applied to every body, so the // whole corpse is thrown rather than one limb being yanked off. @@ -147,18 +209,24 @@ macro_rules! __bloom_ffi_ragdoll { pub extern "C" fn bloom_ragdoll_push(rag: f64, dx: f64, dy: f64, dz: f64, impulse: f64) { $crate::ffi::guard("bloom_ragdoll_push", move || { let eng = engine(); - let Some(r) = eng.ragdolls.get(rag as u32) else { return }; - if !r.active { return } + let Some(r) = eng.ragdolls.get(rag as u32) else { + return; + }; + if !r.active { + return; + } let bodies = r.bodies(); - if bodies.is_empty() { return } + if bodies.is_empty() { + return; + } // Spread the impulse over the bodies so a 12-bone corpse and a // 4-bone one take off at the same speed. let per = (impulse as f32) / (bodies.len() as f32); for b in bodies { - eng.jolt.body_add_impulse( - b, dx as f32 * per, dy as f32 * per, dz as f32 * per); + eng.jolt + .body_add_impulse(b, dx as f32 * per, dy as f32 * per, dz as f32 * per); } - }) + }) } #[cfg(not(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32"))))] #[no_mangle] @@ -175,8 +243,12 @@ macro_rules! __bloom_ffi_ragdoll { let eng = engine(); let (bodies, scale, pos, rot) = { - let Some(r) = eng.ragdolls.get(rag as u32) else { return 0.0 }; - if !r.active { return 0.0 } + let Some(r) = eng.ragdolls.get(rag as u32) else { + return 0.0; + }; + if !r.active { + return 0.0; + } let (s, p, ry) = r.upload_params(); (r.bodies(), s, p, ry) }; @@ -185,19 +257,29 @@ macro_rules! __bloom_ffi_ragdoll { for b in bodies.iter() { match eng.jolt.body_transform(*b) { Some((p, q)) => world.push($crate::ragdoll::from_pos_quat(p, q)), - None => world.push([[1.0,0.0,0.0,0.0],[0.0,1.0,0.0,0.0],[0.0,0.0,1.0,0.0],[0.0,0.0,0.0,1.0]]), + None => world.push([ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]), } } { - let Some(r) = eng.ragdolls.get_mut(rag as u32) else { return 0.0 }; + let Some(r) = eng.ragdolls.get_mut(rag as u32) else { + return 0.0; + }; r.age += dt as f32; } let age = eng.ragdolls.get(rag as u32).map(|r| r.age).unwrap_or(0.0); // Split borrow: apply() needs &mut ModelAnimation and &Ragdoll. let ragdoll_ptr: *const $crate::ragdoll::Ragdoll = - match eng.ragdolls.get(rag as u32) { Some(r) => r, None => return 0.0 }; + match eng.ragdolls.get(rag as u32) { + Some(r) => r, + None => return 0.0, + }; if let Some(a) = eng.models.get_animation_mut(anim) { unsafe { (*ragdoll_ptr).apply(a, &world) }; } @@ -207,15 +289,23 @@ macro_rules! __bloom_ffi_ragdoll { let (s, c) = (rot.sin(), rot.cos()); // PT-7: anim handle = prev-palette pairing key. eng.renderer.set_joint_matrices_scaled( - anim.to_bits(), &a.joint_matrices, scale, pos, s, c); + anim.to_bits(), + &a.joint_matrices, + scale, + pos, + s, + c, + ); } } age as f64 - }) + }) } #[cfg(not(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32"))))] #[no_mangle] - pub extern "C" fn bloom_ragdoll_update(_r: f64, _a: f64, _d: f64) -> f64 { 0.0 } + pub extern "C" fn bloom_ragdoll_update(_r: f64, _a: f64, _d: f64) -> f64 { + 0.0 + } // bloom_ragdoll_release — destroy the bodies and constraints, free the // slot for reuse. A pooled ragdoll that is never released leaks bodies @@ -226,21 +316,26 @@ macro_rules! __bloom_ffi_ragdoll { $crate::ffi::guard("bloom_ragdoll_release", move || { let eng = engine(); let (bodies, cons) = { - let Some(r) = eng.ragdolls.get(rag as u32) else { return }; + let Some(r) = eng.ragdolls.get(rag as u32) else { + return; + }; (r.bodies(), r.constraint_handles().to_vec()) }; // Constraints first: destroying a body out from under a live // constraint is how you get a use-after-free in the solver. - for c in cons { eng.jolt.constraint_destroy(c); } - for b in bodies { eng.jolt.destroy_body(b); } + for c in cons { + eng.jolt.constraint_destroy(c); + } + for b in bodies { + eng.jolt.destroy_body(b); + } if let Some(r) = eng.ragdolls.get_mut(rag as u32) { *r = $crate::ragdoll::Ragdoll::new(); } - }) + }) } #[cfg(not(all(feature = "models3d", feature = "jolt", not(target_arch = "wasm32"))))] #[no_mangle] pub extern "C" fn bloom_ragdoll_release(_r: f64) {} - }; } diff --git a/native/shared/src/ffi_core/scene.rs b/native/shared/src/ffi_core/scene.rs index bb43c000..cee5d82a 100644 --- a/native/shared/src/ffi_core/scene.rs +++ b/native/shared/src/ffi_core/scene.rs @@ -9,13 +9,12 @@ #[macro_export] macro_rules! __bloom_ffi_scene { () => { - // bloom_scene_create_node [source: macos] #[no_mangle] pub extern "C" fn bloom_scene_create_node() -> f64 { $crate::ffi::guard("bloom_scene_create_node", move || { engine().scene.create_node() - }) + }) } // bloom_scene_destroy_node [source: macos] @@ -23,15 +22,15 @@ macro_rules! __bloom_ffi_scene { pub extern "C" fn bloom_scene_destroy_node(handle: f64) { $crate::ffi::guard("bloom_scene_destroy_node", move || { engine().scene.destroy_node(handle); - }) + }) } // bloom_scene_set_visible [source: macos] #[no_mangle] - pub extern "C" fn bloom_scene_set_visible(handle: f64, visible: f64) { - $crate::ffi::guard("bloom_scene_set_visible", move || { + pub extern "C" fn bloom_scene_set_visible(handle: f64, visible: f64) -> f64 { + $crate::ffi::guard_applied("bloom_scene_set_visible", move || { engine().scene.set_visible(handle, visible != 0.0); - }) + }) } // bloom_scene_set_trs — position + Y-rotation + uniform scale as six @@ -39,18 +38,32 @@ macro_rules! __bloom_ffi_scene { // array into an i64 pointer param, which Perry 0.5.x rejects; this // covers the common placement case without touching that ABI. #[no_mangle] - pub extern "C" fn bloom_scene_set_trs(handle: f64, px: f64, py: f64, pz: f64, yaw: f64, scale: f64) { - $crate::ffi::guard("bloom_scene_set_trs", move || { - engine().scene.set_trs(handle, px as f32, py as f32, pz as f32, yaw as f32, scale as f32); - }) + pub extern "C" fn bloom_scene_set_trs( + handle: f64, + px: f64, + py: f64, + pz: f64, + yaw: f64, + scale: f64, + ) -> f64 { + $crate::ffi::guard_applied("bloom_scene_set_trs", move || { + engine().scene.set_trs( + handle, + px as f32, + py as f32, + pz as f32, + yaw as f32, + scale as f32, + ); + }) } // bloom_scene_set_cast_shadow [source: macos] #[no_mangle] - pub extern "C" fn bloom_scene_set_cast_shadow(handle: f64, cast: f64) { - $crate::ffi::guard("bloom_scene_set_cast_shadow", move || { + pub extern "C" fn bloom_scene_set_cast_shadow(handle: f64, cast: f64) -> f64 { + $crate::ffi::guard_applied("bloom_scene_set_cast_shadow", move || { engine().scene.set_cast_shadow(handle, cast != 0.0); - }) + }) } // bloom_scene_set_gi_only — mark a node as a GI proxy: it feeds @@ -59,33 +72,35 @@ macro_rules! __bloom_ffi_scene { // and the sun-shadow pass. For material-system games whose world // never becomes scene nodes. #[no_mangle] - pub extern "C" fn bloom_scene_set_gi_only(handle: f64, gi_only: f64) { - $crate::ffi::guard("bloom_scene_set_gi_only", move || { + pub extern "C" fn bloom_scene_set_gi_only(handle: f64, gi_only: f64) -> f64 { + $crate::ffi::guard_applied("bloom_scene_set_gi_only", move || { engine().scene.set_gi_only(handle, gi_only != 0.0); - }) + }) } // bloom_scene_set_receive_shadow [source: macos] #[no_mangle] - pub extern "C" fn bloom_scene_set_receive_shadow(handle: f64, receive: f64) { - $crate::ffi::guard("bloom_scene_set_receive_shadow", move || { + pub extern "C" fn bloom_scene_set_receive_shadow(handle: f64, receive: f64) -> f64 { + $crate::ffi::guard_applied("bloom_scene_set_receive_shadow", move || { engine().scene.set_receive_shadow(handle, receive != 0.0); - }) + }) } // bloom_scene_set_parent [source: macos] #[no_mangle] - pub extern "C" fn bloom_scene_set_parent(handle: f64, parent: f64) { - $crate::ffi::guard("bloom_scene_set_parent", move || { + pub extern "C" fn bloom_scene_set_parent(handle: f64, parent: f64) -> f64 { + $crate::ffi::guard_applied("bloom_scene_set_parent", move || { engine().scene.set_parent(handle, parent); - }) + }) } // bloom_scene_set_transform [source: macos] #[no_mangle] - pub extern "C" fn bloom_scene_set_transform(handle: f64, mat_ptr: *const f64) { + pub extern "C" fn bloom_scene_set_transform(handle: f64, mat_ptr: *const f64) -> f64 { $crate::ffi::guard("bloom_scene_set_transform", move || { - if mat_ptr.is_null() { return; } + if mat_ptr.is_null() { + return 0.0; + } let slice = unsafe { std::slice::from_raw_parts(mat_ptr, 16) }; let mut mat = [[0.0f32; 4]; 4]; for col in 0..4 { @@ -94,7 +109,8 @@ macro_rules! __bloom_ffi_scene { } } engine().scene.set_transform(handle, mat); - }) + 1.0 + }) } // bloom_scene_set_transform16 — all-f64 variant of set_transform. @@ -109,17 +125,26 @@ macro_rules! __bloom_ffi_scene { #[allow(clippy::too_many_arguments)] pub extern "C" fn bloom_scene_set_transform16( handle: f64, - m0: f64, m1: f64, m2: f64, m3: f64, - m4: f64, m5: f64, m6: f64, m7: f64, - m8: f64, m9: f64, m10: f64, m11: f64, - m12: f64, m13: f64, m14: f64, m15: f64, - ) { - $crate::ffi::guard("bloom_scene_set_transform16", move || { + m0: f64, + m1: f64, + m2: f64, + m3: f64, + m4: f64, + m5: f64, + m6: f64, + m7: f64, + m8: f64, + m9: f64, + m10: f64, + m11: f64, + m12: f64, + m13: f64, + m14: f64, + m15: f64, + ) -> f64 { + $crate::ffi::guard_applied("bloom_scene_set_transform16", move || { let s = [ - m0, m1, m2, m3, - m4, m5, m6, m7, - m8, m9, m10, m11, - m12, m13, m14, m15, + m0, m1, m2, m3, m4, m5, m6, m7, m8, m9, m10, m11, m12, m13, m14, m15, ]; let mut mat = [[0.0f32; 4]; 4]; for col in 0..4 { @@ -128,7 +153,7 @@ macro_rules! __bloom_ffi_scene { } } engine().scene.set_transform(handle, mat); - }) + }) } // bloom_scene_update_geometry [source: macos] @@ -141,7 +166,9 @@ macro_rules! __bloom_ffi_scene { idx_count: f64, ) { $crate::ffi::guard("bloom_scene_update_geometry", move || { - if vert_ptr.is_null() || idx_ptr.is_null() { return; } + if vert_ptr.is_null() || idx_ptr.is_null() { + return; + } let nv = vert_count as usize; let ni = idx_count as usize; @@ -152,10 +179,23 @@ macro_rules! __bloom_ffi_scene { for i in 0..nv { let base = i * 12; vertices.push($crate::renderer::Vertex3D { - position: [vert_floats[base] as f32, vert_floats[base+1] as f32, vert_floats[base+2] as f32], - normal: [vert_floats[base+3] as f32, vert_floats[base+4] as f32, vert_floats[base+5] as f32], - color: [vert_floats[base+6] as f32, vert_floats[base+7] as f32, vert_floats[base+8] as f32, vert_floats[base+9] as f32], - uv: [vert_floats[base+10] as f32, vert_floats[base+11] as f32], + position: [ + vert_floats[base] as f32, + vert_floats[base + 1] as f32, + vert_floats[base + 2] as f32, + ], + normal: [ + vert_floats[base + 3] as f32, + vert_floats[base + 4] as f32, + vert_floats[base + 5] as f32, + ], + color: [ + vert_floats[base + 6] as f32, + vert_floats[base + 7] as f32, + vert_floats[base + 8] as f32, + vert_floats[base + 9] as f32, + ], + uv: [vert_floats[base + 10] as f32, vert_floats[base + 11] as f32], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4], @@ -165,7 +205,7 @@ macro_rules! __bloom_ffi_scene { let indices: Vec = idx_floats.iter().map(|&v| v as u32).collect(); engine().scene.update_geometry(handle, vertices, indices); - }) + }) } // bloom_scene_set_lod — reduced-detail variant for a node. Same @@ -181,9 +221,11 @@ macro_rules! __bloom_ffi_scene { idx_ptr: *const f64, idx_count: f64, max_coverage: f64, - ) { + ) -> f64 { $crate::ffi::guard("bloom_scene_set_lod", move || { - if vert_ptr.is_null() || idx_ptr.is_null() { return; } + if vert_ptr.is_null() || idx_ptr.is_null() { + return 0.0; + } let nv = vert_count as usize; let ni = idx_count as usize; let vert_floats = unsafe { std::slice::from_raw_parts(vert_ptr, nv * 12) }; @@ -192,67 +234,199 @@ macro_rules! __bloom_ffi_scene { for i in 0..nv { let base = i * 12; vertices.push($crate::renderer::Vertex3D { - position: [vert_floats[base] as f32, vert_floats[base+1] as f32, vert_floats[base+2] as f32], - normal: [vert_floats[base+3] as f32, vert_floats[base+4] as f32, vert_floats[base+5] as f32], - color: [vert_floats[base+6] as f32, vert_floats[base+7] as f32, vert_floats[base+8] as f32, vert_floats[base+9] as f32], - uv: [vert_floats[base+10] as f32, vert_floats[base+11] as f32], + position: [ + vert_floats[base] as f32, + vert_floats[base + 1] as f32, + vert_floats[base + 2] as f32, + ], + normal: [ + vert_floats[base + 3] as f32, + vert_floats[base + 4] as f32, + vert_floats[base + 5] as f32, + ], + color: [ + vert_floats[base + 6] as f32, + vert_floats[base + 7] as f32, + vert_floats[base + 8] as f32, + vert_floats[base + 9] as f32, + ], + uv: [vert_floats[base + 10] as f32, vert_floats[base + 11] as f32], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4], }); } let indices: Vec = idx_floats.iter().map(|&v| v as u32).collect(); - engine().scene.set_lod_geometry(handle, lod_index as usize, vertices, indices, max_coverage as f32); - }) + engine().scene.set_lod_geometry( + handle, + lod_index as usize, + vertices, + indices, + max_coverage as f32, + ); + 1.0 + }) } // bloom_scene_attach_model_lod — model mesh as a reduced-detail // variant (LOD counterpart of bloom_scene_attach_model). #[cfg(feature = "models3d")] #[no_mangle] - pub extern "C" fn bloom_scene_attach_model_lod(node_handle: f64, model_handle: f64, mesh_index: f64, lod_index: f64, max_coverage: f64) { + pub extern "C" fn bloom_scene_attach_model_lod( + node_handle: f64, + model_handle: f64, + mesh_index: f64, + lod_index: f64, + max_coverage: f64, + ) -> f64 { $crate::ffi::guard("bloom_scene_attach_model_lod", move || { let eng = engine(); let mi = mesh_index as usize; let model_data = match eng.models.models.get(model_handle) { Some(md) => md, - None => return, + None => return 0.0, }; - if mi >= model_data.meshes.len() { return; } + if mi >= model_data.meshes.len() { + return 0.0; + } let mesh = &model_data.meshes[mi]; let vertices = mesh.vertices.clone(); + let secondary_tex_coords = mesh.secondary_tex_coords.clone(); let indices = mesh.indices.clone(); - eng.scene.set_lod_geometry(node_handle, lod_index as usize, vertices, indices, max_coverage as f32); - }) + eng.scene.set_lod_geometry_with_secondary_uv( + node_handle, + lod_index as usize, + vertices, + secondary_tex_coords, + indices, + max_coverage as f32, + ); + 1.0 + }) } #[cfg(not(feature = "models3d"))] #[no_mangle] - pub extern "C" fn bloom_scene_attach_model_lod(_node_handle: f64, _model_handle: f64, _mesh_index: f64, _lod_index: f64, _max_coverage: f64) { + pub extern "C" fn bloom_scene_attach_model_lod( + _node_handle: f64, + _model_handle: f64, + _mesh_index: f64, + _lod_index: f64, + _max_coverage: f64, + ) -> f64 { $crate::ffi::feature_off_warn_once("bloom_scene_attach_model_lod", "models3d"); + 0.0 } // bloom_scene_set_material_color [source: macos] #[no_mangle] - pub extern "C" fn bloom_scene_set_material_color(handle: f64, r: f64, g: f64, b: f64, a: f64) { - $crate::ffi::guard("bloom_scene_set_material_color", move || { - engine().scene.set_material_color(handle, r as f32, g as f32, b as f32, a as f32); - }) + pub extern "C" fn bloom_scene_set_material_color( + handle: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) -> f64 { + $crate::ffi::guard_applied("bloom_scene_set_material_color", move || { + engine() + .scene + .set_material_color(handle, r as f32, g as f32, b as f32, a as f32); + }) } // bloom_scene_set_material_pbr [source: macos] #[no_mangle] - pub extern "C" fn bloom_scene_set_material_pbr(handle: f64, roughness: f64, metalness: f64) { - $crate::ffi::guard("bloom_scene_set_material_pbr", move || { - engine().scene.set_material_pbr(handle, roughness as f32, metalness as f32); - }) + pub extern "C" fn bloom_scene_set_material_pbr( + handle: f64, + roughness: f64, + metalness: f64, + ) -> f64 { + $crate::ffi::guard_applied("bloom_scene_set_material_pbr", move || { + engine() + .scene + .set_material_pbr(handle, roughness as f32, metalness as f32); + }) + } + + // bloom_scene_set_material_emissive [source: macos] + #[no_mangle] + pub extern "C" fn bloom_scene_set_material_emissive( + handle: f64, + r: f64, + g: f64, + b: f64, + ) -> f64 { + $crate::ffi::guard_applied("bloom_scene_set_material_emissive", move || { + let finite_non_negative = |value: f64| { + if value.is_finite() { + (value as f32).max(0.0) + } else { + 0.0 + } + }; + engine().scene.set_material_emissive_factor( + handle, + finite_non_negative(r), + finite_non_negative(g), + finite_non_negative(b), + ); + }) + } + + // bloom_scene_set_material_layered_pbr [source: macos] + #[no_mangle] + #[allow(clippy::too_many_arguments)] + pub extern "C" fn bloom_scene_set_material_layered_pbr( + handle: f64, + lobe_mask: f64, + clearcoat_factor: f64, + clearcoat_roughness: f64, + clearcoat_normal_scale: f64, + specular_factor: f64, + specular_r: f64, + specular_g: f64, + specular_b: f64, + ior: f64, + sheen_r: f64, + sheen_g: f64, + sheen_b: f64, + sheen_roughness: f64, + anisotropy_strength: f64, + anisotropy_rotation: f64, + iridescence_factor: f64, + iridescence_ior: f64, + iridescence_thickness_minimum: f64, + iridescence_thickness_maximum: f64, + ) -> f64 { + $crate::ffi::guard_applied("bloom_scene_set_material_layered_pbr", move || { + let layered = $crate::models::MaterialLayeredPbr::from_authoring_factors( + lobe_mask as u32, + clearcoat_factor as f32, + clearcoat_roughness as f32, + clearcoat_normal_scale as f32, + specular_factor as f32, + [specular_r as f32, specular_g as f32, specular_b as f32], + ior as f32, + [sheen_r as f32, sheen_g as f32, sheen_b as f32], + sheen_roughness as f32, + anisotropy_strength as f32, + anisotropy_rotation as f32, + iridescence_factor as f32, + iridescence_ior as f32, + iridescence_thickness_minimum as f32, + iridescence_thickness_maximum as f32, + ); + engine().scene.set_material_layered_pbr(handle, layered); + }) } // bloom_scene_set_material_texture [source: macos] #[no_mangle] - pub extern "C" fn bloom_scene_set_material_texture(handle: f64, texture_idx: f64) { - $crate::ffi::guard("bloom_scene_set_material_texture", move || { - engine().scene.set_material_texture(handle, texture_idx as u32); - }) + pub extern "C" fn bloom_scene_set_material_texture(handle: f64, texture_idx: f64) -> f64 { + $crate::ffi::guard_applied("bloom_scene_set_material_texture", move || { + engine() + .scene + .set_material_texture(handle, texture_idx as u32); + }) } // bloom_scene_node_count [source: macos] @@ -260,7 +434,7 @@ macro_rules! __bloom_ffi_scene { pub extern "C" fn bloom_scene_node_count() -> f64 { $crate::ffi::guard("bloom_scene_node_count", move || { engine().scene.node_count() as f64 - }) + }) } // bloom_scene_node_vertex_count [source: macos] @@ -271,7 +445,7 @@ macro_rules! __bloom_ffi_scene { Some(node) => node.vertices.len() as f64, None => -1.0, } - }) + }) } // bloom_scene_node_index_count [source: macos] @@ -282,12 +456,16 @@ macro_rules! __bloom_ffi_scene { Some(node) => node.indices.len() as f64, None => -1.0, } - }) + }) } // bloom_scene_pick_all [source: macos] #[no_mangle] - pub extern "C" fn bloom_scene_pick_all(screen_x: f64, screen_y: f64, max_results: f64) -> f64 { + pub extern "C" fn bloom_scene_pick_all( + screen_x: f64, + screen_y: f64, + max_results: f64, + ) -> f64 { $crate::ffi::guard("bloom_scene_pick_all", move || { let eng = engine(); let inv_vp = eng.renderer.inverse_vp_matrix(); @@ -295,13 +473,23 @@ macro_rules! __bloom_ffi_scene { let w = eng.renderer.width() as f32; let h = eng.renderer.height() as f32; let (origin, direction) = $crate::picking::screen_to_ray( - screen_x as f32, screen_y as f32, w, h, &inv_vp, &cam_pos, + screen_x as f32, + screen_y as f32, + w, + h, + &inv_vp, + &cam_pos, + ); + let results = $crate::picking::raycast_scene_all( + &eng.scene, + &origin, + &direction, + max_results as usize, ); - let results = $crate::picking::raycast_scene_all(&eng.scene, &origin, &direction, max_results as usize); let count = results.len(); eng.last_pick_all = results; count as f64 - }) + }) } // bloom_pick_all_handle [source: macos] @@ -309,8 +497,12 @@ macro_rules! __bloom_ffi_scene { pub extern "C" fn bloom_pick_all_handle(index: f64) -> f64 { $crate::ffi::guard("bloom_pick_all_handle", move || { let i = index as usize; - engine().last_pick_all.get(i).map(|r| r.handle).unwrap_or(0.0) - }) + engine() + .last_pick_all + .get(i) + .map(|r| r.handle) + .unwrap_or(0.0) + }) } // bloom_pick_all_distance [source: macos] @@ -318,16 +510,36 @@ macro_rules! __bloom_ffi_scene { pub extern "C" fn bloom_pick_all_distance(index: f64) -> f64 { $crate::ffi::guard("bloom_pick_all_distance", move || { let i = index as usize; - engine().last_pick_all.get(i).map(|r| r.distance as f64).unwrap_or(0.0) - }) + engine() + .last_pick_all + .get(i) + .map(|r| r.distance as f64) + .unwrap_or(0.0) + }) } // bloom_scene_set_material_water [source: macos] #[no_mangle] - pub extern "C" fn bloom_scene_set_material_water(handle: f64, wave_amp: f64, wave_speed: f64, r: f64, g: f64, b: f64, a: f64) { - $crate::ffi::guard("bloom_scene_set_material_water", move || { - engine().scene.set_material_water(handle, wave_amp as f32, wave_speed as f32, r as f32, g as f32, b as f32, a as f32); - }) + pub extern "C" fn bloom_scene_set_material_water( + handle: f64, + wave_amp: f64, + wave_speed: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) -> f64 { + $crate::ffi::guard_applied("bloom_scene_set_material_water", move || { + engine().scene.set_material_water( + handle, + wave_amp as f32, + wave_speed as f32, + r as f32, + g as f32, + b as f32, + a as f32, + ); + }) } // bloom_scene_get_transform [source: macos] @@ -338,52 +550,68 @@ macro_rules! __bloom_ffi_scene { let i = index as usize; let col = i / 4; let row = i % 4; - if col < 4 && row < 4 { mat[col][row] as f64 } else { 0.0 } - }) + if col < 4 && row < 4 { + mat[col][row] as f64 + } else { + 0.0 + } + }) } // bloom_scene_get_bounds_min_x [source: macos] #[no_mangle] pub extern "C" fn bloom_scene_get_bounds_min_x(handle: f64) -> f64 { - $crate::ffi::guard("bloom_scene_get_bounds_min_x", move || { engine().scene.get_bounds(handle).0[0] as f64 }) + $crate::ffi::guard("bloom_scene_get_bounds_min_x", move || { + engine().scene.get_bounds(handle).0[0] as f64 + }) } // bloom_scene_get_bounds_min_y [source: macos] #[no_mangle] pub extern "C" fn bloom_scene_get_bounds_min_y(handle: f64) -> f64 { - $crate::ffi::guard("bloom_scene_get_bounds_min_y", move || { engine().scene.get_bounds(handle).0[1] as f64 }) + $crate::ffi::guard("bloom_scene_get_bounds_min_y", move || { + engine().scene.get_bounds(handle).0[1] as f64 + }) } // bloom_scene_get_bounds_min_z [source: macos] #[no_mangle] pub extern "C" fn bloom_scene_get_bounds_min_z(handle: f64) -> f64 { - $crate::ffi::guard("bloom_scene_get_bounds_min_z", move || { engine().scene.get_bounds(handle).0[2] as f64 }) + $crate::ffi::guard("bloom_scene_get_bounds_min_z", move || { + engine().scene.get_bounds(handle).0[2] as f64 + }) } // bloom_scene_get_bounds_max_x [source: macos] #[no_mangle] pub extern "C" fn bloom_scene_get_bounds_max_x(handle: f64) -> f64 { - $crate::ffi::guard("bloom_scene_get_bounds_max_x", move || { engine().scene.get_bounds(handle).1[0] as f64 }) + $crate::ffi::guard("bloom_scene_get_bounds_max_x", move || { + engine().scene.get_bounds(handle).1[0] as f64 + }) } // bloom_scene_get_bounds_max_y [source: macos] #[no_mangle] pub extern "C" fn bloom_scene_get_bounds_max_y(handle: f64) -> f64 { - $crate::ffi::guard("bloom_scene_get_bounds_max_y", move || { engine().scene.get_bounds(handle).1[1] as f64 }) + $crate::ffi::guard("bloom_scene_get_bounds_max_y", move || { + engine().scene.get_bounds(handle).1[1] as f64 + }) } // bloom_scene_get_bounds_max_z [source: macos] #[no_mangle] pub extern "C" fn bloom_scene_get_bounds_max_z(handle: f64) -> f64 { - $crate::ffi::guard("bloom_scene_get_bounds_max_z", move || { engine().scene.get_bounds(handle).1[2] as f64 }) + $crate::ffi::guard("bloom_scene_get_bounds_max_z", move || { + engine().scene.get_bounds(handle).1[2] as f64 + }) } // bloom_scene_set_user_data [source: macos] #[no_mangle] - pub extern "C" fn bloom_scene_set_user_data(handle: f64, data: f64) { - $crate::ffi::guard("bloom_scene_set_user_data", move || { + pub extern "C" fn bloom_scene_set_user_data(handle: f64, data: f64) -> f64 { + $crate::ffi::guard_applied("bloom_scene_set_user_data", move || { engine().scene.set_user_data(handle, data as i64); - }) + }) } // bloom_scene_get_user_data [source: macos] @@ -391,7 +619,7 @@ macro_rules! __bloom_ffi_scene { pub extern "C" fn bloom_scene_get_user_data(handle: f64) -> f64 { $crate::ffi::guard("bloom_scene_get_user_data", move || { engine().scene.get_user_data(handle) as f64 - }) + }) } // bloom_scene_extrude_polygon [source: macos] @@ -403,21 +631,29 @@ macro_rules! __bloom_ffi_scene { depth: f64, ) { $crate::ffi::guard("bloom_scene_extrude_polygon", move || { - if polygon_ptr.is_null() { return; } + if polygon_ptr.is_null() { + return; + } let n = polygon_count as usize; let polygon = unsafe { std::slice::from_raw_parts(polygon_ptr, n * 2) }; let geo = $crate::geometry::extrude_polygon(polygon, &[], depth); - engine().scene.update_geometry(handle, geo.vertices, geo.indices); - }) + engine() + .scene + .update_geometry(handle, geo.vertices, geo.indices); + }) } // bloom_scene_subtract_box [source: macos] #[no_mangle] pub extern "C" fn bloom_scene_subtract_box( handle: f64, - min_x: f64, min_y: f64, min_z: f64, - max_x: f64, max_y: f64, max_z: f64, + min_x: f64, + min_y: f64, + min_z: f64, + max_x: f64, + max_y: f64, + max_z: f64, ) { $crate::ffi::guard("bloom_scene_subtract_box", move || { let eng = engine(); @@ -431,15 +667,20 @@ macro_rules! __bloom_ffi_scene { [min_x as f32, min_y as f32, min_z as f32], [max_x as f32, max_y as f32, max_z as f32], ); - eng.scene.update_geometry(handle, result.vertices, result.indices); + eng.scene + .update_geometry(handle, result.vertices, result.indices); } - }) + }) } // bloom_scene_attach_model [source: linux; gated: models3d] #[cfg(feature = "models3d")] #[no_mangle] - pub extern "C" fn bloom_scene_attach_model(node_handle: f64, model_handle: f64, mesh_index: f64) { + pub extern "C" fn bloom_scene_attach_model( + node_handle: f64, + model_handle: f64, + mesh_index: f64, + ) { $crate::ffi::guard("bloom_scene_attach_model", move || { let eng = engine(); let mi = mesh_index as usize; @@ -448,10 +689,13 @@ macro_rules! __bloom_ffi_scene { Some(md) => md, None => return, }; - if mi >= model_data.meshes.len() { return; } + if mi >= model_data.meshes.len() { + return; + } let mesh = &model_data.meshes[mi]; let vertices = mesh.vertices.clone(); + let secondary_tex_coords = mesh.secondary_tex_coords.clone(); let indices = mesh.indices.clone(); let base_color_tex = mesh.texture_idx; let normal_tex = mesh.normal_texture_idx; @@ -460,8 +704,18 @@ macro_rules! __bloom_ffi_scene { let emissive_factor = mesh.emissive_factor; let roughness_factor = mesh.roughness_factor; let metallic_factor = mesh.metallic_factor; + let alpha_mode = mesh.alpha_mode; let alpha_cutoff = mesh.alpha_cutoff; - eng.scene.update_geometry(node_handle, vertices, indices); + let alpha_coverage_mips = mesh.alpha_coverage_mips; + let double_sided = mesh.double_sided; + let transmission = mesh.transmission; + let layered_pbr = mesh.layered_pbr; + eng.scene.update_geometry_with_secondary_uv( + node_handle, + vertices, + secondary_tex_coords, + indices, + ); if let Some(tex_idx) = base_color_tex { eng.scene.set_material_texture(node_handle, tex_idx); @@ -470,10 +724,12 @@ macro_rules! __bloom_ffi_scene { eng.scene.set_material_normal_texture(node_handle, tex_idx); } if let Some(tex_idx) = mr_tex { - eng.scene.set_material_metallic_roughness_texture(node_handle, tex_idx); + eng.scene + .set_material_metallic_roughness_texture(node_handle, tex_idx); } if let Some(tex_idx) = emissive_tex { - eng.scene.set_material_emissive_texture(node_handle, tex_idx); + eng.scene + .set_material_emissive_texture(node_handle, tex_idx); } eng.scene.set_material_emissive_factor( node_handle, @@ -485,13 +741,28 @@ macro_rules! __bloom_ffi_scene { // previously dropped, which left attached foliage opaque // (solid cards) and every attached mesh at the default // roughness 0.8 regardless of its authored material. - eng.scene.set_material_pbr(node_handle, roughness_factor, metallic_factor); - eng.scene.set_material_alpha_cutoff(node_handle, alpha_cutoff); - }) + eng.scene + .set_material_pbr(node_handle, roughness_factor, metallic_factor); + eng.scene.set_material_gltf_alpha( + node_handle, + alpha_mode, + alpha_cutoff, + double_sided, + ); + eng.scene + .set_material_alpha_coverage_mips(node_handle, alpha_coverage_mips); + eng.scene + .set_material_transmission(node_handle, transmission); + eng.scene.set_material_layered_pbr(node_handle, layered_pbr); + }) } #[cfg(not(feature = "models3d"))] #[no_mangle] - pub extern "C" fn bloom_scene_attach_model(_node_handle: f64, _model_handle: f64, _mesh_index: f64) { + pub extern "C" fn bloom_scene_attach_model( + _node_handle: f64, + _model_handle: f64, + _mesh_index: f64, + ) { $crate::ffi::feature_off_warn_once("bloom_scene_attach_model", "models3d"); } @@ -508,9 +779,9 @@ macro_rules! __bloom_ffi_scene { let x = wx as f32; let y = wy as f32; let z = wz as f32; - let clip_x = vp[0][0]*x + vp[1][0]*y + vp[2][0]*z + vp[3][0]; - let clip_y = vp[0][1]*x + vp[1][1]*y + vp[2][1]*z + vp[3][1]; - let clip_w = vp[0][3]*x + vp[1][3]*y + vp[2][3]*z + vp[3][3]; + let clip_x = vp[0][0] * x + vp[1][0] * y + vp[2][0] * z + vp[3][0]; + let clip_y = vp[0][1] * x + vp[1][1] * y + vp[2][1] * z + vp[3][1]; + let clip_w = vp[0][3] * x + vp[1][3] * y + vp[2][3] * z + vp[3][3]; if clip_w <= 0.0 { engine().last_project = (-9999.0, -9999.0); @@ -525,15 +796,13 @@ macro_rules! __bloom_ffi_scene { engine().last_project = (screen_x, screen_y); screen_x - }) + }) } // bloom_project_screen_y [source: macos] #[no_mangle] pub extern "C" fn bloom_project_screen_y() -> f64 { - $crate::ffi::guard("bloom_project_screen_y", move || { - engine().last_project.1 - }) + $crate::ffi::guard("bloom_project_screen_y", move || engine().last_project.1) } // bloom_scene_pick [source: macos] @@ -547,15 +816,23 @@ macro_rules! __bloom_ffi_scene { let h = eng.renderer.height() as f32; let (origin, direction) = $crate::picking::screen_to_ray( - screen_x as f32, screen_y as f32, - w, h, &inv_vp, &cam_pos, + screen_x as f32, + screen_y as f32, + w, + h, + &inv_vp, + &cam_pos, ); let result = $crate::picking::raycast_scene(&eng.scene, &origin, &direction); let hit = result.hit; engine().last_pick = Some(result); - if hit { 1.0 } else { 0.0 } - }) + if hit { + 1.0 + } else { + 0.0 + } + }) } // bloom_pick_hit_handle [source: macos] @@ -563,64 +840,91 @@ macro_rules! __bloom_ffi_scene { pub extern "C" fn bloom_pick_hit_handle() -> f64 { $crate::ffi::guard("bloom_pick_hit_handle", move || { engine().last_pick.as_ref().map(|r| r.handle).unwrap_or(0.0) - }) + }) } // bloom_pick_hit_distance [source: macos] #[no_mangle] pub extern "C" fn bloom_pick_hit_distance() -> f64 { $crate::ffi::guard("bloom_pick_hit_distance", move || { - engine().last_pick.as_ref().map(|r| r.distance as f64).unwrap_or(0.0) - }) + engine() + .last_pick + .as_ref() + .map(|r| r.distance as f64) + .unwrap_or(0.0) + }) } // bloom_pick_hit_x [source: macos] #[no_mangle] pub extern "C" fn bloom_pick_hit_x() -> f64 { $crate::ffi::guard("bloom_pick_hit_x", move || { - engine().last_pick.as_ref().map(|r| r.point[0] as f64).unwrap_or(0.0) - }) + engine() + .last_pick + .as_ref() + .map(|r| r.point[0] as f64) + .unwrap_or(0.0) + }) } // bloom_pick_hit_y [source: macos] #[no_mangle] pub extern "C" fn bloom_pick_hit_y() -> f64 { $crate::ffi::guard("bloom_pick_hit_y", move || { - engine().last_pick.as_ref().map(|r| r.point[1] as f64).unwrap_or(0.0) - }) + engine() + .last_pick + .as_ref() + .map(|r| r.point[1] as f64) + .unwrap_or(0.0) + }) } // bloom_pick_hit_z [source: macos] #[no_mangle] pub extern "C" fn bloom_pick_hit_z() -> f64 { $crate::ffi::guard("bloom_pick_hit_z", move || { - engine().last_pick.as_ref().map(|r| r.point[2] as f64).unwrap_or(0.0) - }) + engine() + .last_pick + .as_ref() + .map(|r| r.point[2] as f64) + .unwrap_or(0.0) + }) } // bloom_pick_hit_normal_x [source: macos] #[no_mangle] pub extern "C" fn bloom_pick_hit_normal_x() -> f64 { $crate::ffi::guard("bloom_pick_hit_normal_x", move || { - engine().last_pick.as_ref().map(|r| r.normal[0] as f64).unwrap_or(0.0) - }) + engine() + .last_pick + .as_ref() + .map(|r| r.normal[0] as f64) + .unwrap_or(0.0) + }) } // bloom_pick_hit_normal_y [source: macos] #[no_mangle] pub extern "C" fn bloom_pick_hit_normal_y() -> f64 { $crate::ffi::guard("bloom_pick_hit_normal_y", move || { - engine().last_pick.as_ref().map(|r| r.normal[1] as f64).unwrap_or(0.0) - }) + engine() + .last_pick + .as_ref() + .map(|r| r.normal[1] as f64) + .unwrap_or(0.0) + }) } // bloom_pick_hit_normal_z [source: macos] #[no_mangle] pub extern "C" fn bloom_pick_hit_normal_z() -> f64 { $crate::ffi::guard("bloom_pick_hit_normal_z", move || { - engine().last_pick.as_ref().map(|r| r.normal[2] as f64).unwrap_or(0.0) - }) + engine() + .last_pick + .as_ref() + .map(|r| r.normal[2] as f64) + .unwrap_or(0.0) + }) } - }; } diff --git a/native/shared/src/ffi_core/vfx.rs b/native/shared/src/ffi_core/vfx.rs index a2a64373..e09f49a2 100644 --- a/native/shared/src/ffi_core/vfx.rs +++ b/native/shared/src/ffi_core/vfx.rs @@ -14,7 +14,6 @@ #[macro_export] macro_rules! __bloom_ffi_vfx { () => { - // ---- EN-026 particles ------------------------------------------ // bloom_particles_create — pool + dynamic instance buffer. @@ -24,10 +23,12 @@ macro_rules! __bloom_ffi_vfx { $crate::ffi::guard("bloom_particles_create", move || { let cap = (capacity as usize).clamp(1, 100_000); let eng = engine(); - let ib = eng.renderer.material_system.create_dynamic_instance_buffer( - &eng.renderer.device, cap as u32); + let ib = eng + .renderer + .material_system + .create_dynamic_instance_buffer(&eng.renderer.device, cap as u32); eng.particles.create(cap, ib) as f64 - }) + }) } // bloom_particles_configure — behaviour, pushed through the mesh @@ -48,15 +49,23 @@ macro_rules! __bloom_ffi_vfx { } } #[cfg(not(feature = "models3d"))] - { let _ = sys; } - }) + { + let _ = sys; + } + }) } // bloom_particles_emit — one burst. 8 args (the ARM64 ceiling). #[no_mangle] pub extern "C" fn bloom_particles_emit( - sys: f64, x: f64, y: f64, z: f64, - dx: f64, dy: f64, dz: f64, count: f64, + sys: f64, + x: f64, + y: f64, + z: f64, + dx: f64, + dy: f64, + dz: f64, + count: f64, ) { $crate::ffi::guard("bloom_particles_emit", move || { if let Some(s) = engine().particles.get_mut(sys as u32) { @@ -66,7 +75,7 @@ macro_rules! __bloom_ffi_vfx { (count as usize).min(4096), ); } - }) + }) } // bloom_particles_update — integrate + upload. Returns the live count, @@ -88,10 +97,14 @@ macro_rules! __bloom_ffi_vfx { None => return 0.0, }; eng.renderer.material_system.update_instance_buffer( - &eng.renderer.queue, ib, &packed, live); + &eng.renderer.queue, + ib, + &packed, + live, + ); } live as f64 - }) + }) } // bloom_particles_instance_buffer — the handle to hand to @@ -99,26 +112,32 @@ macro_rules! __bloom_ffi_vfx { #[no_mangle] pub extern "C" fn bloom_particles_instance_buffer(sys: f64) -> f64 { $crate::ffi::guard("bloom_particles_instance_buffer", move || { - engine().particles.get_mut(sys as u32) + engine() + .particles + .get_mut(sys as u32) .map(|s| s.instance_buffer as f64) .unwrap_or(0.0) - }) + }) } #[no_mangle] pub extern "C" fn bloom_particles_clear(sys: f64) { $crate::ffi::guard("bloom_particles_clear", move || { - if let Some(s) = engine().particles.get_mut(sys as u32) { s.clear(); } - }) + if let Some(s) = engine().particles.get_mut(sys as u32) { + s.clear(); + } + }) } #[no_mangle] pub extern "C" fn bloom_particles_live(sys: f64) -> f64 { $crate::ffi::guard("bloom_particles_live", move || { - engine().particles.get_mut(sys as u32) + engine() + .particles + .get_mut(sys as u32) .map(|s| s.live as f64) .unwrap_or(0.0) - }) + }) } // ---- EN-027 decals --------------------------------------------- @@ -129,11 +148,13 @@ macro_rules! __bloom_ffi_vfx { $crate::ffi::guard("bloom_decals_init", move || { let cap = (capacity as usize).clamp(1, 8192); let eng = engine(); - let ib = eng.renderer.material_system.create_dynamic_instance_buffer( - &eng.renderer.device, cap as u32); + let ib = eng + .renderer + .material_system + .create_dynamic_instance_buffer(&eng.renderer.device, cap as u32); eng.decals.init(cap, ib); ib as f64 - }) + }) } // bloom_decals_spawn — position + normal + size + roll. Colour, atlas @@ -141,24 +162,36 @@ macro_rules! __bloom_ffi_vfx { // sets once per decal *type* rather than paying for them on every hit. #[no_mangle] pub extern "C" fn bloom_decals_spawn( - x: f64, y: f64, z: f64, - nx: f64, ny: f64, nz: f64, - size: f64, roll: f64, + x: f64, + y: f64, + z: f64, + nx: f64, + ny: f64, + nz: f64, + size: f64, + roll: f64, ) { $crate::ffi::guard("bloom_decals_spawn", move || { engine().decals.spawn_styled( [x as f32, y as f32, z as f32], [nx as f32, ny as f32, nz as f32], - size as f32, roll as f32, + size as f32, + roll as f32, ); - }) + }) } // bloom_decals_set_style — frame, rgba, life, fade for subsequent // spawns. 7 args. #[no_mangle] pub extern "C" fn bloom_decals_set_style( - frame: f64, r: f64, g: f64, b: f64, a: f64, life: f64, fade: f64, + frame: f64, + r: f64, + g: f64, + b: f64, + a: f64, + life: f64, + fade: f64, ) { $crate::ffi::guard("bloom_decals_set_style", move || { engine().decals.style = $crate::decals::DecalStyle { @@ -167,7 +200,7 @@ macro_rules! __bloom_ffi_vfx { life: life as f32, fade: fade as f32, }; - }) + }) } #[no_mangle] @@ -179,25 +212,28 @@ macro_rules! __bloom_ffi_vfx { if live > 0 { let packed: Vec = eng.decals.packed()[..(live as usize) * 12].to_vec(); eng.renderer.material_system.update_instance_buffer( - &eng.renderer.queue, ib, &packed, live); + &eng.renderer.queue, + ib, + &packed, + live, + ); } live as f64 - }) + }) } #[no_mangle] pub extern "C" fn bloom_decals_instance_buffer() -> f64 { $crate::ffi::guard("bloom_decals_instance_buffer", move || { engine().decals.instance_buffer as f64 - }) + }) } #[no_mangle] pub extern "C" fn bloom_decals_clear() { $crate::ffi::guard("bloom_decals_clear", move || { engine().decals.clear(); - }) + }) } - }; } diff --git a/native/shared/src/ffi_core/visual.rs b/native/shared/src/ffi_core/visual.rs index bbde5646..1f0df95e 100644 --- a/native/shared/src/ffi_core/visual.rs +++ b/native/shared/src/ffi_core/visual.rs @@ -9,21 +9,21 @@ #[macro_export] macro_rules! __bloom_ffi_visual { () => { - // bloom_set_env_clear_from_hdr [source: curated; gated: image-extras] #[cfg(feature = "image-extras")] #[no_mangle] - pub extern "C" fn bloom_set_env_clear_from_hdr(path_ptr: *const u8) { - $crate::ffi::guard("bloom_set_env_clear_from_hdr", move || { + pub extern "C" fn bloom_set_env_clear_from_hdr(path_ptr: *const u8) -> f64 { + $crate::ffi::guard_applied("bloom_set_env_clear_from_hdr", move || { let path = $crate::string_header::str_from_header(path_ptr); let path: &str = &bloom_resolve_asset_path(path); engine().renderer.set_env_clear_from_hdr_file(path); - }) + }) } #[cfg(not(feature = "image-extras"))] #[no_mangle] - pub extern "C" fn bloom_set_env_clear_from_hdr(_path_ptr: *const u8) { + pub extern "C" fn bloom_set_env_clear_from_hdr(_path_ptr: *const u8) -> f64 { $crate::ffi::feature_off_warn_once("bloom_set_env_clear_from_hdr", "image-extras"); + 0.0 } // bloom_set_target_fps [source: macos] @@ -31,7 +31,7 @@ macro_rules! __bloom_ffi_visual { pub extern "C" fn bloom_set_target_fps(fps: f64) { $crate::ffi::guard("bloom_set_target_fps", move || { engine().target_fps = fps; - }) + }) } // bloom_set_direct_2d_mode [source: macos] @@ -39,7 +39,7 @@ macro_rules! __bloom_ffi_visual { pub extern "C" fn bloom_set_direct_2d_mode(on: f64) { $crate::ffi::guard("bloom_set_direct_2d_mode", move || { engine().direct_2d_mode = on > 0.5; - }) + }) } // bloom_set_sound_volume [source: macos] @@ -47,7 +47,7 @@ macro_rules! __bloom_ffi_visual { pub extern "C" fn bloom_set_sound_volume(handle: f64, volume: f64) { $crate::ffi::guard("bloom_set_sound_volume", move || { engine().audio.set_sound_volume(handle, volume as f32); - }) + }) } // bloom_set_master_volume [source: macos] @@ -55,15 +55,24 @@ macro_rules! __bloom_ffi_visual { pub extern "C" fn bloom_set_master_volume(volume: f64) { $crate::ffi::guard("bloom_set_master_volume", move || { engine().audio.set_master_volume(volume as f32); - }) + }) } // bloom_set_listener_position [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_listener_position(x: f64, y: f64, z: f64, fx: f64, fy: f64, fz: f64) { + pub extern "C" fn bloom_set_listener_position( + x: f64, + y: f64, + z: f64, + fx: f64, + fy: f64, + fz: f64, + ) { $crate::ffi::guard("bloom_set_listener_position", move || { - engine().audio.set_listener_position(x as f32, y as f32, z as f32, fx as f32, fy as f32, fz as f32); - }) + engine().audio.set_listener_position( + x as f32, y as f32, z as f32, fx as f32, fy as f32, fz as f32, + ); + }) } // bloom_set_texture_filter [source: macos] @@ -75,65 +84,73 @@ macro_rules! __bloom_ffi_visual { let bind_group_idx = tex.bind_group_idx; eng.renderer.set_texture_filter(bind_group_idx, mode > 0.5); } - }) + }) } // bloom_set_material_reflection_probe [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_material_reflection_probe( - material: f64, probe: f64, - ) { - $crate::ffi::guard("bloom_set_material_reflection_probe", move || { - engine().renderer.set_material_reflection_probe(material as u32, probe as u32); - }) + pub extern "C" fn bloom_set_material_reflection_probe(material: f64, probe: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_material_reflection_probe", move || { + engine() + .renderer + .set_material_reflection_probe(material as u32, probe as u32); + }) } // bloom_set_material_texture_array [source: macos] #[no_mangle] pub extern "C" fn bloom_set_material_texture_array( - material: f64, slot: f64, array: f64, - ) { - $crate::ffi::guard("bloom_set_material_texture_array", move || { + material: f64, + slot: f64, + array: f64, + ) -> f64 { + $crate::ffi::guard_applied("bloom_set_material_texture_array", move || { engine().renderer.set_material_texture_array( - material as u32, slot as u32, array as u32, + material as u32, + slot as u32, + array as u32, ); - }) + }) } // bloom_set_material_shading_model [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_material_shading_model( - material: f64, model: f64, - ) { - $crate::ffi::guard("bloom_set_material_shading_model", move || { - engine().renderer.set_material_shading_model(material as u32, model as u32); - }) + pub extern "C" fn bloom_set_material_shading_model(material: f64, model: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_material_shading_model", move || { + engine() + .renderer + .set_material_shading_model(material as u32, model as u32); + }) } // bloom_set_material_probe_visible [source: shared] #[no_mangle] - pub extern "C" fn bloom_set_material_probe_visible( - material: f64, visible: f64, - ) { - $crate::ffi::guard("bloom_set_material_probe_visible", move || { - engine().renderer.set_material_probe_visible(material as u32, visible != 0.0); - }) + pub extern "C" fn bloom_set_material_probe_visible(material: f64, visible: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_material_probe_visible", move || { + engine() + .renderer + .set_material_probe_visible(material as u32, visible != 0.0); + }) } // bloom_set_material_foliage [source: macos] #[no_mangle] pub extern "C" fn bloom_set_material_foliage( material: f64, - trans_r: f64, trans_g: f64, trans_b: f64, - trans_amount: f64, wrap_factor: f64, - ) { - $crate::ffi::guard("bloom_set_material_foliage", move || { + trans_r: f64, + trans_g: f64, + trans_b: f64, + trans_amount: f64, + wrap_factor: f64, + ) -> f64 { + $crate::ffi::guard_applied("bloom_set_material_foliage", move || { engine().renderer.set_material_foliage( material as u32, [trans_r as f32, trans_g as f32, trans_b as f32], - trans_amount as f32, wrap_factor as f32, + trans_amount as f32, + wrap_factor as f32, ); - }) + }) } // bloom_set_post_pass [source: macos] @@ -143,169 +160,295 @@ macro_rules! __bloom_ffi_visual { let source = $crate::string_header::str_from_header(source_ptr); match engine().renderer.set_post_pass(source) { Ok(()) => 1.0, - Err(e) => { eprintln!("[post_pass] compile failed: {:?}", e); 0.0 } + Err(e) => { + eprintln!("[post_pass] compile failed: {:?}", e); + 0.0 + } } - }) + }) } // bloom_set_joint_test [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_joint_test(joint_index: f64, angle: f64) { - $crate::ffi::guard("bloom_set_joint_test", move || { - engine().renderer.set_joint_test(joint_index as usize, angle as f32); - }) + pub extern "C" fn bloom_set_joint_test(joint_index: f64, angle: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_joint_test", move || { + engine() + .renderer + .set_joint_test(joint_index as usize, angle as f32); + }) } // bloom_set_ambient_light [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_ambient_light(r: f64, g: f64, b: f64, intensity: f64) { - $crate::ffi::guard("bloom_set_ambient_light", move || { + pub extern "C" fn bloom_set_ambient_light(r: f64, g: f64, b: f64, intensity: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_ambient_light", move || { engine().renderer.set_ambient_light(r, g, b, intensity); - }) + }) } // bloom_set_directional_light [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_directional_light(dx: f64, dy: f64, dz: f64, r: f64, g: f64, b: f64, intensity: f64) { - $crate::ffi::guard("bloom_set_directional_light", move || { - engine().renderer.set_directional_light(dx, dy, dz, r, g, b, intensity); - }) + pub extern "C" fn bloom_set_directional_light( + dx: f64, + dy: f64, + dz: f64, + r: f64, + g: f64, + b: f64, + intensity: f64, + ) -> f64 { + $crate::ffi::guard_applied("bloom_set_directional_light", move || { + engine() + .renderer + .set_directional_light(dx, dy, dz, r, g, b, intensity); + }) } // bloom_set_procedural_sky [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_procedural_sky(enabled: f64, rayleigh_density: f64, mie_density: f64, ground_albedo: f64) { - $crate::ffi::guard("bloom_set_procedural_sky", move || { + pub extern "C" fn bloom_set_procedural_sky( + enabled: f64, + rayleigh_density: f64, + mie_density: f64, + ground_albedo: f64, + ) -> f64 { + $crate::ffi::guard_applied("bloom_set_procedural_sky", move || { engine().renderer.set_procedural_sky( enabled != 0.0, rayleigh_density as f32, mie_density as f32, ground_albedo as f32, ); - }) + }) } // bloom_set_sun_direction [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_sun_direction(dx: f64, dy: f64, dz: f64, intensity: f64) { - $crate::ffi::guard("bloom_set_sun_direction", move || { - engine().renderer.set_sun_direction(dx as f32, dy as f32, dz as f32, intensity as f32); - }) + pub extern "C" fn bloom_set_sun_direction( + dx: f64, + dy: f64, + dz: f64, + intensity: f64, + ) -> f64 { + $crate::ffi::guard_applied("bloom_set_sun_direction", move || { + engine().renderer.set_sun_direction( + dx as f32, + dy as f32, + dz as f32, + intensity as f32, + ); + }) } // bloom_set_fog [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_fog(r: f64, g: f64, b: f64, density: f64, height_ref: f64, height_falloff: f64) { - $crate::ffi::guard("bloom_set_fog", move || { + pub extern "C" fn bloom_set_fog( + r: f64, + g: f64, + b: f64, + density: f64, + height_ref: f64, + height_falloff: f64, + ) -> f64 { + $crate::ffi::guard_applied("bloom_set_fog", move || { let r_ = engine(); r_.renderer.set_fog_color(r as f32, g as f32, b as f32); r_.renderer.set_fog_density(density as f32); - r_.renderer.set_fog_height_falloff(height_ref as f32, height_falloff as f32); - }) + r_.renderer + .set_fog_height_falloff(height_ref as f32, height_falloff as f32); + }) } // bloom_set_chromatic_aberration [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_chromatic_aberration(strength: f64) { - $crate::ffi::guard("bloom_set_chromatic_aberration", move || { + pub extern "C" fn bloom_set_chromatic_aberration(strength: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_chromatic_aberration", move || { engine().renderer.set_chromatic_aberration(strength as f32); - }) + }) } // bloom_set_vignette [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_vignette(strength: f64, softness: f64) { - $crate::ffi::guard("bloom_set_vignette", move || { - engine().renderer.set_vignette(strength as f32, softness as f32); - }) + pub extern "C" fn bloom_set_vignette(strength: f64, softness: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_vignette", move || { + engine() + .renderer + .set_vignette(strength as f32, softness as f32); + }) } // bloom_set_film_grain [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_film_grain(strength: f64) { - $crate::ffi::guard("bloom_set_film_grain", move || { + pub extern "C" fn bloom_set_film_grain(strength: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_film_grain", move || { engine().renderer.set_film_grain(strength as f32); - }) + }) } // bloom_set_sharpen_strength [round-2 audit F8] #[no_mangle] - pub extern "C" fn bloom_set_sharpen_strength(strength: f64) { - $crate::ffi::guard("bloom_set_sharpen_strength", move || { + pub extern "C" fn bloom_set_sharpen_strength(strength: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_sharpen_strength", move || { engine().renderer.set_sharpen_strength(strength as f32); - }) + }) } // bloom_set_present_mode [round-2 audit F6] #[no_mangle] - pub extern "C" fn bloom_set_present_mode(mode: f64) { + pub extern "C" fn bloom_set_present_mode(mode: f64) -> f64 { $crate::ffi::guard("bloom_set_present_mode", move || { - engine().renderer.set_present_mode(mode as u32); - }) + if engine().renderer.set_present_mode(mode as u32) { + 1.0 + } else { + 0.0 + } + }) + } + + // bloom_get_present_mode [quality qualification] + #[no_mangle] + pub extern "C" fn bloom_get_present_mode() -> f64 { + $crate::ffi::guard("bloom_get_present_mode", move || { + engine().renderer.present_mode_code() as f64 + }) + } + + // GH #130 — capability-tiered material/texture indirection report. + #[no_mangle] + pub extern "C" fn bloom_get_material_binding_capabilities() -> *const u8 { + $crate::ffi::guard("bloom_get_material_binding_capabilities", move || { + let report = engine().renderer.material_binding_report_json(); + $crate::string_header::alloc_perry_string(&report) + }) + } + + // GH #138 — complete public renderer capability report. + #[no_mangle] + pub extern "C" fn bloom_get_renderer_capabilities() -> *const u8 { + $crate::ffi::guard("bloom_get_renderer_capabilities", move || { + let report = engine().renderer.renderer_capability_report_json(); + $crate::string_header::alloc_perry_string(&report) + }) + } + + // GH #133 — selected imported glTF transmission/refraction route. + // 0=legacy opt-out, 1=scene snapshot, 2=environment fallback. + #[no_mangle] + pub extern "C" fn bloom_get_imported_refraction_mode() -> f64 { + $crate::ffi::guard("bloom_get_imported_refraction_mode", move || { + engine().renderer.imported_refraction_mode_code() as f64 + }) + } + + // GH #133 — conventional imported-transparency composition. + // preference: 0=sorted, 1=auto, 2=weighted; active: 0=sorted, 1=weighted. + #[no_mangle] + pub extern "C" fn bloom_set_transparency_composition_mode(mode: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_transparency_composition_mode", move || { + engine() + .renderer + .set_transparency_composition_mode(mode as u32); + }) + } + + #[no_mangle] + pub extern "C" fn bloom_get_transparency_composition_mode() -> f64 { + $crate::ffi::guard("bloom_get_transparency_composition_mode", move || { + engine().renderer.transparency_composition_mode_code() as f64 + }) + } + + #[no_mangle] + pub extern "C" fn bloom_get_active_transparency_composition_mode() -> f64 { + $crate::ffi::guard( + "bloom_get_active_transparency_composition_mode", + move || { + engine() + .renderer + .active_transparency_composition_mode_code() as f64 + }, + ) + } + + // Debug qualification override: 0=auto, 1=C, 2=B, 3=A. + #[no_mangle] + pub extern "C" fn bloom_set_material_binding_tier_override(tier: f64) -> f64 { + $crate::ffi::guard("bloom_set_material_binding_tier_override", move || { + engine() + .renderer + .set_material_binding_tier_override(tier as u32) as u8 as f64 + }) } // bloom_set_sun_shafts [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_sun_shafts(strength: f64, decay: f64, r: f64, g: f64, b: f64) { - $crate::ffi::guard("bloom_set_sun_shafts", move || { + pub extern "C" fn bloom_set_sun_shafts( + strength: f64, + decay: f64, + r: f64, + g: f64, + b: f64, + ) -> f64 { + $crate::ffi::guard_applied("bloom_set_sun_shafts", move || { let eng = engine(); eng.renderer.set_sun_shaft_strength(strength as f32); eng.renderer.set_sun_shaft_decay(decay as f32); - eng.renderer.set_sun_shaft_color(r as f32, g as f32, b as f32); - }) + eng.renderer + .set_sun_shaft_color(r as f32, g as f32, b as f32); + }) } // bloom_set_auto_exposure [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_auto_exposure(on: f64) { - $crate::ffi::guard("bloom_set_auto_exposure", move || { + pub extern "C" fn bloom_set_auto_exposure(on: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_auto_exposure", move || { engine().renderer.set_auto_exposure(on != 0.0); - }) + }) } #[no_mangle] - pub extern "C" fn bloom_set_occlusion_culling(on: f64) { - $crate::ffi::guard("bloom_set_occlusion_culling", move || { + pub extern "C" fn bloom_set_occlusion_culling(on: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_occlusion_culling", move || { engine().renderer.occlusion.enabled = on != 0.0; }) } // bloom_set_taa_enabled [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_taa_enabled(on: f64) { - $crate::ffi::guard("bloom_set_taa_enabled", move || { + pub extern "C" fn bloom_set_taa_enabled(on: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_taa_enabled", move || { engine().renderer.set_taa_enabled(on != 0.0); - }) + }) } // bloom_set_render_scale [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_render_scale(scale: f64) { - $crate::ffi::guard("bloom_set_render_scale", move || { + pub extern "C" fn bloom_set_render_scale(scale: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_render_scale", move || { engine().renderer.set_render_scale(scale as f32); - }) + }) } // bloom_set_upscale_mode [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_upscale_mode(mode: f64) { - $crate::ffi::guard("bloom_set_upscale_mode", move || { + pub extern "C" fn bloom_set_upscale_mode(mode: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_upscale_mode", move || { engine().renderer.set_upscale_mode(mode as u32); - }) + }) } // bloom_set_cas_strength [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_cas_strength(strength: f64) { - $crate::ffi::guard("bloom_set_cas_strength", move || { + pub extern "C" fn bloom_set_cas_strength(strength: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_cas_strength", move || { engine().renderer.set_cas_strength(strength as f32); - }) + }) } // bloom_set_auto_resolution [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_auto_resolution(target_hz: f64, enabled: f64) { - $crate::ffi::guard("bloom_set_auto_resolution", move || { + pub extern "C" fn bloom_set_auto_resolution(target_hz: f64, enabled: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_auto_resolution", move || { let eng = engine(); if enabled != 0.0 { let current = eng.renderer.render_scale(); @@ -313,42 +456,57 @@ macro_rules! __bloom_ffi_visual { } else { eng.drs.disable(); } - }) + }) } // bloom_set_manual_exposure [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_manual_exposure(value: f64) { - $crate::ffi::guard("bloom_set_manual_exposure", move || { + pub extern "C" fn bloom_set_manual_exposure(value: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_manual_exposure", move || { engine().renderer.set_manual_exposure(value as f32); - }) + }) } // bloom_set_env_intensity [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_env_intensity(intensity: f64) { - $crate::ffi::guard("bloom_set_env_intensity", move || { + pub extern "C" fn bloom_set_env_intensity(intensity: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_env_intensity", move || { engine().renderer.set_env_intensity(intensity as f32); - }) + }) } // bloom_set_ssgi_enabled [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_ssgi_enabled(enabled: f64) { - $crate::ffi::guard("bloom_set_ssgi_enabled", move || { + pub extern "C" fn bloom_set_ssgi_enabled(enabled: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_ssgi_enabled", move || { engine().renderer.set_ssgi_enabled(enabled != 0.0); - }) + }) } // bloom_set_path_tracing — 0 off / 1 progressive / 2 realtime // (docs/pt/pt-roadmap.md). Needs hardware ray query; without it - // the request is stored but nothing engages — check - // bloom_path_tracing_supported to know which world you are in. + // the request is rejected with a false status. #[no_mangle] - pub extern "C" fn bloom_set_path_tracing(mode: f64) { + pub extern "C" fn bloom_set_path_tracing(mode: f64) -> f64 { $crate::ffi::guard("bloom_set_path_tracing", move || { - engine().renderer.set_path_tracing(mode as u32); - }) + let renderer = &mut engine().renderer; + let mode = mode as u32; + if mode == 0 || renderer.pt_supported() { + renderer.set_path_tracing(mode); + 1.0 + } else { + 0.0 + } + }) + } + + // bloom_reset_temporal_history — call before the next 3D camera + // after a cut, teleport, FOV discontinuity, or world load. + #[no_mangle] + pub extern "C" fn bloom_reset_temporal_history() -> f64 { + $crate::ffi::guard_applied("bloom_reset_temporal_history", move || { + engine().renderer.reset_temporal_history(); + }) } // bloom_path_tracing_supported — 1.0 when the device can trace @@ -356,87 +514,91 @@ macro_rules! __bloom_ffi_visual { #[no_mangle] pub extern "C" fn bloom_path_tracing_supported() -> f64 { $crate::ffi::guard("bloom_path_tracing_supported", move || { - if engine().renderer.pt_supported() { 1.0 } else { 0.0 } - }) + if engine().renderer.pt_supported() { + 1.0 + } else { + 0.0 + } + }) } // bloom_set_ssgi_intensity [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_ssgi_intensity(intensity: f64) { - $crate::ffi::guard("bloom_set_ssgi_intensity", move || { + pub extern "C" fn bloom_set_ssgi_intensity(intensity: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_ssgi_intensity", move || { engine().renderer.set_ssgi_intensity(intensity as f32); - }) + }) } // bloom_set_ssgi_radius [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_ssgi_radius(radius: f64) { - $crate::ffi::guard("bloom_set_ssgi_radius", move || { + pub extern "C" fn bloom_set_ssgi_radius(radius: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_ssgi_radius", move || { engine().renderer.set_ssgi_radius(radius as f32); - }) + }) } // bloom_set_dof [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_dof(enabled: f64, focus_distance: f64, aperture: f64) { - $crate::ffi::guard("bloom_set_dof", move || { + pub extern "C" fn bloom_set_dof(enabled: f64, focus_distance: f64, aperture: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_dof", move || { let r = &mut engine().renderer; r.set_dof_enabled(enabled != 0.0); r.set_dof_focus_distance(focus_distance as f32); r.set_dof_aperture(aperture as f32); - }) + }) } // bloom_set_quality_preset [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_quality_preset(preset: f64) { - $crate::ffi::guard("bloom_set_quality_preset", move || { + pub extern "C" fn bloom_set_quality_preset(preset: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_quality_preset", move || { engine().renderer.apply_quality_preset(preset as u32); - }) + }) } // bloom_set_shadows_enabled [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_shadows_enabled(on: f64) { - $crate::ffi::guard("bloom_set_shadows_enabled", move || { + pub extern "C" fn bloom_set_shadows_enabled(on: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_shadows_enabled", move || { engine().renderer.set_shadows_enabled(on != 0.0); - }) + }) } // bloom_set_shadows_always_fresh [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_shadows_always_fresh(on: f64) { - $crate::ffi::guard("bloom_set_shadows_always_fresh", move || { + pub extern "C" fn bloom_set_shadows_always_fresh(on: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_shadows_always_fresh", move || { engine().renderer.set_shadows_always_fresh(on != 0.0); - }) + }) } // bloom_set_bloom_enabled [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_bloom_enabled(on: f64) { - $crate::ffi::guard("bloom_set_bloom_enabled", move || { + pub extern "C" fn bloom_set_bloom_enabled(on: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_bloom_enabled", move || { engine().renderer.set_bloom_enabled(on != 0.0); - }) + }) } // bloom_set_bloom_intensity [source: art-direction] // Scales the bloom contribution added to the HDR scene before tonemap // (0 = none, ~0.04 subtle default, higher = stronger glow). #[no_mangle] - pub extern "C" fn bloom_set_bloom_intensity(value: f64) { - $crate::ffi::guard("bloom_set_bloom_intensity", move || { + pub extern "C" fn bloom_set_bloom_intensity(value: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_bloom_intensity", move || { engine().renderer.set_bloom_intensity(value as f32); - }) + }) } // bloom_set_tonemap [source: art-direction] // Selects the tonemap operator: 0 = ACES (default), 1 = AgX (more // filmic, better highlight desaturation + a punchier look). #[no_mangle] - pub extern "C" fn bloom_set_tonemap(kind: f64) { - $crate::ffi::guard("bloom_set_tonemap", move || { + pub extern "C" fn bloom_set_tonemap(kind: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_tonemap", move || { engine().renderer.set_tonemap_kind(kind as u32); - }) + }) } // bloom_set_auto_exposure_key [source: art-direction] @@ -444,52 +606,62 @@ macro_rules! __bloom_ffi_visual { // exposure aims for a darker, more saturated midpoint (less wash-out); // higher = brighter. #[no_mangle] - pub extern "C" fn bloom_set_auto_exposure_key(key: f64) { - $crate::ffi::guard("bloom_set_auto_exposure_key", move || { + pub extern "C" fn bloom_set_auto_exposure_key(key: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_auto_exposure_key", move || { engine().renderer.set_auto_exposure_key(key as f32); - }) + }) } // bloom_set_auto_exposure_rate [source: art-direction] // Per-frame adaptation rate for auto-exposure (0 = frozen, ~0.05 = a // smooth eye-adaptation feel, 1 = instant). #[no_mangle] - pub extern "C" fn bloom_set_auto_exposure_rate(rate: f64) { - $crate::ffi::guard("bloom_set_auto_exposure_rate", move || { + pub extern "C" fn bloom_set_auto_exposure_rate(rate: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_auto_exposure_rate", move || { engine().renderer.set_auto_exposure_rate(rate as f32); - }) + }) } // bloom_set_ssao_enabled [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_ssao_enabled(on: f64) { - $crate::ffi::guard("bloom_set_ssao_enabled", move || { + pub extern "C" fn bloom_set_ssao_enabled(on: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_ssao_enabled", move || { engine().renderer.set_ssao_enabled(on != 0.0); - }) + }) } // bloom_set_ssao_intensity [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_ssao_intensity(value: f64) { - $crate::ffi::guard("bloom_set_ssao_intensity", move || { + pub extern "C" fn bloom_set_ssao_intensity(value: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_ssao_intensity", move || { engine().renderer.set_ssao_strength(value as f32); - }) + }) } // bloom_set_ssao_radius [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_ssao_radius(world_radius: f64) { - $crate::ffi::guard("bloom_set_ssao_radius", move || { + pub extern "C" fn bloom_set_ssao_radius(world_radius: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_ssao_radius", move || { engine().renderer.set_ssao_radius(world_radius as f32); - }) + }) } // bloom_set_wind [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_wind(dir_x: f64, dir_z: f64, amplitude: f64, frequency: f64) { - $crate::ffi::guard("bloom_set_wind", move || { - engine().renderer.set_wind(dir_x as f32, dir_z as f32, amplitude as f32, frequency as f32); - }) + pub extern "C" fn bloom_set_wind( + dir_x: f64, + dir_z: f64, + amplitude: f64, + frequency: f64, + ) -> f64 { + $crate::ffi::guard_applied("bloom_set_wind", move || { + engine().renderer.set_wind( + dir_x as f32, + dir_z as f32, + amplitude as f32, + frequency as f32, + ); + }) } // bloom_set_model_foliage_wind [EN-041] @@ -498,10 +670,12 @@ macro_rules! __bloom_ffi_visual { // tree. The engine used to sway alpha-cut materials only, so leaf cards // fluttered and every trunk stood rigid. #[no_mangle] - pub extern "C" fn bloom_set_model_foliage_wind(model: f64, amount: f64) { - $crate::ffi::guard("bloom_set_model_foliage_wind", move || { - engine().renderer.set_model_foliage_wind(model.to_bits(), amount as f32); - }) + pub extern "C" fn bloom_set_model_foliage_wind(model: f64, amount: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_model_foliage_wind", move || { + engine() + .renderer + .set_model_foliage_wind(model.to_bits(), amount as f32); + }) } // bloom_set_foliage_shadow_motion [EN-041] @@ -509,10 +683,10 @@ macro_rules! __bloom_ffi_visual { // Let foliage sway in the shadow pass too, so the canopy dapple moves. // NOT free: a moving caster cannot reuse the cached static shadow depth. #[no_mangle] - pub extern "C" fn bloom_set_foliage_shadow_motion(on: f64) { - $crate::ffi::guard("bloom_set_foliage_shadow_motion", move || { + pub extern "C" fn bloom_set_foliage_shadow_motion(on: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_foliage_shadow_motion", move || { engine().renderer.set_foliage_shadow_motion(on > 0.5); - }) + }) } // bloom_set_output_scale [EN-046] @@ -521,17 +695,17 @@ macro_rules! __bloom_ffi_visual { // the fixed cost of the TSR upscale + final composite, which is what actually // dominates a 4K frame — render_scale does not. #[no_mangle] - pub extern "C" fn bloom_set_output_scale(scale: f64) { - $crate::ffi::guard("bloom_set_output_scale", move || { + pub extern "C" fn bloom_set_output_scale(scale: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_output_scale", move || { engine().renderer.set_output_scale(scale as f32); - }) + }) } #[no_mangle] pub extern "C" fn bloom_get_output_scale() -> f64 { $crate::ffi::guard("bloom_get_output_scale", move || { engine().renderer.output_scale() as f64 - }) + }) } // bloom_set_cloud_shadows [EN-040] @@ -541,37 +715,43 @@ macro_rules! __bloom_ffi_visual { // calls this keeps. #[no_mangle] pub extern "C" fn bloom_set_cloud_shadows( - strength: f64, deck_height: f64, feature_scale: f64, drift_speed: f64, - ) { - $crate::ffi::guard("bloom_set_cloud_shadows", move || { + strength: f64, + deck_height: f64, + feature_scale: f64, + drift_speed: f64, + ) -> f64 { + $crate::ffi::guard_applied("bloom_set_cloud_shadows", move || { engine().renderer.set_cloud_shadows( - strength as f32, deck_height as f32, - feature_scale as f32, drift_speed as f32); - }) + strength as f32, + deck_height as f32, + feature_scale as f32, + drift_speed as f32, + ); + }) } // bloom_set_ssr_enabled [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_ssr_enabled(on: f64) { - $crate::ffi::guard("bloom_set_ssr_enabled", move || { + pub extern "C" fn bloom_set_ssr_enabled(on: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_ssr_enabled", move || { engine().renderer.set_ssr_enabled(on != 0.0); - }) + }) } // bloom_set_motion_blur_enabled [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_motion_blur_enabled(on: f64) { - $crate::ffi::guard("bloom_set_motion_blur_enabled", move || { + pub extern "C" fn bloom_set_motion_blur_enabled(on: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_motion_blur_enabled", move || { engine().renderer.set_motion_blur_enabled(on != 0.0); - }) + }) } // bloom_set_sss_enabled [source: macos] #[no_mangle] - pub extern "C" fn bloom_set_sss_enabled(on: f64) { - $crate::ffi::guard("bloom_set_sss_enabled", move || { + pub extern "C" fn bloom_set_sss_enabled(on: f64) -> f64 { + $crate::ffi::guard_applied("bloom_set_sss_enabled", move || { engine().renderer.set_sss_enabled(on != 0.0); - }) + }) } // bloom_set_profiler_enabled [source: macos] @@ -579,7 +759,7 @@ macro_rules! __bloom_ffi_visual { pub extern "C" fn bloom_set_profiler_enabled(on: f64) { $crate::ffi::guard("bloom_set_profiler_enabled", move || { engine().profiler.set_enabled(on != 0.0); - }) + }) } // bloom_set_music_volume [source: macos] @@ -587,39 +767,57 @@ macro_rules! __bloom_ffi_visual { pub extern "C" fn bloom_set_music_volume(handle: f64, volume: f64) { $crate::ffi::guard("bloom_set_music_volume", move || { engine().audio.set_music_volume(handle, volume as f32); - }) + }) } // bloom_add_directional_light [source: macos] #[no_mangle] pub extern "C" fn bloom_add_directional_light( - dx: f64, dy: f64, dz: f64, - r: f64, g: f64, b: f64, + dx: f64, + dy: f64, + dz: f64, + r: f64, + g: f64, + b: f64, intensity: f64, ) { $crate::ffi::guard("bloom_add_directional_light", move || { engine().renderer.add_directional_light( - dx as f32, dy as f32, dz as f32, - r as f32, g as f32, b as f32, + dx as f32, + dy as f32, + dz as f32, + r as f32, + g as f32, + b as f32, intensity as f32, ); - }) + }) } // bloom_add_point_light [source: macos] #[no_mangle] pub extern "C" fn bloom_add_point_light( - x: f64, y: f64, z: f64, range: f64, - r: f64, g: f64, b: f64, + x: f64, + y: f64, + z: f64, + range: f64, + r: f64, + g: f64, + b: f64, intensity: f64, ) { $crate::ffi::guard("bloom_add_point_light", move || { engine().renderer.add_point_light( - x as f32, y as f32, z as f32, range as f32, - r as f32, g as f32, b as f32, + x as f32, + y as f32, + z as f32, + range as f32, + r as f32, + g as f32, + b as f32, intensity as f32, ); - }) + }) } // bloom_set_cursor_shape [source: macos] @@ -627,23 +825,23 @@ macro_rules! __bloom_ffi_visual { pub extern "C" fn bloom_set_cursor_shape(shape: f64) { $crate::ffi::guard("bloom_set_cursor_shape", move || { engine().input.cursor_shape = shape as u32; - }) + }) } // bloom_enable_shadows [source: macos] #[no_mangle] - pub extern "C" fn bloom_enable_shadows() { - $crate::ffi::guard("bloom_enable_shadows", move || { + pub extern "C" fn bloom_enable_shadows() -> f64 { + $crate::ffi::guard_applied("bloom_enable_shadows", move || { engine().renderer.shadow_map.enable(); - }) + }) } // bloom_disable_shadows [source: macos] #[no_mangle] - pub extern "C" fn bloom_disable_shadows() { - $crate::ffi::guard("bloom_disable_shadows", move || { + pub extern "C" fn bloom_disable_shadows() -> f64 { + $crate::ffi::guard_applied("bloom_disable_shadows", move || { engine().renderer.shadow_map.disable(); - }) + }) } // bloom_dump_shadow_map [source: macos] @@ -652,7 +850,7 @@ macro_rules! __bloom_ffi_visual { $crate::ffi::guard("bloom_dump_shadow_map", move || { let path = $crate::string_header::str_from_header(path_ptr).to_string(); engine().renderer.dump_shadow_map(&path); - }) + }) } // bloom_enable_postfx [source: macos] @@ -664,9 +862,12 @@ macro_rules! __bloom_ffi_visual { let h = eng.renderer.height(); let fmt = eng.renderer.surface_format(); eng.postfx = Some($crate::postfx::PostFxPipeline::new( - &eng.renderer.device, w, h, fmt, + &eng.renderer.device, + w, + h, + fmt, )); - }) + }) } // bloom_disable_postfx [source: macos] @@ -674,7 +875,7 @@ macro_rules! __bloom_ffi_visual { pub extern "C" fn bloom_disable_postfx() { $crate::ffi::guard("bloom_disable_postfx", move || { engine().postfx = None; - }) + }) } // bloom_splat_impulse [source: macos] @@ -682,10 +883,12 @@ macro_rules! __bloom_ffi_visual { pub extern "C" fn bloom_splat_impulse(x: f64, z: f64, radius: f64, strength: f64) { $crate::ffi::guard("bloom_splat_impulse", move || { engine().renderer.impulse_field.submit_splat( - x as f32, z as f32, radius as f32, strength as f32, + x as f32, + z as f32, + radius as f32, + strength as f32, ); - }) + }) } - }; } diff --git a/native/shared/src/geometry.rs b/native/shared/src/geometry.rs index b4a62c31..60d752c3 100644 --- a/native/shared/src/geometry.rs +++ b/native/shared/src/geometry.rs @@ -20,11 +20,7 @@ pub struct GeometryData { /// /// Returns vertices and indices. The geometry extends from Y=0 to Y=depth. /// Normals and UVs are computed automatically. -pub fn extrude_polygon( - polygon: &[f64], - holes: &[Vec], - depth: f64, -) -> GeometryData { +pub fn extrude_polygon(polygon: &[f64], holes: &[Vec], depth: f64) -> GeometryData { let depth = depth as f32; // Build earcutr input: flatten polygon + holes, track hole starts @@ -38,8 +34,7 @@ pub fn extrude_polygon( } // Triangulate the 2D polygon - let triangles = earcutr::earcut(&flat_coords, &hole_indices, 2) - .unwrap_or_default(); + let triangles = earcutr::earcut(&flat_coords, &hole_indices, 2).unwrap_or_default(); let n_points = flat_coords.len() / 2; let mut vertices = Vec::new(); @@ -119,7 +114,9 @@ pub fn extrude_polygon( let dx = x1 - x0; let dz = z1 - z0; let len = (dx * dx + dz * dz).sqrt(); - if len < 1e-6 { continue; } + if len < 1e-6 { + continue; + } let nx = -dz / len; let nz = dx / len; @@ -186,17 +183,15 @@ pub fn extrude_polygon( /// /// - `min`: box minimum corner [x, y, z] /// - `max`: box maximum corner [x, y, z] -pub fn subtract_box( - geo: &GeometryData, - min: [f32; 3], - max: [f32; 3], -) -> GeometryData { +pub fn subtract_box(geo: &GeometryData, min: [f32; 3], max: [f32; 3]) -> GeometryData { let mut out_vertices = Vec::new(); let mut out_indices = Vec::new(); // Process each triangle for tri in geo.indices.chunks(3) { - if tri.len() < 3 { continue; } + if tri.len() < 3 { + continue; + } let v0 = &geo.vertices[tri[0] as usize]; let v1 = &geo.vertices[tri[1] as usize]; let v2 = &geo.vertices[tri[2] as usize]; @@ -230,7 +225,10 @@ pub fn subtract_box( } fn point_in_box(p: &[f32; 3], min: &[f32; 3], max: &[f32; 3]) -> bool { - p[0] >= min[0] && p[0] <= max[0] - && p[1] >= min[1] && p[1] <= max[1] - && p[2] >= min[2] && p[2] <= max[2] + p[0] >= min[0] + && p[0] <= max[0] + && p[1] >= min[1] + && p[1] <= max[1] + && p[2] >= min[2] + && p[2] <= max[2] } diff --git a/native/shared/src/handles.rs b/native/shared/src/handles.rs index 8fff10e5..49db5a43 100644 --- a/native/shared/src/handles.rs +++ b/native/shared/src/handles.rs @@ -111,18 +111,29 @@ impl HandleRegistry { /// current generation — they compare equal to what alloc() returned. pub fn iter(&self) -> impl Iterator { self.items.iter().enumerate().filter_map(|(idx, slot)| { - slot.as_ref() - .map(|item| (((self.generations[idx] << GEN_SHIFT) | (idx as u64 + 1)) as f64, item)) + slot.as_ref().map(|item| { + ( + ((self.generations[idx] << GEN_SHIFT) | (idx as u64 + 1)) as f64, + item, + ) + }) }) } /// Iterate over all live (handle, &mut T) pairs. pub fn iter_mut(&mut self) -> impl Iterator { let generations = &self.generations; - self.items.iter_mut().enumerate().filter_map(move |(idx, slot)| { - slot.as_mut() - .map(|item| (((generations[idx] << GEN_SHIFT) | (idx as u64 + 1)) as f64, item)) - }) + self.items + .iter_mut() + .enumerate() + .filter_map(move |(idx, slot)| { + slot.as_mut().map(|item| { + ( + ((generations[idx] << GEN_SHIFT) | (idx as u64 + 1)) as f64, + item, + ) + }) + }) } } diff --git a/native/shared/src/input.rs b/native/shared/src/input.rs index 39367516..49e2312b 100644 --- a/native/shared/src/input.rs +++ b/native/shared/src/input.rs @@ -100,7 +100,11 @@ pub struct InputState { impl InputState { pub fn new() -> Self { - const EMPTY_TOUCH: TouchPoint = TouchPoint { x: 0.0, y: 0.0, active: false }; + const EMPTY_TOUCH: TouchPoint = TouchPoint { + x: 0.0, + y: 0.0, + active: false, + }; Self { keys_pressed: [false; MAX_KEYS], keys_down: [false; MAX_KEYS], @@ -190,8 +194,10 @@ impl InputState { self.mouse_released[i] = !self.mouse_down[i] && self.prev_mouse_down[i]; } for i in 0..MAX_GAMEPAD_BUTTONS { - self.gamepad_buttons_pressed[i] = self.gamepad_buttons_down[i] && !self.prev_gamepad_buttons[i]; - self.gamepad_buttons_released[i] = !self.gamepad_buttons_down[i] && self.prev_gamepad_buttons[i]; + self.gamepad_buttons_pressed[i] = + self.gamepad_buttons_down[i] && !self.prev_gamepad_buttons[i]; + self.gamepad_buttons_released[i] = + !self.gamepad_buttons_down[i] && self.prev_gamepad_buttons[i]; } } @@ -217,27 +223,60 @@ impl InputState { // Keyboard /// Real key events, delivered from the platform's message pump. These run /// before begin_frame(), so writing keys_down directly is correct here. - pub fn set_key_down(&mut self, key: usize) { if key < MAX_KEYS { self.keys_down[key] = true; } } - pub fn set_key_up(&mut self, key: usize) { if key < MAX_KEYS { self.keys_down[key] = false; } } + pub fn set_key_down(&mut self, key: usize) { + if key < MAX_KEYS { + self.keys_down[key] = true; + } + } + pub fn set_key_up(&mut self, key: usize) { + if key < MAX_KEYS { + self.keys_down[key] = false; + } + } /// Synthetic key events (bloom_inject_key_down/up), which callers raise from /// inside the game loop. Staged, not written — see pending_key_down. pub fn inject_key_down(&mut self, key: usize) { - if key < MAX_KEYS { self.pending_key_down[key] = true; self.pending_key_up[key] = false; } + if key < MAX_KEYS { + self.pending_key_down[key] = true; + self.pending_key_up[key] = false; + } } pub fn inject_key_up(&mut self, key: usize) { - if key < MAX_KEYS { self.pending_key_up[key] = true; self.pending_key_down[key] = false; } + if key < MAX_KEYS { + self.pending_key_up[key] = true; + self.pending_key_down[key] = false; + } } - pub fn is_key_pressed(&self, key: usize) -> bool { key < MAX_KEYS && self.keys_pressed[key] } - pub fn is_key_down(&self, key: usize) -> bool { key < MAX_KEYS && self.keys_down[key] } - pub fn is_key_released(&self, key: usize) -> bool { key < MAX_KEYS && self.keys_released[key] } + pub fn is_key_pressed(&self, key: usize) -> bool { + key < MAX_KEYS && self.keys_pressed[key] + } + pub fn is_key_down(&self, key: usize) -> bool { + key < MAX_KEYS && self.keys_down[key] + } + pub fn is_key_released(&self, key: usize) -> bool { + key < MAX_KEYS && self.keys_released[key] + } // Mouse - pub fn set_mouse_position(&mut self, x: f64, y: f64) { self.mouse_x = x; self.mouse_y = y; } - pub fn accumulate_mouse_delta(&mut self, dx: f64, dy: f64) { self.raw_delta_x += dx; self.raw_delta_y += dy; } - pub fn clear_mouse_delta(&mut self) { self.raw_delta_x = 0.0; self.raw_delta_y = 0.0; self.mouse_delta_x = 0.0; self.mouse_delta_y = 0.0; } - pub fn accumulate_mouse_wheel(&mut self, dy: f64) { self.mouse_wheel_y += dy; } + pub fn set_mouse_position(&mut self, x: f64, y: f64) { + self.mouse_x = x; + self.mouse_y = y; + } + pub fn accumulate_mouse_delta(&mut self, dx: f64, dy: f64) { + self.raw_delta_x += dx; + self.raw_delta_y += dy; + } + pub fn clear_mouse_delta(&mut self) { + self.raw_delta_x = 0.0; + self.raw_delta_y = 0.0; + self.mouse_delta_x = 0.0; + self.mouse_delta_y = 0.0; + } + pub fn accumulate_mouse_wheel(&mut self, dy: f64) { + self.mouse_wheel_y += dy; + } /// Returns the accumulated scroll delta since the last call, and resets it. /// Games/editors call this once per frame. pub fn consume_mouse_wheel(&mut self) -> f64 { @@ -267,22 +306,42 @@ impl InputState { c } - pub fn set_mouse_button_down(&mut self, button: usize) { if button < MAX_MOUSE_BUTTONS { self.mouse_down[button] = true; } } - pub fn set_mouse_button_up(&mut self, button: usize) { if button < MAX_MOUSE_BUTTONS { self.mouse_down[button] = false; } } + pub fn set_mouse_button_down(&mut self, button: usize) { + if button < MAX_MOUSE_BUTTONS { + self.mouse_down[button] = true; + } + } + pub fn set_mouse_button_up(&mut self, button: usize) { + if button < MAX_MOUSE_BUTTONS { + self.mouse_down[button] = false; + } + } - pub fn is_mouse_button_pressed(&self, button: usize) -> bool { button < MAX_MOUSE_BUTTONS && self.mouse_pressed[button] } - pub fn is_mouse_button_down(&self, button: usize) -> bool { button < MAX_MOUSE_BUTTONS && self.mouse_down[button] } - pub fn is_mouse_button_released(&self, button: usize) -> bool { button < MAX_MOUSE_BUTTONS && self.mouse_released[button] } + pub fn is_mouse_button_pressed(&self, button: usize) -> bool { + button < MAX_MOUSE_BUTTONS && self.mouse_pressed[button] + } + pub fn is_mouse_button_down(&self, button: usize) -> bool { + button < MAX_MOUSE_BUTTONS && self.mouse_down[button] + } + pub fn is_mouse_button_released(&self, button: usize) -> bool { + button < MAX_MOUSE_BUTTONS && self.mouse_released[button] + } // Gamepad pub fn set_gamepad_axis(&mut self, axis: usize, value: f32) { - if axis < MAX_GAMEPAD_AXES { self.gamepad_axes[axis] = value; } + if axis < MAX_GAMEPAD_AXES { + self.gamepad_axes[axis] = value; + } } pub fn set_gamepad_button_down(&mut self, button: usize) { - if button < MAX_GAMEPAD_BUTTONS { self.gamepad_buttons_down[button] = true; } + if button < MAX_GAMEPAD_BUTTONS { + self.gamepad_buttons_down[button] = true; + } } pub fn set_gamepad_button_up(&mut self, button: usize) { - if button < MAX_GAMEPAD_BUTTONS { self.gamepad_buttons_down[button] = false; } + if button < MAX_GAMEPAD_BUTTONS { + self.gamepad_buttons_down[button] = false; + } } /// Clear polled gamepad axes + buttons at the top of a hardware poll. @@ -296,9 +355,15 @@ impl InputState { self.gamepad_buttons_down = [false; MAX_GAMEPAD_BUTTONS]; } - pub fn is_gamepad_available(&self) -> bool { self.gamepad_available } + pub fn is_gamepad_available(&self) -> bool { + self.gamepad_available + } pub fn get_gamepad_axis(&self, axis: usize) -> f32 { - if axis < MAX_GAMEPAD_AXES { self.gamepad_axes[axis] } else { 0.0 } + if axis < MAX_GAMEPAD_AXES { + self.gamepad_axes[axis] + } else { + 0.0 + } } pub fn is_gamepad_button_pressed(&self, button: usize) -> bool { button < MAX_GAMEPAD_BUTTONS && self.gamepad_buttons_pressed[button] @@ -309,7 +374,9 @@ impl InputState { pub fn is_gamepad_button_released(&self, button: usize) -> bool { button < MAX_GAMEPAD_BUTTONS && self.gamepad_buttons_released[button] } - pub fn get_gamepad_axis_count(&self) -> usize { self.gamepad_axis_count } + pub fn get_gamepad_axis_count(&self) -> usize { + self.gamepad_axis_count + } // Touch pub fn set_touch(&mut self, index: usize, x: f64, y: f64, active: bool) { @@ -332,12 +399,22 @@ impl InputState { } } pub fn get_touch_x(&self, index: usize) -> f64 { - if index < MAX_TOUCH_POINTS { self.touch_points[index].x } else { 0.0 } + if index < MAX_TOUCH_POINTS { + self.touch_points[index].x + } else { + 0.0 + } } pub fn get_touch_y(&self, index: usize) -> f64 { - if index < MAX_TOUCH_POINTS { self.touch_points[index].y } else { 0.0 } + if index < MAX_TOUCH_POINTS { + self.touch_points[index].y + } else { + 0.0 + } + } + pub fn get_touch_count(&self) -> usize { + self.touch_count } - pub fn get_touch_count(&self) -> usize { self.touch_count } /// Whether touch *slot* `index` currently holds a live finger. /// /// `get_touch_count` counts active fingers, but `touch_points` is indexed @@ -350,10 +427,14 @@ impl InputState { pub fn is_touch_active(&self, index: usize) -> bool { index < MAX_TOUCH_POINTS && self.touch_points[index].active } - pub fn max_touch_points(&self) -> usize { MAX_TOUCH_POINTS } + pub fn max_touch_points(&self) -> usize { + MAX_TOUCH_POINTS + } // Digital Crown (watchOS) - pub fn accumulate_crown_rotation(&mut self, delta: f64) { self.crown_rotation += delta; } + pub fn accumulate_crown_rotation(&mut self, delta: f64) { + self.crown_rotation += delta; + } pub fn consume_crown_rotation(&mut self) -> f64 { let v = self.crown_rotation; self.crown_rotation = 0.0; diff --git a/native/shared/src/jolt_sys.rs b/native/shared/src/jolt_sys.rs index 9992350d..332c9751 100644 --- a/native/shared/src/jolt_sys.rs +++ b/native/shared/src/jolt_sys.rs @@ -11,12 +11,12 @@ use std::os::raw::c_char; // Handles // --------------------------------------------------------------------------- -pub type bj_world = u64; -pub type bj_shape = u64; -pub type bj_body = u64; +pub type bj_world = u64; +pub type bj_shape = u64; +pub type bj_body = u64; pub type bj_constraint = u64; -pub type bj_character = u64; -pub type bj_vehicle = u64; +pub type bj_character = u64; +pub type bj_vehicle = u64; pub const BJ_INVALID: u64 = 0; pub const BJ_MAX_OBJECT_LAYERS: u32 = 16; @@ -28,51 +28,51 @@ pub const BJ_MAX_OBJECT_LAYERS: u32 = 16; #[repr(u32)] #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum BjMotionType { - Static = 0, + Static = 0, Kinematic = 1, - Dynamic = 2, + Dynamic = 2, } #[repr(u32)] #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum BjDefaultLayer { NonMoving = 0, - Moving = 1, - Sensor = 2, + Moving = 1, + Sensor = 2, } #[repr(u32)] #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum BjActivation { - Activate = 0, + Activate = 0, DontActivate = 1, } #[repr(u32)] #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum BjContactEvent { - Added = 0, + Added = 0, Persisted = 1, - Removed = 2, + Removed = 2, } #[repr(u32)] #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum BjResult { - Ok = 0, - ErrUninitialized = 1, - ErrInvalidHandle = 2, - ErrOutOfMemory = 3, - ErrInvalidArg = 4, + Ok = 0, + ErrUninitialized = 1, + ErrInvalidHandle = 2, + ErrOutOfMemory = 3, + ErrInvalidArg = 4, } #[repr(u32)] #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum BjGroundState { - OnGround = 0, - OnSteep = 1, + OnGround = 0, + OnSteep = 1, NotSupported = 2, - InAir = 3, + InAir = 3, } // --------------------------------------------------------------------------- @@ -81,18 +81,37 @@ pub enum BjGroundState { #[repr(C)] #[derive(Copy, Clone, Debug, Default)] -pub struct BjVec3 { pub x: f32, pub y: f32, pub z: f32 } +pub struct BjVec3 { + pub x: f32, + pub y: f32, + pub z: f32, +} #[repr(C)] #[derive(Copy, Clone, Debug)] -pub struct BjQuat { pub x: f32, pub y: f32, pub z: f32, pub w: f32 } +pub struct BjQuat { + pub x: f32, + pub y: f32, + pub z: f32, + pub w: f32, +} impl Default for BjQuat { - fn default() -> Self { Self { x: 0.0, y: 0.0, z: 0.0, w: 1.0 } } + fn default() -> Self { + Self { + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + } + } } #[repr(C)] #[derive(Copy, Clone, Debug, Default)] -pub struct BjTransform { pub position: BjVec3, pub rotation: BjQuat } +pub struct BjTransform { + pub position: BjVec3, + pub rotation: BjQuat, +} #[repr(C)] #[derive(Copy, Clone, Debug)] @@ -111,7 +130,11 @@ impl Default for BjWorldDesc { max_body_pairs: 65_536, max_contact_constraints: 10_240, num_threads: 0, - gravity: BjVec3 { x: 0.0, y: -9.81, z: 0.0 }, + gravity: BjVec3 { + x: 0.0, + y: -9.81, + z: 0.0, + }, temp_allocator_bytes: 0, } } @@ -203,7 +226,7 @@ pub struct BjConstraintAnchors { pub struct BjVehicleDesc { pub up: BjVec3, pub forward: BjVec3, - pub wheel_positions: [BjVec3; 4], // front-left, front-right, rear-left, rear-right + pub wheel_positions: [BjVec3; 4], // front-left, front-right, rear-left, rear-right pub wheel_radius: f32, pub wheel_width: f32, pub suspension_min_length: f32, @@ -220,16 +243,40 @@ impl Default for BjVehicleDesc { // Sensible defaults for a typical compact car. Wheel positions // are for a ~3.8m × 1.6m wheelbase/track. Self { - up: BjVec3 { x: 0.0, y: 1.0, z: 0.0 }, - forward: BjVec3 { x: 0.0, y: 0.0, z: 1.0 }, + up: BjVec3 { + x: 0.0, + y: 1.0, + z: 0.0, + }, + forward: BjVec3 { + x: 0.0, + y: 0.0, + z: 1.0, + }, wheel_positions: [ - BjVec3 { x: -0.8, y: -0.4, z: 1.3 }, // FL - BjVec3 { x: 0.8, y: -0.4, z: 1.3 }, // FR - BjVec3 { x: -0.8, y: -0.4, z: -1.3 }, // RL - BjVec3 { x: 0.8, y: -0.4, z: -1.3 }, // RR + BjVec3 { + x: -0.8, + y: -0.4, + z: 1.3, + }, // FL + BjVec3 { + x: 0.8, + y: -0.4, + z: 1.3, + }, // FR + BjVec3 { + x: -0.8, + y: -0.4, + z: -1.3, + }, // RL + BjVec3 { + x: 0.8, + y: -0.4, + z: -1.3, + }, // RR ], wheel_radius: 0.35, - wheel_width: 0.2, + wheel_width: 0.2, suspension_min_length: 0.3, suspension_max_length: 0.5, max_steer_angle: 35.0_f32.to_radians(), @@ -257,7 +304,11 @@ pub struct BjCharacterDesc { impl Default for BjCharacterDesc { fn default() -> Self { Self { - up: BjVec3 { x: 0.0, y: 1.0, z: 0.0 }, + up: BjVec3 { + x: 0.0, + y: 1.0, + z: 0.0, + }, max_slope_angle: 50.0_f32.to_radians(), character_padding: 0.02, penetration_recovery_speed: 1.0, @@ -298,15 +349,22 @@ extern "C" { pub fn bj_shape_cylinder(half_height: f32, radius: f32, convex_radius: f32) -> bj_shape; pub fn bj_shape_convex_hull(points: *const BjVec3, count: u32, convex_radius: f32) -> bj_shape; pub fn bj_shape_mesh( - vertices: *const BjVec3, vertex_count: u32, - indices: *const u32, triangle_count: u32, + vertices: *const BjVec3, + vertex_count: u32, + indices: *const u32, + triangle_count: u32, ) -> bj_shape; pub fn bj_shape_heightfield( - samples: *const f32, sample_count: u32, - offset: BjVec3, scale: BjVec3, block_size: u32, + samples: *const f32, + sample_count: u32, + offset: BjVec3, + scale: BjVec3, + block_size: u32, ) -> bj_shape; pub fn bj_shape_compound_static( - shapes: *const bj_shape, local_transforms: *const BjTransform, count: u32, + shapes: *const bj_shape, + local_transforms: *const BjTransform, + count: u32, ) -> bj_shape; pub fn bj_shape_scaled(base: bj_shape, scale: BjVec3) -> bj_shape; pub fn bj_shape_offset_com(base: bj_shape, offset: BjVec3) -> bj_shape; @@ -323,36 +381,79 @@ extern "C" { pub fn bj_body_is_active(world: bj_world, body: bj_body) -> u8; pub fn bj_body_is_valid(world: bj_world, body: bj_body) -> u8; pub fn bj_body_get_transform(world: bj_world, body: bj_body, out: *mut BjTransform); - pub fn bj_body_set_transform(world: bj_world, body: bj_body, xform: *const BjTransform, act: BjActivation); + pub fn bj_body_set_transform( + world: bj_world, + body: bj_body, + xform: *const BjTransform, + act: BjActivation, + ); pub fn bj_body_get_position(world: bj_world, body: bj_body, out: *mut BjVec3); pub fn bj_body_get_rotation(world: bj_world, body: bj_body, out: *mut BjQuat); pub fn bj_body_set_position(world: bj_world, body: bj_body, pos: BjVec3, act: BjActivation); pub fn bj_body_set_rotation(world: bj_world, body: bj_body, rot: BjQuat, act: BjActivation); - pub fn bj_body_move_kinematic(world: bj_world, body: bj_body, target: *const BjTransform, delta_time: f32); + pub fn bj_body_move_kinematic( + world: bj_world, + body: bj_body, + target: *const BjTransform, + delta_time: f32, + ); pub fn bj_body_get_linear_velocity(world: bj_world, body: bj_body, out: *mut BjVec3); pub fn bj_body_get_angular_velocity(world: bj_world, body: bj_body, out: *mut BjVec3); pub fn bj_body_set_linear_velocity(world: bj_world, body: bj_body, v: BjVec3); pub fn bj_body_set_angular_velocity(world: bj_world, body: bj_body, v: BjVec3); - pub fn bj_body_get_point_velocity(world: bj_world, body: bj_body, world_point: BjVec3, out: *mut BjVec3); + pub fn bj_body_get_point_velocity( + world: bj_world, + body: bj_body, + world_point: BjVec3, + out: *mut BjVec3, + ); pub fn bj_body_add_force(world: bj_world, body: bj_body, force: BjVec3); pub fn bj_body_add_impulse(world: bj_world, body: bj_body, impulse: BjVec3); pub fn bj_body_add_torque(world: bj_world, body: bj_body, torque: BjVec3); pub fn bj_body_add_angular_impulse(world: bj_world, body: bj_body, impulse: BjVec3); pub fn bj_body_add_force_at(world: bj_world, body: bj_body, force: BjVec3, world_point: BjVec3); - pub fn bj_body_add_impulse_at(world: bj_world, body: bj_body, impulse: BjVec3, world_point: BjVec3); + pub fn bj_body_add_impulse_at( + world: bj_world, + body: bj_body, + impulse: BjVec3, + world_point: BjVec3, + ); pub fn bj_body_set_friction(world: bj_world, body: bj_body, friction: f32); pub fn bj_body_set_restitution(world: bj_world, body: bj_body, restitution: f32); pub fn bj_body_set_linear_damping(world: bj_world, body: bj_body, damping: f32); pub fn bj_body_set_angular_damping(world: bj_world, body: bj_body, damping: f32); pub fn bj_body_set_gravity_factor(world: bj_world, body: bj_body, factor: f32); pub fn bj_body_set_ccd(world: bj_world, body: bj_body, enabled: u8); - pub fn bj_body_set_motion_type(world: bj_world, body: bj_body, mtype: BjMotionType, act: BjActivation); + pub fn bj_body_set_motion_type( + world: bj_world, + body: bj_body, + mtype: BjMotionType, + act: BjActivation, + ); pub fn bj_body_set_object_layer(world: bj_world, body: bj_body, layer: u32); pub fn bj_body_set_is_sensor(world: bj_world, body: bj_body, enabled: u8); pub fn bj_body_set_allow_sleeping(world: bj_world, body: bj_body, enabled: u8); - pub fn bj_body_set_shape(world: bj_world, body: bj_body, shape: bj_shape, update_mass: u8, act: BjActivation); - pub fn bj_body_lock_rotation_axes(world: bj_world, body: bj_body, lock_x: u8, lock_y: u8, lock_z: u8); - pub fn bj_body_lock_translation_axes(world: bj_world, body: bj_body, lock_x: u8, lock_y: u8, lock_z: u8); + pub fn bj_body_set_shape( + world: bj_world, + body: bj_body, + shape: bj_shape, + update_mass: u8, + act: BjActivation, + ); + pub fn bj_body_lock_rotation_axes( + world: bj_world, + body: bj_body, + lock_x: u8, + lock_y: u8, + lock_z: u8, + ); + pub fn bj_body_lock_translation_axes( + world: bj_world, + body: bj_body, + lock_x: u8, + lock_y: u8, + lock_z: u8, + ); pub fn bj_body_get_mass(world: bj_world, body: bj_body) -> f32; pub fn bj_body_get_friction(world: bj_world, body: bj_body) -> f32; pub fn bj_body_get_restitution(world: bj_world, body: bj_body) -> f32; @@ -360,53 +461,88 @@ extern "C" { pub fn bj_body_set_user_data(world: bj_world, body: bj_body, user_data: u64); pub fn bj_body_get_user_data(world: bj_world, body: bj_body) -> u64; pub fn bj_world_sync_transforms( - world: bj_world, bodies: *const bj_body, count: u32, + world: bj_world, + bodies: *const bj_body, + count: u32, out_transforms: *mut BjTransform, ) -> u32; // Queries pub fn bj_query_raycast_closest( - world: bj_world, origin: BjVec3, direction: BjVec3, max_distance: f32, - layer_mask: u32, out_hit: *mut BjRayHit, + world: bj_world, + origin: BjVec3, + direction: BjVec3, + max_distance: f32, + layer_mask: u32, + out_hit: *mut BjRayHit, ) -> u8; pub fn bj_query_raycast_all( - world: bj_world, origin: BjVec3, direction: BjVec3, max_distance: f32, - layer_mask: u32, out_hits: *mut BjRayHit, max_hits: u32, + world: bj_world, + origin: BjVec3, + direction: BjVec3, + max_distance: f32, + layer_mask: u32, + out_hits: *mut BjRayHit, + max_hits: u32, ) -> u32; pub fn bj_query_shape_cast_closest( - world: bj_world, shape: bj_shape, start: *const BjTransform, direction: BjVec3, - layer_mask: u32, out_hit: *mut BjRayHit, + world: bj_world, + shape: bj_shape, + start: *const BjTransform, + direction: BjVec3, + layer_mask: u32, + out_hit: *mut BjRayHit, ) -> u8; pub fn bj_query_overlap_sphere( - world: bj_world, center: BjVec3, radius: f32, - layer_mask: u32, out_bodies: *mut bj_body, max_results: u32, + world: bj_world, + center: BjVec3, + radius: f32, + layer_mask: u32, + out_bodies: *mut bj_body, + max_results: u32, ) -> u32; pub fn bj_query_overlap_box( - world: bj_world, box_transform: *const BjTransform, half_extents: BjVec3, - layer_mask: u32, out_bodies: *mut bj_body, max_results: u32, + world: bj_world, + box_transform: *const BjTransform, + half_extents: BjVec3, + layer_mask: u32, + out_bodies: *mut bj_body, + max_results: u32, ) -> u32; pub fn bj_query_overlap_point( - world: bj_world, point: BjVec3, - layer_mask: u32, out_bodies: *mut bj_body, max_results: u32, + world: bj_world, + point: BjVec3, + layer_mask: u32, + out_bodies: *mut bj_body, + max_results: u32, ) -> u32; // Constraints pub fn bj_constraint_fixed(world: bj_world, a: *const BjConstraintAnchors) -> bj_constraint; pub fn bj_constraint_point(world: bj_world, a: *const BjConstraintAnchors) -> bj_constraint; pub fn bj_constraint_hinge( - world: bj_world, a: *const BjConstraintAnchors, axis: BjVec3, - limit_min: f32, limit_max: f32, + world: bj_world, + a: *const BjConstraintAnchors, + axis: BjVec3, + limit_min: f32, + limit_max: f32, ) -> bj_constraint; pub fn bj_constraint_slider( - world: bj_world, a: *const BjConstraintAnchors, axis: BjVec3, - limit_min: f32, limit_max: f32, + world: bj_world, + a: *const BjConstraintAnchors, + axis: BjVec3, + limit_min: f32, + limit_max: f32, ) -> bj_constraint; pub fn bj_constraint_distance( - world: bj_world, a: *const BjConstraintAnchors, - min_distance: f32, max_distance: f32, + world: bj_world, + a: *const BjConstraintAnchors, + min_distance: f32, + max_distance: f32, ) -> bj_constraint; pub fn bj_constraint_six_dof( - world: bj_world, a: *const BjConstraintAnchors, + world: bj_world, + a: *const BjConstraintAnchors, translation_limits_xyz_minmax: *const f32, rotation_limits_xyz_minmax: *const f32, ) -> bj_constraint; @@ -420,55 +556,103 @@ extern "C" { // Character controller pub fn bj_character_create( - world: bj_world, shape: bj_shape, desc: *const BjCharacterDesc, - position: BjVec3, rotation: BjQuat, + world: bj_world, + shape: bj_shape, + desc: *const BjCharacterDesc, + position: BjVec3, + rotation: BjQuat, ) -> bj_character; pub fn bj_character_destroy(world: bj_world, character: bj_character); - pub fn bj_character_update(world: bj_world, character: bj_character, - delta_time: f32, gravity: BjVec3); + pub fn bj_character_update( + world: bj_world, + character: bj_character, + delta_time: f32, + gravity: BjVec3, + ); pub fn bj_character_get_position(world: bj_world, character: bj_character, out: *mut BjVec3); pub fn bj_character_get_rotation(world: bj_world, character: bj_character, out: *mut BjQuat); pub fn bj_character_set_position(world: bj_world, character: bj_character, position: BjVec3); pub fn bj_character_set_rotation(world: bj_world, character: bj_character, rotation: BjQuat); - pub fn bj_character_get_linear_velocity(world: bj_world, character: bj_character, out: *mut BjVec3); - pub fn bj_character_set_linear_velocity(world: bj_world, character: bj_character, velocity: BjVec3); - pub fn bj_character_get_ground_state(world: bj_world, character: bj_character) -> BjGroundState; - pub fn bj_character_get_ground_normal (world: bj_world, character: bj_character, out: *mut BjVec3); - pub fn bj_character_get_ground_position(world: bj_world, character: bj_character, out: *mut BjVec3); - pub fn bj_character_get_ground_body (world: bj_world, character: bj_character) -> bj_body; - pub fn bj_character_set_shape (world: bj_world, character: bj_character, shape: bj_shape); + pub fn bj_character_get_linear_velocity( + world: bj_world, + character: bj_character, + out: *mut BjVec3, + ); + pub fn bj_character_set_linear_velocity( + world: bj_world, + character: bj_character, + velocity: BjVec3, + ); + pub fn bj_character_get_ground_state(world: bj_world, character: bj_character) + -> BjGroundState; + pub fn bj_character_get_ground_normal( + world: bj_world, + character: bj_character, + out: *mut BjVec3, + ); + pub fn bj_character_get_ground_position( + world: bj_world, + character: bj_character, + out: *mut BjVec3, + ); + pub fn bj_character_get_ground_body(world: bj_world, character: bj_character) -> bj_body; + pub fn bj_character_set_shape(world: bj_world, character: bj_character, shape: bj_shape); // Soft bodies pub fn bj_soft_body_create( world: bj_world, - vertex_data: *const f32, vertex_count: u32, - indices: *const u32, triangle_count: u32, - position: BjVec3, rotation: BjQuat, + vertex_data: *const f32, + vertex_count: u32, + indices: *const u32, + triangle_count: u32, + position: BjVec3, + rotation: BjQuat, object_layer: u32, - edge_compliance: f32, gravity_factor: f32, linear_damping: f32, pressure: f32, + edge_compliance: f32, + gravity_factor: f32, + linear_damping: f32, + pressure: f32, ) -> bj_body; pub fn bj_soft_body_vertex_count(world: bj_world, body: bj_body) -> u32; pub fn bj_soft_body_get_vertex(world: bj_world, body: bj_body, idx: u32, out: *mut BjVec3); pub fn bj_soft_body_set_vertex(world: bj_world, body: bj_body, idx: u32, position: BjVec3); - pub fn bj_soft_body_set_vertex_inv_mass(world: bj_world, body: bj_body, idx: u32, inv_mass: f32); + pub fn bj_soft_body_set_vertex_inv_mass( + world: bj_world, + body: bj_body, + idx: u32, + inv_mass: f32, + ); // Wheeled vehicles pub fn bj_vehicle_create( - world: bj_world, chassis_shape: bj_shape, + world: bj_world, + chassis_shape: bj_shape, desc: *const BjVehicleDesc, - position: BjVec3, rotation: BjQuat, + position: BjVec3, + rotation: BjQuat, ) -> bj_vehicle; pub fn bj_vehicle_destroy(world: bj_world, vehicle: bj_vehicle); pub fn bj_vehicle_get_chassis(world: bj_world, vehicle: bj_vehicle) -> bj_body; pub fn bj_vehicle_set_input( - world: bj_world, vehicle: bj_vehicle, - forward: f32, right: f32, brake: f32, handbrake: f32, + world: bj_world, + vehicle: bj_vehicle, + forward: f32, + right: f32, + brake: f32, + handbrake: f32, ); pub fn bj_vehicle_get_wheel_transform( - world: bj_world, vehicle: bj_vehicle, wheel_index: u32, axis: u32, + world: bj_world, + vehicle: bj_vehicle, + wheel_index: u32, + axis: u32, ) -> f32; pub fn bj_vehicle_get_engine_rpm(world: bj_world, vehicle: bj_vehicle) -> f32; - pub fn bj_vehicle_get_wheel_angular_velocity(world: bj_world, vehicle: bj_vehicle, wheel_index: u32) -> f32; + pub fn bj_vehicle_get_wheel_angular_velocity( + world: bj_world, + vehicle: bj_vehicle, + wheel_index: u32, + ) -> f32; } // --------------------------------------------------------------------------- @@ -534,11 +718,22 @@ mod tests { assert_ne!(world, BJ_INVALID); // Ground: static box, top surface at y=0 (half-height 0.5, centred y=-0.5). - let ground_shape = bj_shape_box(BjVec3 { x: 50.0, y: 0.5, z: 50.0 }, 0.05); + let ground_shape = bj_shape_box( + BjVec3 { + x: 50.0, + y: 0.5, + z: 50.0, + }, + 0.05, + ); assert_ne!(ground_shape, BJ_INVALID); let ground_desc = BjBodyDesc { motion_type: BjMotionType::Static, - position: BjVec3 { x: 0.0, y: -0.5, z: 0.0 }, + position: BjVec3 { + x: 0.0, + y: -0.5, + z: 0.0, + }, object_layer: BjDefaultLayer::NonMoving as u32, ..Default::default() }; @@ -550,7 +745,11 @@ mod tests { assert_ne!(sphere_shape, BJ_INVALID); let sphere_desc = BjBodyDesc { motion_type: BjMotionType::Dynamic, - position: BjVec3 { x: 0.0, y: 10.0, z: 0.0 }, + position: BjVec3 { + x: 0.0, + y: 10.0, + z: 0.0, + }, object_layer: BjDefaultLayer::Moving as u32, restitution: 0.0, friction: 0.5, @@ -614,21 +813,43 @@ mod tests { // Triangle-mesh floor: two triangles forming a 20×20 quad at y=0. let verts = [ - BjVec3 { x: -10.0, y: 0.0, z: -10.0 }, - BjVec3 { x: 10.0, y: 0.0, z: -10.0 }, - BjVec3 { x: 10.0, y: 0.0, z: 10.0 }, - BjVec3 { x: -10.0, y: 0.0, z: 10.0 }, + BjVec3 { + x: -10.0, + y: 0.0, + z: -10.0, + }, + BjVec3 { + x: 10.0, + y: 0.0, + z: -10.0, + }, + BjVec3 { + x: 10.0, + y: 0.0, + z: 10.0, + }, + BjVec3 { + x: -10.0, + y: 0.0, + z: 10.0, + }, ]; - let indices: [u32; 6] = [0, 2, 1, 0, 3, 2]; + let indices: [u32; 6] = [0, 2, 1, 0, 3, 2]; let ground_shape = bj_shape_mesh( - verts.as_ptr(), verts.len() as u32, - indices.as_ptr(), (indices.len() / 3) as u32, + verts.as_ptr(), + verts.len() as u32, + indices.as_ptr(), + (indices.len() / 3) as u32, ); assert_ne!(ground_shape, BJ_INVALID, "mesh shape creation failed"); let ground_desc = BjBodyDesc { motion_type: BjMotionType::Static, - position: BjVec3 { x: 0.0, y: 0.0, z: 0.0 }, + position: BjVec3 { + x: 0.0, + y: 0.0, + z: 0.0, + }, object_layer: BjDefaultLayer::NonMoving as u32, ..Default::default() }; @@ -639,15 +860,25 @@ mod tests { let char_shape = bj_shape_capsule(0.5, 0.3); let desc = BjCharacterDesc::default(); let character = bj_character_create( - world, char_shape, &desc, - BjVec3 { x: 0.0, y: 5.0, z: 0.0 }, + world, + char_shape, + &desc, + BjVec3 { + x: 0.0, + y: 5.0, + z: 0.0, + }, BjQuat::default(), ); assert_ne!(character, BJ_INVALID); bj_world_optimize_broadphase(world); - let gravity = BjVec3 { x: 0.0, y: -9.81, z: 0.0 }; + let gravity = BjVec3 { + x: 0.0, + y: -9.81, + z: 0.0, + }; for _ in 0..240 { bj_character_update(world, character, 1.0 / 120.0, gravity); bj_world_step(world, 1.0 / 120.0, 1); @@ -657,13 +888,16 @@ mod tests { bj_character_get_position(world, character, &mut pos); assert!( pos.y < 5.0 && pos.y > -0.1, - "character didn't land correctly: y={}", pos.y + "character didn't land correctly: y={}", + pos.y ); let state = bj_character_get_ground_state(world, character); assert_eq!( - state, BjGroundState::OnGround, - "expected OnGround, got {:?}", state + state, + BjGroundState::OnGround, + "expected OnGround, got {:?}", + state ); bj_character_destroy(world, character); @@ -712,11 +946,17 @@ mod tests { // Compliance 1e-3 = realistically soft cloth (stiff cloth = 1e-5). let body = bj_soft_body_create( world, - vertex_data.as_ptr(), 9, - indices.as_ptr(), (indices.len() / 3) as u32, - BjVec3::default(), BjQuat::default(), + vertex_data.as_ptr(), + 9, + indices.as_ptr(), + (indices.len() / 3) as u32, + BjVec3::default(), + BjQuat::default(), BjDefaultLayer::Moving as u32, - 1e-3, 1.0, 0.01, 0.0, + 1e-3, + 1.0, + 0.01, + 0.0, ); assert_ne!(body, BJ_INVALID, "soft body creation failed"); @@ -739,7 +979,8 @@ mod tests { assert!( end_center.y < start_y - 0.1, "cloth center should sag below starting y={}; ended at y={}", - start_y, end_center.y + start_y, + end_center.y ); // Corners (index 0, 2, 6, 8) should stay pinned near y=5. @@ -747,7 +988,8 @@ mod tests { bj_soft_body_get_vertex(world, body, 0, &mut corner); assert!( (corner.y - 5.0).abs() < 0.01, - "corner should be pinned at y=5, got {}", corner.y + "corner should be pinned at y=5, got {}", + corner.y ); bj_body_destroy(world, body); @@ -772,22 +1014,53 @@ mod tests { let world = bj_world_create(&BjWorldDesc::default()); assert_ne!(world, BJ_INVALID); - let ground_shape = bj_shape_box(BjVec3 { x: 100.0, y: 0.5, z: 100.0 }, 0.05); + let ground_shape = bj_shape_box( + BjVec3 { + x: 100.0, + y: 0.5, + z: 100.0, + }, + 0.05, + ); let ground_desc = BjBodyDesc { motion_type: BjMotionType::Static, - position: BjVec3 { x: 0.0, y: -0.5, z: 0.0 }, + position: BjVec3 { + x: 0.0, + y: -0.5, + z: 0.0, + }, object_layer: BjDefaultLayer::NonMoving as u32, friction: 0.8, ..Default::default() }; let ground = bj_body_create(world, ground_shape, &ground_desc); - let inner_box = bj_shape_box(BjVec3 { x: 1.0, y: 0.2, z: 1.9 }, 0.05); - let chassis_shape = bj_shape_offset_com(inner_box, BjVec3 { x: 0.0, y: -0.6, z: 0.0 }); + let inner_box = bj_shape_box( + BjVec3 { + x: 1.0, + y: 0.2, + z: 1.9, + }, + 0.05, + ); + let chassis_shape = bj_shape_offset_com( + inner_box, + BjVec3 { + x: 0.0, + y: -0.6, + z: 0.0, + }, + ); let desc = BjVehicleDesc::default(); let vehicle = bj_vehicle_create( - world, chassis_shape, &desc, - BjVec3 { x: 0.0, y: 2.0, z: 0.0 }, + world, + chassis_shape, + &desc, + BjVec3 { + x: 0.0, + y: 2.0, + z: 0.0, + }, BjQuat::default(), ); assert_ne!(vehicle, BJ_INVALID, "vehicle creation failed"); @@ -801,15 +1074,27 @@ mod tests { bj_world_step(world, 1.0 / 120.0, 1); } let rpm = bj_vehicle_get_engine_rpm(world, vehicle); - assert!(rpm > 500.0, "engine RPM should rise under throttle; got {}", rpm); + assert!( + rpm > 500.0, + "engine RPM should rise under throttle; got {}", + rpm + ); // Rear wheel should have non-zero angular velocity. let rear_omega = bj_vehicle_get_wheel_angular_velocity(world, vehicle, 2); - assert!(rear_omega.abs() > 0.5, "rear wheel should be spinning; ω={}", rear_omega); + assert!( + rear_omega.abs() > 0.5, + "rear wheel should be spinning; ω={}", + rear_omega + ); // Wheel transform query returns non-zero position. let wheel_y = bj_vehicle_get_wheel_transform(world, vehicle, 0, 1); - assert!(wheel_y.abs() > 0.01, "wheel 0 world Y should be non-zero; got {}", wheel_y); + assert!( + wheel_y.abs() > 0.01, + "wheel 0 world Y should be non-zero; got {}", + wheel_y + ); bj_vehicle_destroy(world, vehicle); bj_body_destroy(world, ground); diff --git a/native/shared/src/lib.rs b/native/shared/src/lib.rs index f4ea4bee..fe1cd527 100644 --- a/native/shared/src/lib.rs +++ b/native/shared/src/lib.rs @@ -3,58 +3,59 @@ // the crate root rather than leaving 16+ warnings in every build. #![allow(static_mut_refs)] -pub mod string_header; +pub mod audio; pub mod ffi; #[cfg(not(target_arch = "wasm32"))] pub mod ffi_core; pub mod handles; pub mod input; pub mod renderer; +pub mod string_header; pub mod text_renderer; -pub mod audio; pub mod textures; // Not gated on models3d: the mixer is pure per-instance state embedded in // ModelAnimation (always compiled); only the gltf/image_dds LOADERS are // behind the feature, in models_gltf.rs (EN-063). pub mod anim_mixer; -pub mod models; -pub mod scene; +pub mod custom_shaders; +pub mod decals; pub mod frame_callbacks; pub mod geometry; +pub mod models; +pub mod particles; pub mod picking; -pub mod shadows; pub mod postfx; -pub mod custom_shaders; -pub mod staging; pub mod profiler; -pub mod particles; -pub mod decals; #[cfg(all(feature = "models3d", feature = "jolt"))] pub mod ragdoll; +pub mod scene; pub mod sdf_cache; +pub mod shadows; +pub mod staging; +pub(crate) mod virtual_shadows; // Jolt C ABI + Rust wrapper live on native only. On wasm32 the web crate // routes bloom_physics_* calls through wasm_bindgen to JoltPhysics.js; // no Rust-side Jolt integration is needed. +pub mod drs; +pub mod engine; #[cfg(all(feature = "jolt", not(target_arch = "wasm32")))] pub mod jolt_sys; #[cfg(all(feature = "jolt", not(target_arch = "wasm32")))] pub mod physics_jolt; -pub mod engine; -pub mod drs; // Host-surface attach path (PerryTS/perry#5519). Pulls in wgpu's // raw-surface API; web builds its surface from a canvas id instead, so // this is native-only. #[cfg(not(target_arch = "wasm32"))] pub mod attach; -pub use engine::EngineState; -pub use renderer::Renderer; -pub use string_header::str_from_header; -pub use audio::{AudioMixer, SoundData, parse_wav, parse_ogg}; #[cfg(feature = "mp3")] pub use audio::parse_mp3; -pub use textures::TextureManager; +pub use audio::{parse_ogg, parse_wav, AudioMixer, SoundData}; +pub use engine::EngineState; +pub use frame_callbacks::FrameCallbackSystem; #[cfg(feature = "models3d")] pub use models::ModelManager; +pub use renderer::Renderer; pub use scene::SceneGraph; -pub use frame_callbacks::FrameCallbackSystem; +pub use string_header::str_from_header; +pub use textures::TextureManager; diff --git a/native/shared/src/models.rs b/native/shared/src/models.rs index 322e52ec..1dbc7797 100644 --- a/native/shared/src/models.rs +++ b/native/shared/src/models.rs @@ -2,8 +2,512 @@ use crate::handles::HandleRegistry; use crate::renderer::Vertex3D; use std::sync::Arc; +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub enum MaterialAlphaMode { + #[default] + Opaque, + Mask, + Blend, +} + +impl MaterialAlphaMode { + pub(crate) fn shader_alpha_value(self, mask_cutoff: f32) -> f32 { + match self { + Self::Opaque => 0.0, + Self::Mask => mask_cutoff.max(0.0), + // Negative is an internal ABI tag: no binary discard, but preserve + // sampled base-colour alpha in the translucent output. + Self::Blend => -1.0, + } + } +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub enum MaterialThicknessSource { + #[default] + Unavailable, + Authored, + Approximated, +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct MaterialTextureTransform { + pub offset: [f32; 2], + pub rotation: f32, + pub scale: [f32; 2], + pub tex_coord: u32, +} + +impl Default for MaterialTextureTransform { + fn default() -> Self { + Self { + offset: [0.0; 2], + rotation: 0.0, + scale: [1.0; 2], + tex_coord: 0, + } + } +} + +/// Lossless source metadata plus the optional renderer texture resolved by a +/// particular loading path. Plain CPU-only model loading preserves the source +/// reference while leaving `runtime_texture_idx` empty. +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct MaterialTextureBinding { + pub source_texture_index: u32, + pub source_image_index: u32, + pub runtime_texture_idx: Option, + pub transform: MaterialTextureTransform, +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct MaterialLayeredPbr { + pub clearcoat_authored: bool, + pub clearcoat_factor: f32, + pub clearcoat_roughness_factor: f32, + pub clearcoat_texture: Option, + pub clearcoat_roughness_texture: Option, + pub clearcoat_normal_texture: Option, + pub clearcoat_normal_scale: f32, + pub specular_authored: bool, + pub specular_factor: f32, + pub specular_texture: Option, + pub specular_color_factor: [f32; 3], + pub specular_color_texture: Option, + pub ior_authored: bool, + pub ior: f32, + pub sheen_authored: bool, + pub sheen_color_factor: [f32; 3], + pub sheen_color_texture: Option, + pub sheen_roughness_factor: f32, + pub sheen_roughness_texture: Option, + pub anisotropy_authored: bool, + pub anisotropy_strength: f32, + pub anisotropy_rotation: f32, + pub anisotropy_texture: Option, + pub iridescence_authored: bool, + pub iridescence_factor: f32, + pub iridescence_texture: Option, + pub iridescence_ior: f32, + pub iridescence_thickness_minimum: f32, + pub iridescence_thickness_maximum: f32, + pub iridescence_thickness_texture: Option, +} + +impl Default for MaterialLayeredPbr { + fn default() -> Self { + Self { + clearcoat_authored: false, + clearcoat_factor: 0.0, + clearcoat_roughness_factor: 0.0, + clearcoat_texture: None, + clearcoat_roughness_texture: None, + clearcoat_normal_texture: None, + clearcoat_normal_scale: 1.0, + specular_authored: false, + specular_factor: 1.0, + specular_texture: None, + specular_color_factor: [1.0; 3], + specular_color_texture: None, + ior_authored: false, + ior: 1.5, + sheen_authored: false, + sheen_color_factor: [0.0; 3], + sheen_color_texture: None, + sheen_roughness_factor: 0.0, + sheen_roughness_texture: None, + anisotropy_authored: false, + anisotropy_strength: 0.0, + anisotropy_rotation: 0.0, + anisotropy_texture: None, + iridescence_authored: false, + iridescence_factor: 0.0, + iridescence_texture: None, + iridescence_ior: 1.3, + iridescence_thickness_minimum: 100.0, + iridescence_thickness_maximum: 400.0, + iridescence_thickness_texture: None, + } + } +} + +impl MaterialLayeredPbr { + pub const CLEARCOAT_LOBE: u32 = 1 << 0; + pub const SHEEN_LOBE: u32 = 1 << 1; + pub const ANISOTROPY_LOBE: u32 = 1 << 2; + pub const IRIDESCENCE_LOBE: u32 = 1 << 3; + pub const SPECULAR_IOR_LOBE: u32 = 1 << 4; + + /// Build the texture-free layered material used by the public authoring + /// API. The lobe mask is authoritative: values belonging to an absent + /// lobe are ignored so an omitted descriptor always restores glTF + /// defaults and the allocation-free base-material path. + #[doc(hidden)] + #[allow(clippy::too_many_arguments)] + pub fn from_authoring_factors( + lobe_mask: u32, + clearcoat_factor: f32, + clearcoat_roughness: f32, + clearcoat_normal_scale: f32, + specular_factor: f32, + specular_color: [f32; 3], + ior: f32, + sheen_color: [f32; 3], + sheen_roughness: f32, + anisotropy_strength: f32, + anisotropy_rotation: f32, + iridescence_factor: f32, + iridescence_ior: f32, + iridescence_thickness_minimum: f32, + iridescence_thickness_maximum: f32, + ) -> Self { + fn finite_or(value: f32, fallback: f32) -> f32 { + if value.is_finite() { + value + } else { + fallback + } + } + fn unit(value: f32, fallback: f32) -> f32 { + finite_or(value, fallback).clamp(0.0, 1.0) + } + fn non_negative(value: f32, fallback: f32) -> f32 { + finite_or(value, fallback).max(0.0) + } + fn material_ior(value: f32) -> f32 { + let value = finite_or(value, 1.5); + if value == 0.0 { + 0.0 + } else { + value.max(1.0) + } + } + + let mut material = Self::default(); + if lobe_mask & Self::CLEARCOAT_LOBE != 0 { + material.clearcoat_authored = true; + material.clearcoat_factor = unit(clearcoat_factor, 0.0); + material.clearcoat_roughness_factor = unit(clearcoat_roughness, 0.0); + material.clearcoat_normal_scale = finite_or(clearcoat_normal_scale, 1.0); + } + if lobe_mask & Self::SPECULAR_IOR_LOBE != 0 { + material.specular_authored = true; + material.specular_factor = unit(specular_factor, 1.0); + material.specular_color_factor = specular_color.map(|value| non_negative(value, 1.0)); + material.ior_authored = true; + material.ior = material_ior(ior); + } + if lobe_mask & Self::SHEEN_LOBE != 0 { + material.sheen_authored = true; + material.sheen_color_factor = sheen_color.map(|value| unit(value, 0.0)); + material.sheen_roughness_factor = unit(sheen_roughness, 0.0); + } + if lobe_mask & Self::ANISOTROPY_LOBE != 0 { + material.anisotropy_authored = true; + material.anisotropy_strength = unit(anisotropy_strength, 0.0); + material.anisotropy_rotation = finite_or(anisotropy_rotation, 0.0); + } + if lobe_mask & Self::IRIDESCENCE_LOBE != 0 { + material.iridescence_authored = true; + material.iridescence_factor = unit(iridescence_factor, 0.0); + material.iridescence_ior = finite_or(iridescence_ior, 1.3).max(1.0); + material.iridescence_thickness_minimum = + non_negative(iridescence_thickness_minimum, 100.0); + material.iridescence_thickness_maximum = + non_negative(iridescence_thickness_maximum, 400.0); + } + material + } + + pub(crate) fn is_active(self) -> bool { + self.has_clearcoat() + || self.has_specular_ior() + || self.has_sheen() + || self.has_anisotropy() + || self.has_iridescence() + } + + pub(crate) fn has_clearcoat(self) -> bool { + self.clearcoat_authored && self.clearcoat_factor.is_finite() && self.clearcoat_factor > 0.0 + } + + pub(crate) fn has_specular_ior(self) -> bool { + self.ior != 1.5 + || self.specular_factor != 1.0 + || self.specular_color_factor != [1.0; 3] + || self.specular_texture.is_some() + || self.specular_color_texture.is_some() + } + + pub(crate) fn has_sheen(self) -> bool { + self.sheen_authored + && self + .sheen_color_factor + .iter() + .any(|value| value.is_finite() && *value > 0.0) + } + + pub(crate) fn has_anisotropy(self) -> bool { + self.anisotropy_authored + && self.anisotropy_strength.is_finite() + && self.anisotropy_strength > 0.0 + } + + pub(crate) fn has_iridescence(self) -> bool { + let thickness_can_contribute = if self.iridescence_thickness_texture.is_some() { + self.iridescence_thickness_minimum > 0.0 || self.iridescence_thickness_maximum > 0.0 + } else { + self.iridescence_thickness_maximum > 0.0 + }; + self.iridescence_authored + && self.iridescence_factor.is_finite() + && self.iridescence_factor > 0.0 + && self.iridescence_thickness_minimum.is_finite() + && self.iridescence_thickness_maximum.is_finite() + && thickness_can_contribute + } + + pub(crate) fn requests_tex_coord(self, tex_coord: u32) -> bool { + let clearcoat = if self.has_clearcoat() { + [ + self.clearcoat_texture, + self.clearcoat_roughness_texture, + self.clearcoat_normal_texture, + ] + .into_iter() + .flatten() + .any(|binding| binding.transform.tex_coord == tex_coord) + } else { + false + }; + let specular = if self.has_specular_ior() { + [self.specular_texture, self.specular_color_texture] + .into_iter() + .flatten() + .any(|binding| binding.transform.tex_coord == tex_coord) + } else { + false + }; + let sheen = self.has_sheen() + && [self.sheen_color_texture, self.sheen_roughness_texture] + .into_iter() + .flatten() + .any(|binding| binding.transform.tex_coord == tex_coord); + let anisotropy = self.has_anisotropy() + && self + .anisotropy_texture + .is_some_and(|binding| binding.transform.tex_coord == tex_coord); + let iridescence = self.has_iridescence() + && [self.iridescence_texture, self.iridescence_thickness_texture] + .into_iter() + .flatten() + .any(|binding| binding.transform.tex_coord == tex_coord); + clearcoat || specular || sheen || anisotropy || iridescence + } + + pub(crate) fn has_resolved_tex_coord(self, tex_coord: u32) -> bool { + self.requests_tex_coord(tex_coord) + && [ + self.clearcoat_texture, + self.clearcoat_roughness_texture, + self.clearcoat_normal_texture, + self.specular_texture, + self.specular_color_texture, + self.sheen_color_texture, + self.sheen_roughness_texture, + self.anisotropy_texture, + self.iridescence_texture, + self.iridescence_thickness_texture, + ] + .into_iter() + .flatten() + .any(|binding| { + binding.transform.tex_coord == tex_coord + && binding.runtime_texture_idx.is_some_and(|index| index != 0) + }) + } +} + +#[cfg(test)] +mod layered_pbr_authoring_tests { + use super::MaterialLayeredPbr; + + #[test] + fn omitted_lobes_restore_exact_defaults() { + let material = MaterialLayeredPbr::from_authoring_factors( + 0, + 1.0, + 1.0, + 2.0, + 0.0, + [3.0, 2.0, 1.0], + 1.1, + [1.0; 3], + 1.0, + 1.0, + 2.0, + 1.0, + 1.8, + 20.0, + 80.0, + ); + assert_eq!(material, MaterialLayeredPbr::default()); + assert!(!material.is_active()); + } + + #[test] + fn authoring_factors_are_finite_and_range_safe() { + let mask = MaterialLayeredPbr::CLEARCOAT_LOBE + | MaterialLayeredPbr::SPECULAR_IOR_LOBE + | MaterialLayeredPbr::SHEEN_LOBE + | MaterialLayeredPbr::ANISOTROPY_LOBE + | MaterialLayeredPbr::IRIDESCENCE_LOBE; + let material = MaterialLayeredPbr::from_authoring_factors( + mask, + 2.0, + -1.0, + f32::NAN, + -2.0, + [-1.0, 2.0, f32::INFINITY], + 0.5, + [-1.0, 0.5, 2.0], + f32::NAN, + 4.0, + f32::INFINITY, + 3.0, + 0.2, + -10.0, + f32::NAN, + ); + assert_eq!(material.clearcoat_factor, 1.0); + assert_eq!(material.clearcoat_roughness_factor, 0.0); + assert_eq!(material.clearcoat_normal_scale, 1.0); + assert_eq!(material.specular_factor, 0.0); + assert_eq!(material.specular_color_factor, [0.0, 2.0, 1.0]); + assert_eq!(material.ior, 1.0); + assert_eq!(material.sheen_color_factor, [0.0, 0.5, 1.0]); + assert_eq!(material.sheen_roughness_factor, 0.0); + assert_eq!(material.anisotropy_strength, 1.0); + assert_eq!(material.anisotropy_rotation, 0.0); + assert_eq!(material.iridescence_factor, 1.0); + assert_eq!(material.iridescence_ior, 1.0); + assert_eq!(material.iridescence_thickness_minimum, 0.0); + assert_eq!(material.iridescence_thickness_maximum, 400.0); + assert!(material.is_active()); + } +} + +pub(crate) fn effective_material_ior(ior: f32) -> f32 { + if ior == 0.0 { + 1_000_000.0 + } else if ior.is_finite() { + ior.max(1.0) + } else { + 1.5 + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct MaterialTransmission { + pub authored: bool, + pub factor: f32, + pub texture: Option, + pub ior_authored: bool, + pub ior: f32, + pub volume_authored: bool, + pub thickness_factor: f32, + pub thickness_texture: Option, + pub attenuation_distance: f32, + pub attenuation_color: [f32; 3], + pub thickness_source: MaterialThicknessSource, +} + +impl Default for MaterialTransmission { + fn default() -> Self { + Self { + authored: false, + factor: 0.0, + texture: None, + ior_authored: false, + // KHR_materials_ior default. + ior: 1.5, + volume_authored: false, + thickness_factor: 0.0, + thickness_texture: None, + // KHR_materials_volume defaults: no attenuation. + attenuation_distance: f32::INFINITY, + attenuation_color: [1.0; 3], + thickness_source: MaterialThicknessSource::Unavailable, + } + } +} + +impl MaterialTransmission { + pub(crate) fn effective_ior(self) -> f32 { + effective_material_ior(self.ior) + } + + /// True when the authored transmission lobe can contribute energy. + /// + /// The texture modulates (rather than replaces) `factor`, so a zero + /// scalar remains inactive even when a texture is present. Invalid + /// non-finite/range values are rejected by the glTF boundary; the + /// defensive checks here also keep programmatically-created materials + /// out of the refractive bucket until they have a usable value. + pub(crate) fn is_active(self) -> bool { + self.authored && self.factor.is_finite() && self.factor > 0.0 + } + + /// Whether either physical texture requests a particular glTF UV set. + /// + /// Importers use this to retain TEXCOORD_1 only for contributing + /// transmission materials. Ordinary and scalar-only meshes therefore keep + /// their established CPU/GPU geometry footprint. + pub(crate) fn requests_tex_coord(self, tex_coord: u32) -> bool { + self.is_active() + && [self.texture, self.thickness_texture] + .into_iter() + .flatten() + .any(|binding| binding.transform.tex_coord == tex_coord) + } + + pub(crate) fn has_resolved_tex_coord(self, tex_coord: u32) -> bool { + self.requests_tex_coord(tex_coord) + && [self.texture, self.thickness_texture] + .into_iter() + .flatten() + .any(|binding| { + binding.transform.tex_coord == tex_coord + && binding.runtime_texture_idx.is_some_and(|index| index != 0) + }) + } +} + +/// Startup kill switch for the imported physical-transmission path. +/// +/// Default-on is the production behavior. Setting +/// `BLOOM_GLTF_REFRACTION=0|false|off` before loading the renderer/assets +/// restores the previous mirror-like import fallback for A/B diagnosis. +/// The value is intentionally read at each startup/load boundary rather than +/// cached globally so isolated renderer tests can select a mode reliably. +pub(crate) fn physical_transmission_requested() -> bool { + std::env::var("BLOOM_GLTF_REFRACTION") + .ok() + .map(|value| { + !matches!( + value.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" + ) + }) + .unwrap_or(true) +} + pub struct MeshData { pub vertices: Vec, + /// Compact glTF TEXCOORD_1 sidecar retained only when an active physical + /// transmission/thickness texture requests it. Keeping it out of + /// `Vertex3D` preserves every ordinary vertex ABI and fetch footprint. + pub secondary_tex_coords: Option>, pub indices: Vec, pub texture_idx: Option, pub normal_texture_idx: Option, @@ -13,12 +517,22 @@ pub struct MeshData { pub metallic_factor: f32, pub roughness_factor: f32, pub emissive_factor: [f32; 3], + pub alpha_mode: MaterialAlphaMode, /// glTF alpha cutoff for MASK mode — fragments with base-colour - /// alpha below this are discarded. 0.0 means OPAQUE mode (no - /// discard); glTF spec default for MASK is 0.5. BLEND mode is - /// currently treated as MASK @ 0.5 since we don't have a sorted - /// transparent pipeline yet. + /// alpha below this are discarded. Ignored by OPAQUE and BLEND. pub alpha_cutoff: f32, + /// The bound base-color texture has MASK-specific lower mips whose alpha + /// stores surviving texel coverage. Level zero remains authored RGBA. + pub alpha_coverage_mips: bool, + pub double_sided: bool, + /// Preserved KHR_materials_transmission/volume/ior source contract. + /// Authoritative for the imported refractive bucket; the legacy PBR + /// approximation is used only when the startup kill switch disables it. + pub transmission: MaterialTransmission, + /// Preserved KHR_materials_clearcoat/specular/ior source contract. + /// Scalar and texture metadata is retained before any renderer + /// specialization is selected. + pub layered_pbr: MaterialLayeredPbr, } pub struct ModelData { @@ -118,8 +632,12 @@ impl ModelManager { self.scratch_f32.clear(); self.scratch_u32.clear(); } - pub fn mesh_scratch_push_f32(&mut self, v: f32) { self.scratch_f32.push(v); } - pub fn mesh_scratch_push_u32(&mut self, v: u32) { self.scratch_u32.push(v); } + pub fn mesh_scratch_push_f32(&mut self, v: f32) { + self.scratch_f32.push(v); + } + pub fn mesh_scratch_push_u32(&mut self, v: u32) { + self.scratch_u32.push(v); + } /// Build a mesh from the scratch buffers: `vertex_count` vertices of 12 /// floats each in `scratch_f32`, `index_count` indices in `scratch_u32`. @@ -170,8 +688,12 @@ impl ModelManager { } #[cfg(feature = "models3d")] - pub fn load_model_with_textures(&mut self, file_data: &[u8], renderer: &mut crate::renderer::Renderer) -> f64 { - match gltf_load::load_gltf_with_textures(file_data, renderer, None) { + pub fn load_model_with_textures( + &mut self, + file_data: &[u8], + renderer: &mut crate::renderer::Renderer, + ) -> f64 { + match gltf_load::load_gltf_with_textures(file_data, renderer, None, None) { Some(model) => self.models.alloc(model), None => 0.0, } @@ -187,7 +709,36 @@ impl ModelManager { base_dir: &std::path::Path, renderer: &mut crate::renderer::Renderer, ) -> f64 { - match gltf_load::load_gltf_with_textures(file_data, renderer, Some(base_dir)) { + match gltf_load::load_gltf_with_textures( + file_data, + renderer, + Some(base_dir), + Some(&base_dir.display().to_string()), + ) { + Some(model) => self.models.alloc(model), + None => 0.0, + } + } + + /// Path-aware variant used by the public FFI so import diagnostics can + /// identify the exact asset while external buffers/images still resolve + /// relative to its parent directory. + #[cfg(feature = "models3d")] + pub fn load_model_with_textures_from_source_path( + &mut self, + file_data: &[u8], + source_path: &std::path::Path, + renderer: &mut crate::renderer::Renderer, + ) -> f64 { + let base_dir = source_path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")); + match gltf_load::load_gltf_with_textures( + file_data, + renderer, + Some(base_dir), + Some(&source_path.display().to_string()), + ) { Some(model) => self.models.alloc(model), None => 0.0, } @@ -263,15 +814,18 @@ impl ModelManager { ([-hw, -hh, hd], [0.0, -1.0, 0.0], [0.0, 0.0]), ]; - let vertices: Vec = faces.iter().map(|(pos, norm, uv)| Vertex3D { - position: *pos, - normal: *norm, - color: white, - uv: *uv, - joints: [0.0; 4], - weights: [0.0; 4], - tangent: [0.0; 4], - }).collect(); + let vertices: Vec = faces + .iter() + .map(|(pos, norm, uv)| Vertex3D { + position: *pos, + normal: *norm, + color: white, + uv: *uv, + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }) + .collect(); let mut indices = Vec::with_capacity(36); for face in 0..6u32 { @@ -280,17 +834,45 @@ impl ModelManager { } let model = ModelData { - meshes: vec![MeshData { vertices, indices, texture_idx: None, normal_texture_idx: None, metallic_roughness_texture_idx: None, emissive_texture_idx: None, occlusion_texture_idx: None, metallic_factor: 0.0, roughness_factor: 1.0, emissive_factor: [0.0; 3], alpha_cutoff: 0.0 }], + meshes: vec![MeshData { + vertices, + secondary_tex_coords: None, + indices, + texture_idx: None, + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.0, + roughness_factor: 1.0, + emissive_factor: [0.0; 3], + alpha_mode: MaterialAlphaMode::Opaque, + alpha_cutoff: 0.0, + alpha_coverage_mips: false, + double_sided: false, + transmission: MaterialTransmission::default(), + layered_pbr: MaterialLayeredPbr::default(), + }], bbox_min: [-hw, -hh, -hd], bbox_max: [hw, hh, hd], }; self.models.alloc(model) } - pub fn gen_mesh_heightmap(&mut self, image_data: &[u8], img_w: u32, img_h: u32, size_x: f32, size_y: f32, size_z: f32) -> f64 { + pub fn gen_mesh_heightmap( + &mut self, + image_data: &[u8], + img_w: u32, + img_h: u32, + size_x: f32, + size_y: f32, + size_z: f32, + ) -> f64 { let cols = img_w as usize; let rows = img_h as usize; - if cols < 2 || rows < 2 { return 0.0; } + if cols < 2 || rows < 2 { + return 0.0; + } let mut vertices = Vec::with_capacity(cols * rows); let white = [1.0, 1.0, 1.0, 1.0]; @@ -301,7 +883,8 @@ impl ModelManager { let luminance = if pixel_idx + 2 < image_data.len() { (image_data[pixel_idx] as f32 * 0.299 + image_data[pixel_idx + 1] as f32 * 0.587 - + image_data[pixel_idx + 2] as f32 * 0.114) / 255.0 + + image_data[pixel_idx + 2] as f32 * 0.114) + / 255.0 } else { 0.0 }; @@ -328,10 +911,26 @@ impl ModelManager { for z in 0..rows { for x in 0..cols { let idx = z * cols + x; - let left = if x > 0 { vertices[z * cols + x - 1].position[1] } else { vertices[idx].position[1] }; - let right = if x < cols - 1 { vertices[z * cols + x + 1].position[1] } else { vertices[idx].position[1] }; - let up = if z > 0 { vertices[(z - 1) * cols + x].position[1] } else { vertices[idx].position[1] }; - let down = if z < rows - 1 { vertices[(z + 1) * cols + x].position[1] } else { vertices[idx].position[1] }; + let left = if x > 0 { + vertices[z * cols + x - 1].position[1] + } else { + vertices[idx].position[1] + }; + let right = if x < cols - 1 { + vertices[z * cols + x + 1].position[1] + } else { + vertices[idx].position[1] + }; + let up = if z > 0 { + vertices[(z - 1) * cols + x].position[1] + } else { + vertices[idx].position[1] + }; + let down = if z < rows - 1 { + vertices[(z + 1) * cols + x].position[1] + } else { + vertices[idx].position[1] + }; let sx = size_x / (cols - 1) as f32; let sz = size_z / (rows - 1) as f32; let nx = (left - right) / (2.0 * sx); @@ -353,7 +952,25 @@ impl ModelManager { } let model = ModelData { - meshes: vec![MeshData { vertices, indices, texture_idx: None, normal_texture_idx: None, metallic_roughness_texture_idx: None, emissive_texture_idx: None, occlusion_texture_idx: None, metallic_factor: 0.0, roughness_factor: 1.0, emissive_factor: [0.0; 3], alpha_cutoff: 0.0 }], + meshes: vec![MeshData { + vertices, + secondary_tex_coords: None, + indices, + texture_idx: None, + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.0, + roughness_factor: 1.0, + emissive_factor: [0.0; 3], + alpha_mode: MaterialAlphaMode::Opaque, + alpha_cutoff: 0.0, + alpha_coverage_mips: false, + double_sided: false, + transmission: MaterialTransmission::default(), + layered_pbr: MaterialLayeredPbr::default(), + }], bbox_min: [-size_x * 0.5, 0.0, -size_z * 0.5], bbox_max: [size_x * 0.5, size_y, size_z * 0.5], }; @@ -365,7 +982,9 @@ impl ModelManager { pub fn create_mesh(&mut self, vertex_data: &[f32], index_data: &[u32]) -> f64 { let floats_per_vert = 12; let vert_count = vertex_data.len() / floats_per_vert; - if vert_count == 0 { return 0.0; } + if vert_count == 0 { + return 0.0; + } let mut vertices = Vec::with_capacity(vert_count); let mut bbox_min = [f32::MAX; 3]; @@ -373,16 +992,25 @@ impl ModelManager { for i in 0..vert_count { let o = i * floats_per_vert; - let pos = [vertex_data[o], vertex_data[o+1], vertex_data[o+2]]; + let pos = [vertex_data[o], vertex_data[o + 1], vertex_data[o + 2]]; for k in 0..3 { - if pos[k] < bbox_min[k] { bbox_min[k] = pos[k]; } - if pos[k] > bbox_max[k] { bbox_max[k] = pos[k]; } + if pos[k] < bbox_min[k] { + bbox_min[k] = pos[k]; + } + if pos[k] > bbox_max[k] { + bbox_max[k] = pos[k]; + } } vertices.push(Vertex3D { position: pos, - normal: [vertex_data[o+3], vertex_data[o+4], vertex_data[o+5]], - color: [vertex_data[o+6], vertex_data[o+7], vertex_data[o+8], vertex_data[o+9]], - uv: [vertex_data[o+10], vertex_data[o+11]], + normal: [vertex_data[o + 3], vertex_data[o + 4], vertex_data[o + 5]], + color: [ + vertex_data[o + 6], + vertex_data[o + 7], + vertex_data[o + 8], + vertex_data[o + 9], + ], + uv: [vertex_data[o + 10], vertex_data[o + 11]], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4], @@ -391,7 +1019,25 @@ impl ModelManager { let indices = index_data.to_vec(); let model = ModelData { - meshes: vec![MeshData { vertices, indices, texture_idx: None, normal_texture_idx: None, metallic_roughness_texture_idx: None, emissive_texture_idx: None, occlusion_texture_idx: None, metallic_factor: 0.0, roughness_factor: 1.0, emissive_factor: [0.0; 3], alpha_cutoff: 0.0 }], + meshes: vec![MeshData { + vertices, + secondary_tex_coords: None, + indices, + texture_idx: None, + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.0, + roughness_factor: 1.0, + emissive_factor: [0.0; 3], + alpha_mode: MaterialAlphaMode::Opaque, + alpha_cutoff: 0.0, + alpha_coverage_mips: false, + double_sided: false, + transmission: MaterialTransmission::default(), + layered_pbr: MaterialLayeredPbr::default(), + }], bbox_min, bbox_max, }; @@ -403,7 +1049,9 @@ impl ModelManager { /// `widths` has one width per control point. pub fn gen_mesh_spline_ribbon(&mut self, points: &[f32], widths: &[f32]) -> f64 { let n = points.len() / 3; - if n < 2 || widths.len() < n { return 0.0; } + if n < 2 || widths.len() < n { + return 0.0; + } // Evaluate Catmull-Rom at fine intervals. let segments = (n - 1) * 8; // 8 subdivisions per segment. @@ -500,7 +1148,25 @@ impl ModelManager { } let model = ModelData { - meshes: vec![MeshData { vertices, indices, texture_idx: None, normal_texture_idx: None, metallic_roughness_texture_idx: None, emissive_texture_idx: None, occlusion_texture_idx: None, metallic_factor: 0.0, roughness_factor: 1.0, emissive_factor: [0.0; 3], alpha_cutoff: 0.0 }], + meshes: vec![MeshData { + vertices, + secondary_tex_coords: None, + indices, + texture_idx: None, + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.0, + roughness_factor: 1.0, + emissive_factor: [0.0; 3], + alpha_mode: MaterialAlphaMode::Opaque, + alpha_cutoff: 0.0, + alpha_coverage_mips: false, + double_sided: false, + transmission: MaterialTransmission::default(), + layered_pbr: MaterialLayeredPbr::default(), + }], bbox_min, bbox_max, }; @@ -544,7 +1210,9 @@ impl ModelManager { Some(s) => s, None => return, }; - if anim_index >= model_anim.animations.len() { return; } + if anim_index >= model_anim.animations.len() { + return; + } #[cfg(debug_assertions)] let joint_count = skeleton.joints.len(); @@ -557,7 +1225,10 @@ impl ModelManager { unsafe { if !DEBUG_PRINTED && joint_count > 0 { DEBUG_PRINTED = true; - eprintln!("[anim] joints={}, t={:.3}, anim_index={}", joint_count, time, anim_index); + eprintln!( + "[anim] joints={}, t={:.3}, anim_index={}", + joint_count, time, anim_index + ); } } } @@ -572,7 +1243,9 @@ impl ModelManager { /// this gets used in practice and where the pops came from before. pub fn anim_play(&mut self, handle: f64, clip: usize, fade: f32, speed: f32, looping: bool) { if let Some(ma) = self.animations.get_mut(handle) { - if clip >= ma.animations.len() { return; } + if clip >= ma.animations.len() { + return; + } let m = &mut ma.mixer; // Re-requesting the clip that is ALREADY current is a no-op by // contract ("safe to call every frame with the clip you want"), and @@ -612,7 +1285,15 @@ impl ModelManager { /// Masked layer: `clip` drives every joint at or below `mask_root`, /// blended in by `weight`. weight <= 0 (or clip < 0) turns it off. - pub fn anim_set_layer(&mut self, handle: f64, clip: i32, weight: f32, mask_root: i32, speed: f32, looping: bool) { + pub fn anim_set_layer( + &mut self, + handle: f64, + clip: i32, + weight: f32, + mask_root: i32, + speed: f32, + looping: bool, + ) { if let Some(ma) = self.animations.get_mut(handle) { let m = &mut ma.mixer; let off = clip < 0 || weight <= 0.0 || (clip as usize) >= ma.animations.len(); @@ -640,32 +1321,43 @@ impl ModelManager { } pub fn anim_finished(&self, handle: f64) -> bool { - self.animations.get(handle).map(|m| m.mixer.finished).unwrap_or(true) + self.animations + .get(handle) + .map(|m| m.mixer.finished) + .unwrap_or(true) } pub fn anim_clip_duration(&self, handle: f64, clip: usize) -> f32 { - self.animations.get(handle) + self.animations + .get(handle) .and_then(|m| m.animations.get(clip)) .map(|a| a.duration) .unwrap_or(0.0) } pub fn anim_root_delta(&self, handle: f64) -> [f32; 3] { - self.animations.get(handle).map(|m| m.mixer.root_delta).unwrap_or([0.0; 3]) + self.animations + .get(handle) + .map(|m| m.mixer.root_delta) + .unwrap_or([0.0; 3]) } pub fn find_joint(&self, handle: f64, name: &str) -> i32 { if let Some(ma) = self.animations.get(handle) { if let Some(sk) = &ma.skeleton { for (i, j) in sk.joints.iter().enumerate() { - if j.name == name { return i as i32; } + if j.name == name { + return i as i32; + } } // Fall back to a case-insensitive contains match: exporters // decorate names ("mixamorig:Hand_R", "Bip01 R Hand") often // enough that an exact match is the exception, not the rule. let want = name.to_ascii_lowercase(); for (i, j) in sk.joints.iter().enumerate() { - if j.name.to_ascii_lowercase().contains(&want) { return i as i32; } + if j.name.to_ascii_lowercase().contains(&want) { + return i as i32; + } } } } @@ -675,7 +1367,8 @@ impl ModelManager { /// Model-space transform of a joint (EN-033 sockets). Valid after the /// frame's `advance_and_update`. pub fn joint_world(&self, handle: f64, joint: usize) -> Option<[[f32; 4]; 4]> { - self.animations.get(handle) + self.animations + .get(handle) .and_then(|m| m.joint_world.get(joint)) .copied() } @@ -684,18 +1377,28 @@ impl ModelManager { /// model per frame; the game never touches clip time. pub fn advance_and_update(&mut self, handle: f64, dt: f32) { if let Some(ma) = self.animations.get_mut(handle) { - if ma.skeleton.is_none() || ma.animations.is_empty() { return; } - if !ma.mixer.started { ma.mixer.started = true; } + if ma.skeleton.is_none() || ma.animations.is_empty() { + return; + } + if !ma.mixer.started { + ma.mixer.started = true; + } // --- advance clocks - let cur_dur = ma.animations.get(ma.mixer.cur_clip).map(|a| a.duration).unwrap_or(0.0); + let cur_dur = ma + .animations + .get(ma.mixer.cur_clip) + .map(|a| a.duration) + .unwrap_or(0.0); let t_before = ma.mixer.cur_time; let t_raw = ma.mixer.cur_time + dt * ma.mixer.cur_speed; let mut wrapped = false; ma.mixer.cur_time = if cur_dur <= 0.0 { 0.0 } else if ma.mixer.cur_loop { - if t_raw >= cur_dur { wrapped = true; } + if t_raw >= cur_dur { + wrapped = true; + } t_raw.rem_euclid(cur_dur) } else if t_raw >= cur_dur { ma.mixer.finished = true; @@ -705,7 +1408,11 @@ impl ModelManager { }; if ma.mixer.fade_dur > 0.0 { - let prev_dur = ma.animations.get(ma.mixer.prev_clip).map(|a| a.duration).unwrap_or(0.0); + let prev_dur = ma + .animations + .get(ma.mixer.prev_clip) + .map(|a| a.duration) + .unwrap_or(0.0); let pt = ma.mixer.prev_time + dt * ma.mixer.prev_speed; ma.mixer.prev_time = if prev_dur <= 0.0 { 0.0 @@ -737,14 +1444,24 @@ impl ModelManager { // --- sample + blend the base track let skel = ma.skeleton.as_ref().unwrap(); let strip_root = !ma.mixer.root_motion; - let mut pose = sample_local_pose(skel, &ma.animations[ma.mixer.cur_clip], ma.mixer.cur_time, strip_root); + let mut pose = sample_local_pose( + skel, + &ma.animations[ma.mixer.cur_clip], + ma.mixer.cur_time, + strip_root, + ); if ma.mixer.fade_dur > 0.0 { let w = (ma.mixer.fade_t / ma.mixer.fade_dur).clamp(0.0, 1.0); // Smoothstep the fade — a linear pose blend still reads as a // slope discontinuity at both ends of the transition. let w = w * w * (3.0 - 2.0 * w); - let prev = sample_local_pose(skel, &ma.animations[ma.mixer.prev_clip], ma.mixer.prev_time, strip_root); + let prev = sample_local_pose( + skel, + &ma.animations[ma.mixer.prev_clip], + ma.mixer.prev_time, + strip_root, + ); blend_pose(&mut pose, &prev, 1.0 - w, None); } @@ -763,7 +1480,11 @@ impl ModelManager { (p_end[2] - p_old[2]) + (p_now[2] - p_start[2]), ] } else { - [p_now[0] - p_old[0], p_now[1] - p_old[1], p_now[2] - p_old[2]] + [ + p_now[0] - p_old[0], + p_now[1] - p_old[1], + p_now[2] - p_old[2], + ] }; ma.mixer.root_delta = d; // The delta is handed to the character controller, so the pose @@ -798,7 +1519,10 @@ impl ModelAnimation { /// Local TRS pose -> world transforms -> skinning matrices. Keeps the /// world transforms around too, because that is what sockets read. fn apply_pose(&mut self, pose: &LocalPose) { - let skeleton = match &self.skeleton { Some(s) => s, None => return }; + let skeleton = match &self.skeleton { + Some(s) => s, + None => return, + }; let joint_count = skeleton.joints.len(); if self.joint_matrices.len() != joint_count { self.joint_matrices = vec![mat4_identity(); joint_count]; @@ -808,7 +1532,15 @@ impl ModelAnimation { } let mut world = vec![mat4_identity(); joint_count]; for &root in &skeleton.root_joints { - compute_joint_transforms(skeleton, root, &mat4_identity(), &pose.0, &pose.1, &pose.2, &mut world); + compute_joint_transforms( + skeleton, + root, + &mat4_identity(), + &pose.0, + &pose.1, + &pose.2, + &mut world, + ); } for i in 0..joint_count { self.joint_matrices[i] = mat4_mul(&world[i], &skeleton.joints[i].inverse_bind); @@ -819,27 +1551,50 @@ impl ModelAnimation { /// Sample one clip into a local TRS pose, rest pose as the fallback for /// joints the clip does not animate. -fn sample_local_pose(skeleton: &SkeletonData, anim: &AnimationData, time: f32, strip_root: bool) -> LocalPose { +fn sample_local_pose( + skeleton: &SkeletonData, + anim: &AnimationData, + time: f32, + strip_root: bool, +) -> LocalPose { let joint_count = skeleton.joints.len(); let mut t: Vec<[f32; 3]> = skeleton.joints.iter().map(|j| j.rest_translation).collect(); let mut r: Vec<[f32; 4]> = skeleton.joints.iter().map(|j| j.rest_rotation).collect(); let mut s: Vec<[f32; 3]> = skeleton.joints.iter().map(|j| j.rest_scale).collect(); - let time = if anim.duration > 0.0 { time.rem_euclid(anim.duration) } else { 0.0 }; + let time = if anim.duration > 0.0 { + time.rem_euclid(anim.duration) + } else { + 0.0 + }; for channel in &anim.channels { let ji = channel.joint_index; - if ji >= joint_count { continue; } + if ji >= joint_count { + continue; + } if !channel.translations.is_empty() && !channel.timestamps.is_empty() { t[ji] = sample_vec3(&channel.timestamps, &channel.translations, time); } if !channel.rotations.is_empty() { - let ts = if !channel.rotation_timestamps.is_empty() { &channel.rotation_timestamps } else { &channel.timestamps }; - if !ts.is_empty() { r[ji] = sample_quat(ts, &channel.rotations, time); } + let ts = if !channel.rotation_timestamps.is_empty() { + &channel.rotation_timestamps + } else { + &channel.timestamps + }; + if !ts.is_empty() { + r[ji] = sample_quat(ts, &channel.rotations, time); + } } if !channel.scales.is_empty() { - let ts = if !channel.scale_timestamps.is_empty() { &channel.scale_timestamps } else { &channel.timestamps }; - if !ts.is_empty() { s[ji] = sample_vec3(ts, &channel.scales, time); } + let ts = if !channel.scale_timestamps.is_empty() { + &channel.scale_timestamps + } else { + &channel.timestamps + }; + if !ts.is_empty() { + s[ji] = sample_vec3(ts, &channel.scales, time); + } } } @@ -852,10 +1607,19 @@ fn sample_local_pose(skeleton: &SkeletonData, anim: &AnimationData, time: f32, s /// The root joint's authored translation at `time` — the raw channel value, /// *not* the rest-locked one, which is the whole point of root motion. fn root_translation_at(skeleton: &SkeletonData, anim: &AnimationData, time: f32) -> [f32; 3] { - if skeleton.joints.is_empty() { return [0.0; 3]; } - let time = if anim.duration > 0.0 { time.clamp(0.0, anim.duration) } else { 0.0 }; + if skeleton.joints.is_empty() { + return [0.0; 3]; + } + let time = if anim.duration > 0.0 { + time.clamp(0.0, anim.duration) + } else { + 0.0 + }; for channel in &anim.channels { - if channel.joint_index == 0 && !channel.translations.is_empty() && !channel.timestamps.is_empty() { + if channel.joint_index == 0 + && !channel.translations.is_empty() + && !channel.timestamps.is_empty() + { return sample_vec3(&channel.timestamps, &channel.translations, time); } } @@ -873,7 +1637,9 @@ fn blend_pose(dst: &mut LocalPose, src: &LocalPose, w: f32, mask: Option<&[f32]> Some(m) => w * m.get(j).copied().unwrap_or(0.0), None => w, }; - if jw <= 0.0 { continue; } + if jw <= 0.0 { + continue; + } let jw = jw.min(1.0); for k in 0..3 { dst.0[j][k] = dst.0[j][k] + (src.0[j][k] - dst.0[j][k]) * jw; @@ -881,17 +1647,19 @@ fn blend_pose(dst: &mut LocalPose, src: &LocalPose, w: f32, mask: Option<&[f32]> } let a = dst.1[j]; let mut b = src.1[j]; - let dot = a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3]; - if dot < 0.0 { b = [-b[0], -b[1], -b[2], -b[3]]; } + let dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; + if dot < 0.0 { + b = [-b[0], -b[1], -b[2], -b[3]]; + } let mut q = [ a[0] + (b[0] - a[0]) * jw, a[1] + (b[1] - a[1]) * jw, a[2] + (b[2] - a[2]) * jw, a[3] + (b[3] - a[3]) * jw, ]; - let len = (q[0]*q[0] + q[1]*q[1] + q[2]*q[2] + q[3]*q[3]).sqrt(); + let len = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt(); if len > 1e-6 { - q = [q[0]/len, q[1]/len, q[2]/len, q[3]/len]; + q = [q[0] / len, q[1] / len, q[2] / len, q[3] / len]; } else { q = a; } @@ -903,13 +1671,19 @@ fn blend_pose(dst: &mut LocalPose, src: &LocalPose, w: f32, mask: Option<&[f32]> /// "whole skeleton" so a layer with no mask is a plain full-body override. fn build_mask_weights(skeleton: &SkeletonData, root: i32) -> Vec { let n = skeleton.joints.len(); - if root < 0 || (root as usize) >= n { return vec![1.0; n]; } + if root < 0 || (root as usize) >= n { + return vec![1.0; n]; + } let mut w = vec![0.0f32; n]; let mut stack = vec![root as usize]; while let Some(j) = stack.pop() { - if j >= n || w[j] > 0.0 { continue; } + if j >= n || w[j] > 0.0 { + continue; + } w[j] = 1.0; - for &c in &skeleton.joints[j].children { stack.push(c); } + for &c in &skeleton.joints[j].children { + stack.push(c); + } } w } @@ -931,7 +1705,10 @@ fn mat4_mul(a: &[[f32; 4]; 4], b: &[[f32; 4]; 4]) -> [[f32; 4]; 4] { let mut out = [[0.0f32; 4]; 4]; for col in 0..4 { for row in 0..4 { - out[col][row] = a[0][row]*b[col][0] + a[1][row]*b[col][1] + a[2][row]*b[col][2] + a[3][row]*b[col][3]; + out[col][row] = a[0][row] * b[col][0] + + a[1][row] * b[col][1] + + a[2][row] * b[col][2] + + a[3][row] * b[col][3]; } } out @@ -939,22 +1716,45 @@ fn mat4_mul(a: &[[f32; 4]; 4], b: &[[f32; 4]; 4]) -> [[f32; 4]; 4] { fn mat4_from_trs(t: &[f32; 3], r: &[f32; 4], s: &[f32; 3]) -> [[f32; 4]; 4] { let (x, y, z, w) = (r[0], r[1], r[2], r[3]); - let x2 = x + x; let y2 = y + y; let z2 = z + z; - let xx = x * x2; let xy = x * y2; let xz = x * z2; - let yy = y * y2; let yz = y * z2; let zz = z * z2; - let wx = w * x2; let wy = w * y2; let wz = w * z2; + let x2 = x + x; + let y2 = y + y; + let z2 = z + z; + let xx = x * x2; + let xy = x * y2; + let xz = x * z2; + let yy = y * y2; + let yz = y * z2; + let zz = z * z2; + let wx = w * x2; + let wy = w * y2; + let wz = w * z2; // Column-major: m[col][row] [ - [(1.0 - (yy + zz)) * s[0], (xy + wz) * s[0], (xz - wy) * s[0], 0.0], // column 0 - [(xy - wz) * s[1], (1.0 - (xx + zz)) * s[1], (yz + wx) * s[1], 0.0], // column 1 - [(xz + wy) * s[2], (yz - wx) * s[2], (1.0 - (xx + yy)) * s[2], 0.0], // column 2 - [t[0], t[1], t[2], 1.0], // column 3 (translation) + [ + (1.0 - (yy + zz)) * s[0], + (xy + wz) * s[0], + (xz - wy) * s[0], + 0.0, + ], // column 0 + [ + (xy - wz) * s[1], + (1.0 - (xx + zz)) * s[1], + (yz + wx) * s[1], + 0.0, + ], // column 1 + [ + (xz + wy) * s[2], + (yz - wx) * s[2], + (1.0 - (xx + yy)) * s[2], + 0.0, + ], // column 2 + [t[0], t[1], t[2], 1.0], // column 3 (translation) ] } fn quat_slerp(a: &[f32; 4], b: &[f32; 4], t: f32) -> [f32; 4] { - let mut dot = a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3]; + let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; let mut b2 = *b; if dot < 0.0 { dot = -dot; @@ -967,8 +1767,12 @@ fn quat_slerp(a: &[f32; 4], b: &[f32; 4], t: f32) -> [f32; 4] { a[2] + t * (b2[2] - a[2]), a[3] + t * (b2[3] - a[3]), ]; - let len = (out[0]*out[0] + out[1]*out[1] + out[2]*out[2] + out[3]*out[3]).sqrt(); - if len > 0.0 { for v in &mut out { *v /= len; } } + let len = (out[0] * out[0] + out[1] * out[1] + out[2] * out[2] + out[3] * out[3]).sqrt(); + if len > 0.0 { + for v in &mut out { + *v /= len; + } + } return out; } let theta = dot.acos(); @@ -1005,7 +1809,11 @@ fn find_keyframe_pair(timestamps: &[f32], time: f32) -> (usize, usize, f32) { for i in 0..timestamps.len() - 1 { if time >= timestamps[i] && time < timestamps[i + 1] { let dt = timestamps[i + 1] - timestamps[i]; - let t = if dt > 0.0 { (time - timestamps[i]) / dt } else { 0.0 }; + let t = if dt > 0.0 { + (time - timestamps[i]) / dt + } else { + 0.0 + }; return (i, i + 1, t); } } @@ -1014,20 +1822,36 @@ fn find_keyframe_pair(timestamps: &[f32], time: f32) -> (usize, usize, f32) { } fn sample_vec3(timestamps: &[f32], values: &[[f32; 3]], time: f32) -> [f32; 3] { - if values.is_empty() { return [0.0; 3]; } - if values.len() == 1 { return values[0]; } + if values.is_empty() { + return [0.0; 3]; + } + if values.len() == 1 { + return values[0]; + } let (i0, i1, t) = find_keyframe_pair(timestamps, time); - if i0 >= values.len() { return values[values.len() - 1]; } - if i1 >= values.len() { return values[values.len() - 1]; } + if i0 >= values.len() { + return values[values.len() - 1]; + } + if i1 >= values.len() { + return values[values.len() - 1]; + } lerp_vec3(&values[i0], &values[i1], t) } fn sample_quat(timestamps: &[f32], values: &[[f32; 4]], time: f32) -> [f32; 4] { - if values.is_empty() { return [0.0, 0.0, 0.0, 1.0]; } - if values.len() == 1 { return values[0]; } + if values.is_empty() { + return [0.0, 0.0, 0.0, 1.0]; + } + if values.len() == 1 { + return values[0]; + } let (i0, i1, t) = find_keyframe_pair(timestamps, time); - if i0 >= values.len() { return values[values.len() - 1]; } - if i1 >= values.len() { return values[values.len() - 1]; } + if i0 >= values.len() { + return values[values.len() - 1]; + } + if i1 >= values.len() { + return values[values.len() - 1]; + } quat_slerp(&values[i0], &values[i1], t) } @@ -1040,13 +1864,27 @@ fn compute_joint_transforms( scales: &[[f32; 3]], world_transforms: &mut [[[f32; 4]; 4]], ) { - if joint_idx >= skeleton.joints.len() { return; } - let local = mat4_from_trs(&translations[joint_idx], &rotations[joint_idx], &scales[joint_idx]); + if joint_idx >= skeleton.joints.len() { + return; + } + let local = mat4_from_trs( + &translations[joint_idx], + &rotations[joint_idx], + &scales[joint_idx], + ); let world = mat4_mul(parent_transform, &local); world_transforms[joint_idx] = world; let children = skeleton.joints[joint_idx].children.clone(); for &child in &children { - compute_joint_transforms(skeleton, child, &world, translations, rotations, scales, world_transforms); + compute_joint_transforms( + skeleton, + child, + &world, + translations, + rotations, + scales, + world_transforms, + ); } } @@ -1069,21 +1907,32 @@ fn catmull_rom_point(points: &[f32], n: usize, segment: usize, t: f32) -> [f32; let t3 = t2 * t; let mut out = [0.0f32; 3]; for k in 0..3 { - out[k] = 0.5 * ( - (2.0 * p1[k]) + - (-p0[k] + p2[k]) * t + - (2.0 * p0[k] - 5.0 * p1[k] + 4.0 * p2[k] - p3[k]) * t2 + - (-p0[k] + 3.0 * p1[k] - 3.0 * p2[k] + p3[k]) * t3 - ); + out[k] = 0.5 + * ((2.0 * p1[k]) + + (-p0[k] + p2[k]) * t + + (2.0 * p0[k] - 5.0 * p1[k] + 4.0 * p2[k] - p3[k]) * t2 + + (-p0[k] + 3.0 * p1[k] - 3.0 * p2[k] + p3[k]) * t3); } out } fn update_bounds(bmin: &mut [f32; 3], bmax: &mut [f32; 3], x: f32, y: f32, z: f32) { - if x < bmin[0] { bmin[0] = x; } - if y < bmin[1] { bmin[1] = y; } - if z < bmin[2] { bmin[2] = z; } - if x > bmax[0] { bmax[0] = x; } - if y > bmax[1] { bmax[1] = y; } - if z > bmax[2] { bmax[2] = z; } + if x < bmin[0] { + bmin[0] = x; + } + if y < bmin[1] { + bmin[1] = y; + } + if z < bmin[2] { + bmin[2] = z; + } + if x > bmax[0] { + bmax[0] = x; + } + if y > bmax[1] { + bmax[1] = y; + } + if z > bmax[2] { + bmax[2] = z; + } } diff --git a/native/shared/src/models_gltf.rs b/native/shared/src/models_gltf.rs index a2a84cf7..304cd7b4 100644 --- a/native/shared/src/models_gltf.rs +++ b/native/shared/src/models_gltf.rs @@ -8,6 +8,18 @@ use super::*; +#[path = "models_gltf_layered_pbr.rs"] +mod layered_pbr_import; +use layered_pbr_import::{ + layered_pbr_from_material, retain_layered_normal_image_indices, retain_material_tex_coords_1, + texture_binding_from_info, +}; +#[path = "models_gltf_transform.rs"] +mod transform; +use transform::{ + mat3_transform_vec, mat4_inverse_transpose_3x3, mat4_transform_direction, mat4_transform_point, +}; + /// Walk the scene graph and collect EVERY world-space transform that /// references each mesh. Unlike `walk_scene_for_mesh_transforms` which /// records only the first occurrence, this version captures every @@ -32,71 +44,23 @@ fn walk_scene_collect_instances( } } -/// Transform a 3D point by a 4x4 matrix (column-major). Treats the -/// point as having w=1 and drops w from the result. -fn mat4_transform_point(m: &[[f32; 4]; 4], p: &[f32; 3]) -> [f32; 3] { - [ - m[0][0]*p[0] + m[1][0]*p[1] + m[2][0]*p[2] + m[3][0], - m[0][1]*p[0] + m[1][1]*p[1] + m[2][1]*p[2] + m[3][1], - m[0][2]*p[0] + m[1][2]*p[1] + m[2][2]*p[2] + m[3][2], - ] -} - -/// Transform a direction vector by a 3x3 matrix (extracted from a 4x4 -/// column-major stored as the top-left 3x3). Used for normals under -/// the inverse-transpose matrix. -fn mat3_transform_vec(m: &[[f32; 3]; 3], v: &[f32; 3]) -> [f32; 3] { - [ - m[0][0]*v[0] + m[1][0]*v[1] + m[2][0]*v[2], - m[0][1]*v[0] + m[1][1]*v[1] + m[2][1]*v[2], - m[0][2]*v[0] + m[1][2]*v[1] + m[2][2]*v[2], - ] -} - -/// Inverse-transpose of the 3x3 rotation+scale part of a 4x4 matrix. -/// Correct way to transform normals when the matrix has non-uniform -/// scale; falls back to identity if the 3x3 block isn't invertible. -fn mat4_inverse_transpose_3x3(m: &[[f32; 4]; 4]) -> [[f32; 3]; 3] { - let a = m[0][0]; let b = m[1][0]; let c = m[2][0]; - let d = m[0][1]; let e = m[1][1]; let f = m[2][1]; - let g = m[0][2]; let h = m[1][2]; let i = m[2][2]; - - let inv00 = e*i - f*h; - let inv01 = f*g - d*i; - let inv02 = d*h - e*g; - let inv10 = c*h - b*i; - let inv11 = a*i - c*g; - let inv12 = b*g - a*h; - let inv20 = b*f - c*e; - let inv21 = c*d - a*f; - let inv22 = a*e - b*d; - - let det = a*inv00 + b*inv01 + c*inv02; - if det.abs() < 1e-10 { - return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; - } - let inv_det = 1.0 / det; - // Store in column-major like the rest of the file (columns first). - // The result is the inverse-transpose, so rows/cols are swapped - // from the plain inverse. - [ - [inv00 * inv_det, inv01 * inv_det, inv02 * inv_det], - [inv10 * inv_det, inv11 * inv_det, inv12 * inv_det], - [inv20 * inv_det, inv21 * inv_det, inv22 * inv_det], - ] -} - // ============================================================ // glTF animation loader // ============================================================ -fn read_accessor_f32(_gltf: &gltf::Gltf, buffer_data: &[Vec], accessor: &gltf::Accessor) -> Vec { +fn read_accessor_f32( + _gltf: &gltf::Gltf, + buffer_data: &[Vec], + accessor: &gltf::Accessor, +) -> Vec { let view = match accessor.view() { Some(v) => v, None => return Vec::new(), }; let buf_idx = view.buffer().index(); - if buf_idx >= buffer_data.len() { return Vec::new(); } + if buf_idx >= buffer_data.len() { + return Vec::new(); + } let buf = &buffer_data[buf_idx]; let offset = view.offset() + accessor.offset(); let count = accessor.count(); @@ -116,7 +80,12 @@ fn read_accessor_f32(_gltf: &gltf::Gltf, buffer_data: &[Vec], accessor: &glt for c in 0..component_count { let byte_offset = base + c * 4; if byte_offset + 4 <= buf.len() { - let val = f32::from_le_bytes([buf[byte_offset], buf[byte_offset+1], buf[byte_offset+2], buf[byte_offset+3]]); + let val = f32::from_le_bytes([ + buf[byte_offset], + buf[byte_offset + 1], + buf[byte_offset + 2], + buf[byte_offset + 3], + ]); result.push(val); } else { result.push(0.0); @@ -168,10 +137,7 @@ pub(super) fn load_gltf_animation(data: &[u8]) -> Option { let mut default_ibm = Vec::with_capacity(joint_count * 16); for _ in 0..joint_count { default_ibm.extend_from_slice(&[ - 1.0, 0.0, 0.0, 0.0, - 0.0, 1.0, 0.0, 0.0, - 0.0, 0.0, 1.0, 0.0, - 0.0, 0.0, 0.0, 1.0, + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, ]); } default_ibm @@ -199,7 +165,8 @@ pub(super) fn load_gltf_animation(data: &[u8]) -> Option { // The 100x in IBMs converts meter-space vertices to cm-space bone transforms. // DO NOT normalize — the scale is intentional and required. - let children: Vec = node.children() + let children: Vec = node + .children() .filter_map(|child| node_to_joint.get(&child.index()).copied()) .collect(); @@ -207,7 +174,9 @@ pub(super) fn load_gltf_animation(data: &[u8]) -> Option { let (t, r, s) = node.transform().decomposed(); joints.push(JointData { - inverse_bind: ibm, children, name, + inverse_bind: ibm, + children, + name, rest_translation: t, rest_rotation: r, rest_scale: s, @@ -218,24 +187,38 @@ pub(super) fn load_gltf_animation(data: &[u8]) -> Option { let mut is_child = vec![false; joint_count]; for joint in &joints { for &child in &joint.children { - if child < joint_count { is_child[child] = true; } + if child < joint_count { + is_child[child] = true; + } } } for i in 0..joint_count { - if !is_child[i] { root_joints.push(i); } + if !is_child[i] { + root_joints.push(i); + } } #[cfg(debug_assertions)] { - eprintln!("[anim] Skeleton: {} joints, {} roots", joints.len(), root_joints.len()); + eprintln!( + "[anim] Skeleton: {} joints, {} roots", + joints.len(), + root_joints.len() + ); for (i, j) in joints.iter().enumerate() { if i < 5 || i == joints.len() - 1 { - eprintln!("[anim] joint {}: '{}' children={:?}", i, j.name, j.children); + eprintln!( + "[anim] joint {}: '{}' children={:?}", + i, j.name, j.children + ); } } } - Some(SkeletonData { joints, root_joints }) + Some(SkeletonData { + joints, + root_joints, + }) } else { #[cfg(debug_assertions)] eprintln!("[anim] No skin found in glTF!"); @@ -249,14 +232,28 @@ pub(super) fn load_gltf_animation(data: &[u8]) -> Option { let mut duration: f32 = 0.0; // Build node-to-joint mapping for channel resolution - let node_to_joint: std::collections::HashMap = if let Some(skin) = gltf.skins().next() { - skin.joints().enumerate().map(|(ji, node)| (node.index(), ji)).collect() - } else { - std::collections::HashMap::new() - }; + let node_to_joint: std::collections::HashMap = + if let Some(skin) = gltf.skins().next() { + skin.joints() + .enumerate() + .map(|(ji, node)| (node.index(), ji)) + .collect() + } else { + std::collections::HashMap::new() + }; // Group channels by target node: (trans_ts, translations, rot_ts, rotations, scale_ts, scales) - let mut node_channels: std::collections::HashMap, Vec<[f32; 3]>, Vec, Vec<[f32; 4]>, Vec, Vec<[f32; 3]>)> = std::collections::HashMap::new(); + let mut node_channels: std::collections::HashMap< + usize, + ( + Vec, + Vec<[f32; 3]>, + Vec, + Vec<[f32; 4]>, + Vec, + Vec<[f32; 3]>, + ), + > = std::collections::HashMap::new(); #[cfg(debug_assertions)] let mut skipped_channels = 0usize; @@ -264,14 +261,22 @@ pub(super) fn load_gltf_animation(data: &[u8]) -> Option { let mut mapped_channels = 0usize; #[cfg(debug_assertions)] { - eprintln!("[anim] Animation '{}' has {} channels, node_to_joint map has {} entries", - anim.name().unwrap_or("?"), anim.channels().count(), node_to_joint.len()); + eprintln!( + "[anim] Animation '{}' has {} channels, node_to_joint map has {} entries", + anim.name().unwrap_or("?"), + anim.channels().count(), + node_to_joint.len() + ); for (ci, ch) in anim.channels().enumerate() { if ci < 5 { let tn = ch.target().node(); - eprintln!("[anim] channel {} targets node {} '{}' mapped={}", - ci, tn.index(), tn.name().unwrap_or("?"), - node_to_joint.contains_key(&tn.index())); + eprintln!( + "[anim] channel {} targets node {} '{}' mapped={}", + ci, + tn.index(), + tn.name().unwrap_or("?"), + node_to_joint.contains_key(&tn.index()) + ); } } } @@ -280,14 +285,18 @@ pub(super) fn load_gltf_animation(data: &[u8]) -> Option { let joint_index = match node_to_joint.get(&target_node) { Some(&ji) => { #[cfg(debug_assertions)] - { mapped_channels += 1; } + { + mapped_channels += 1; + } ji - }, + } None => { #[cfg(debug_assertions)] - { skipped_channels += 1; } + { + skipped_channels += 1; + } continue; - }, + } }; let sampler = channel.sampler(); @@ -298,10 +307,21 @@ pub(super) fn load_gltf_animation(data: &[u8]) -> Option { let values = read_accessor_f32(&gltf, &buffer_data, &output_accessor); if let Some(&last) = timestamps.last() { - if last > duration { duration = last; } + if last > duration { + duration = last; + } } - let entry = node_channels.entry(joint_index).or_insert_with(|| (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new())); + let entry = node_channels.entry(joint_index).or_insert_with(|| { + ( + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ) + }); match channel.target().property() { gltf::animation::Property::Translation => { @@ -320,7 +340,9 @@ pub(super) fn load_gltf_animation(data: &[u8]) -> Option { } } - for (joint_index, (trans_ts, translations, rot_ts, rotations, scale_ts, scales)) in node_channels { + for (joint_index, (trans_ts, translations, rot_ts, rotations, scale_ts, scales)) in + node_channels + { // Use the longest timestamp array as the primary (for backward compat) let timestamps = if rot_ts.len() >= trans_ts.len() && rot_ts.len() >= scale_ts.len() { rot_ts.clone() @@ -344,11 +366,19 @@ pub(super) fn load_gltf_animation(data: &[u8]) -> Option { #[cfg(debug_assertions)] { let total_kf: usize = channels.iter().map(|c| c.timestamps.len()).sum(); - let avg_kf = if !channels.is_empty() { total_kf / channels.len() } else { 0 }; + let avg_kf = if !channels.is_empty() { + total_kf / channels.len() + } else { + 0 + }; eprintln!("[anim] Animation '{}': {} channels mapped, {} skipped, duration={:.2}s, avg {}/ch keyframes", name, mapped_channels, skipped_channels, duration, avg_kf); } - animations.push(AnimationData { channels, duration, name }); + animations.push(AnimationData { + channels, + duration, + name, + }); } let joint_count = skeleton.as_ref().map(|s| s.joints.len()).unwrap_or(0); @@ -361,14 +391,25 @@ pub(super) fn load_gltf_animation(data: &[u8]) -> Option { let anim0 = &animations[0]; for ch in &anim0.channels { if ch.joint_index < joint_count_s && !ch.rotations.is_empty() { - rest_rots[ch.joint_index] = if ch.rotations.len() > 0 { ch.rotations[0] } else { [0.0, 0.0, 0.0, 1.0] }; + rest_rots[ch.joint_index] = if ch.rotations.len() > 0 { + ch.rotations[0] + } else { + [0.0, 0.0, 0.0, 1.0] + }; } } #[cfg(debug_assertions)] - eprintln!("[retarget] Built reference rest rotations from anim 0 for {} joints", joint_count_s); + eprintln!( + "[retarget] Built reference rest rotations from anim 0 for {} joints", + joint_count_s + ); Some(rest_rots) - } else { None } - } else { None }; + } else { + None + } + } else { + None + }; Some(ModelAnimation { skeleton: skeleton.map(Arc::new), @@ -386,15 +427,19 @@ pub(super) fn load_gltf_with_textures( data: &[u8], renderer: &mut crate::renderer::Renderer, base_dir: Option<&std::path::Path>, + source_label: Option<&str>, ) -> Option { let gltf = gltf::Gltf::from_slice(data).ok()?; + emit_unsupported_material_extension_diagnostics(&gltf, source_label.unwrap_or("")); // Get buffer data let mut buffer_data: Vec> = Vec::new(); for buffer in gltf.buffers() { match buffer.source() { gltf::buffer::Source::Bin => { - if let Some(blob) = gltf.blob.as_ref() { buffer_data.push(blob.clone()); } + if let Some(blob) = gltf.blob.as_ref() { + buffer_data.push(blob.clone()); + } } gltf::buffer::Source::Uri(uri) => { if let Some(encoded) = uri.strip_prefix("data:application/octet-stream;base64,") { @@ -424,6 +469,12 @@ pub(super) fn load_gltf_with_textures( normal_image_set.insert(nt.texture().source().index()); } } + retain_layered_normal_image_indices(&gltf, &mut normal_image_set); + let mask_coverage_references = target_mask_texture_coverage_references(&gltf); + let mask_only_images = + mask_only_texture_images(&gltf, mask_coverage_references.keys().copied()); + let mut mask_texture_indices: std::collections::HashMap = + Default::default(); // Extract and register textures let mut texture_indices: Vec = Vec::new(); // maps glTF image index -> renderer texture index @@ -441,8 +492,17 @@ pub(super) fn load_gltf_with_textures( if let Ok(img) = image::load_from_memory(img_data) { let rgba = img.to_rgba8(); let (w, h) = (rgba.width(), rgba.height()); - let tex_idx = renderer.register_texture_kind(w, h, &rgba, is_normal); - texture_indices.push(tex_idx); + texture_indices.push(register_gltf_image_with_mask_variants( + renderer, + image_idx, + w, + h, + &rgba, + is_normal, + mask_coverage_references.get(&image_idx).map(Vec::as_slice), + mask_only_images.contains(&image_idx), + &mut mask_texture_indices, + )); } else { texture_indices.push(0); // fallback to white } @@ -486,7 +546,17 @@ pub(super) fn load_gltf_with_textures( }; match bytes.and_then(|b| decode_texture_bytes(&b, &effective_uri)) { Some((rgba, w, h)) => { - texture_indices.push(renderer.register_texture_kind(w, h, &rgba, is_normal)); + texture_indices.push(register_gltf_image_with_mask_variants( + renderer, + image_idx, + w, + h, + &rgba, + is_normal, + mask_coverage_references.get(&image_idx).map(Vec::as_slice), + mask_only_images.contains(&image_idx), + &mut mask_texture_indices, + )); } None => texture_indices.push(0), } @@ -527,14 +597,32 @@ pub(super) fn load_gltf_with_textures( let data = &buffer_data[buf_idx]; if offset + 12 <= data.len() { // Read first 3 floats (first column of first IBM) - let f0 = f32::from_le_bytes([data[offset], data[offset+1], data[offset+2], data[offset+3]]); - let f1 = f32::from_le_bytes([data[offset+4], data[offset+5], data[offset+6], data[offset+7]]); - let f2 = f32::from_le_bytes([data[offset+8], data[offset+9], data[offset+10], data[offset+11]]); - let diag = (f0*f0 + f1*f1 + f2*f2).sqrt(); + let f0 = f32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]); + let f1 = f32::from_le_bytes([ + data[offset + 4], + data[offset + 5], + data[offset + 6], + data[offset + 7], + ]); + let f2 = f32::from_le_bytes([ + data[offset + 8], + data[offset + 9], + data[offset + 10], + data[offset + 11], + ]); + let diag = (f0 * f0 + f1 * f1 + f2 * f2).sqrt(); if diag > 10.0 { scale = diag; #[cfg(debug_assertions)] - eprintln!("[skin] IBM col0 len={:.1}, applying {:.0}x vertex scale", diag, scale); + eprintln!( + "[skin] IBM col0 len={:.1}, applying {:.0}x vertex scale", + diag, scale + ); } } } @@ -543,7 +631,10 @@ pub(super) fn load_gltf_with_textures( } if (scale - 1.0).abs() > 0.01 { #[cfg(debug_assertions)] - eprintln!("[skin] Applying {:.0}x vertex scale to compensate armature transform", scale); + eprintln!( + "[skin] Applying {:.0}x vertex scale to compensate armature transform", + scale + ); } scale }; @@ -563,7 +654,12 @@ pub(super) fn load_gltf_with_textures( // meshes are unaffected — the armature transforms apply on top. let mesh_count = gltf.meshes().count(); let mut mesh_instances: Vec> = vec![Vec::new(); mesh_count]; - let identity = [[1.0f32, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]; + let identity = [ + [1.0f32, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; for scene in gltf.scenes() { for node in scene.nodes() { walk_scene_collect_instances(&node, &identity, &mut mesh_instances); @@ -585,201 +681,279 @@ pub(super) fn load_gltf_with_textures( let mesh_world = *mesh_world; // Inverse-transpose 3×3 for normals under non-uniform scale. let normal_xform = mesh_world.map(|m| mat4_inverse_transpose_3x3(&m)); - for primitive in mesh.primitives() { - let reader = primitive.reader(|buf| buffer_data.get(buf.index()).map(|d| d.as_slice())); - let positions: Vec<[f32; 3]> = match reader.read_positions() { - Some(iter) => iter.collect(), - None => continue, - }; - let normals: Vec<[f32; 3]> = reader.read_normals() - .map(|iter| iter.collect()) - .unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; positions.len()]); - let tex_coords: Vec<[f32; 2]> = reader.read_tex_coords(0) - .map(|iter| iter.into_f32().collect()) - .unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]); - // Tangents (vec4: xyz = tangent, w = bitangent sign ±1). - // If absent, we leave them as zero so the shader knows to - // skip normal-map perturbation for this mesh. - let tangents: Vec<[f32; 4]> = reader.read_tangents() - .map(|iter| iter.collect()) - .unwrap_or_else(|| vec![[0.0; 4]; positions.len()]); - - // Get vertex colors if available - let vert_colors: Option> = reader.read_colors(0) - .map(|iter| iter.into_rgba_f32().collect()); - - let mat = primitive.material(); - let pbr = mat.pbr_metallic_roughness(); - let emissive_factor = mat.emissive_factor(); - - let tex_idx_of = |img_idx: usize| -> Option { - texture_indices.get(img_idx).copied() - }; - - let normal_tex_idx = mat.normal_texture() - .and_then(|info| tex_idx_of(info.texture().source().index())); - let emissive_tex_idx = mat.emissive_texture() - .and_then(|info| tex_idx_of(info.texture().source().index())); - let occlusion_tex_idx = mat.occlusion_texture() - .and_then(|info| tex_idx_of(info.texture().source().index())); + for primitive in mesh.primitives() { + let reader = + primitive.reader(|buf| buffer_data.get(buf.index()).map(|d| d.as_slice())); + let positions: Vec<[f32; 3]> = match reader.read_positions() { + Some(iter) => iter.collect(), + None => continue, + }; + let normals: Vec<[f32; 3]> = reader + .read_normals() + .map(|iter| iter.collect()) + .unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; positions.len()]); + let tex_coords: Vec<[f32; 2]> = reader + .read_tex_coords(0) + .map(|iter| iter.into_f32().collect()) + .unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]); + // Tangents (vec4: xyz = tangent, w = bitangent sign ±1). + // If absent, we leave them as zero so the shader knows to + // skip normal-map perturbation for this mesh. + let tangents: Vec<[f32; 4]> = reader + .read_tangents() + .map(|iter| iter.collect()) + .unwrap_or_else(|| vec![[0.0; 4]; positions.len()]); + + // Get vertex colors if available + let vert_colors: Option> = reader + .read_colors(0) + .map(|iter| iter.into_rgba_f32().collect()); + + let mat = primitive.material(); + let pbr = mat.pbr_metallic_roughness(); + let emissive_factor = mat.emissive_factor(); + let transmission = + match transmission_from_material(&mat, Some(texture_indices.as_slice())) { + Ok(value) => value, + Err(error) => { + log::error!("{error}"); + return None; + } + }; + let layered_pbr = match layered_pbr_from_material( + &gltf, + &mat, + Some(texture_indices.as_slice()), + ) { + Ok(value) => value, + Err(error) => { + log::error!("{error}"); + return None; + } + }; + let secondary_tex_coords = retain_material_tex_coords_1( + transmission, + layered_pbr, + positions.len(), + || { + reader + .read_tex_coords(1) + .map(|iter| iter.into_f32().collect()) + }, + ); - // Metallic-roughness first; fall back to - // KHR_materials_pbrSpecularGlossiness when only that's - // authored (Lumberyard Bistro + many FBX exports). - // Conversion matches the load_gltf_staged path — see - // specgloss_to_metalrough for the algorithm. - let (mut base_color, mut metallic_factor, mut roughness_factor, tex_idx, mr_tex_idx) = - if pbr.base_color_texture().is_none() { + let tex_idx_of = + |img_idx: usize| -> Option { texture_indices.get(img_idx).copied() }; + let (base_color_tex_idx, alpha_coverage_mips) = + base_color_texture_selection(&mat, &texture_indices, &mask_texture_indices); + + let normal_tex_idx = mat + .normal_texture() + .and_then(|info| tex_idx_of(info.texture().source().index())); + let emissive_tex_idx = mat + .emissive_texture() + .and_then(|info| tex_idx_of(info.texture().source().index())); + let occlusion_tex_idx = mat + .occlusion_texture() + .and_then(|info| tex_idx_of(info.texture().source().index())); + + // Metallic-roughness first; fall back to + // KHR_materials_pbrSpecularGlossiness when only that's + // authored (Lumberyard Bistro + many FBX exports). + // Conversion matches the load_gltf_staged path — see + // specgloss_to_metalrough for the algorithm. + let ( + mut base_color, + mut metallic_factor, + mut roughness_factor, + tex_idx, + mr_tex_idx, + ) = if pbr.base_color_texture().is_none() { if let Some(sg) = mat.pbr_specular_glossiness() { let diffuse = sg.diffuse_factor(); let spec = sg.specular_factor(); - let (base_color, metallic) = - specgloss_to_metalrough(diffuse, spec); + let (base_color, metallic) = specgloss_to_metalrough(diffuse, spec); let roughness = 1.0 - sg.glossiness_factor(); - let diffuse_tex = sg.diffuse_texture() - .and_then(|info| tex_idx_of(info.texture().source().index())); - (base_color, metallic, roughness, diffuse_tex, None) + (base_color, metallic, roughness, base_color_tex_idx, None) } else { - (pbr.base_color_factor(), pbr.metallic_factor(), pbr.roughness_factor(), None, None) + ( + pbr.base_color_factor(), + pbr.metallic_factor(), + pbr.roughness_factor(), + None, + None, + ) } } else { - let tex = pbr.base_color_texture() + let mr = pbr + .metallic_roughness_texture() .and_then(|info| tex_idx_of(info.texture().source().index())); - let mr = pbr.metallic_roughness_texture() - .and_then(|info| tex_idx_of(info.texture().source().index())); - (pbr.base_color_factor(), pbr.metallic_factor(), pbr.roughness_factor(), tex, mr) + ( + pbr.base_color_factor(), + pbr.metallic_factor(), + pbr.roughness_factor(), + base_color_tex_idx, + mr, + ) }; - if let Some(t) = mat.transmission() { - apply_transmission_hack( - t.transmission_factor(), - &mut base_color, - &mut metallic_factor, - &mut roughness_factor, - ); - } - - let mut vertices = Vec::with_capacity(positions.len()); - for i in 0..positions.len() { - let p = positions[i]; - for k in 0..3 { - if p[k] < bbox_min[k] { bbox_min[k] = p[k]; } - if p[k] > bbox_max[k] { bbox_max[k] = p[k]; } + if !crate::models::physical_transmission_requested() { + if let Some(t) = mat.transmission() { + apply_transmission_hack( + t.transmission_factor(), + &mut base_color, + &mut metallic_factor, + &mut roughness_factor, + ); + } } - let color = if let Some(ref vc) = vert_colors { - vc[i] - } else { - [base_color[0], base_color[1], base_color[2], base_color[3]] - }; - // Skin data (joints + weights) - let joint_vals: Option> = reader.read_joints(0) - .map(|iter| iter.into_u16().collect()); - let weight_vals: Option> = reader.read_weights(0) - .map(|iter| iter.into_f32().collect()); - let jv = if let Some(ref j) = joint_vals { - [j[i][0] as f32, j[i][1] as f32, j[i][2] as f32, j[i][3] as f32] - } else { - [0.0; 4] - }; - let wv = if let Some(ref w) = weight_vals { - w[i] - } else { - [0.0; 4] - }; - // Apply inverse armature scale to skinned vertex positions - let is_skinned = wv[0] + wv[1] + wv[2] + wv[3] > 0.01; - let base_pos = if is_skinned && (skin_vertex_scale - 1.0).abs() > 0.01 { - [p[0] * skin_vertex_scale, p[1] * skin_vertex_scale, p[2] * skin_vertex_scale] - } else { - p - }; - // Bake the mesh's scene node transform into world-space - // position/normal. Skinned meshes are NOT world-baked: - // their node transform is expected to be consumed by the - // armature, and the pose is driven by joint matrices at - // draw time. Static (non-skinned) meshes get the baked - // transform so drawModel's position/scale arguments - // apply on top of the correct base pose. - let (final_pos, final_normal, final_tangent) = if is_skinned { - (base_pos, normals[i], tangents[i]) - } else if let Some(xform) = mesh_world { - let t_in = [tangents[i][0], tangents[i][1], tangents[i][2]]; - let t_out = match normal_xform { - // Tangents transform like positions (as directions) - // under the linear part of the transform — we use - // the upper 3×3 of the model matrix, not its - // inverse-transpose. But since our mesh_world is - // rigid-ish (no shear), the normal_xform gets us - // close enough for the common case. For a purely - // orthonormal node transform these are identical. - Some(ref n) => mat3_transform_vec(n, &t_in), - None => t_in, + let mut vertices = Vec::with_capacity(positions.len()); + for i in 0..positions.len() { + let p = positions[i]; + for k in 0..3 { + if p[k] < bbox_min[k] { + bbox_min[k] = p[k]; + } + if p[k] > bbox_max[k] { + bbox_max[k] = p[k]; + } + } + let color = vert_colors + .as_ref() + .map(|colors| multiply_rgba(colors[i], base_color)) + .unwrap_or(base_color); + // Skin data (joints + weights) + let joint_vals: Option> = + reader.read_joints(0).map(|iter| iter.into_u16().collect()); + let weight_vals: Option> = + reader.read_weights(0).map(|iter| iter.into_f32().collect()); + + let jv = if let Some(ref j) = joint_vals { + [ + j[i][0] as f32, + j[i][1] as f32, + j[i][2] as f32, + j[i][3] as f32, + ] + } else { + [0.0; 4] }; - ( - mat4_transform_point(&xform, &base_pos), - match normal_xform { - Some(ref n) => mat3_transform_vec(n, &normals[i]), - None => normals[i], - }, - [t_out[0], t_out[1], t_out[2], tangents[i][3]], - ) - } else { - (base_pos, normals[i], tangents[i]) - }; - // Update bbox to reflect the final (possibly transformed) - // position so the camera auto-framing still works right. - for k in 0..3 { - if final_pos[k] < bbox_min[k] { bbox_min[k] = final_pos[k]; } - if final_pos[k] > bbox_max[k] { bbox_max[k] = final_pos[k]; } + let wv = if let Some(ref w) = weight_vals { + w[i] + } else { + [0.0; 4] + }; + // Apply inverse armature scale to skinned vertex positions + let is_skinned = wv[0] + wv[1] + wv[2] + wv[3] > 0.01; + let base_pos = if is_skinned && (skin_vertex_scale - 1.0).abs() > 0.01 { + [ + p[0] * skin_vertex_scale, + p[1] * skin_vertex_scale, + p[2] * skin_vertex_scale, + ] + } else { + p + }; + // Bake the mesh's scene node transform into world-space + // position/normal. Skinned meshes are NOT world-baked: + // their node transform is expected to be consumed by the + // armature, and the pose is driven by joint matrices at + // draw time. Static (non-skinned) meshes get the baked + // transform so drawModel's position/scale arguments + // apply on top of the correct base pose. + let (final_pos, final_normal, final_tangent) = if is_skinned { + (base_pos, normals[i], tangents[i]) + } else if let Some(xform) = mesh_world { + let t_in = [tangents[i][0], tangents[i][1], tangents[i][2]]; + // Tangents follow the linear model transform. Normals + // use the inverse transpose below; sharing that matrix + // is only valid for uniform scale and breaks the TBN + // under ordinary non-uniform glTF node transforms. + let t_out = mat4_transform_direction(&xform, &t_in); + ( + mat4_transform_point(&xform, &base_pos), + match normal_xform { + Some(ref n) => mat3_transform_vec(n, &normals[i]), + None => normals[i], + }, + [t_out[0], t_out[1], t_out[2], tangents[i][3]], + ) + } else { + (base_pos, normals[i], tangents[i]) + }; + // Update bbox to reflect the final (possibly transformed) + // position so the camera auto-framing still works right. + for k in 0..3 { + if final_pos[k] < bbox_min[k] { + bbox_min[k] = final_pos[k]; + } + if final_pos[k] > bbox_max[k] { + bbox_max[k] = final_pos[k]; + } + } + vertices.push(Vertex3D { + position: final_pos, + normal: final_normal, + color, + uv: tex_coords[i], + joints: jv, + weights: wv, + tangent: final_tangent, + }); } - vertices.push(Vertex3D { - position: final_pos, - normal: final_normal, - color, - uv: tex_coords[i], - joints: jv, - weights: wv, - tangent: final_tangent, + let indices: Vec = match reader.read_indices() { + Some(iter) => iter.into_u32().collect(), + None => (0..positions.len() as u32).collect(), + }; + meshes.push(MeshData { + vertices, + secondary_tex_coords, + indices, + texture_idx: tex_idx, + normal_texture_idx: normal_tex_idx, + metallic_roughness_texture_idx: mr_tex_idx, + emissive_texture_idx: emissive_tex_idx, + occlusion_texture_idx: occlusion_tex_idx, + metallic_factor, + roughness_factor, + emissive_factor, + alpha_mode: alpha_mode_from_material(&mat), + alpha_cutoff: alpha_cutoff_from_material(&mat), + alpha_coverage_mips, + double_sided: mat.double_sided(), + transmission, + layered_pbr, }); } - let indices: Vec = match reader.read_indices() { - Some(iter) => iter.into_u32().collect(), - None => (0..positions.len() as u32).collect(), - }; - meshes.push(MeshData { - vertices, - indices, - texture_idx: tex_idx, - normal_texture_idx: normal_tex_idx, - metallic_roughness_texture_idx: mr_tex_idx, - emissive_texture_idx: emissive_tex_idx, - occlusion_texture_idx: occlusion_tex_idx, - metallic_factor, - roughness_factor, - emissive_factor, - alpha_cutoff: alpha_cutoff_from_material(&mat), - }); - } } // end instance loop } - if meshes.is_empty() { return None; } - Some(ModelData { meshes, bbox_min, bbox_max }) + if meshes.is_empty() { + return None; + } + Some(ModelData { + meshes, + bbox_min, + bbox_max, + }) } /// Like load_gltf_with_textures but decodes textures to RGBA without GPU registration. /// Returns a StagedModel with decoded textures that can later be committed on the main thread. pub fn load_gltf_staged(data: &[u8]) -> Option { - use crate::staging::{StagedTexture, StagedModel}; + use crate::staging::{StagedModel, StagedTexture}; let gltf = gltf::Gltf::from_slice(data).ok()?; + emit_unsupported_material_extension_diagnostics(&gltf, ""); let mut buffer_data: Vec> = Vec::new(); for buffer in gltf.buffers() { match buffer.source() { gltf::buffer::Source::Bin => { - if let Some(blob) = gltf.blob.as_ref() { buffer_data.push(blob.clone()); } + if let Some(blob) = gltf.blob.as_ref() { + buffer_data.push(blob.clone()); + } } gltf::buffer::Source::Uri(uri) => { if let Some(encoded) = uri.strip_prefix("data:application/octet-stream;base64,") { @@ -802,6 +976,12 @@ pub fn load_gltf_staged(data: &[u8]) -> Option { normal_image_set.insert(nt.texture().source().index()); } } + retain_layered_normal_image_indices(&gltf, &mut normal_image_set); + let mask_coverage_references = target_mask_texture_coverage_references(&gltf); + let mask_only_images = + mask_only_texture_images(&gltf, mask_coverage_references.keys().copied()); + let mut mask_texture_indices: std::collections::HashMap = + Default::default(); // Decode textures to RGBA without GPU registration. // staged_textures[i] corresponds to glTF image index i. @@ -820,14 +1000,40 @@ pub fn load_gltf_staged(data: &[u8]) -> Option { if let Ok(img) = image::load_from_memory(img_data) { let rgba = img.to_rgba8(); let (w, h) = (rgba.width(), rgba.height()); - staged_textures.push(StagedTexture { - data: rgba.into_raw(), - width: w, - height: h, - is_normal: normal_image_set.contains(&image_idx), - }); - // 1-based index into staged_textures - texture_indices.push(staged_textures.len() as u32); + let data = rgba.into_raw(); + let mask_only = mask_only_images.contains(&image_idx); + let mut primary_texture_idx = None; + if !mask_only { + staged_textures.push(StagedTexture { + data: data.clone(), + width: w, + height: h, + is_normal: normal_image_set.contains(&image_idx), + alpha_coverage_reference: None, + }); + primary_texture_idx = Some(staged_textures.len() as u32); + } + if let Some(references) = mask_coverage_references.get(&image_idx) { + for reference in references { + staged_textures.push(StagedTexture { + data: data.clone(), + width: w, + height: h, + is_normal: false, + alpha_coverage_reference: Some(*reference), + }); + mask_texture_indices.insert( + (image_idx, reference.to_bits()), + staged_textures.len() as u32, + ); + primary_texture_idx.get_or_insert(staged_textures.len() as u32); + } + } + // 1-based index into staged_textures. A texture used + // only by MASK materials aliases its first coverage + // variant instead of retaining an unreachable + // ordinary chain. + texture_indices.push(primary_texture_idx.unwrap_or(0)); } else { texture_indices.push(0); } @@ -838,7 +1044,9 @@ pub fn load_gltf_staged(data: &[u8]) -> Option { texture_indices.push(0); } } - _ => { texture_indices.push(0); } + _ => { + texture_indices.push(0); + } } } @@ -869,10 +1077,25 @@ pub fn load_gltf_staged(data: &[u8]) -> Option { let offset = view.offset() + accessor.offset(); let data = &buffer_data[buf_idx]; if offset + 12 <= data.len() { - let f0 = f32::from_le_bytes([data[offset], data[offset+1], data[offset+2], data[offset+3]]); - let f1 = f32::from_le_bytes([data[offset+4], data[offset+5], data[offset+6], data[offset+7]]); - let f2 = f32::from_le_bytes([data[offset+8], data[offset+9], data[offset+10], data[offset+11]]); - let diag = (f0*f0 + f1*f1 + f2*f2).sqrt(); + let f0 = f32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]); + let f1 = f32::from_le_bytes([ + data[offset + 4], + data[offset + 5], + data[offset + 6], + data[offset + 7], + ]); + let f2 = f32::from_le_bytes([ + data[offset + 8], + data[offset + 9], + data[offset + 10], + data[offset + 11], + ]); + let diag = (f0 * f0 + f1 * f1 + f2 * f2).sqrt(); if diag > 10.0 { scale = diag; } @@ -895,31 +1118,61 @@ pub fn load_gltf_staged(data: &[u8]) -> Option { Some(iter) => iter.collect(), None => continue, }; - let normals: Vec<[f32; 3]> = reader.read_normals() + let normals: Vec<[f32; 3]> = reader + .read_normals() .map(|iter| iter.collect()) .unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; positions.len()]); - let tex_coords: Vec<[f32; 2]> = reader.read_tex_coords(0) + let tex_coords: Vec<[f32; 2]> = reader + .read_tex_coords(0) .map(|iter| iter.into_f32().collect()) .unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]); - let tangents: Vec<[f32; 4]> = reader.read_tangents() + let tangents: Vec<[f32; 4]> = reader + .read_tangents() .map(|iter| iter.collect()) .unwrap_or_else(|| vec![[0.0; 4]; positions.len()]); - let vert_colors: Option> = reader.read_colors(0) + let vert_colors: Option> = reader + .read_colors(0) .map(|iter| iter.into_rgba_f32().collect()); let mat = primitive.material(); let pbr = mat.pbr_metallic_roughness(); let emissive_factor = mat.emissive_factor(); + let transmission = + match transmission_from_material(&mat, Some(texture_indices.as_slice())) { + Ok(value) => value, + Err(error) => { + log::error!("{error}"); + return None; + } + }; + let layered_pbr = + match layered_pbr_from_material(&gltf, &mat, Some(texture_indices.as_slice())) { + Ok(value) => value, + Err(error) => { + log::error!("{error}"); + return None; + } + }; + let secondary_tex_coords = + retain_material_tex_coords_1(transmission, layered_pbr, positions.len(), || { + reader + .read_tex_coords(1) + .map(|iter| iter.into_f32().collect()) + }); - let tex_idx_of = |img_idx: usize| -> Option { - texture_indices.get(img_idx).copied() - }; + let tex_idx_of = + |img_idx: usize| -> Option { texture_indices.get(img_idx).copied() }; + let (base_color_tex_idx, alpha_coverage_mips) = + base_color_texture_selection(&mat, &texture_indices, &mask_texture_indices); - let normal_tex_idx = mat.normal_texture() + let normal_tex_idx = mat + .normal_texture() .and_then(|info| tex_idx_of(info.texture().source().index())); - let emissive_tex_idx = mat.emissive_texture() + let emissive_tex_idx = mat + .emissive_texture() .and_then(|info| tex_idx_of(info.texture().source().index())); - let occlusion_tex_idx = mat.occlusion_texture() + let occlusion_tex_idx = mat + .occlusion_texture() .and_then(|info| tex_idx_of(info.texture().source().index())); // Prefer the glTF 2.0 metallic-roughness model. Fall back @@ -937,57 +1190,83 @@ pub fn load_gltf_staged(data: &[u8]) -> Option { if let Some(sg) = mat.pbr_specular_glossiness() { let diffuse = sg.diffuse_factor(); let spec = sg.specular_factor(); - let (base_color, metallic) = - specgloss_to_metalrough(diffuse, spec); + let (base_color, metallic) = specgloss_to_metalrough(diffuse, spec); let roughness = 1.0 - sg.glossiness_factor(); - let diffuse_tex = sg.diffuse_texture() - .and_then(|info| tex_idx_of(info.texture().source().index())); - (base_color, metallic, roughness, diffuse_tex, None) + (base_color, metallic, roughness, base_color_tex_idx, None) } else { - (pbr.base_color_factor(), pbr.metallic_factor(), pbr.roughness_factor(), None, None) + ( + pbr.base_color_factor(), + pbr.metallic_factor(), + pbr.roughness_factor(), + None, + None, + ) } } else { - let tex = pbr.base_color_texture() - .and_then(|info| tex_idx_of(info.texture().source().index())); - let mr = pbr.metallic_roughness_texture() + let mr = pbr + .metallic_roughness_texture() .and_then(|info| tex_idx_of(info.texture().source().index())); - (pbr.base_color_factor(), pbr.metallic_factor(), pbr.roughness_factor(), tex, mr) + ( + pbr.base_color_factor(), + pbr.metallic_factor(), + pbr.roughness_factor(), + base_color_tex_idx, + mr, + ) }; - if let Some(t) = mat.transmission() { - apply_transmission_hack( - t.transmission_factor(), - &mut base_color, - &mut metallic_factor, - &mut roughness_factor, - ); + if !crate::models::physical_transmission_requested() { + if let Some(t) = mat.transmission() { + apply_transmission_hack( + t.transmission_factor(), + &mut base_color, + &mut metallic_factor, + &mut roughness_factor, + ); + } } let mut vertices = Vec::with_capacity(positions.len()); for i in 0..positions.len() { let p = positions[i]; for k in 0..3 { - if p[k] < bbox_min[k] { bbox_min[k] = p[k]; } - if p[k] > bbox_max[k] { bbox_max[k] = p[k]; } + if p[k] < bbox_min[k] { + bbox_min[k] = p[k]; + } + if p[k] > bbox_max[k] { + bbox_max[k] = p[k]; + } } - let color = if let Some(ref vc) = vert_colors { - vc[i] + let color = vert_colors + .as_ref() + .map(|colors| multiply_rgba(colors[i], base_color)) + .unwrap_or(base_color); + let joint_vals: Option> = + reader.read_joints(0).map(|iter| iter.into_u16().collect()); + let weight_vals: Option> = + reader.read_weights(0).map(|iter| iter.into_f32().collect()); + let jv = if let Some(ref j) = joint_vals { + [ + j[i][0] as f32, + j[i][1] as f32, + j[i][2] as f32, + j[i][3] as f32, + ] } else { - [base_color[0], base_color[1], base_color[2], base_color[3]] + [0.0; 4] }; - let joint_vals: Option> = reader.read_joints(0) - .map(|iter| iter.into_u16().collect()); - let weight_vals: Option> = reader.read_weights(0) - .map(|iter| iter.into_f32().collect()); - let jv = if let Some(ref j) = joint_vals { - [j[i][0] as f32, j[i][1] as f32, j[i][2] as f32, j[i][3] as f32] + let wv = if let Some(ref w) = weight_vals { + w[i] } else { [0.0; 4] }; - let wv = if let Some(ref w) = weight_vals { w[i] } else { [0.0; 4] }; let is_skinned = wv[0] + wv[1] + wv[2] + wv[3] > 0.01; let final_pos = if is_skinned && (skin_vertex_scale - 1.0).abs() > 0.01 { - [p[0] * skin_vertex_scale, p[1] * skin_vertex_scale, p[2] * skin_vertex_scale] + [ + p[0] * skin_vertex_scale, + p[1] * skin_vertex_scale, + p[2] * skin_vertex_scale, + ] } else { p }; @@ -1007,6 +1286,7 @@ pub fn load_gltf_staged(data: &[u8]) -> Option { }; meshes.push(MeshData { vertices, + secondary_tex_coords, indices, texture_idx: tex_idx, normal_texture_idx: normal_tex_idx, @@ -1016,20 +1296,32 @@ pub fn load_gltf_staged(data: &[u8]) -> Option { metallic_factor, roughness_factor, emissive_factor, + alpha_mode: alpha_mode_from_material(&mat), alpha_cutoff: alpha_cutoff_from_material(&mat), + alpha_coverage_mips, + double_sided: mat.double_sided(), + transmission, + layered_pbr, }); } } - if meshes.is_empty() { return None; } + if meshes.is_empty() { + return None; + } Some(StagedModel { - model: ModelData { meshes, bbox_min, bbox_max }, + model: ModelData { + meshes, + bbox_min, + bbox_max, + }, textures: staged_textures, }) } pub(super) fn load_gltf(data: &[u8]) -> Option { let gltf = gltf::Gltf::from_slice(data).ok()?; + emit_unsupported_material_extension_diagnostics(&gltf, ""); // Get buffer data (for .glb, embedded; for .gltf, inline base64) let mut buffer_data: Vec> = Vec::new(); @@ -1066,35 +1358,78 @@ pub(super) fn load_gltf(data: &[u8]) -> Option { None => continue, }; - let normals: Vec<[f32; 3]> = reader.read_normals() + let normals: Vec<[f32; 3]> = reader + .read_normals() .map(|iter| iter.collect()) .unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; positions.len()]); - let tex_coords: Vec<[f32; 2]> = reader.read_tex_coords(0) + let tex_coords: Vec<[f32; 2]> = reader + .read_tex_coords(0) .map(|iter| iter.into_f32().collect()) .unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]); + let tangents: Vec<[f32; 4]> = reader + .read_tangents() + .map(|iter| iter.collect()) + .unwrap_or_else(|| vec![[0.0; 4]; positions.len()]); - // Material base color - let base_color = primitive.material().pbr_metallic_roughness() - .base_color_factor(); + // Material base color. The plain CPU-only loader intentionally + // leaves texture runtime IDs empty, but it must still preserve + // authored bucket and physical-extension metadata. + let mat = primitive.material(); + let base_color = mat.pbr_metallic_roughness().base_color_factor(); + let transmission = match transmission_from_material(&mat, None) { + Ok(value) => value, + Err(error) => { + log::error!("{error}"); + return None; + } + }; + let layered_pbr = match layered_pbr_from_material(&gltf, &mat, None) { + Ok(value) => value, + Err(error) => { + log::error!("{error}"); + return None; + } + }; + let secondary_tex_coords = + retain_material_tex_coords_1(transmission, layered_pbr, positions.len(), || { + reader + .read_tex_coords(1) + .map(|iter| iter.into_f32().collect()) + }); let color = [base_color[0], base_color[1], base_color[2], base_color[3]]; let mut vertices = Vec::with_capacity(positions.len()); for i in 0..positions.len() { let p = positions[i]; for k in 0..3 { - if p[k] < bbox_min[k] { bbox_min[k] = p[k]; } - if p[k] > bbox_max[k] { bbox_max[k] = p[k]; } + if p[k] < bbox_min[k] { + bbox_min[k] = p[k]; + } + if p[k] > bbox_max[k] { + bbox_max[k] = p[k]; + } } // Read skin data if available - let joint_vals: Option> = reader.read_joints(0) - .map(|iter| iter.into_u16().collect()); - let weight_vals: Option> = reader.read_weights(0) - .map(|iter| iter.into_f32().collect()); + let joint_vals: Option> = + reader.read_joints(0).map(|iter| iter.into_u16().collect()); + let weight_vals: Option> = + reader.read_weights(0).map(|iter| iter.into_f32().collect()); let jv = if let Some(ref j) = joint_vals { - [j[i][0] as f32, j[i][1] as f32, j[i][2] as f32, j[i][3] as f32] - } else { [0.0; 4] }; - let wv = if let Some(ref w) = weight_vals { w[i] } else { [0.0; 4] }; + [ + j[i][0] as f32, + j[i][1] as f32, + j[i][2] as f32, + j[i][3] as f32, + ] + } else { + [0.0; 4] + }; + let wv = if let Some(ref w) = weight_vals { + w[i] + } else { + [0.0; 4] + }; vertices.push(Vertex3D { position: p, @@ -1103,7 +1438,7 @@ pub(super) fn load_gltf(data: &[u8]) -> Option { uv: tex_coords[i], joints: jv, weights: wv, - tangent: [0.0; 4], + tangent: tangents[i], }); } @@ -1112,12 +1447,36 @@ pub(super) fn load_gltf(data: &[u8]) -> Option { None => (0..positions.len() as u32).collect(), }; - meshes.push(MeshData { vertices, indices, texture_idx: None, normal_texture_idx: None, metallic_roughness_texture_idx: None, emissive_texture_idx: None, occlusion_texture_idx: None, metallic_factor: 0.0, roughness_factor: 1.0, emissive_factor: [0.0; 3], alpha_cutoff: 0.0 }); + meshes.push(MeshData { + vertices, + secondary_tex_coords, + indices, + texture_idx: None, + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.0, + roughness_factor: 1.0, + emissive_factor: [0.0; 3], + alpha_mode: alpha_mode_from_material(&mat), + alpha_cutoff: alpha_cutoff_from_material(&mat), + alpha_coverage_mips: false, + double_sided: mat.double_sided(), + transmission, + layered_pbr, + }); } } - if meshes.is_empty() { return None; } - Some(ModelData { meshes, bbox_min, bbox_max }) + if meshes.is_empty() { + return None; + } + Some(ModelData { + meshes, + bbox_min, + bbox_max, + }) } /// Convert a KHR_materials_pbrSpecularGlossiness (diffuse + specular @@ -1133,36 +1492,324 @@ pub(super) fn load_gltf(data: &[u8]) -> Option { /// specular color becomes their base_color; dielectrics carry their /// diffuse color through at metallic ≈ 0. /// -/// Map glTF alpha mode + cutoff to a single shader cutoff value. -/// OPAQUE → 0.0 (fragment shader's `< cutoff` discard never fires). -/// MASK → material-authored cutoff (default 0.5 per glTF spec). -/// BLEND → treated as MASK @ 0.5 since we don't yet have a sorted -/// transparent pipeline. Better than silently rendering -/// foliage + fabric as fully opaque — an alpha-cutout leaf -/// card is at least the right *shape*. +fn alpha_mode_from_material(mat: &gltf::Material) -> crate::models::MaterialAlphaMode { + match mat.alpha_mode() { + gltf::material::AlphaMode::Opaque => crate::models::MaterialAlphaMode::Opaque, + gltf::material::AlphaMode::Mask => crate::models::MaterialAlphaMode::Mask, + gltf::material::AlphaMode::Blend => crate::models::MaterialAlphaMode::Blend, + } +} + +/// MASK keeps its authored cutoff (0.5 by spec default). OPAQUE and +/// BLEND do not use binary coverage and therefore carry no cutoff. fn alpha_cutoff_from_material(mat: &gltf::Material) -> f32 { match mat.alpha_mode() { gltf::material::AlphaMode::Opaque => 0.0, gltf::material::AlphaMode::Mask => mat.alpha_cutoff().unwrap_or(0.5), - gltf::material::AlphaMode::Blend => 0.5, + gltf::material::AlphaMode::Blend => 0.0, } } -/// Fake KHR_materials_transmission as a near-mirror dielectric. -/// -/// We don't implement real refractive transmission (no back-buffer -/// refraction pass, no thin-walled/volume distinction), so a -/// transmission=0.9 glass pane loads as a plain diffuse white surface -/// that drowns the 4% Fresnel specular in a bright diffuse term — the -/// classic "painted white window" look. -/// -/// As a stand-in we force heavy transmission materials to behave like -/// chrome: metallic=1 so f0=base_color (not 0.04), roughness ≤ 0.05 -/// so reflections stay crisp, and a mild (0.85×) tint on base_color -/// so pure-white glass doesn't read as perfectly reflective chrome. -/// Not physically correct for glass — real glass only reflects 4% at -/// normal incidence — but it matches how windows *read* in photos -/// (reflecting sky/buildings) far better than a flat diffuse surface. +fn multiply_rgba(lhs: [f32; 4], rhs: [f32; 4]) -> [f32; 4] { + [ + lhs[0] * rhs[0], + lhs[1] * rhs[1], + lhs[2] * rhs[2], + lhs[3] * rhs[3], + ] +} + +type MaskTextureVariantKey = (usize, u32); + +fn register_gltf_image_with_mask_variants( + renderer: &mut crate::renderer::Renderer, + image_index: usize, + width: u32, + height: u32, + rgba: &[u8], + is_normal: bool, + coverage_references: Option<&[f32]>, + mask_only: bool, + variants: &mut std::collections::HashMap, +) -> u32 { + let mut primary = if mask_only { + None + } else { + Some(renderer.register_texture_kind(width, height, rgba, is_normal)) + }; + if let Some(references) = coverage_references { + for reference in references { + let variant = renderer.register_texture_kind_with_alpha_coverage( + width, + height, + rgba, + false, + Some(*reference), + ); + variants.insert((image_index, reference.to_bits()), variant); + primary.get_or_insert(variant); + } + } + primary.unwrap_or(0) +} + +/// Texture-space alpha reference for a MASK material. The shader compares +/// `texture alpha * base factor alpha` against the authored cutoff, so the +/// mip generator must bake coverage at `cutoff / factor`. Values above one +/// intentionally mean that no source texel can survive. +fn mask_base_color_coverage_reference(mat: &gltf::Material<'_>) -> Option<(usize, f32)> { + if mat.alpha_mode() != gltf::material::AlphaMode::Mask { + return None; + } + let pbr = mat.pbr_metallic_roughness(); + let (image_index, factor_alpha) = if let Some(info) = pbr.base_color_texture() { + (info.texture().source().index(), pbr.base_color_factor()[3]) + } else { + let spec_gloss = mat.pbr_specular_glossiness()?; + let info = spec_gloss.diffuse_texture()?; + ( + info.texture().source().index(), + spec_gloss.diffuse_factor()[3], + ) + }; + let cutoff = alpha_cutoff_from_material(mat).max(0.0); + if cutoff <= 0.0 { + return None; + } + let reference = if factor_alpha > 0.0 { + cutoff / factor_alpha + } else { + 2.0 + }; + Some((image_index, reference)) +} + +#[cfg(any(not(target_os = "android"), test))] +fn mask_texture_coverage_references( + gltf: &gltf::Gltf, +) -> std::collections::BTreeMap> { + let mut by_image: std::collections::BTreeMap> = + Default::default(); + for material in gltf.materials() { + if let Some((image_index, reference)) = mask_base_color_coverage_reference(&material) { + by_image + .entry(image_index) + .or_default() + .insert(reference.to_bits(), reference); + } + } + by_image + .into_iter() + .map(|(image, references)| (image, references.into_values().collect())) + .collect() +} + +fn target_mask_texture_coverage_references( + gltf: &gltf::Gltf, +) -> std::collections::BTreeMap> { + // Android retains the established one-mip safety path until current wgpu + // multi-level uploads are qualified on hardware. Do not mark authored + // level-zero alpha as coverage data when no lower mip can exist. + #[cfg(target_os = "android")] + { + let _ = gltf; + Default::default() + } + #[cfg(not(target_os = "android"))] + { + if mask_coverage_setting_enabled(std::env::var("BLOOM_MASK_COVERAGE").ok().as_deref()) { + mask_texture_coverage_references(gltf) + } else { + Default::default() + } + } +} + +#[cfg(any(not(target_os = "android"), test))] +fn mask_coverage_setting_enabled(value: Option<&str>) -> bool { + !value + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "0" | "off" | "false" | "disabled" + ) + }) + .unwrap_or(false) +} + +/// Images referenced exclusively as the base color of one or more MASK +/// materials can let their first coverage chain stand in for the otherwise +/// unreachable ordinary chain. Shared material inputs retain both semantics. +fn mask_only_texture_images( + gltf: &gltf::Gltf, + coverage_images: impl IntoIterator, +) -> std::collections::HashSet { + let mut mask_only: std::collections::HashSet = coverage_images.into_iter().collect(); + for mat in gltf.materials() { + let pbr = mat.pbr_metallic_roughness(); + if mask_base_color_coverage_reference(&mat).is_none() { + if let Some(info) = pbr.base_color_texture() { + mask_only.remove(&info.texture().source().index()); + } else if let Some(info) = mat + .pbr_specular_glossiness() + .and_then(|spec_gloss| spec_gloss.diffuse_texture()) + { + mask_only.remove(&info.texture().source().index()); + } + } + if let Some(info) = pbr.metallic_roughness_texture() { + mask_only.remove(&info.texture().source().index()); + } + if let Some(info) = mat.normal_texture() { + mask_only.remove(&info.texture().source().index()); + } + if let Some(info) = mat.emissive_texture() { + mask_only.remove(&info.texture().source().index()); + } + if let Some(info) = mat.occlusion_texture() { + mask_only.remove(&info.texture().source().index()); + } + if let Some(info) = mat + .transmission() + .and_then(|transmission| transmission.transmission_texture()) + { + mask_only.remove(&info.texture().source().index()); + } + if let Some(info) = mat.volume().and_then(|volume| volume.thickness_texture()) { + mask_only.remove(&info.texture().source().index()); + } + } + mask_only +} + +fn base_color_texture_selection( + mat: &gltf::Material<'_>, + ordinary: &[u32], + mask_variants: &std::collections::HashMap, +) -> (Option, bool) { + let pbr = mat.pbr_metallic_roughness(); + let image_index = pbr + .base_color_texture() + .map(|info| info.texture().source().index()) + .or_else(|| { + mat.pbr_specular_glossiness() + .and_then(|spec_gloss| spec_gloss.diffuse_texture()) + .map(|info| info.texture().source().index()) + }); + let Some(image_index) = image_index else { + return (None, false); + }; + if let Some((_, reference)) = mask_base_color_coverage_reference(mat) { + if let Some(index) = mask_variants.get(&(image_index, reference.to_bits())) { + return (Some(*index), true); + } + } + (ordinary.get(image_index).copied(), false) +} + +fn unsupported_material_extension_diagnostics( + gltf: &gltf::Gltf, + source_label: &str, +) -> Vec { + let mut diagnostics = Vec::new(); + for material in gltf.materials() { + let name = material + .name() + .map(|name| format!("\"{name}\"")) + .or_else(|| material.index().map(|index| format!("#{index}"))) + .unwrap_or_else(|| "".to_owned()); + if let Some(extensions) = material.extensions() { + for extension in extensions.keys() { + if matches!( + extension.as_str(), + "KHR_materials_clearcoat" | "KHR_materials_sheen" | "KHR_materials_anisotropy" + ) { + continue; + } + diagnostics.push(format!( + "glTF asset \"{source_label}\", material {name}: unsupported extension \ + \"{extension}\" is ignored" + )); + } + } + } + diagnostics +} + +fn emit_unsupported_material_extension_diagnostics(gltf: &gltf::Gltf, source_label: &str) { + for diagnostic in unsupported_material_extension_diagnostics(gltf, source_label) { + log::warn!("{diagnostic}"); + } +} + +fn transmission_from_material( + mat: &gltf::Material<'_>, + runtime_texture_indices: Option<&[u32]>, +) -> Result { + use crate::models::{MaterialThicknessSource, MaterialTransmission}; + + let mut out = MaterialTransmission::default(); + if let Some(transmission) = mat.transmission() { + out.authored = true; + out.factor = transmission.transmission_factor(); + out.texture = transmission + .transmission_texture() + .map(|info| texture_binding_from_info(info, runtime_texture_indices)); + } + if let Some(ior) = mat.ior() { + out.ior_authored = true; + out.ior = ior; + } + if let Some(volume) = mat.volume() { + out.volume_authored = true; + out.thickness_factor = volume.thickness_factor(); + out.thickness_texture = volume + .thickness_texture() + .map(|info| texture_binding_from_info(info, runtime_texture_indices)); + out.attenuation_distance = volume.attenuation_distance(); + out.attenuation_color = volume.attenuation_color(); + out.thickness_source = MaterialThicknessSource::Authored; + } + let material = mat + .name() + .map(|name| format!("\"{name}\"")) + .or_else(|| mat.index().map(|index| format!("#{index}"))) + .unwrap_or_else(|| "".to_owned()); + let invalid = if !out.factor.is_finite() || !(0.0..=1.0).contains(&out.factor) { + Some(format!("transmissionFactor {} outside [0, 1]", out.factor)) + } else if !out.ior.is_finite() || (out.ior != 0.0 && out.ior < 1.0) { + Some(format!("ior {} must be zero or at least 1.0", out.ior)) + } else if !out.thickness_factor.is_finite() || out.thickness_factor < 0.0 { + Some(format!("thicknessFactor {} below 0", out.thickness_factor)) + } else if out.attenuation_distance.is_nan() || out.attenuation_distance <= 0.0 { + Some(format!( + "attenuationDistance {} must be positive", + out.attenuation_distance + )) + } else if out + .attenuation_color + .iter() + .any(|value| !value.is_finite() || !(0.0..=1.0).contains(value)) + { + Some(format!( + "attenuationColor {:?} has a component outside [0, 1]", + out.attenuation_color + )) + } else { + None + }; + if let Some(reason) = invalid { + Err(format!( + "glTF material {material}: invalid physical extension data: {reason}" + )) + } else { + Ok(out) + } +} + +/// Exact pre-refraction approximation retained only for the diagnostic +/// `BLOOM_GLTF_REFRACTION=0` path: strong transmission becomes mildly tinted, +/// smooth metal instead of a painted-white dielectric. fn apply_transmission_hack( transmission: f32, base_color: &mut [f32; 4], @@ -1179,6 +1826,10 @@ fn apply_transmission_hack( } } +#[cfg(test)] +#[path = "models_gltf_tests.rs"] +mod alpha_mode_tests; + /// Reference: Khronos glTF sample specGloss→metallicRoughness /// converter (https://github.com/KhronosGroup/glTF/pull/1355). fn specgloss_to_metalrough(diffuse: [f32; 4], specular: [f32; 3]) -> ([f32; 4], f32) { @@ -1193,9 +1844,8 @@ fn specgloss_to_metalrough(diffuse: [f32; 4], specular: [f32; 3]) -> ([f32; 4], // reference: mapping perceived brightness split between diffuse // and specular back to a single metallic parameter. let a = dielectric_specular; - let b = diffuse_max * one_minus_dielectric / dielectric_specular.max(epsilon) - + specular_max - - 2.0 * dielectric_specular; + let b = diffuse_max * one_minus_dielectric / dielectric_specular.max(epsilon) + specular_max + - 2.0 * dielectric_specular; let c = dielectric_specular - specular_max; let discriminant = (b * b - 4.0 * a * c).max(0.0); let metallic = if specular_max < dielectric_specular { @@ -1206,14 +1856,22 @@ fn specgloss_to_metalrough(diffuse: [f32; 4], specular: [f32; 3]) -> ([f32; 4], // base_color = mix(diffuse, specular, metallic²) with the diffuse // branch scaled to undo the dielectric energy split. - let diffuse_branch_scale = one_minus_dielectric - / (1.0 - metallic * dielectric_specular).max(epsilon); + let diffuse_branch_scale = + one_minus_dielectric / (1.0 - metallic * dielectric_specular).max(epsilon); let metal_weight = metallic * metallic; let lerp = |a: f32, b: f32, t: f32| a * (1.0 - t) + b * t; let r = lerp(diffuse[0] * diffuse_branch_scale, specular[0], metal_weight); let g = lerp(diffuse[1] * diffuse_branch_scale, specular[1], metal_weight); let bl = lerp(diffuse[2] * diffuse_branch_scale, specular[2], metal_weight); - ([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), bl.clamp(0.0, 1.0), diffuse[3]], metallic) + ( + [ + r.clamp(0.0, 1.0), + g.clamp(0.0, 1.0), + bl.clamp(0.0, 1.0), + diffuse[3], + ], + metallic, + ) } /// Replace the extension on a URI (keeps directories / query strings @@ -1236,8 +1894,8 @@ fn swap_extension(uri: &str, new_ext: &str) -> String { /// Lumberyard Bistro that ship BC-compressed textures), falling back /// to the `image` crate for PNG/JPEG/etc. Returns None on failure. fn decode_texture_bytes(bytes: &[u8], uri: &str) -> Option<(Vec, u32, u32)> { - let is_dds = uri.to_ascii_lowercase().ends_with(".dds") - || bytes.len() >= 4 && &bytes[..4] == b"DDS "; + let is_dds = + uri.to_ascii_lowercase().ends_with(".dds") || bytes.len() >= 4 && &bytes[..4] == b"DDS "; if is_dds { if let Ok(dds) = image_dds::ddsfile::Dds::read(bytes) { // Decode mip 0 → RGBA8. image_from_dds handles the common @@ -1277,4 +1935,3 @@ fn base64_decode(input: &str, output: &mut Vec) { } } } - diff --git a/native/shared/src/models_gltf_layered_pbr.rs b/native/shared/src/models_gltf_layered_pbr.rs new file mode 100644 index 00000000..990f9066 --- /dev/null +++ b/native/shared/src/models_gltf_layered_pbr.rs @@ -0,0 +1,457 @@ +//! Import and validation for glTF layered-PBR material extensions. +//! +//! Kept separate from the core mesh loader so adding physical lobes does not +//! grow `models_gltf.rs` beyond the repository's 2,000-line file policy. + +use crate::models::{MaterialLayeredPbr, MaterialTextureBinding, MaterialTextureTransform}; + +pub(super) fn retain_layered_normal_image_indices( + gltf: &gltf::Gltf, + out: &mut std::collections::HashSet, +) { + for material in gltf.materials() { + let Some(texture_index) = material + .extension_value("KHR_materials_clearcoat") + .and_then(serde_json::Value::as_object) + .and_then(|clearcoat| clearcoat.get("clearcoatNormalTexture")) + .and_then(serde_json::Value::as_object) + .and_then(|texture| texture.get("index")) + .and_then(serde_json::Value::as_u64) + .and_then(|index| usize::try_from(index).ok()) + else { + continue; + }; + if let Some(texture) = gltf.textures().nth(texture_index) { + out.insert(texture.source().index()); + } + } +} + +pub(super) fn retain_material_tex_coords_1( + transmission: crate::models::MaterialTransmission, + layered_pbr: MaterialLayeredPbr, + position_count: usize, + read_values: impl FnOnce() -> Option>, +) -> Option> { + if !transmission.requests_tex_coord(1) && !layered_pbr.requests_tex_coord(1) { + return None; + } + match read_values() { + Some(values) if values.len() == position_count => Some(values), + Some(values) => { + log::warn!( + "bloom glTF: physical TEXCOORD_1 count {} does not match POSITION count {}; \ + preserving the material but using its scalar physical factor", + values.len(), + position_count, + ); + None + } + None => { + log::warn!( + "bloom glTF: physical texture requests TEXCOORD_1 but the primitive has no \ + TEXCOORD_1 accessor; preserving the material but using its scalar physical factor" + ); + None + } + } +} + +pub(super) fn texture_binding_from_info( + info: gltf::texture::Info<'_>, + runtime_texture_indices: Option<&[u32]>, +) -> MaterialTextureBinding { + let texture = info.texture(); + let source_image_index = texture.source().index(); + let mut transform = MaterialTextureTransform { + tex_coord: info.tex_coord(), + ..Default::default() + }; + if let Some(authored) = info.texture_transform() { + transform.offset = authored.offset(); + transform.rotation = authored.rotation(); + transform.scale = authored.scale(); + transform.tex_coord = authored.tex_coord().unwrap_or(transform.tex_coord); + } + MaterialTextureBinding { + source_texture_index: texture.index() as u32, + source_image_index: source_image_index as u32, + runtime_texture_idx: runtime_texture_indices + .and_then(|indices| indices.get(source_image_index).copied()), + transform, + } +} + +fn texture_binding_from_extension_value( + info: &serde_json::Value, + gltf: &gltf::Gltf, + runtime_texture_indices: Option<&[u32]>, + field: &str, +) -> Result { + let object = info + .as_object() + .ok_or_else(|| format!("{field} must be an object"))?; + let texture_index = object + .get("index") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| format!("{field}.index must be a non-negative integer"))? + as usize; + let texture = gltf + .textures() + .nth(texture_index) + .ok_or_else(|| format!("{field}.index {texture_index} is out of range"))?; + let source_image_index = texture.source().index(); + let mut transform = MaterialTextureTransform { + tex_coord: object + .get("texCoord") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) as u32, + ..Default::default() + }; + if let Some(authored) = object + .get("extensions") + .and_then(serde_json::Value::as_object) + .and_then(|extensions| extensions.get("KHR_texture_transform")) + .and_then(serde_json::Value::as_object) + { + let vec2 = |name: &str, fallback: [f32; 2]| -> Result<[f32; 2], String> { + let Some(value) = authored.get(name) else { + return Ok(fallback); + }; + let values = value + .as_array() + .filter(|values| values.len() == 2) + .ok_or_else(|| format!("{field}.KHR_texture_transform.{name} must be vec2"))?; + let result = [ + values[0] + .as_f64() + .ok_or_else(|| format!("{field}.{name}[0] must be numeric"))? + as f32, + values[1] + .as_f64() + .ok_or_else(|| format!("{field}.{name}[1] must be numeric"))? + as f32, + ]; + if result.iter().any(|value| !value.is_finite()) { + return Err(format!("{field}.{name} must be finite")); + } + Ok(result) + }; + transform.offset = vec2("offset", transform.offset)?; + transform.scale = vec2("scale", transform.scale)?; + if let Some(rotation) = authored.get("rotation") { + transform.rotation = rotation + .as_f64() + .ok_or_else(|| format!("{field}.rotation must be numeric"))? + as f32; + if !transform.rotation.is_finite() { + return Err(format!("{field}.rotation must be finite")); + } + } + if let Some(tex_coord) = authored.get("texCoord") { + transform.tex_coord = tex_coord + .as_u64() + .ok_or_else(|| format!("{field}.texCoord must be a non-negative integer"))? + as u32; + } + } + Ok(MaterialTextureBinding { + source_texture_index: texture_index as u32, + source_image_index: source_image_index as u32, + runtime_texture_idx: runtime_texture_indices + .and_then(|indices| indices.get(source_image_index).copied()), + transform, + }) +} + +pub(super) fn layered_pbr_from_material( + gltf: &gltf::Gltf, + mat: &gltf::Material<'_>, + runtime_texture_indices: Option<&[u32]>, +) -> Result { + let mut out = MaterialLayeredPbr::default(); + if let Some(ior) = mat.ior() { + out.ior_authored = true; + out.ior = ior; + } + if let Some(specular) = mat.specular() { + out.specular_authored = true; + out.specular_factor = specular.specular_factor(); + out.specular_color_factor = specular.specular_color_factor(); + out.specular_texture = specular + .specular_texture() + .map(|info| texture_binding_from_info(info, runtime_texture_indices)); + out.specular_color_texture = specular + .specular_color_texture() + .map(|info| texture_binding_from_info(info, runtime_texture_indices)); + } + if let Some(clearcoat) = mat + .extension_value("KHR_materials_clearcoat") + .and_then(serde_json::Value::as_object) + { + out.clearcoat_authored = true; + if let Some(factor) = clearcoat.get("clearcoatFactor") { + out.clearcoat_factor = factor.as_f64().ok_or("clearcoatFactor must be numeric")? as f32; + } + if let Some(roughness) = clearcoat.get("clearcoatRoughnessFactor") { + out.clearcoat_roughness_factor = roughness + .as_f64() + .ok_or("clearcoatRoughnessFactor must be numeric")? + as f32; + } + if let Some(info) = clearcoat.get("clearcoatTexture") { + out.clearcoat_texture = Some(texture_binding_from_extension_value( + info, + gltf, + runtime_texture_indices, + "clearcoatTexture", + )?); + } + if let Some(info) = clearcoat.get("clearcoatRoughnessTexture") { + out.clearcoat_roughness_texture = Some(texture_binding_from_extension_value( + info, + gltf, + runtime_texture_indices, + "clearcoatRoughnessTexture", + )?); + } + if let Some(info) = clearcoat.get("clearcoatNormalTexture") { + out.clearcoat_normal_texture = Some(texture_binding_from_extension_value( + info, + gltf, + runtime_texture_indices, + "clearcoatNormalTexture", + )?); + if let Some(scale) = info + .as_object() + .and_then(|object| object.get("scale")) + .and_then(serde_json::Value::as_f64) + { + out.clearcoat_normal_scale = scale as f32; + } + } + } + if let Some(sheen) = mat + .extension_value("KHR_materials_sheen") + .and_then(serde_json::Value::as_object) + { + out.sheen_authored = true; + if let Some(color) = sheen.get("sheenColorFactor") { + out.sheen_color_factor = extension_vec3(color, "sheenColorFactor")?; + } + if let Some(roughness) = sheen.get("sheenRoughnessFactor") { + out.sheen_roughness_factor = roughness + .as_f64() + .ok_or("sheenRoughnessFactor must be numeric")? + as f32; + } + if let Some(info) = sheen.get("sheenColorTexture") { + out.sheen_color_texture = Some(texture_binding_from_extension_value( + info, + gltf, + runtime_texture_indices, + "sheenColorTexture", + )?); + } + if let Some(info) = sheen.get("sheenRoughnessTexture") { + out.sheen_roughness_texture = Some(texture_binding_from_extension_value( + info, + gltf, + runtime_texture_indices, + "sheenRoughnessTexture", + )?); + } + } + if let Some(anisotropy) = mat + .extension_value("KHR_materials_anisotropy") + .and_then(serde_json::Value::as_object) + { + out.anisotropy_authored = true; + if let Some(strength) = anisotropy.get("anisotropyStrength") { + out.anisotropy_strength = strength + .as_f64() + .ok_or("anisotropyStrength must be numeric")? + as f32; + } + if let Some(rotation) = anisotropy.get("anisotropyRotation") { + out.anisotropy_rotation = rotation + .as_f64() + .ok_or("anisotropyRotation must be numeric")? + as f32; + } + if let Some(info) = anisotropy.get("anisotropyTexture") { + out.anisotropy_texture = Some(texture_binding_from_extension_value( + info, + gltf, + runtime_texture_indices, + "anisotropyTexture", + )?); + } + } + if let Some(iridescence) = mat + .extension_value("KHR_materials_iridescence") + .and_then(serde_json::Value::as_object) + { + out.iridescence_authored = true; + if let Some(factor) = iridescence.get("iridescenceFactor") { + out.iridescence_factor = + factor.as_f64().ok_or("iridescenceFactor must be numeric")? as f32; + } + if let Some(info) = iridescence.get("iridescenceTexture") { + out.iridescence_texture = Some(texture_binding_from_extension_value( + info, + gltf, + runtime_texture_indices, + "iridescenceTexture", + )?); + } + if let Some(ior) = iridescence.get("iridescenceIor") { + out.iridescence_ior = ior.as_f64().ok_or("iridescenceIor must be numeric")? as f32; + } + if let Some(thickness) = iridescence.get("iridescenceThicknessMinimum") { + out.iridescence_thickness_minimum = thickness + .as_f64() + .ok_or("iridescenceThicknessMinimum must be numeric")? + as f32; + } + if let Some(thickness) = iridescence.get("iridescenceThicknessMaximum") { + out.iridescence_thickness_maximum = thickness + .as_f64() + .ok_or("iridescenceThicknessMaximum must be numeric")? + as f32; + } + if let Some(info) = iridescence.get("iridescenceThicknessTexture") { + out.iridescence_thickness_texture = Some(texture_binding_from_extension_value( + info, + gltf, + runtime_texture_indices, + "iridescenceThicknessTexture", + )?); + } + } + validate_layered_material(mat, out) +} + +fn extension_vec3(value: &serde_json::Value, field: &str) -> Result<[f32; 3], String> { + let values = value + .as_array() + .filter(|values| values.len() == 3) + .ok_or_else(|| format!("{field} must be a three-component array"))?; + let mut result = [0.0; 3]; + for (index, value) in values.iter().enumerate() { + result[index] = value + .as_f64() + .ok_or_else(|| format!("{field}[{index}] must be numeric"))? + as f32; + } + Ok(result) +} + +fn validate_layered_material( + mat: &gltf::Material<'_>, + out: MaterialLayeredPbr, +) -> Result { + let material = mat + .name() + .map(|name| format!("\"{name}\"")) + .or_else(|| mat.index().map(|index| format!("#{index}"))) + .unwrap_or_else(|| "".to_owned()); + let invalid = if !out.clearcoat_factor.is_finite() + || !(0.0..=1.0).contains(&out.clearcoat_factor) + { + Some(format!( + "clearcoatFactor {} outside [0, 1]", + out.clearcoat_factor + )) + } else if !out.clearcoat_roughness_factor.is_finite() + || !(0.0..=1.0).contains(&out.clearcoat_roughness_factor) + { + Some(format!( + "clearcoatRoughnessFactor {} outside [0, 1]", + out.clearcoat_roughness_factor + )) + } else if !out.clearcoat_normal_scale.is_finite() { + Some(format!( + "clearcoatNormalTexture.scale {} must be finite", + out.clearcoat_normal_scale + )) + } else if !out.specular_factor.is_finite() || !(0.0..=1.0).contains(&out.specular_factor) { + Some(format!( + "specularFactor {} outside [0, 1]", + out.specular_factor + )) + } else if out + .specular_color_factor + .iter() + .any(|value| !value.is_finite() || *value < 0.0) + { + Some(format!( + "specularColorFactor {:?} has a negative or non-finite component", + out.specular_color_factor + )) + } else if !out.ior.is_finite() || (out.ior != 0.0 && out.ior < 1.0) { + Some(format!("ior {} must be zero or at least 1.0", out.ior)) + } else if out + .sheen_color_factor + .iter() + .any(|value| !value.is_finite() || !(0.0..=1.0).contains(value)) + { + Some(format!( + "sheenColorFactor {:?} has a component outside [0, 1]", + out.sheen_color_factor + )) + } else if !out.sheen_roughness_factor.is_finite() + || !(0.0..=1.0).contains(&out.sheen_roughness_factor) + { + Some(format!( + "sheenRoughnessFactor {} outside [0, 1]", + out.sheen_roughness_factor + )) + } else if !out.anisotropy_strength.is_finite() + || !(0.0..=1.0).contains(&out.anisotropy_strength) + { + Some(format!( + "anisotropyStrength {} outside [0, 1]", + out.anisotropy_strength + )) + } else if !out.anisotropy_rotation.is_finite() { + Some(format!( + "anisotropyRotation {} must be finite", + out.anisotropy_rotation + )) + } else if !out.iridescence_factor.is_finite() || !(0.0..=1.0).contains(&out.iridescence_factor) + { + Some(format!( + "iridescenceFactor {} outside [0, 1]", + out.iridescence_factor + )) + } else if !out.iridescence_ior.is_finite() || out.iridescence_ior < 1.0 { + Some(format!( + "iridescenceIor {} must be at least 1.0", + out.iridescence_ior + )) + } else if !out.iridescence_thickness_minimum.is_finite() + || out.iridescence_thickness_minimum < 0.0 + { + Some(format!( + "iridescenceThicknessMinimum {} must be non-negative", + out.iridescence_thickness_minimum + )) + } else if !out.iridescence_thickness_maximum.is_finite() + || out.iridescence_thickness_maximum < 0.0 + { + Some(format!( + "iridescenceThicknessMaximum {} must be non-negative", + out.iridescence_thickness_maximum + )) + } else { + None + }; + if let Some(reason) = invalid { + Err(format!( + "glTF material {material}: invalid layered-PBR extension data: {reason}" + )) + } else { + Ok(out) + } +} diff --git a/native/shared/src/models_gltf_tests.rs b/native/shared/src/models_gltf_tests.rs new file mode 100644 index 00000000..e264eecf --- /dev/null +++ b/native/shared/src/models_gltf_tests.rs @@ -0,0 +1,1061 @@ +use super::*; +use crate::models::{ + MaterialAlphaMode, MaterialLayeredPbr, MaterialThicknessSource, MaterialTransmission, +}; + +fn minimal_triangle_glb(material: &str) -> Vec { + let mut binary = Vec::new(); + for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0] { + binary.extend_from_slice(&value.to_le_bytes()); + } + for tangent in [ + [1.0_f32, 0.0, 0.0, -1.0], + [1.0, 0.0, 0.0, -1.0], + [1.0, 0.0, 0.0, -1.0], + ] { + for value in tangent { + binary.extend_from_slice(&value.to_le_bytes()); + } + } + for index in [0_u16, 1, 2] { + binary.extend_from_slice(&index.to_le_bytes()); + } + let binary_byte_length = binary.len(); + while binary.len() % 4 != 0 { + binary.push(0); + } + + let mut json = format!( + r#"{{ + "asset":{{"version":"2.0"}}, + "extensionsUsed":[ + "KHR_materials_transmission", + "KHR_materials_volume", + "KHR_materials_ior", + "KHR_materials_clearcoat", + "KHR_materials_specular", + "KHR_materials_sheen", + "KHR_materials_anisotropy" + ], + "buffers":[{{"byteLength":{binary_byte_length}}}], + "bufferViews":[ + {{"buffer":0,"byteOffset":0,"byteLength":36,"target":34962}}, + {{"buffer":0,"byteOffset":36,"byteLength":48,"target":34962}}, + {{"buffer":0,"byteOffset":84,"byteLength":6,"target":34963}} + ], + "accessors":[ + {{ + "bufferView":0, + "componentType":5126, + "count":3, + "type":"VEC3", + "min":[0,0,0], + "max":[1,1,0] + }}, + {{"bufferView":1,"componentType":5126,"count":3,"type":"VEC4"}}, + {{"bufferView":2,"componentType":5123,"count":3,"type":"SCALAR"}} + ], + "materials":[{material}], + "meshes":[{{"primitives":[{{ + "attributes":{{"POSITION":0,"TANGENT":1}}, + "indices":2, + "material":0 + }}]}}] + }}"# + ) + .into_bytes(); + while json.len() % 4 != 0 { + json.push(b' '); + } + + let total_length = 12 + 8 + json.len() + 8 + binary.len(); + let mut glb = Vec::with_capacity(total_length); + glb.extend_from_slice(b"glTF"); + glb.extend_from_slice(&2_u32.to_le_bytes()); + glb.extend_from_slice(&(total_length as u32).to_le_bytes()); + glb.extend_from_slice(&(json.len() as u32).to_le_bytes()); + glb.extend_from_slice(&0x4E4F_534A_u32.to_le_bytes()); + glb.extend_from_slice(&json); + glb.extend_from_slice(&(binary.len() as u32).to_le_bytes()); + glb.extend_from_slice(&0x004E_4942_u32.to_le_bytes()); + glb.extend_from_slice(&binary); + glb +} + +fn physical_uv_triangle_glb(texture_tex_coord: u32, include_texcoord_1: bool) -> Vec { + let mut binary = Vec::new(); + for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0] { + binary.extend_from_slice(&value.to_le_bytes()); + } + for uv in [[0.0_f32, 0.0], [1.0, 0.0], [0.0, 1.0]] { + binary.extend_from_slice(&uv[0].to_le_bytes()); + binary.extend_from_slice(&uv[1].to_le_bytes()); + } + if include_texcoord_1 { + for uv in [[0.2_f32, 0.3], [0.8, 0.3], [0.2, 0.9]] { + binary.extend_from_slice(&uv[0].to_le_bytes()); + binary.extend_from_slice(&uv[1].to_le_bytes()); + } + } + let index_offset = binary.len(); + for index in [0_u16, 1, 2] { + binary.extend_from_slice(&index.to_le_bytes()); + } + let binary_byte_length = binary.len(); + while binary.len() % 4 != 0 { + binary.push(0); + } + + let uv1_view = if include_texcoord_1 { + r#",{"buffer":0,"byteOffset":60,"byteLength":24,"target":34962}"# + } else { + "" + }; + let uv1_accessor = if include_texcoord_1 { + r#",{"bufferView":2,"componentType":5126,"count":3,"type":"VEC2"}"# + } else { + "" + }; + let index_view = if include_texcoord_1 { 3 } else { 2 }; + let index_accessor = if include_texcoord_1 { 3 } else { 2 }; + let uv1_attribute = if include_texcoord_1 { + r#","TEXCOORD_1":2"# + } else { + "" + }; + let mut json = format!( + r#"{{ + "asset":{{"version":"2.0"}}, + "extensionsUsed":["KHR_materials_transmission","KHR_materials_volume"], + "buffers":[{{"byteLength":{binary_byte_length}}}], + "bufferViews":[ + {{"buffer":0,"byteOffset":0,"byteLength":36,"target":34962}}, + {{"buffer":0,"byteOffset":36,"byteLength":24,"target":34962}} + {uv1_view}, + {{"buffer":0,"byteOffset":{index_offset},"byteLength":6,"target":34963}} + ], + "accessors":[ + {{ + "bufferView":0, + "componentType":5126, + "count":3, + "type":"VEC3", + "min":[0,0,0], + "max":[1,1,0] + }}, + {{"bufferView":1,"componentType":5126,"count":3,"type":"VEC2"}} + {uv1_accessor}, + {{"bufferView":{index_view},"componentType":5123,"count":3,"type":"SCALAR"}} + ], + "images":[{{"uri":"glass.png"}}], + "textures":[{{"source":0}}], + "materials":[{{ + "extensions":{{ + "KHR_materials_transmission":{{ + "transmissionFactor":1.0, + "transmissionTexture":{{ + "index":0, + "texCoord":{texture_tex_coord} + }} + }}, + "KHR_materials_volume":{{ + "thicknessFactor":0.25, + "thicknessTexture":{{"index":0,"texCoord":0}} + }} + }} + }}], + "meshes":[{{"primitives":[{{ + "attributes":{{"POSITION":0,"TEXCOORD_0":1{uv1_attribute}}}, + "indices":{index_accessor}, + "material":0 + }}]}}] + }}"# + ) + .into_bytes(); + while json.len() % 4 != 0 { + json.push(b' '); + } + + let total_length = 12 + 8 + json.len() + 8 + binary.len(); + let mut glb = Vec::with_capacity(total_length); + glb.extend_from_slice(b"glTF"); + glb.extend_from_slice(&2_u32.to_le_bytes()); + glb.extend_from_slice(&(total_length as u32).to_le_bytes()); + glb.extend_from_slice(&(json.len() as u32).to_le_bytes()); + glb.extend_from_slice(&0x4E4F_534A_u32.to_le_bytes()); + glb.extend_from_slice(&json); + glb.extend_from_slice(&(binary.len() as u32).to_le_bytes()); + glb.extend_from_slice(&0x004E_4942_u32.to_le_bytes()); + glb.extend_from_slice(&binary); + glb +} + +fn textured_mask_triangle_glb() -> Vec { + let mut binary = Vec::new(); + for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0] { + binary.extend_from_slice(&value.to_le_bytes()); + } + for _ in 0..3 { + for value in [0.2_f32, 0.4, 0.6, 0.5] { + binary.extend_from_slice(&value.to_le_bytes()); + } + } + for index in [0_u16, 1, 2] { + binary.extend_from_slice(&index.to_le_bytes()); + } + while binary.len() % 4 != 0 { + binary.push(0); + } + let image_offset = binary.len(); + let image = image::RgbaImage::from_raw( + 2, + 2, + vec![ + 20, 200, 30, 255, 255, 0, 255, 0, 20, 200, 30, 255, 255, 0, 255, 0, + ], + ) + .unwrap(); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let png = png.into_inner(); + let image_length = png.len(); + binary.extend_from_slice(&png); + let binary_byte_length = binary.len(); + while binary.len() % 4 != 0 { + binary.push(0); + } + + let mut json = format!( + r#"{{ + "asset":{{"version":"2.0"}}, + "buffers":[{{"byteLength":{binary_byte_length}}}], + "bufferViews":[ + {{"buffer":0,"byteOffset":0,"byteLength":36,"target":34962}}, + {{"buffer":0,"byteOffset":36,"byteLength":48,"target":34962}}, + {{"buffer":0,"byteOffset":84,"byteLength":6,"target":34963}}, + {{"buffer":0,"byteOffset":{image_offset},"byteLength":{image_length}}} + ], + "accessors":[ + {{ + "bufferView":0, + "componentType":5126, + "count":3, + "type":"VEC3", + "min":[0,0,0], + "max":[1,1,0] + }}, + {{"bufferView":1,"componentType":5126,"count":3,"type":"VEC4"}}, + {{"bufferView":2,"componentType":5123,"count":3,"type":"SCALAR"}} + ], + "images":[{{"bufferView":3,"mimeType":"image/png"}}], + "textures":[{{"source":0}}], + "materials":[{{ + "alphaMode":"MASK", + "alphaCutoff":0.5, + "pbrMetallicRoughness":{{ + "baseColorFactor":[0.5,0.25,0.75,0.8], + "baseColorTexture":{{"index":0}} + }} + }}], + "meshes":[{{"primitives":[{{ + "attributes":{{"POSITION":0,"COLOR_0":1}}, + "indices":2, + "material":0 + }}]}}] + }}"# + ) + .into_bytes(); + while json.len() % 4 != 0 { + json.push(b' '); + } + + let total_length = 12 + 8 + json.len() + 8 + binary.len(); + let mut glb = Vec::with_capacity(total_length); + glb.extend_from_slice(b"glTF"); + glb.extend_from_slice(&2_u32.to_le_bytes()); + glb.extend_from_slice(&(total_length as u32).to_le_bytes()); + glb.extend_from_slice(&(json.len() as u32).to_le_bytes()); + glb.extend_from_slice(&0x4E4F_534A_u32.to_le_bytes()); + glb.extend_from_slice(&json); + glb.extend_from_slice(&(binary.len() as u32).to_le_bytes()); + glb.extend_from_slice(&0x004E_4942_u32.to_le_bytes()); + glb.extend_from_slice(&binary); + glb +} + +#[test] +fn gltf_alpha_modes_remain_distinct() { + let source = br#"{ + "asset":{"version":"2.0"}, + "materials":[ + {"name":"opaque","alphaMode":"OPAQUE"}, + {"name":"mask","alphaMode":"MASK","alphaCutoff":0.37}, + {"name":"blend","alphaMode":"BLEND","doubleSided":true} + ] + }"#; + let document = gltf::Gltf::from_slice(source).unwrap(); + let materials: Vec<_> = document.materials().collect(); + assert_eq!( + alpha_mode_from_material(&materials[0]), + MaterialAlphaMode::Opaque + ); + assert_eq!( + alpha_mode_from_material(&materials[1]), + MaterialAlphaMode::Mask + ); + assert_eq!( + alpha_mode_from_material(&materials[2]), + MaterialAlphaMode::Blend + ); + assert_eq!(alpha_cutoff_from_material(&materials[0]), 0.0); + assert!((alpha_cutoff_from_material(&materials[1]) - 0.37).abs() < 1e-6); + assert_eq!(alpha_cutoff_from_material(&materials[2]), 0.0); + assert!(materials[2].double_sided()); +} + +#[test] +fn shader_alpha_tag_preserves_fractional_blend_coverage() { + assert_eq!(MaterialAlphaMode::Opaque.shader_alpha_value(0.8), 0.0); + assert_eq!(MaterialAlphaMode::Mask.shader_alpha_value(0.37), 0.37); + assert_eq!(MaterialAlphaMode::Blend.shader_alpha_value(0.8), -1.0); +} + +#[test] +fn mask_textures_select_cutoff_specific_coverage_variants_only() { + let source = br#"{ + "asset":{"version":"2.0"}, + "images":[{"uri":"shared.png"}], + "textures":[{"source":0}], + "materials":[ + { + "name":"mask-a", + "alphaMode":"MASK", + "alphaCutoff":0.5, + "pbrMetallicRoughness":{ + "baseColorFactor":[1,1,1,0.8], + "baseColorTexture":{"index":0} + } + }, + { + "name":"mask-b", + "alphaMode":"MASK", + "alphaCutoff":0.25, + "pbrMetallicRoughness":{"baseColorTexture":{"index":0}} + }, + { + "name":"blend", + "alphaMode":"BLEND", + "pbrMetallicRoughness":{"baseColorTexture":{"index":0}} + }, + { + "name":"mask-zero-cutoff", + "alphaMode":"MASK", + "alphaCutoff":0, + "pbrMetallicRoughness":{"baseColorTexture":{"index":0}} + } + ] + }"#; + let document = gltf::Gltf::from_slice(source).unwrap(); + let materials: Vec<_> = document.materials().collect(); + let references = mask_texture_coverage_references(&document); + let image_references = references.get(&0).unwrap(); + assert_eq!(image_references.len(), 2); + assert!( + mask_only_texture_images(&document, references.keys().copied()).is_empty(), + "BLEND and zero-cutoff MASK users require the shared ordinary chain" + ); + assert!(image_references + .iter() + .any(|reference| (*reference - 0.625).abs() < 1e-6)); + assert!(image_references + .iter() + .any(|reference| (*reference - 0.25).abs() < 1e-6)); + + let mut variants = std::collections::HashMap::new(); + variants.insert((0, 0.625f32.to_bits()), 21); + variants.insert((0, 0.25f32.to_bits()), 22); + assert_eq!( + base_color_texture_selection(&materials[0], &[11], &variants), + (Some(21), true) + ); + assert_eq!( + base_color_texture_selection(&materials[1], &[11], &variants), + (Some(22), true) + ); + assert_eq!( + base_color_texture_selection(&materials[2], &[11], &variants), + (Some(11), false), + "BLEND must retain its ordinary opacity mip chain" + ); + assert_eq!( + base_color_texture_selection(&materials[3], &[11], &variants), + (Some(11), false), + "zero-cutoff MASK is fully covered and needs no duplicate chain" + ); +} + +#[test] +fn vertex_color_multiplies_the_material_factor_per_gltf() { + assert_eq!( + multiply_rgba([0.5, 0.25, 0.8, 0.4], [0.2, 0.6, 0.5, 0.75]), + [0.1, 0.15, 0.4, 0.3] + ); +} + +#[test] +fn staged_mask_loader_routes_coverage_variant_and_keeps_plain_loader_image_neutral() { + let glb = textured_mask_triangle_glb(); + let staged = load_gltf_staged(&glb).expect("textured MASK GLB stages"); + assert_eq!( + staged.textures.len(), + 1, + "MASK-only images must not retain an unreachable ordinary mip chain" + ); + assert_eq!(staged.textures[0].alpha_coverage_reference, Some(0.625)); + assert_eq!(staged.model.meshes[0].texture_idx, Some(1)); + assert!(staged.model.meshes[0].alpha_coverage_mips); + let color = staged.model.meshes[0].vertices[0].color; + for (actual, expected) in color.into_iter().zip([0.1, 0.1, 0.45, 0.4]) { + assert!( + (actual - expected).abs() < 1e-6, + "COLOR_0 must multiply baseColorFactor: {color:?}" + ); + } + + let plain = load_gltf(&glb).expect("plain CPU loader accepts the same GLB"); + assert_eq!(plain.meshes[0].texture_idx, None); + assert!( + !plain.meshes[0].alpha_coverage_mips, + "CPU-only loading must not claim an unregistered coverage texture" + ); +} + +#[test] +fn mask_coverage_diagnostic_setting_defaults_on_and_accepts_false_spellings() { + assert!(mask_coverage_setting_enabled(None)); + assert!(mask_coverage_setting_enabled(Some("1"))); + assert!(mask_coverage_setting_enabled(Some("yes"))); + for value in ["0", "off", "FALSE", " disabled "] { + assert!(!mask_coverage_setting_enabled(Some(value)), "{value}"); + } +} + +#[test] +fn transmission_volume_ior_and_texture_transforms_are_preserved() { + let json = br#"{ + "asset":{"version":"2.0"}, + "extensionsUsed":[ + "KHR_materials_transmission", + "KHR_materials_volume", + "KHR_materials_ior", + "KHR_texture_transform" + ], + "images":[{"uri":"glass.png"}], + "textures":[{"source":0},{"source":0}], + "materials":[{ + "name":"Blue glass", + "extensions":{ + "KHR_materials_transmission":{ + "transmissionFactor":0.72, + "transmissionTexture":{ + "index":0, + "texCoord":1, + "extensions":{"KHR_texture_transform":{ + "offset":[0.1,0.2], + "rotation":0.3, + "scale":[0.4,0.5], + "texCoord":2 + }} + } + }, + "KHR_materials_ior":{"ior":1.33}, + "KHR_materials_volume":{ + "thicknessFactor":0.42, + "thicknessTexture":{"index":1,"texCoord":3}, + "attenuationDistance":3.5, + "attenuationColor":[0.2,0.4,0.8] + } + } + }] + }"#; + let document = gltf::Gltf::from_slice(json).expect("valid physical glTF material"); + let material = document.materials().next().unwrap(); + let physical = transmission_from_material(&material, Some(&[17])).unwrap(); + + assert!(physical.authored); + assert_eq!(physical.factor, 0.72); + let transmission_texture = physical.texture.unwrap(); + assert_eq!(transmission_texture.source_texture_index, 0); + assert_eq!(transmission_texture.source_image_index, 0); + assert_eq!(transmission_texture.runtime_texture_idx, Some(17)); + assert_eq!(transmission_texture.transform.offset, [0.1, 0.2]); + assert_eq!(transmission_texture.transform.rotation, 0.3); + assert_eq!(transmission_texture.transform.scale, [0.4, 0.5]); + assert_eq!(transmission_texture.transform.tex_coord, 2); + + assert!(physical.ior_authored); + assert_eq!(physical.ior, 1.33); + assert!(physical.volume_authored); + assert_eq!(physical.thickness_factor, 0.42); + assert_eq!(physical.thickness_source, MaterialThicknessSource::Authored); + let thickness_texture = physical.thickness_texture.unwrap(); + assert_eq!(thickness_texture.source_texture_index, 1); + assert_eq!(thickness_texture.source_image_index, 0); + assert_eq!(thickness_texture.runtime_texture_idx, Some(17)); + assert_eq!(thickness_texture.transform.tex_coord, 3); + assert_eq!(physical.attenuation_distance, 3.5); + assert_eq!(physical.attenuation_color, [0.2, 0.4, 0.8]); + + let cpu_only = transmission_from_material(&material, None).unwrap(); + assert_eq!( + cpu_only.texture.unwrap().runtime_texture_idx, + None, + "CPU-only loading must preserve the source binding without inventing a GPU handle" + ); +} + +#[test] +fn missing_physical_extensions_keep_spec_defaults_and_no_fake_thickness() { + let document = + gltf::Gltf::from_slice(br#"{"asset":{"version":"2.0"},"materials":[{"name":"ordinary"}]}"#) + .unwrap(); + let material = document.materials().next().unwrap(); + assert_eq!( + transmission_from_material(&material, None), + Ok(MaterialTransmission::default()) + ); + assert_eq!( + layered_pbr_from_material(&document, &material, None), + Ok(MaterialLayeredPbr::default()) + ); +} + +#[test] +fn clearcoat_specular_ior_and_texture_transforms_are_preserved() { + let json = br#"{ + "asset":{"version":"2.0"}, + "extensionsUsed":[ + "KHR_materials_clearcoat", + "KHR_materials_specular", + "KHR_materials_ior", + "KHR_texture_transform" + ], + "images":[{"uri":"layers.png"}], + "textures":[{"source":0},{"source":0},{"source":0},{"source":0},{"source":0}], + "materials":[{ + "name":"Car paint", + "extensions":{ + "KHR_materials_clearcoat":{ + "clearcoatFactor":0.8, + "clearcoatTexture":{"index":0}, + "clearcoatRoughnessFactor":0.22, + "clearcoatRoughnessTexture":{ + "index":1, + "texCoord":1, + "extensions":{"KHR_texture_transform":{ + "offset":[0.1,0.2], + "rotation":0.3, + "scale":[0.4,0.5], + "texCoord":2 + }} + }, + "clearcoatNormalTexture":{"index":2,"scale":0.65} + }, + "KHR_materials_specular":{ + "specularFactor":0.7, + "specularTexture":{"index":3,"texCoord":1}, + "specularColorFactor":[1.4,0.6,0.2], + "specularColorTexture":{"index":4} + }, + "KHR_materials_ior":{"ior":1.76} + } + }] + }"#; + let document = gltf::Gltf::from_slice(json).expect("valid layered glTF material"); + let material = document.materials().next().unwrap(); + let layered = layered_pbr_from_material(&document, &material, Some(&[17])).unwrap(); + + assert!(layered.clearcoat_authored); + assert_eq!(layered.clearcoat_factor, 0.8); + assert_eq!(layered.clearcoat_roughness_factor, 0.22); + assert_eq!( + layered.clearcoat_texture.unwrap().runtime_texture_idx, + Some(17) + ); + let roughness = layered.clearcoat_roughness_texture.unwrap(); + assert_eq!(roughness.source_texture_index, 1); + assert_eq!(roughness.transform.offset, [0.1, 0.2]); + assert_eq!(roughness.transform.rotation, 0.3); + assert_eq!(roughness.transform.scale, [0.4, 0.5]); + assert_eq!(roughness.transform.tex_coord, 2); + assert_eq!(layered.clearcoat_normal_scale, 0.65); + + assert!(layered.specular_authored); + assert_eq!(layered.specular_factor, 0.7); + assert_eq!(layered.specular_color_factor, [1.4, 0.6, 0.2]); + assert_eq!(layered.specular_texture.unwrap().transform.tex_coord, 1); + assert_eq!( + layered.specular_color_texture.unwrap().source_texture_index, + 4 + ); + assert!(layered.ior_authored); + assert_eq!(layered.ior, 1.76); + assert!(layered.has_clearcoat()); + assert!(layered.has_specular_ior()); + assert!(layered.requests_tex_coord(1)); + assert!(layered.requests_tex_coord(2)); + + let cpu_only = layered_pbr_from_material(&document, &material, None).unwrap(); + assert_eq!( + cpu_only + .clearcoat_roughness_texture + .unwrap() + .runtime_texture_idx, + None + ); +} + +#[test] +fn sheen_anisotropy_and_texture_transforms_are_preserved() { + let json = br#"{ + "asset":{"version":"2.0"}, + "extensionsUsed":[ + "KHR_materials_sheen", + "KHR_materials_anisotropy", + "KHR_texture_transform" + ], + "images":[{"uri":"fabric.png"}], + "textures":[{"source":0},{"source":0},{"source":0}], + "materials":[{ + "name":"Brushed velvet", + "extensions":{ + "KHR_materials_sheen":{ + "sheenColorFactor":[0.8,0.25,0.1], + "sheenColorTexture":{"index":0}, + "sheenRoughnessFactor":0.37, + "sheenRoughnessTexture":{ + "index":1, + "texCoord":1, + "extensions":{"KHR_texture_transform":{ + "offset":[0.15,0.2], + "rotation":0.4, + "scale":[0.5,0.75] + }} + } + }, + "KHR_materials_anisotropy":{ + "anisotropyStrength":0.72, + "anisotropyRotation":1.25, + "anisotropyTexture":{"index":2,"texCoord":1} + } + } + }] + }"#; + let document = gltf::Gltf::from_slice(json).expect("valid sheen/anisotropy material"); + let material = document.materials().next().unwrap(); + let layered = layered_pbr_from_material(&document, &material, Some(&[23])).unwrap(); + + assert!(layered.sheen_authored); + assert_eq!(layered.sheen_color_factor, [0.8, 0.25, 0.1]); + assert_eq!(layered.sheen_roughness_factor, 0.37); + assert_eq!( + layered.sheen_color_texture.unwrap().runtime_texture_idx, + Some(23) + ); + let roughness = layered.sheen_roughness_texture.unwrap(); + assert_eq!(roughness.transform.offset, [0.15, 0.2]); + assert_eq!(roughness.transform.rotation, 0.4); + assert_eq!(roughness.transform.scale, [0.5, 0.75]); + assert_eq!(roughness.transform.tex_coord, 1); + assert!(layered.has_sheen()); + + assert!(layered.anisotropy_authored); + assert_eq!(layered.anisotropy_strength, 0.72); + assert_eq!(layered.anisotropy_rotation, 1.25); + assert_eq!(layered.anisotropy_texture.unwrap().transform.tex_coord, 1); + assert!(layered.has_anisotropy()); + assert!(layered.requests_tex_coord(1)); + + let cpu_only = layered_pbr_from_material(&document, &material, None).unwrap(); + assert_eq!( + cpu_only.anisotropy_texture.unwrap().runtime_texture_idx, + None + ); +} + +#[test] +fn iridescence_parameters_channels_and_texture_transforms_are_preserved() { + let json = br#"{ + "asset":{"version":"2.0"}, + "extensionsUsed":["KHR_materials_iridescence","KHR_texture_transform"], + "images":[{"uri":"thin-film.png"}], + "textures":[{"source":0},{"source":0}], + "materials":[{ + "name":"Oil film", + "extensions":{"KHR_materials_iridescence":{ + "iridescenceFactor":0.82, + "iridescenceTexture":{ + "index":0, + "texCoord":1, + "extensions":{"KHR_texture_transform":{ + "offset":[0.1,0.2], + "rotation":0.3, + "scale":[0.4,0.5] + }} + }, + "iridescenceIor":1.42, + "iridescenceThicknessMinimum":620.0, + "iridescenceThicknessMaximum":180.0, + "iridescenceThicknessTexture":{"index":1,"texCoord":1} + }} + }] + }"#; + let document = gltf::Gltf::from_slice(json).expect("valid iridescence material"); + let material = document.materials().next().unwrap(); + let layered = layered_pbr_from_material(&document, &material, Some(&[29])).unwrap(); + + assert!(layered.iridescence_authored); + assert_eq!(layered.iridescence_factor, 0.82); + assert_eq!(layered.iridescence_ior, 1.42); + assert_eq!(layered.iridescence_thickness_minimum, 620.0); + assert_eq!(layered.iridescence_thickness_maximum, 180.0); + let factor = layered.iridescence_texture.unwrap(); + assert_eq!(factor.runtime_texture_idx, Some(29)); + assert_eq!(factor.transform.offset, [0.1, 0.2]); + assert_eq!(factor.transform.rotation, 0.3); + assert_eq!(factor.transform.scale, [0.4, 0.5]); + assert_eq!(factor.transform.tex_coord, 1); + assert_eq!( + layered + .iridescence_thickness_texture + .unwrap() + .transform + .tex_coord, + 1 + ); + assert!(layered.has_iridescence()); + assert!(layered.is_active()); + assert!(layered.requests_tex_coord(1)); + + let cpu_only = layered_pbr_from_material(&document, &material, None).unwrap(); + assert_eq!( + cpu_only + .iridescence_thickness_texture + .unwrap() + .runtime_texture_idx, + None + ); +} + +#[test] +fn clearcoat_normal_images_use_the_normal_map_upload_path() { + let document = gltf::Gltf::from_slice( + br#"{ + "asset":{"version":"2.0"}, + "extensionsUsed":["KHR_materials_clearcoat"], + "images":[{"uri":"color.png"},{"uri":"coat-normal.png"}], + "textures":[{"source":0},{"source":1}], + "materials":[{ + "extensions":{"KHR_materials_clearcoat":{ + "clearcoatNormalTexture":{"index":1} + }} + }] + }"#, + ) + .unwrap(); + let mut normal_images = std::collections::HashSet::new(); + retain_layered_normal_image_indices(&document, &mut normal_images); + assert_eq!(normal_images, std::collections::HashSet::from([1])); +} + +#[test] +fn invalid_physical_extension_ranges_are_rejected_by_the_importer() { + let cases = [ + ( + r#""KHR_materials_transmission":{"transmissionFactor":1.5}"#, + "transmissionFactor", + ), + (r#""KHR_materials_ior":{"ior":0.8}"#, "ior"), + ( + r#""KHR_materials_volume":{"thicknessFactor":-0.1}"#, + "thicknessFactor", + ), + ( + r#""KHR_materials_volume":{"attenuationDistance":0.0}"#, + "attenuationDistance", + ), + ( + r#""KHR_materials_volume":{"attenuationColor":[1.2,0.5,0.5]}"#, + "attenuationColor", + ), + ]; + for (extension, expected) in cases { + let invalid = format!( + r#"{{ + "asset":{{"version":"2.0"}}, + "materials":[{{"name":"Bad glass","extensions":{{{extension}}}}}] + }}"# + ); + let document = gltf::Gltf::from_slice(invalid.as_bytes()).unwrap(); + let material = document.materials().next().unwrap(); + let error = transmission_from_material(&material, None).unwrap_err(); + assert!(error.contains("material \"Bad glass\""), "{error}"); + assert!(error.contains(expected), "{error}"); + } +} + +#[test] +fn invalid_layered_extension_ranges_are_rejected_and_ior_zero_is_preserved() { + let cases = [ + ( + r#""KHR_materials_clearcoat":{"clearcoatFactor":1.2}"#, + "clearcoatFactor", + ), + ( + r#""KHR_materials_clearcoat":{"clearcoatRoughnessFactor":-0.1}"#, + "clearcoatRoughnessFactor", + ), + ( + r#""KHR_materials_specular":{"specularFactor":-0.1}"#, + "specularFactor", + ), + ( + r#""KHR_materials_specular":{"specularColorFactor":[1.0,-0.1,1.0]}"#, + "specularColorFactor", + ), + ( + r#""KHR_materials_sheen":{"sheenColorFactor":[1.0,-0.1,1.0]}"#, + "sheenColorFactor", + ), + ( + r#""KHR_materials_sheen":{"sheenRoughnessFactor":1.1}"#, + "sheenRoughnessFactor", + ), + ( + r#""KHR_materials_anisotropy":{"anisotropyStrength":-0.1}"#, + "anisotropyStrength", + ), + ( + r#""KHR_materials_iridescence":{"iridescenceFactor":1.1}"#, + "iridescenceFactor", + ), + ( + r#""KHR_materials_iridescence":{"iridescenceIor":0.9}"#, + "iridescenceIor", + ), + ( + r#""KHR_materials_iridescence":{"iridescenceThicknessMinimum":-1.0}"#, + "iridescenceThicknessMinimum", + ), + ( + r#""KHR_materials_iridescence":{"iridescenceThicknessMaximum":-1.0}"#, + "iridescenceThicknessMaximum", + ), + ]; + for (extension, expected) in cases { + let invalid = format!( + r#"{{ + "asset":{{"version":"2.0"}}, + "materials":[{{"name":"Bad layer","extensions":{{{extension}}}}}] + }}"# + ); + let document = gltf::Gltf::from_slice(invalid.as_bytes()).unwrap(); + let material = document.materials().next().unwrap(); + let error = layered_pbr_from_material(&document, &material, None).unwrap_err(); + assert!(error.contains("material \"Bad layer\""), "{error}"); + assert!(error.contains(expected), "{error}"); + } + + let compatibility = gltf::Gltf::from_slice( + br#"{ + "asset":{"version":"2.0"}, + "materials":[{ + "extensions":{"KHR_materials_ior":{"ior":0.0}} + }] + }"#, + ) + .unwrap(); + let material = compatibility.materials().next().unwrap(); + assert_eq!( + layered_pbr_from_material(&compatibility, &material, None) + .unwrap() + .ior, + 0.0 + ); + assert_eq!( + transmission_from_material(&material, None).unwrap().ior, + 0.0 + ); +} + +#[test] +fn unsupported_material_extensions_name_the_asset_and_material() { + let source = br#"{ + "asset":{"version":"2.0"}, + "extensionsUsed":["VENDOR_materials_velvet"], + "materials":[{ + "name":"Velvet", + "extensions":{"VENDOR_materials_velvet":{"roughnessFactor":0.8}} + }] + }"#; + let document = gltf::Gltf::from_slice(source).unwrap(); + let diagnostics = unsupported_material_extension_diagnostics(&document, "vehicles/coupe.glb"); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].contains("vehicles/coupe.glb")); + assert!(diagnostics[0].contains("Velvet")); + assert!(diagnostics[0].contains("VENDOR_materials_velvet")); + assert!(diagnostics[0].contains("ignored")); +} + +#[test] +fn physical_metadata_round_trips_through_plain_and_staged_glb_loaders() { + let glb = minimal_triangle_glb( + r#"{ + "name":"Window", + "alphaMode":"BLEND", + "doubleSided":true, + "extensions":{ + "KHR_materials_transmission":{"transmissionFactor":0.8}, + "KHR_materials_ior":{"ior":1.45}, + "KHR_materials_specular":{ + "specularFactor":0.65, + "specularColorFactor":[1.2,0.8,0.4] + }, + "KHR_materials_clearcoat":{ + "clearcoatFactor":0.75, + "clearcoatRoughnessFactor":0.18 + }, + "KHR_materials_sheen":{ + "sheenColorFactor":[0.7,0.2,0.1], + "sheenRoughnessFactor":0.4 + }, + "KHR_materials_anisotropy":{ + "anisotropyStrength":0.6, + "anisotropyRotation":0.75 + }, + "KHR_materials_volume":{ + "thicknessFactor":0.25, + "attenuationDistance":2.0, + "attenuationColor":[0.8,0.9,1.0] + } + } + }"#, + ); + let plain = load_gltf(&glb).expect("plain GLB load"); + let staged = load_gltf_staged(&glb).expect("staged GLB load").model; + for (label, model) in [("plain", plain), ("staged", staged)] { + assert_eq!(model.meshes.len(), 1, "{label}"); + let mesh = &model.meshes[0]; + assert_eq!(mesh.alpha_mode, MaterialAlphaMode::Blend, "{label}"); + assert!(mesh.double_sided, "{label}"); + assert_eq!(mesh.alpha_cutoff, 0.0, "{label}"); + assert!(mesh.transmission.authored, "{label}"); + assert_eq!(mesh.transmission.factor, 0.8, "{label}"); + assert!(mesh.transmission.ior_authored, "{label}"); + assert_eq!(mesh.transmission.ior, 1.45, "{label}"); + assert!(mesh.layered_pbr.clearcoat_authored, "{label}"); + assert_eq!(mesh.layered_pbr.clearcoat_factor, 0.75, "{label}"); + assert_eq!(mesh.layered_pbr.clearcoat_roughness_factor, 0.18, "{label}"); + assert!(mesh.layered_pbr.specular_authored, "{label}"); + assert_eq!(mesh.layered_pbr.specular_factor, 0.65, "{label}"); + assert_eq!( + mesh.layered_pbr.specular_color_factor, + [1.2, 0.8, 0.4], + "{label}" + ); + assert!(mesh.layered_pbr.ior_authored, "{label}"); + assert_eq!(mesh.layered_pbr.ior, 1.45, "{label}"); + assert!(mesh.layered_pbr.sheen_authored, "{label}"); + assert_eq!( + mesh.layered_pbr.sheen_color_factor, + [0.7, 0.2, 0.1], + "{label}" + ); + assert_eq!(mesh.layered_pbr.sheen_roughness_factor, 0.4, "{label}"); + assert!(mesh.layered_pbr.anisotropy_authored, "{label}"); + assert_eq!(mesh.layered_pbr.anisotropy_strength, 0.6, "{label}"); + assert_eq!(mesh.layered_pbr.anisotropy_rotation, 0.75, "{label}"); + assert!( + mesh.vertices + .iter() + .all(|vertex| vertex.tangent == [1.0, 0.0, 0.0, -1.0]), + "{label}: authored mirrored tangents were not preserved" + ); + assert!(mesh.transmission.volume_authored, "{label}"); + assert_eq!(mesh.transmission.thickness_factor, 0.25, "{label}"); + assert_eq!( + mesh.transmission.thickness_source, + MaterialThicknessSource::Authored, + "{label}" + ); + assert_eq!(mesh.transmission.attenuation_distance, 2.0, "{label}"); + assert_eq!( + mesh.transmission.attenuation_color, + [0.8, 0.9, 1.0], + "{label}" + ); + } +} + +#[test] +fn physical_texcoord_1_is_retained_lazily_and_exactly() { + let uv1_glb = physical_uv_triangle_glb(1, true); + for (label, model) in [ + ("plain", load_gltf(&uv1_glb).expect("plain UV1 GLB load")), + ( + "staged", + load_gltf_staged(&uv1_glb) + .expect("staged UV1 GLB load") + .model, + ), + ] { + assert_eq!( + model.meshes[0].secondary_tex_coords.as_deref(), + Some(&[[0.2, 0.3], [0.8, 0.3], [0.2, 0.9]][..]), + "{label} loader must preserve authored TEXCOORD_1 exactly" + ); + assert_eq!( + model.meshes[0].vertices[1].uv, + [1.0, 0.0], + "{label} loader must leave the established UV0 vertex ABI intact" + ); + } + + let uv0_glb = physical_uv_triangle_glb(0, true); + for model in [ + load_gltf(&uv0_glb).expect("plain UV0 GLB load"), + load_gltf_staged(&uv0_glb) + .expect("staged UV0 GLB load") + .model, + ] { + assert!( + model.meshes[0].secondary_tex_coords.is_none(), + "an unreferenced TEXCOORD_1 accessor must add no CPU/GPU footprint" + ); + } +} + +#[test] +fn missing_physical_texcoord_1_preserves_material_and_uses_scalar_fallback() { + let glb = physical_uv_triangle_glb(1, false); + for model in [ + load_gltf(&glb).expect("plain missing-UV1 GLB load"), + load_gltf_staged(&glb) + .expect("staged missing-UV1 GLB load") + .model, + ] { + let mesh = &model.meshes[0]; + assert!(mesh.transmission.is_active()); + assert_eq!( + mesh.transmission.texture.unwrap().transform.tex_coord, + 1, + "source metadata must remain lossless for diagnostics/re-export" + ); + assert!( + mesh.secondary_tex_coords.is_none(), + "missing UV1 must never synthesize or silently substitute UV0" + ); + } +} diff --git a/native/shared/src/models_gltf_transform.rs b/native/shared/src/models_gltf_transform.rs new file mode 100644 index 00000000..77b64a36 --- /dev/null +++ b/native/shared/src/models_gltf_transform.rs @@ -0,0 +1,144 @@ +//! Column-major glTF node-transform helpers. +//! +//! Keeping point, direction, and normal transforms distinct is load-bearing: +//! points use the full affine matrix, tangents use its linear part, and normals +//! use the inverse transpose of that linear part. + +/// Transform a 3D point by a column-major 4x4 matrix (w = 1). +pub(super) fn mat4_transform_point(m: &[[f32; 4]; 4], p: &[f32; 3]) -> [f32; 3] { + [ + m[0][0] * p[0] + m[1][0] * p[1] + m[2][0] * p[2] + m[3][0], + m[0][1] * p[0] + m[1][1] * p[1] + m[2][1] * p[2] + m[3][1], + m[0][2] * p[0] + m[1][2] * p[1] + m[2][2] * p[2] + m[3][2], + ] +} + +/// Transform a tangent/direction by the linear part of a column-major 4x4. +pub(super) fn mat4_transform_direction(m: &[[f32; 4]; 4], v: &[f32; 3]) -> [f32; 3] { + [ + m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2], + m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2], + m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2], + ] +} + +/// Transform a direction by a column-major 3x3 matrix. +pub(super) fn mat3_transform_vec(m: &[[f32; 3]; 3], v: &[f32; 3]) -> [f32; 3] { + [ + m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2], + m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2], + m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2], + ] +} + +/// Inverse transpose of the 3x3 rotation/scale part of a column-major 4x4. +/// +/// A singular transform has no well-defined normal matrix. Identity is the +/// conservative fallback: it retains authored normals instead of introducing +/// infinities or NaNs. +pub(super) fn mat4_inverse_transpose_3x3(m: &[[f32; 4]; 4]) -> [[f32; 3]; 3] { + // Row-major names for the linear block while the source remains + // column-major: A = [[a,b,c], [d,e,f], [g,h,i]]. + let a = m[0][0]; + let b = m[1][0]; + let c = m[2][0]; + let d = m[0][1]; + let e = m[1][1]; + let f = m[2][1]; + let g = m[0][2]; + let h = m[1][2]; + let i = m[2][2]; + + // Adjugate entries grouped as columns of A^-1. + let inv00 = e * i - f * h; + let inv10 = f * g - d * i; + let inv20 = d * h - e * g; + let inv01 = c * h - b * i; + let inv11 = a * i - c * g; + let inv21 = b * g - a * h; + let inv02 = b * f - c * e; + let inv12 = c * d - a * f; + let inv22 = a * e - b * d; + + let det = a * inv00 + b * inv10 + c * inv20; + if det.abs() < 1e-10 { + return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + } + let inv_det = 1.0 / det; + + // Columns of (A^-1)^T are rows of A^-1. The previous implementation + // returned the columns of A^-1 itself, applying the opposite rotation to + // every normal on a rotated glTF node. + [ + [inv00 * inv_det, inv01 * inv_det, inv02 * inv_det], + [inv10 * inv_det, inv11 * inv_det, inv12 * inv_det], + [inv20 * inv_det, inv21 * inv_det, inv22 * inv_det], + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_vec3_close(actual: [f32; 3], expected: [f32; 3]) { + for channel in 0..3 { + assert!( + (actual[channel] - expected[channel]).abs() < 1e-6, + "channel {channel}: actual={actual:?}, expected={expected:?}" + ); + } + } + + #[test] + fn pure_rotation_moves_directions_and_normals_the_same_way() { + // +90 degrees around X, column-major. + let matrix = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, -1.0, 0.0, 0.0], + [3.0, 4.0, 5.0, 1.0], + ]; + let direction = mat4_transform_direction(&matrix, &[0.0, 1.0, 0.0]); + let normal_matrix = mat4_inverse_transpose_3x3(&matrix); + let normal = mat3_transform_vec(&normal_matrix, &[0.0, 1.0, 0.0]); + assert_vec3_close(direction, [0.0, 0.0, 1.0]); + assert_vec3_close(normal, direction); + assert_vec3_close( + mat4_transform_point(&matrix, &[0.0, 1.0, 0.0]), + [3.0, 4.0, 6.0], + ); + } + + #[test] + fn non_uniform_scale_uses_distinct_tangent_and_normal_matrices() { + // +90 degrees around Z after scale (2, 3, 4), column-major. + let matrix = [ + [0.0, 2.0, 0.0, 0.0], + [-3.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 4.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + assert_vec3_close( + mat4_transform_direction(&matrix, &[1.0, 0.0, 0.0]), + [0.0, 2.0, 0.0], + ); + assert_vec3_close( + mat3_transform_vec(&mat4_inverse_transpose_3x3(&matrix), &[1.0, 0.0, 0.0]), + [0.0, 0.5, 0.0], + ); + } + + #[test] + fn singular_normal_matrix_falls_back_without_non_finite_values() { + let singular = [ + [0.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + assert_eq!( + mat4_inverse_transpose_3x3(&singular), + [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + ); + } +} diff --git a/native/shared/src/particles.rs b/native/shared/src/particles.rs index bd89607a..a170111d 100644 --- a/native/shared/src/particles.rs +++ b/native/shared/src/particles.rs @@ -60,19 +60,26 @@ pub struct ParticleConfig { impl Default for ParticleConfig { fn default() -> Self { Self { - life: 1.0, life_var: 0.0, - speed: 1.0, speed_var: 0.0, + life: 1.0, + life_var: 0.0, + speed: 1.0, + speed_var: 0.0, spread: 0.3, gravity: -9.81, drag: 0.0, - size0: 0.2, size1: 0.2, size_var: 0.0, - color0: [1.0; 4], color1: [1.0, 1.0, 1.0, 0.0], - spin: 0.0, spin_var: 0.0, + size0: 0.2, + size1: 0.2, + size_var: 0.0, + color0: [1.0; 4], + color1: [1.0, 1.0, 1.0, 0.0], + spin: 0.0, + spin_var: 0.0, pos_jitter: 0.0, stretch: 0.0, inherit: 0.0, frames: 1.0, - floor_y: 0.0, restitution: 0.0, + floor_y: 0.0, + restitution: 0.0, } } } @@ -87,11 +94,18 @@ pub struct ParticleSystem { /// GPU instance buffer handle (dynamic, capacity-sized). pub instance_buffer: u32, - px: Vec, py: Vec, pz: Vec, - vx: Vec, vy: Vec, vz: Vec, - age: Vec, life: Vec, - rot: Vec, spin: Vec, - size: Vec, seed: Vec, + px: Vec, + py: Vec, + pz: Vec, + vx: Vec, + vy: Vec, + vz: Vec, + age: Vec, + life: Vec, + rot: Vec, + spin: Vec, + size: Vec, + seed: Vec, /// Packed 12-float-per-instance staging buffer, reused every frame. packed: Vec, @@ -106,11 +120,18 @@ impl ParticleSystem { live: 0, cfg: ParticleConfig::default(), instance_buffer, - px: z(), py: z(), pz: z(), - vx: z(), vy: z(), vz: z(), - age: z(), life: z(), - rot: z(), spin: z(), - size: z(), seed: z(), + px: z(), + py: z(), + pz: z(), + vx: z(), + vy: z(), + vz: z(), + age: z(), + life: z(), + rot: z(), + spin: z(), + size: z(), + seed: z(), packed: vec![0.0; capacity * 12], rng: 0x9E3779B9, } @@ -130,20 +151,27 @@ impl ParticleSystem { /// Symmetric ±1 noise. #[inline] - fn rand_s(&mut self) -> f32 { self.rand() * 2.0 - 1.0 } + fn rand_s(&mut self) -> f32 { + self.rand() * 2.0 - 1.0 + } pub fn configure_from_slice(&mut self, p: &[f32]) { let g = |i: usize| -> f32 { p.get(i).copied().unwrap_or(0.0) }; self.cfg = ParticleConfig { - life: g(0).max(0.01), life_var: g(1), - speed: g(2), speed_var: g(3), + life: g(0).max(0.01), + life_var: g(1), + speed: g(2), + speed_var: g(3), spread: g(4), gravity: g(5), drag: g(6), - size0: g(7), size1: g(8), size_var: g(9), + size0: g(7), + size1: g(8), + size_var: g(9), color0: [g(10), g(11), g(12), g(13)], color1: [g(14), g(15), g(16), g(17)], - spin: g(18), spin_var: g(19), + spin: g(18), + spin_var: g(19), pos_jitter: g(20), stretch: g(21), inherit: g(22), @@ -166,11 +194,19 @@ impl ParticleSystem { [0.0, 1.0, 0.0] }; // Basis around the emit direction, for the cone sample. - let up = if base[1].abs() > 0.99 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] }; + let up = if base[1].abs() > 0.99 { + [1.0, 0.0, 0.0] + } else { + [0.0, 1.0, 0.0] + }; let right = normalize(cross(up, base)); let realup = cross(base, right); - let spread = if dlen > 1e-5 { c.spread } else { std::f32::consts::PI }; + let spread = if dlen > 1e-5 { + c.spread + } else { + std::f32::consts::PI + }; for _ in 0..count { if self.live >= self.capacity { @@ -218,11 +254,18 @@ impl ParticleSystem { fn kill(&mut self, i: usize) { let last = self.live - 1; if i != last { - self.px.swap(i, last); self.py.swap(i, last); self.pz.swap(i, last); - self.vx.swap(i, last); self.vy.swap(i, last); self.vz.swap(i, last); - self.age.swap(i, last); self.life.swap(i, last); - self.rot.swap(i, last); self.spin.swap(i, last); - self.size.swap(i, last); self.seed.swap(i, last); + self.px.swap(i, last); + self.py.swap(i, last); + self.pz.swap(i, last); + self.vx.swap(i, last); + self.vy.swap(i, last); + self.vz.swap(i, last); + self.age.swap(i, last); + self.life.swap(i, last); + self.rot.swap(i, last); + self.spin.swap(i, last); + self.size.swap(i, last); + self.seed.swap(i, last); } self.live = last; } @@ -243,7 +286,9 @@ impl ParticleSystem { self.vy[i] += c.gravity * dt; if c.drag > 0.0 { let k = (1.0 - c.drag * dt).max(0.0); - self.vx[i] *= k; self.vy[i] *= k; self.vz[i] *= k; + self.vx[i] *= k; + self.vy[i] *= k; + self.vz[i] *= k; } self.px[i] += self.vx[i] * dt; self.py[i] += self.vy[i] * dt; @@ -255,7 +300,9 @@ impl ParticleSystem { // Kill the horizontal skid too, or shells slide forever. self.vx[i] *= 0.6; self.vz[i] *= 0.6; - if self.vy[i].abs() < 0.4 { self.vy[i] = 0.0; } + if self.vy[i].abs() < 0.4 { + self.vy[i] = 0.0; + } } self.rot[i] += self.spin[i] * dt; @@ -275,43 +322,66 @@ impl ParticleSystem { ]; let frame = if c.frames > 1.0 { (t * c.frames).floor().min(c.frames - 1.0) - } else { 0.0 }; + } else { + 0.0 + }; // Velocity-stretch length in metres — the shader elongates the // quad along the projected velocity by this much. let stretch = if c.stretch > 0.0 { - let v = (self.vx[i] * self.vx[i] + self.vy[i] * self.vy[i] + self.vz[i] * self.vz[i]).sqrt(); + let v = + (self.vx[i] * self.vx[i] + self.vy[i] * self.vy[i] + self.vz[i] * self.vz[i]) + .sqrt(); v * c.stretch - } else { 0.0 }; + } else { + 0.0 + }; let o = i * 12; - self.packed[o] = self.px[i]; - self.packed[o + 1] = self.py[i]; - self.packed[o + 2] = self.pz[i]; - self.packed[o + 3] = self.rot[i]; - self.packed[o + 4] = size; - self.packed[o + 5] = col[0]; - self.packed[o + 6] = col[1]; - self.packed[o + 7] = col[2]; - self.packed[o + 8] = col[3]; - self.packed[o + 9] = t; // extra.x — normalized age - self.packed[o + 10] = frame; // extra.y — atlas frame - self.packed[o + 11] = stretch; // extra.z — stretch metres + self.packed[o] = self.px[i]; + self.packed[o + 1] = self.py[i]; + self.packed[o + 2] = self.pz[i]; + self.packed[o + 3] = self.rot[i]; + self.packed[o + 4] = size; + self.packed[o + 5] = col[0]; + self.packed[o + 6] = col[1]; + self.packed[o + 7] = col[2]; + self.packed[o + 8] = col[3]; + self.packed[o + 9] = t; // extra.x — normalized age + self.packed[o + 10] = frame; // extra.y — atlas frame + self.packed[o + 11] = stretch; // extra.z — stretch metres } self.live as u32 } - pub fn packed(&self) -> &[f32] { &self.packed } + pub fn packed(&self) -> &[f32] { + &self.packed + } - pub fn clear(&mut self) { self.live = 0; } + pub fn clear(&mut self) { + self.live = 0; + } } -#[inline] fn lerp(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t } -#[inline] fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { - [a[1]*b[2] - a[2]*b[1], a[2]*b[0] - a[0]*b[2], a[0]*b[1] - a[1]*b[0]] +#[inline] +fn lerp(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t } -#[inline] fn normalize(v: [f32; 3]) -> [f32; 3] { - let l = (v[0]*v[0] + v[1]*v[1] + v[2]*v[2]).sqrt(); - if l > 1e-6 { [v[0]/l, v[1]/l, v[2]/l] } else { [1.0, 0.0, 0.0] } +#[inline] +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} +#[inline] +fn normalize(v: [f32; 3]) -> [f32; 3] { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + if l > 1e-6 { + [v[0] / l, v[1] / l, v[2] / l] + } else { + [1.0, 0.0, 0.0] + } } /// Registry of all systems. Handles are 1-based; 0 is "no system". @@ -320,28 +390,39 @@ pub struct ParticleManager { } impl ParticleManager { - pub fn new() -> Self { Self { systems: Vec::new() } } + pub fn new() -> Self { + Self { + systems: Vec::new(), + } + } pub fn create(&mut self, capacity: usize, instance_buffer: u32) -> u32 { - self.systems.push(Some(ParticleSystem::new(capacity, instance_buffer))); + self.systems + .push(Some(ParticleSystem::new(capacity, instance_buffer))); self.systems.len() as u32 } pub fn get_mut(&mut self, handle: u32) -> Option<&mut ParticleSystem> { - if handle == 0 { return None; } + if handle == 0 { + return None; + } self.systems.get_mut(handle as usize - 1)?.as_mut() } } impl Default for ParticleManager { - fn default() -> Self { Self::new() } + fn default() -> Self { + Self::new() + } } #[cfg(test)] mod tests { use super::*; - fn sys() -> ParticleSystem { ParticleSystem::new(64, 1) } + fn sys() -> ParticleSystem { + ParticleSystem::new(64, 1) + } #[test] fn emit_then_expire() { @@ -352,7 +433,9 @@ mod tests { assert_eq!(s.update(0.1), 10); // Past the 0.5 s lifetime everything must be reclaimed — the swap-remove // loop has to re-test the swapped-in particle or it leaks half the pool. - for _ in 0..10 { s.update(0.1); } + for _ in 0..10 { + s.update(0.1); + } assert_eq!(s.live, 0); } @@ -379,13 +462,19 @@ mod tests { fn floor_bounce_reverses_velocity() { let mut s = sys(); let mut p = vec![0.0f32; 26]; - p[0] = 10.0; // life - p[5] = -10.0; // gravity - p[24] = 0.0; // floor_y - p[25] = 0.5; // restitution + p[0] = 10.0; // life + p[5] = -10.0; // gravity + p[24] = 0.0; // floor_y + p[25] = 0.5; // restitution s.configure_from_slice(&p); s.emit([0.0, 0.1, 0.0], [0.0, -1.0, 0.0], 1); - for _ in 0..20 { s.update(0.016); } - assert!(s.py[0] >= 0.0, "particle sank through the floor: y={}", s.py[0]); + for _ in 0..20 { + s.update(0.016); + } + assert!( + s.py[0] >= 0.0, + "particle sank through the floor: y={}", + s.py[0] + ); } } diff --git a/native/shared/src/physics_jolt.rs b/native/shared/src/physics_jolt.rs index 1b2258d9..076bbc82 100644 --- a/native/shared/src/physics_jolt.rs +++ b/native/shared/src/physics_jolt.rs @@ -132,7 +132,9 @@ impl Default for WorldStepState { const MAX_FRAME_DT: f32 = 0.25; impl Default for JoltPhysics { - fn default() -> Self { Self::new() } + fn default() -> Self { + Self::new() + } } impl JoltPhysics { @@ -161,46 +163,78 @@ impl JoltPhysics { self.scratch_f32.clear(); self.scratch_u32.clear(); } - pub fn scratch_push_f32(&mut self, v: f32) { self.scratch_f32.push(v); } - pub fn scratch_push_u32(&mut self, v: u32) { self.scratch_u32.push(v); } + pub fn scratch_push_f32(&mut self, v: f32) { + self.scratch_f32.push(v); + } + pub fn scratch_push_u32(&mut self, v: u32) { + self.scratch_u32.push(v); + } pub fn shape_convex_hull_from_scratch(&mut self, num_points: u32, convex_radius: f32) -> f64 { let need = (num_points * 3) as usize; - if self.scratch_f32.len() < need || num_points < 3 { return 0.0; } - let points: Vec = (0..num_points as usize).map(|i| BjVec3 { - x: self.scratch_f32[i * 3], - y: self.scratch_f32[i * 3 + 1], - z: self.scratch_f32[i * 3 + 2], - }).collect(); + if self.scratch_f32.len() < need || num_points < 3 { + return 0.0; + } + let points: Vec = (0..num_points as usize) + .map(|i| BjVec3 { + x: self.scratch_f32[i * 3], + y: self.scratch_f32[i * 3 + 1], + z: self.scratch_f32[i * 3 + 2], + }) + .collect(); self.create_convex_hull_shape(&points, convex_radius) } pub fn shape_mesh_from_scratch(&mut self, vertex_count: u32, triangle_count: u32) -> f64 { let need_f = (vertex_count * 3) as usize; let need_u = (triangle_count * 3) as usize; - if self.scratch_f32.len() < need_f || self.scratch_u32.len() < need_u { return 0.0; } - if vertex_count == 0 || triangle_count == 0 { return 0.0; } - let vertices: Vec = (0..vertex_count as usize).map(|i| BjVec3 { - x: self.scratch_f32[i * 3], - y: self.scratch_f32[i * 3 + 1], - z: self.scratch_f32[i * 3 + 2], - }).collect(); + if self.scratch_f32.len() < need_f || self.scratch_u32.len() < need_u { + return 0.0; + } + if vertex_count == 0 || triangle_count == 0 { + return 0.0; + } + let vertices: Vec = (0..vertex_count as usize) + .map(|i| BjVec3 { + x: self.scratch_f32[i * 3], + y: self.scratch_f32[i * 3 + 1], + z: self.scratch_f32[i * 3 + 2], + }) + .collect(); let indices: Vec = self.scratch_u32[..need_u].to_vec(); self.create_mesh_shape(&vertices, &indices) } #[allow(clippy::too_many_arguments)] pub fn shape_heightfield_from_scratch( - &mut self, sample_count: u32, - ox: f32, oy: f32, oz: f32, sx: f32, sy: f32, sz: f32, block_size: u32, + &mut self, + sample_count: u32, + ox: f32, + oy: f32, + oz: f32, + sx: f32, + sy: f32, + sz: f32, + block_size: u32, ) -> f64 { let need = (sample_count * sample_count) as usize; - if self.scratch_f32.len() < need || sample_count < 2 { return 0.0; } + if self.scratch_f32.len() < need || sample_count < 2 { + return 0.0; + } let samples = self.scratch_f32[..need].to_vec(); self.create_heightfield_shape( - &samples, sample_count, - BjVec3 { x: ox, y: oy, z: oz }, - BjVec3 { x: sx, y: sy, z: sz }, + &samples, + sample_count, + BjVec3 { + x: ox, + y: oy, + z: oz, + }, + BjVec3 { + x: sx, + y: sy, + z: sz, + }, block_size, ) } @@ -209,44 +243,77 @@ impl JoltPhysics { self.compound_children.clear(); } #[allow(clippy::too_many_arguments)] - pub fn compound_add_child(&mut self, + pub fn compound_add_child( + &mut self, shape_h: f64, - px: f32, py: f32, pz: f32, - rx: f32, ry: f32, rz: f32, rw: f32, + px: f32, + py: f32, + pz: f32, + rx: f32, + ry: f32, + rz: f32, + rw: f32, ) { if let Some(&shape) = self.shapes.get(shape_h) { - self.compound_children.push((shape, BjTransform { - position: BjVec3 { x: px, y: py, z: pz }, - rotation: BjQuat { x: rx, y: ry, z: rz, w: rw }, - })); + self.compound_children.push(( + shape, + BjTransform { + position: BjVec3 { + x: px, + y: py, + z: pz, + }, + rotation: BjQuat { + x: rx, + y: ry, + z: rz, + w: rw, + }, + }, + )); } } pub fn compound_end(&mut self) -> f64 { - if self.compound_children.is_empty() { return 0.0; } + if self.compound_children.is_empty() { + return 0.0; + } let shapes: Vec = self.compound_children.iter().map(|(s, _)| *s).collect(); let xforms: Vec = self.compound_children.iter().map(|(_, x)| *x).collect(); let s = unsafe { bj_shape_compound_static(shapes.as_ptr(), xforms.as_ptr(), shapes.len() as u32) }; self.compound_children.clear(); - if s == BJ_INVALID { 0.0 } else { self.shapes.alloc(s) } + if s == BJ_INVALID { + 0.0 + } else { + self.shapes.alloc(s) + } } // -------------------------------------------------------- World pub fn create_world( &mut self, - gx: f32, gy: f32, gz: f32, - max_bodies: u32, num_threads: u32, + gx: f32, + gy: f32, + gz: f32, + max_bodies: u32, + num_threads: u32, ) -> f64 { let desc = BjWorldDesc { - gravity: BjVec3 { x: gx, y: gy, z: gz }, + gravity: BjVec3 { + x: gx, + y: gy, + z: gz, + }, max_bodies: if max_bodies > 0 { max_bodies } else { 65536 }, num_threads, ..Default::default() }; let world = unsafe { bj_world_create(&desc) }; - if world == BJ_INVALID { return 0.0; } + if world == BJ_INVALID { + return 0.0; + } self.worlds.alloc(world) } @@ -254,53 +321,76 @@ impl JoltPhysics { if let Some(world) = self.worlds.free(handle) { // Destroy bodies and constraints tied to this world first. // (Iterate and collect to avoid borrow conflicts.) - let body_handles: Vec = self.bodies.iter() + let body_handles: Vec = self + .bodies + .iter() .filter(|(_, (w, _))| *w == world) .map(|(h, _)| h) .collect(); for h in body_handles { if let Some((w, b)) = self.bodies.free(h) { - unsafe { bj_body_destroy(w, b); } + unsafe { + bj_body_destroy(w, b); + } } } - let c_handles: Vec = self.constraints.iter() + let c_handles: Vec = self + .constraints + .iter() .filter(|(_, (w, _))| *w == world) .map(|(h, _)| h) .collect(); for h in c_handles { if let Some((w, c)) = self.constraints.free(h) { - unsafe { bj_constraint_destroy(w, c); } + unsafe { + bj_constraint_destroy(w, c); + } } } self.step_states.remove(&world); - unsafe { bj_world_destroy(world); } + unsafe { + bj_world_destroy(world); + } } } pub fn set_gravity(&self, world_h: f64, x: f32, y: f32, z: f32) { if let Some(&world) = self.worlds.get(world_h) { - unsafe { bj_world_set_gravity(world, BjVec3 { x, y, z }); } + unsafe { + bj_world_set_gravity(world, BjVec3 { x, y, z }); + } } } pub fn get_gravity_axis(&self, world_h: f64, axis: u32) -> f64 { if let Some(&world) = self.worlds.get(world_h) { let mut g = BjVec3::default(); - unsafe { bj_world_get_gravity(world, &mut g); } - return match axis { 0 => g.x, 1 => g.y, 2 => g.z, _ => 0.0 } as f64; + unsafe { + bj_world_get_gravity(world, &mut g); + } + return match axis { + 0 => g.x, + 1 => g.y, + 2 => g.z, + _ => 0.0, + } as f64; } 0.0 } pub fn optimize_broadphase(&self, world_h: f64) { if let Some(&world) = self.worlds.get(world_h) { - unsafe { bj_world_optimize_broadphase(world); } + unsafe { + bj_world_optimize_broadphase(world); + } } } pub fn step(&mut self, world_h: f64, dt: f32, collision_steps: u32) { if let Some(&world) = self.worlds.get(world_h) { - unsafe { bj_world_step(world, dt, collision_steps.max(1)); } + unsafe { + bj_world_step(world, dt, collision_steps.max(1)); + } self.drain_contacts(world); } } @@ -312,7 +402,9 @@ impl JoltPhysics { fn drain_contacts(&mut self, world: bj_world) { let count = unsafe { bj_world_contact_count(world) }; if count > 0 { - self.contact_cache.events.resize(count as usize, unsafe { std::mem::zeroed() }); + self.contact_cache + .events + .resize(count as usize, unsafe { std::mem::zeroed() }); let drained = unsafe { bj_world_pop_contacts(world, self.contact_cache.events.as_mut_ptr(), count) }; @@ -329,8 +421,14 @@ impl JoltPhysics { /// This is the default stepping mode for the TS `physics.step()` API; /// the variable-rate `step()` above remains as an explicit opt-out. pub fn step_fixed(&mut self, world_h: f64, frame_dt: f32, collision_steps: u32) -> f32 { - let Some(&world) = self.worlds.get(world_h) else { return 1.0 }; - let dt = if frame_dt.is_finite() { frame_dt.clamp(0.0, MAX_FRAME_DT) } else { 0.0 }; + let Some(&world) = self.worlds.get(world_h) else { + return 1.0; + }; + let dt = if frame_dt.is_finite() { + frame_dt.clamp(0.0, MAX_FRAME_DT) + } else { + 0.0 + }; let st = self.step_states.entry(world).or_default(); st.accumulator += dt; @@ -354,7 +452,9 @@ impl JoltPhysics { if interpolate && i + 1 == steps { snap = Some(self.snapshot_world_bodies(world)); } - unsafe { bj_world_step(world, fixed, collision_steps.max(1)); } + unsafe { + bj_world_step(world, fixed, collision_steps.max(1)); + } } if let Some(s) = snap { self.step_states.get_mut(&world).unwrap().prev = s; @@ -371,7 +471,9 @@ impl JoltPhysics { /// Configure the fixed step rate (`hz`, e.g. 60.0) and the per-frame /// catch-up cap for a world. Values <= 0 keep the current setting. pub fn set_fixed_timestep(&mut self, world_h: f64, hz: f32, max_steps: u32) { - let Some(&world) = self.worlds.get(world_h) else { return }; + let Some(&world) = self.worlds.get(world_h) else { + return; + }; let st = self.step_states.entry(world).or_default(); if hz > 0.0 && hz.is_finite() { st.fixed_dt = 1.0 / hz; @@ -384,7 +486,9 @@ impl JoltPhysics { /// Enable/disable render interpolation for a world's body transform /// getters. Off by default (getters return the raw simulation state). pub fn set_interpolation(&mut self, world_h: f64, on: bool) { - let Some(&world) = self.worlds.get(world_h) else { return }; + let Some(&world) = self.worlds.get(world_h) else { + return; + }; let st = self.step_states.entry(world).or_default(); st.interpolate = on; if !on { @@ -404,13 +508,25 @@ impl JoltPhysics { } /// Capture (position, rotation) of every body in `world`. - fn snapshot_world_bodies(&self, world: bj_world) -> std::collections::HashMap { + fn snapshot_world_bodies( + &self, + world: bj_world, + ) -> std::collections::HashMap { self.bodies .iter() .filter(|(_, &(w, _))| w == world) .map(|(_, &(w, b))| { - let mut v = BjVec3 { x: 0.0, y: 0.0, z: 0.0 }; - let mut q = BjQuat { x: 0.0, y: 0.0, z: 0.0, w: 1.0 }; + let mut v = BjVec3 { + x: 0.0, + y: 0.0, + z: 0.0, + }; + let mut q = BjQuat { + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + }; unsafe { bj_body_get_position(w, b, &mut v); bj_body_get_rotation(w, b, &mut q); @@ -432,7 +548,9 @@ impl JoltPhysics { pub fn set_layer_collides(&self, world_h: f64, a: u32, b: u32, collides: bool) { if let Some(&world) = self.worlds.get(world_h) { - unsafe { bj_world_set_layer_collides(world, a, b, collides as u8); } + unsafe { + bj_world_set_layer_collides(world, a, b, collides as u8); + } } } @@ -460,71 +578,149 @@ impl JoltPhysics { // -------------------------------------------------------- Shapes pub fn create_box_shape(&mut self, hx: f32, hy: f32, hz: f32, convex_radius: f32) -> f64 { - let s = unsafe { bj_shape_box(BjVec3 { x: hx, y: hy, z: hz }, convex_radius) }; - if s == BJ_INVALID { 0.0 } else { self.shapes.alloc(s) } + let s = unsafe { + bj_shape_box( + BjVec3 { + x: hx, + y: hy, + z: hz, + }, + convex_radius, + ) + }; + if s == BJ_INVALID { + 0.0 + } else { + self.shapes.alloc(s) + } } pub fn create_sphere_shape(&mut self, radius: f32) -> f64 { let s = unsafe { bj_shape_sphere(radius) }; - if s == BJ_INVALID { 0.0 } else { self.shapes.alloc(s) } + if s == BJ_INVALID { + 0.0 + } else { + self.shapes.alloc(s) + } } pub fn create_capsule_shape(&mut self, half_height: f32, radius: f32) -> f64 { let s = unsafe { bj_shape_capsule(half_height, radius) }; - if s == BJ_INVALID { 0.0 } else { self.shapes.alloc(s) } + if s == BJ_INVALID { + 0.0 + } else { + self.shapes.alloc(s) + } } - pub fn create_cylinder_shape(&mut self, half_height: f32, radius: f32, convex_radius: f32) -> f64 { + pub fn create_cylinder_shape( + &mut self, + half_height: f32, + radius: f32, + convex_radius: f32, + ) -> f64 { let s = unsafe { bj_shape_cylinder(half_height, radius, convex_radius) }; - if s == BJ_INVALID { 0.0 } else { self.shapes.alloc(s) } + if s == BJ_INVALID { + 0.0 + } else { + self.shapes.alloc(s) + } } pub fn create_convex_hull_shape(&mut self, points: &[BjVec3], convex_radius: f32) -> f64 { - let s = unsafe { - bj_shape_convex_hull(points.as_ptr(), points.len() as u32, convex_radius) - }; - if s == BJ_INVALID { 0.0 } else { self.shapes.alloc(s) } + let s = + unsafe { bj_shape_convex_hull(points.as_ptr(), points.len() as u32, convex_radius) }; + if s == BJ_INVALID { + 0.0 + } else { + self.shapes.alloc(s) + } } pub fn create_mesh_shape(&mut self, vertices: &[BjVec3], indices: &[u32]) -> f64 { debug_assert!(indices.len() % 3 == 0); let s = unsafe { bj_shape_mesh( - vertices.as_ptr(), vertices.len() as u32, - indices.as_ptr(), (indices.len() / 3) as u32, + vertices.as_ptr(), + vertices.len() as u32, + indices.as_ptr(), + (indices.len() / 3) as u32, ) }; - if s == BJ_INVALID { 0.0 } else { self.shapes.alloc(s) } + if s == BJ_INVALID { + 0.0 + } else { + self.shapes.alloc(s) + } } pub fn create_heightfield_shape( &mut self, - samples: &[f32], sample_count: u32, - offset: BjVec3, scale: BjVec3, block_size: u32, + samples: &[f32], + sample_count: u32, + offset: BjVec3, + scale: BjVec3, + block_size: u32, ) -> f64 { let s = unsafe { - bj_shape_heightfield( - samples.as_ptr(), sample_count, offset, scale, block_size, - ) + bj_shape_heightfield(samples.as_ptr(), sample_count, offset, scale, block_size) }; - if s == BJ_INVALID { 0.0 } else { self.shapes.alloc(s) } + if s == BJ_INVALID { + 0.0 + } else { + self.shapes.alloc(s) + } } pub fn create_scaled_shape(&mut self, base_h: f64, sx: f32, sy: f32, sz: f32) -> f64 { - let base = match self.shapes.get(base_h) { Some(&s) => s, None => return 0.0 }; - let s = unsafe { bj_shape_scaled(base, BjVec3 { x: sx, y: sy, z: sz }) }; - if s == BJ_INVALID { 0.0 } else { self.shapes.alloc(s) } + let base = match self.shapes.get(base_h) { + Some(&s) => s, + None => return 0.0, + }; + let s = unsafe { + bj_shape_scaled( + base, + BjVec3 { + x: sx, + y: sy, + z: sz, + }, + ) + }; + if s == BJ_INVALID { + 0.0 + } else { + self.shapes.alloc(s) + } } pub fn create_offset_com_shape(&mut self, base_h: f64, ox: f32, oy: f32, oz: f32) -> f64 { - let base = match self.shapes.get(base_h) { Some(&s) => s, None => return 0.0 }; - let s = unsafe { bj_shape_offset_com(base, BjVec3 { x: ox, y: oy, z: oz }) }; - if s == BJ_INVALID { 0.0 } else { self.shapes.alloc(s) } + let base = match self.shapes.get(base_h) { + Some(&s) => s, + None => return 0.0, + }; + let s = unsafe { + bj_shape_offset_com( + base, + BjVec3 { + x: ox, + y: oy, + z: oz, + }, + ) + }; + if s == BJ_INVALID { + 0.0 + } else { + self.shapes.alloc(s) + } } pub fn release_shape(&mut self, handle: f64) { if let Some(s) = self.shapes.free(handle) { - unsafe { bj_shape_release(s); } + unsafe { + bj_shape_release(s); + } } } @@ -532,10 +728,16 @@ impl JoltPhysics { if let Some(&s) = self.shapes.get(handle) { let mut mn = BjVec3::default(); let mut mx = BjVec3::default(); - unsafe { bj_shape_get_local_bounds(s, &mut mn, &mut mx); } + unsafe { + bj_shape_get_local_bounds(s, &mut mn, &mut mx); + } return match axis { - 0 => mn.x, 1 => mn.y, 2 => mn.z, - 3 => mx.x, 4 => mx.y, 5 => mx.z, + 0 => mn.x, + 1 => mn.y, + 2 => mn.z, + 3 => mx.x, + 4 => mx.y, + 5 => mx.z, _ => 0.0, } as f64; } @@ -543,7 +745,10 @@ impl JoltPhysics { } pub fn shape_volume(&self, handle: f64) -> f32 { - self.shapes.get(handle).map(|&s| unsafe { bj_shape_get_volume(s) }).unwrap_or(0.0) + self.shapes + .get(handle) + .map(|&s| unsafe { bj_shape_get_volume(s) }) + .unwrap_or(0.0) } // -------------------------------------------------------- Bodies @@ -552,22 +757,46 @@ impl JoltPhysics { #[allow(clippy::too_many_arguments)] pub fn create_body( &mut self, - world_h: f64, shape_h: f64, + world_h: f64, + shape_h: f64, motion_type: u32, - px: f32, py: f32, pz: f32, - rx: f32, ry: f32, rz: f32, rw: f32, - vx: f32, vy: f32, vz: f32, - wx: f32, wy: f32, wz: f32, + px: f32, + py: f32, + pz: f32, + rx: f32, + ry: f32, + rz: f32, + rw: f32, + vx: f32, + vy: f32, + vz: f32, + wx: f32, + wy: f32, + wz: f32, object_layer: u32, - is_sensor: bool, allow_sleeping: bool, use_ccd: bool, start_awake: bool, - friction: f32, restitution: f32, - lin_damp: f32, ang_damp: f32, gravity_factor: f32, + is_sensor: bool, + allow_sleeping: bool, + use_ccd: bool, + start_awake: bool, + friction: f32, + restitution: f32, + lin_damp: f32, + ang_damp: f32, + gravity_factor: f32, mass_override: f32, - ix: f32, iy: f32, iz: f32, + ix: f32, + iy: f32, + iz: f32, user_data: u64, ) -> f64 { - let world = match self.worlds.get(world_h) { Some(&w) => w, None => return 0.0 }; - let shape = match self.shapes.get(shape_h) { Some(&s) => s, None => return 0.0 }; + let world = match self.worlds.get(world_h) { + Some(&w) => w, + None => return 0.0, + }; + let shape = match self.shapes.get(shape_h) { + Some(&s) => s, + None => return 0.0, + }; let desc = BjBodyDesc { motion_type: match motion_type { @@ -575,31 +804,58 @@ impl JoltPhysics { 1 => BjMotionType::Kinematic, _ => BjMotionType::Dynamic, }, - position: BjVec3 { x: px, y: py, z: pz }, - rotation: BjQuat { x: rx, y: ry, z: rz, w: rw }, - linear_velocity: BjVec3 { x: vx, y: vy, z: vz }, - angular_velocity: BjVec3 { x: wx, y: wy, z: wz }, + position: BjVec3 { + x: px, + y: py, + z: pz, + }, + rotation: BjQuat { + x: rx, + y: ry, + z: rz, + w: rw, + }, + linear_velocity: BjVec3 { + x: vx, + y: vy, + z: vz, + }, + angular_velocity: BjVec3 { + x: wx, + y: wy, + z: wz, + }, gravity_factor, - linear_damping: lin_damp, - angular_damping: ang_damp, + linear_damping: lin_damp, + angular_damping: ang_damp, friction, restitution, mass_override, - inertia_diag_override: BjVec3 { x: ix, y: iy, z: iz }, + inertia_diag_override: BjVec3 { + x: ix, + y: iy, + z: iz, + }, object_layer, - is_sensor: is_sensor as u8, - allow_sleeping: allow_sleeping as u8, - use_ccd: use_ccd as u8, - start_awake: start_awake as u8, + is_sensor: is_sensor as u8, + allow_sleeping: allow_sleeping as u8, + use_ccd: use_ccd as u8, + start_awake: start_awake as u8, user_data, }; let body = unsafe { bj_body_create(world, shape, &desc) }; - if body == BJ_INVALID { 0.0 } else { self.bodies.alloc((world, body)) } + if body == BJ_INVALID { + 0.0 + } else { + self.bodies.alloc((world, body)) + } } pub fn destroy_body(&mut self, handle: f64) { if let Some((world, body)) = self.bodies.free(handle) { - unsafe { bj_body_destroy(world, body); } + unsafe { + bj_body_destroy(world, body); + } } } @@ -608,16 +864,28 @@ impl JoltPhysics { } pub fn body_activate(&self, h: f64) { - if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_activate(w, b); } } + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_activate(w, b); + } + } } pub fn body_deactivate(&self, h: f64) { - if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_deactivate(w, b); } } + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_deactivate(w, b); + } + } } pub fn body_is_active(&self, h: f64) -> bool { - self.resolve_body(h).map(|(w, b)| unsafe { bj_body_is_active(w, b) } != 0).unwrap_or(false) + self.resolve_body(h) + .map(|(w, b)| unsafe { bj_body_is_active(w, b) } != 0) + .unwrap_or(false) } pub fn body_is_valid(&self, h: f64) -> bool { - self.resolve_body(h).map(|(w, b)| unsafe { bj_body_is_valid(w, b) } != 0).unwrap_or(false) + self.resolve_body(h) + .map(|(w, b)| unsafe { bj_body_is_valid(w, b) } != 0) + .unwrap_or(false) } /// Position for rendering. When the world has interpolation enabled @@ -626,7 +894,9 @@ impl JoltPhysics { pub fn body_get_position_axis(&self, h: f64, axis: u32) -> f64 { if let Some((w, b)) = self.resolve_body(h) { let mut v = BjVec3::default(); - unsafe { bj_body_get_position(w, b, &mut v); } + unsafe { + bj_body_get_position(w, b, &mut v); + } if let Some((a, (pv, _))) = self.interp_prev(w, b) { v = BjVec3 { x: pv.x + (v.x - pv.x) * a, @@ -634,7 +904,12 @@ impl JoltPhysics { z: pv.z + (v.z - pv.z) * a, }; } - return match axis { 0 => v.x, 1 => v.y, 2 => v.z, _ => 0.0 } as f64; + return match axis { + 0 => v.x, + 1 => v.y, + 2 => v.z, + _ => 0.0, + } as f64; } 0.0 } @@ -645,7 +920,9 @@ impl JoltPhysics { pub fn body_get_rotation_axis(&self, h: f64, axis: u32) -> f64 { if let Some((w, b)) = self.resolve_body(h) { let mut q = BjQuat::default(); - unsafe { bj_body_get_rotation(w, b, &mut q); } + unsafe { + bj_body_get_rotation(w, b, &mut q); + } if let Some((a, (_, pq))) = self.interp_prev(w, b) { // shortest path: flip the previous quat if the hemispheres differ let dot = pq.x * q.x + pq.y * q.y + pq.z * q.z + pq.w * q.w; @@ -658,133 +935,436 @@ impl JoltPhysics { }; let len = (r.x * r.x + r.y * r.y + r.z * r.z + r.w * r.w).sqrt(); if len > 1e-6 { - r = BjQuat { x: r.x / len, y: r.y / len, z: r.z / len, w: r.w / len }; + r = BjQuat { + x: r.x / len, + y: r.y / len, + z: r.z / len, + w: r.w / len, + }; } q = r; } - return match axis { 0 => q.x, 1 => q.y, 2 => q.z, 3 => q.w, _ => 0.0 } as f64; + return match axis { + 0 => q.x, + 1 => q.y, + 2 => q.z, + 3 => q.w, + _ => 0.0, + } as f64; } 0.0 } pub fn body_set_position(&self, h: f64, x: f32, y: f32, z: f32, activate: bool) { if let Some((w, b)) = self.resolve_body(h) { - unsafe { bj_body_set_position(w, b, BjVec3 { x, y, z }, if activate { BjActivation::Activate } else { BjActivation::DontActivate }); } + unsafe { + bj_body_set_position( + w, + b, + BjVec3 { x, y, z }, + if activate { + BjActivation::Activate + } else { + BjActivation::DontActivate + }, + ); + } } } pub fn body_set_rotation(&self, h: f64, x: f32, y: f32, z: f32, ww: f32, activate: bool) { if let Some((w, b)) = self.resolve_body(h) { - unsafe { bj_body_set_rotation(w, b, BjQuat { x, y, z, w: ww }, if activate { BjActivation::Activate } else { BjActivation::DontActivate }); } + unsafe { + bj_body_set_rotation( + w, + b, + BjQuat { x, y, z, w: ww }, + if activate { + BjActivation::Activate + } else { + BjActivation::DontActivate + }, + ); + } } } #[allow(clippy::too_many_arguments)] - pub fn body_set_transform(&self, h: f64, px: f32, py: f32, pz: f32, rx: f32, ry: f32, rz: f32, rw: f32, activate: bool) { + pub fn body_set_transform( + &self, + h: f64, + px: f32, + py: f32, + pz: f32, + rx: f32, + ry: f32, + rz: f32, + rw: f32, + activate: bool, + ) { if let Some((w, b)) = self.resolve_body(h) { - let x = BjTransform { position: BjVec3 { x: px, y: py, z: pz }, rotation: BjQuat { x: rx, y: ry, z: rz, w: rw } }; - unsafe { bj_body_set_transform(w, b, &x, if activate { BjActivation::Activate } else { BjActivation::DontActivate }); } + let x = BjTransform { + position: BjVec3 { + x: px, + y: py, + z: pz, + }, + rotation: BjQuat { + x: rx, + y: ry, + z: rz, + w: rw, + }, + }; + unsafe { + bj_body_set_transform( + w, + b, + &x, + if activate { + BjActivation::Activate + } else { + BjActivation::DontActivate + }, + ); + } } } #[allow(clippy::too_many_arguments)] - pub fn body_move_kinematic(&self, h: f64, px: f32, py: f32, pz: f32, rx: f32, ry: f32, rz: f32, rw: f32, dt: f32) { + pub fn body_move_kinematic( + &self, + h: f64, + px: f32, + py: f32, + pz: f32, + rx: f32, + ry: f32, + rz: f32, + rw: f32, + dt: f32, + ) { if let Some((w, b)) = self.resolve_body(h) { - let target = BjTransform { position: BjVec3 { x: px, y: py, z: pz }, rotation: BjQuat { x: rx, y: ry, z: rz, w: rw } }; - unsafe { bj_body_move_kinematic(w, b, &target, dt); } + let target = BjTransform { + position: BjVec3 { + x: px, + y: py, + z: pz, + }, + rotation: BjQuat { + x: rx, + y: ry, + z: rz, + w: rw, + }, + }; + unsafe { + bj_body_move_kinematic(w, b, &target, dt); + } } } pub fn body_get_linear_velocity_axis(&self, h: f64, axis: u32) -> f64 { if let Some((w, b)) = self.resolve_body(h) { let mut v = BjVec3::default(); - unsafe { bj_body_get_linear_velocity(w, b, &mut v); } - return match axis { 0 => v.x, 1 => v.y, 2 => v.z, _ => 0.0 } as f64; + unsafe { + bj_body_get_linear_velocity(w, b, &mut v); + } + return match axis { + 0 => v.x, + 1 => v.y, + 2 => v.z, + _ => 0.0, + } as f64; } 0.0 } pub fn body_get_angular_velocity_axis(&self, h: f64, axis: u32) -> f64 { if let Some((w, b)) = self.resolve_body(h) { let mut v = BjVec3::default(); - unsafe { bj_body_get_angular_velocity(w, b, &mut v); } - return match axis { 0 => v.x, 1 => v.y, 2 => v.z, _ => 0.0 } as f64; + unsafe { + bj_body_get_angular_velocity(w, b, &mut v); + } + return match axis { + 0 => v.x, + 1 => v.y, + 2 => v.z, + _ => 0.0, + } as f64; } 0.0 } - pub fn body_get_point_velocity_axis(&self, h: f64, px: f32, py: f32, pz: f32, axis: u32) -> f64 { + pub fn body_get_point_velocity_axis( + &self, + h: f64, + px: f32, + py: f32, + pz: f32, + axis: u32, + ) -> f64 { if let Some((w, b)) = self.resolve_body(h) { let mut v = BjVec3::default(); - unsafe { bj_body_get_point_velocity(w, b, BjVec3 { x: px, y: py, z: pz }, &mut v); } - return match axis { 0 => v.x, 1 => v.y, 2 => v.z, _ => 0.0 } as f64; + unsafe { + bj_body_get_point_velocity( + w, + b, + BjVec3 { + x: px, + y: py, + z: pz, + }, + &mut v, + ); + } + return match axis { + 0 => v.x, + 1 => v.y, + 2 => v.z, + _ => 0.0, + } as f64; } 0.0 } pub fn body_set_linear_velocity(&self, h: f64, x: f32, y: f32, z: f32) { - if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_set_linear_velocity(w, b, BjVec3 { x, y, z }); } } + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_set_linear_velocity(w, b, BjVec3 { x, y, z }); + } + } } pub fn body_set_angular_velocity(&self, h: f64, x: f32, y: f32, z: f32) { - if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_set_angular_velocity(w, b, BjVec3 { x, y, z }); } } + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_set_angular_velocity(w, b, BjVec3 { x, y, z }); + } + } } pub fn body_add_force(&self, h: f64, x: f32, y: f32, z: f32) { - if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_add_force(w, b, BjVec3 { x, y, z }); } } + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_add_force(w, b, BjVec3 { x, y, z }); + } + } } pub fn body_add_impulse(&self, h: f64, x: f32, y: f32, z: f32) { - if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_add_impulse(w, b, BjVec3 { x, y, z }); } } + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_add_impulse(w, b, BjVec3 { x, y, z }); + } + } } pub fn body_add_torque(&self, h: f64, x: f32, y: f32, z: f32) { - if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_add_torque(w, b, BjVec3 { x, y, z }); } } + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_add_torque(w, b, BjVec3 { x, y, z }); + } + } } pub fn body_add_angular_impulse(&self, h: f64, x: f32, y: f32, z: f32) { - if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_add_angular_impulse(w, b, BjVec3 { x, y, z }); } } + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_add_angular_impulse(w, b, BjVec3 { x, y, z }); + } + } } #[allow(clippy::too_many_arguments)] pub fn body_add_force_at(&self, h: f64, fx: f32, fy: f32, fz: f32, px: f32, py: f32, pz: f32) { if let Some((w, b)) = self.resolve_body(h) { - unsafe { bj_body_add_force_at(w, b, BjVec3 { x: fx, y: fy, z: fz }, BjVec3 { x: px, y: py, z: pz }); } + unsafe { + bj_body_add_force_at( + w, + b, + BjVec3 { + x: fx, + y: fy, + z: fz, + }, + BjVec3 { + x: px, + y: py, + z: pz, + }, + ); + } } } #[allow(clippy::too_many_arguments)] - pub fn body_add_impulse_at(&self, h: f64, ix: f32, iy: f32, iz: f32, px: f32, py: f32, pz: f32) { + pub fn body_add_impulse_at( + &self, + h: f64, + ix: f32, + iy: f32, + iz: f32, + px: f32, + py: f32, + pz: f32, + ) { if let Some((w, b)) = self.resolve_body(h) { - unsafe { bj_body_add_impulse_at(w, b, BjVec3 { x: ix, y: iy, z: iz }, BjVec3 { x: px, y: py, z: pz }); } + unsafe { + bj_body_add_impulse_at( + w, + b, + BjVec3 { + x: ix, + y: iy, + z: iz, + }, + BjVec3 { + x: px, + y: py, + z: pz, + }, + ); + } } } - pub fn body_set_friction(&self, h: f64, v: f32) { if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_set_friction(w, b, v); } } } - pub fn body_set_restitution(&self, h: f64, v: f32) { if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_set_restitution(w, b, v); } } } - pub fn body_set_linear_damping(&self, h: f64, v: f32) { if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_set_linear_damping(w, b, v); } } } - pub fn body_set_angular_damping(&self, h: f64, v: f32) { if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_set_angular_damping(w, b, v); } } } - pub fn body_set_gravity_factor(&self, h: f64, v: f32) { if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_set_gravity_factor(w, b, v); } } } - pub fn body_set_ccd(&self, h: f64, enabled: bool) { if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_set_ccd(w, b, enabled as u8); } } } + pub fn body_set_friction(&self, h: f64, v: f32) { + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_set_friction(w, b, v); + } + } + } + pub fn body_set_restitution(&self, h: f64, v: f32) { + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_set_restitution(w, b, v); + } + } + } + pub fn body_set_linear_damping(&self, h: f64, v: f32) { + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_set_linear_damping(w, b, v); + } + } + } + pub fn body_set_angular_damping(&self, h: f64, v: f32) { + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_set_angular_damping(w, b, v); + } + } + } + pub fn body_set_gravity_factor(&self, h: f64, v: f32) { + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_set_gravity_factor(w, b, v); + } + } + } + pub fn body_set_ccd(&self, h: f64, enabled: bool) { + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_set_ccd(w, b, enabled as u8); + } + } + } pub fn body_set_motion_type(&self, h: f64, t: u32, activate: bool) { if let Some((w, b)) = self.resolve_body(h) { - let mt = match t { 0 => BjMotionType::Static, 1 => BjMotionType::Kinematic, _ => BjMotionType::Dynamic }; - unsafe { bj_body_set_motion_type(w, b, mt, if activate { BjActivation::Activate } else { BjActivation::DontActivate }); } + let mt = match t { + 0 => BjMotionType::Static, + 1 => BjMotionType::Kinematic, + _ => BjMotionType::Dynamic, + }; + unsafe { + bj_body_set_motion_type( + w, + b, + mt, + if activate { + BjActivation::Activate + } else { + BjActivation::DontActivate + }, + ); + } + } + } + pub fn body_set_object_layer(&self, h: f64, layer: u32) { + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_set_object_layer(w, b, layer); + } + } + } + pub fn body_set_is_sensor(&self, h: f64, enabled: bool) { + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_set_is_sensor(w, b, enabled as u8); + } + } + } + pub fn body_set_allow_sleeping(&self, h: f64, enabled: bool) { + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_set_allow_sleeping(w, b, enabled as u8); + } } } - pub fn body_set_object_layer(&self, h: f64, layer: u32) { if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_set_object_layer(w, b, layer); } } } - pub fn body_set_is_sensor(&self, h: f64, enabled: bool) { if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_set_is_sensor(w, b, enabled as u8); } } } - pub fn body_set_allow_sleeping(&self, h: f64, enabled: bool) { if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_set_allow_sleeping(w, b, enabled as u8); } } } pub fn body_set_shape(&self, h: f64, shape_h: f64, update_mass: bool, activate: bool) { if let (Some((w, b)), Some(&s)) = (self.resolve_body(h), self.shapes.get(shape_h)) { - unsafe { bj_body_set_shape(w, b, s, update_mass as u8, if activate { BjActivation::Activate } else { BjActivation::DontActivate }); } + unsafe { + bj_body_set_shape( + w, + b, + s, + update_mass as u8, + if activate { + BjActivation::Activate + } else { + BjActivation::DontActivate + }, + ); + } } } pub fn body_lock_rotation_axes(&self, h: f64, x: bool, y: bool, z: bool) { - if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_lock_rotation_axes(w, b, x as u8, y as u8, z as u8); } } + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_lock_rotation_axes(w, b, x as u8, y as u8, z as u8); + } + } } pub fn body_lock_translation_axes(&self, h: f64, x: bool, y: bool, z: bool) { - if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_lock_translation_axes(w, b, x as u8, y as u8, z as u8); } } + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_lock_translation_axes(w, b, x as u8, y as u8, z as u8); + } + } } - pub fn body_get_mass(&self, h: f64) -> f32 { self.resolve_body(h).map(|(w, b)| unsafe { bj_body_get_mass(w, b) }).unwrap_or(0.0) } - pub fn body_get_friction(&self, h: f64) -> f32 { self.resolve_body(h).map(|(w, b)| unsafe { bj_body_get_friction(w, b) }).unwrap_or(0.0) } - pub fn body_get_restitution(&self, h: f64) -> f32 { self.resolve_body(h).map(|(w, b)| unsafe { bj_body_get_restitution(w, b) }).unwrap_or(0.0) } - pub fn body_get_object_layer(&self, h: f64) -> u32 { self.resolve_body(h).map(|(w, b)| unsafe { bj_body_get_object_layer(w, b) }).unwrap_or(0) } + pub fn body_get_mass(&self, h: f64) -> f32 { + self.resolve_body(h) + .map(|(w, b)| unsafe { bj_body_get_mass(w, b) }) + .unwrap_or(0.0) + } + pub fn body_get_friction(&self, h: f64) -> f32 { + self.resolve_body(h) + .map(|(w, b)| unsafe { bj_body_get_friction(w, b) }) + .unwrap_or(0.0) + } + pub fn body_get_restitution(&self, h: f64) -> f32 { + self.resolve_body(h) + .map(|(w, b)| unsafe { bj_body_get_restitution(w, b) }) + .unwrap_or(0.0) + } + pub fn body_get_object_layer(&self, h: f64) -> u32 { + self.resolve_body(h) + .map(|(w, b)| unsafe { bj_body_get_object_layer(w, b) }) + .unwrap_or(0) + } pub fn body_set_user_data(&self, h: f64, user_data: u64) { - if let Some((w, b)) = self.resolve_body(h) { unsafe { bj_body_set_user_data(w, b, user_data); } } + if let Some((w, b)) = self.resolve_body(h) { + unsafe { + bj_body_set_user_data(w, b, user_data); + } + } } pub fn body_get_user_data(&self, h: f64) -> u64 { - self.resolve_body(h).map(|(w, b)| unsafe { bj_body_get_user_data(w, b) }).unwrap_or(0) + self.resolve_body(h) + .map(|(w, b)| unsafe { bj_body_get_user_data(w, b) }) + .unwrap_or(0) } // -------------------------------------------------------- Queries @@ -792,91 +1372,206 @@ impl JoltPhysics { /// Single-hit raycast. Stores the hit (if any) at cache index 0. #[allow(clippy::too_many_arguments)] pub fn raycast_closest( - &mut self, world_h: f64, - ox: f32, oy: f32, oz: f32, - dx: f32, dy: f32, dz: f32, - max_distance: f32, layer_mask: u32, + &mut self, + world_h: f64, + ox: f32, + oy: f32, + oz: f32, + dx: f32, + dy: f32, + dz: f32, + max_distance: f32, + layer_mask: u32, ) -> bool { self.ray_hit_cache.hits.clear(); - let world = match self.worlds.get(world_h) { Some(&w) => w, None => return false }; + let world = match self.worlds.get(world_h) { + Some(&w) => w, + None => return false, + }; let mut hit: BjRayHit = unsafe { std::mem::zeroed() }; let any = unsafe { - bj_query_raycast_closest(world, - BjVec3 { x: ox, y: oy, z: oz }, - BjVec3 { x: dx, y: dy, z: dz }, - max_distance, layer_mask, &mut hit) + bj_query_raycast_closest( + world, + BjVec3 { + x: ox, + y: oy, + z: oz, + }, + BjVec3 { + x: dx, + y: dy, + z: dz, + }, + max_distance, + layer_mask, + &mut hit, + ) }; if any != 0 { self.ray_hit_cache.hits.push(hit); true - } else { false } + } else { + false + } } /// Multi-hit raycast. Stores up to max_hits in the cache. #[allow(clippy::too_many_arguments)] pub fn raycast_all( - &mut self, world_h: f64, - ox: f32, oy: f32, oz: f32, - dx: f32, dy: f32, dz: f32, - max_distance: f32, layer_mask: u32, max_hits: u32, + &mut self, + world_h: f64, + ox: f32, + oy: f32, + oz: f32, + dx: f32, + dy: f32, + dz: f32, + max_distance: f32, + layer_mask: u32, + max_hits: u32, ) -> u32 { self.ray_hit_cache.hits.clear(); - let world = match self.worlds.get(world_h) { Some(&w) => w, None => return 0 }; - self.ray_hit_cache.hits.resize(max_hits as usize, unsafe { std::mem::zeroed() }); + let world = match self.worlds.get(world_h) { + Some(&w) => w, + None => return 0, + }; + self.ray_hit_cache + .hits + .resize(max_hits as usize, unsafe { std::mem::zeroed() }); let n = unsafe { - bj_query_raycast_all(world, - BjVec3 { x: ox, y: oy, z: oz }, - BjVec3 { x: dx, y: dy, z: dz }, - max_distance, layer_mask, - self.ray_hit_cache.hits.as_mut_ptr(), max_hits) + bj_query_raycast_all( + world, + BjVec3 { + x: ox, + y: oy, + z: oz, + }, + BjVec3 { + x: dx, + y: dy, + z: dz, + }, + max_distance, + layer_mask, + self.ray_hit_cache.hits.as_mut_ptr(), + max_hits, + ) }; self.ray_hit_cache.hits.truncate(n as usize); n } - pub fn ray_hit_count(&self) -> u32 { self.ray_hit_cache.hits.len() as u32 } + pub fn ray_hit_count(&self) -> u32 { + self.ray_hit_cache.hits.len() as u32 + } pub fn ray_hit_body(&self, i: usize) -> f64 { - self.ray_hit_cache.hits.get(i) - .and_then(|h| self.bodies.iter().find(|(_, (_, b))| *b == h.body).map(|(hh, _)| hh)) + self.ray_hit_cache + .hits + .get(i) + .and_then(|h| { + self.bodies + .iter() + .find(|(_, (_, b))| *b == h.body) + .map(|(hh, _)| hh) + }) .unwrap_or(0.0) } /// field: 0..5 = point.xyz, normal.xyz pub fn ray_hit_axis(&self, i: usize, field: u32) -> f64 { - let h = match self.ray_hit_cache.hits.get(i) { Some(v) => v, None => return 0.0 }; + let h = match self.ray_hit_cache.hits.get(i) { + Some(v) => v, + None => return 0.0, + }; (match field { - 0 => h.point.x, 1 => h.point.y, 2 => h.point.z, - 3 => h.normal.x, 4 => h.normal.y, 5 => h.normal.z, + 0 => h.point.x, + 1 => h.point.y, + 2 => h.point.z, + 3 => h.normal.x, + 4 => h.normal.y, + 5 => h.normal.z, _ => 0.0, }) as f64 } pub fn ray_hit_fraction(&self, i: usize) -> f32 { - self.ray_hit_cache.hits.get(i).map(|h| h.fraction).unwrap_or(0.0) + self.ray_hit_cache + .hits + .get(i) + .map(|h| h.fraction) + .unwrap_or(0.0) } pub fn ray_hit_sub_shape(&self, i: usize) -> u32 { - self.ray_hit_cache.hits.get(i).map(|h| h.sub_shape_id).unwrap_or(0) + self.ray_hit_cache + .hits + .get(i) + .map(|h| h.sub_shape_id) + .unwrap_or(0) } - pub fn overlap_sphere(&mut self, world_h: f64, cx: f32, cy: f32, cz: f32, r: f32, layer_mask: u32, max_results: u32) -> u32 { + pub fn overlap_sphere( + &mut self, + world_h: f64, + cx: f32, + cy: f32, + cz: f32, + r: f32, + layer_mask: u32, + max_results: u32, + ) -> u32 { self.overlap_cache.bodies.clear(); - let world = match self.worlds.get(world_h) { Some(&w) => w, None => return 0 }; - self.overlap_cache.bodies.resize(max_results as usize, BJ_INVALID); + let world = match self.worlds.get(world_h) { + Some(&w) => w, + None => return 0, + }; + self.overlap_cache + .bodies + .resize(max_results as usize, BJ_INVALID); let n = unsafe { - bj_query_overlap_sphere(world, - BjVec3 { x: cx, y: cy, z: cz }, r, layer_mask, - self.overlap_cache.bodies.as_mut_ptr(), max_results) + bj_query_overlap_sphere( + world, + BjVec3 { + x: cx, + y: cy, + z: cz, + }, + r, + layer_mask, + self.overlap_cache.bodies.as_mut_ptr(), + max_results, + ) }; self.overlap_cache.bodies.truncate(n as usize); n } - pub fn overlap_point(&mut self, world_h: f64, px: f32, py: f32, pz: f32, layer_mask: u32, max_results: u32) -> u32 { + pub fn overlap_point( + &mut self, + world_h: f64, + px: f32, + py: f32, + pz: f32, + layer_mask: u32, + max_results: u32, + ) -> u32 { self.overlap_cache.bodies.clear(); - let world = match self.worlds.get(world_h) { Some(&w) => w, None => return 0 }; - self.overlap_cache.bodies.resize(max_results as usize, BJ_INVALID); + let world = match self.worlds.get(world_h) { + Some(&w) => w, + None => return 0, + }; + self.overlap_cache + .bodies + .resize(max_results as usize, BJ_INVALID); let n = unsafe { - bj_query_overlap_point(world, - BjVec3 { x: px, y: py, z: pz }, layer_mask, - self.overlap_cache.bodies.as_mut_ptr(), max_results) + bj_query_overlap_point( + world, + BjVec3 { + x: px, + y: py, + z: pz, + }, + layer_mask, + self.overlap_cache.bodies.as_mut_ptr(), + max_results, + ) }; self.overlap_cache.bodies.truncate(n as usize); n @@ -884,60 +1579,167 @@ impl JoltPhysics { #[allow(clippy::too_many_arguments)] pub fn overlap_box( - &mut self, world_h: f64, - px: f32, py: f32, pz: f32, rx: f32, ry: f32, rz: f32, rw: f32, - hx: f32, hy: f32, hz: f32, - layer_mask: u32, max_results: u32, + &mut self, + world_h: f64, + px: f32, + py: f32, + pz: f32, + rx: f32, + ry: f32, + rz: f32, + rw: f32, + hx: f32, + hy: f32, + hz: f32, + layer_mask: u32, + max_results: u32, ) -> u32 { self.overlap_cache.bodies.clear(); - let world = match self.worlds.get(world_h) { Some(&w) => w, None => return 0 }; - self.overlap_cache.bodies.resize(max_results as usize, BJ_INVALID); - let xform = BjTransform { position: BjVec3 { x: px, y: py, z: pz }, rotation: BjQuat { x: rx, y: ry, z: rz, w: rw } }; + let world = match self.worlds.get(world_h) { + Some(&w) => w, + None => return 0, + }; + self.overlap_cache + .bodies + .resize(max_results as usize, BJ_INVALID); + let xform = BjTransform { + position: BjVec3 { + x: px, + y: py, + z: pz, + }, + rotation: BjQuat { + x: rx, + y: ry, + z: rz, + w: rw, + }, + }; let n = unsafe { - bj_query_overlap_box(world, &xform, BjVec3 { x: hx, y: hy, z: hz }, layer_mask, - self.overlap_cache.bodies.as_mut_ptr(), max_results) + bj_query_overlap_box( + world, + &xform, + BjVec3 { + x: hx, + y: hy, + z: hz, + }, + layer_mask, + self.overlap_cache.bodies.as_mut_ptr(), + max_results, + ) }; self.overlap_cache.bodies.truncate(n as usize); n } pub fn overlap_body(&self, i: usize) -> f64 { - self.overlap_cache.bodies.get(i) - .and_then(|bj| self.bodies.iter().find(|(_, (_, b))| *b == *bj).map(|(h, _)| h)) + self.overlap_cache + .bodies + .get(i) + .and_then(|bj| { + self.bodies + .iter() + .find(|(_, (_, b))| *b == *bj) + .map(|(h, _)| h) + }) .unwrap_or(0.0) } // -------------------------------------------------------- Constraints - fn make_anchors(&self, body_a_h: f64, body_b_h: f64, ax: f32, ay: f32, az: f32, bx: f32, by: f32, bz: f32, world_space: bool) -> Option<(bj_world, BjConstraintAnchors)> { + fn make_anchors( + &self, + body_a_h: f64, + body_b_h: f64, + ax: f32, + ay: f32, + az: f32, + bx: f32, + by: f32, + bz: f32, + world_space: bool, + ) -> Option<(bj_world, BjConstraintAnchors)> { let (wa, ba) = self.resolve_body(body_a_h)?; let bb = if body_b_h == 0.0 { BJ_INVALID } else { let (wb, bb) = self.resolve_body(body_b_h)?; - if wb != wa { return None; } + if wb != wa { + return None; + } bb }; - Some((wa, BjConstraintAnchors { - body_a: ba, body_b: bb, - anchor_a: BjVec3 { x: ax, y: ay, z: az }, - anchor_b: BjVec3 { x: bx, y: by, z: bz }, - use_world_space: world_space as u8, - })) + Some(( + wa, + BjConstraintAnchors { + body_a: ba, + body_b: bb, + anchor_a: BjVec3 { + x: ax, + y: ay, + z: az, + }, + anchor_b: BjVec3 { + x: bx, + y: by, + z: bz, + }, + use_world_space: world_space as u8, + }, + )) } #[allow(clippy::too_many_arguments)] - pub fn constraint_fixed(&mut self, body_a: f64, body_b: f64, ax: f32, ay: f32, az: f32, bx: f32, by: f32, bz: f32, world_space: bool) -> f64 { - let (w, anchors) = match self.make_anchors(body_a, body_b, ax, ay, az, bx, by, bz, world_space) { Some(v) => v, None => return 0.0 }; + pub fn constraint_fixed( + &mut self, + body_a: f64, + body_b: f64, + ax: f32, + ay: f32, + az: f32, + bx: f32, + by: f32, + bz: f32, + world_space: bool, + ) -> f64 { + let (w, anchors) = + match self.make_anchors(body_a, body_b, ax, ay, az, bx, by, bz, world_space) { + Some(v) => v, + None => return 0.0, + }; let c = unsafe { bj_constraint_fixed(w, &anchors) }; - if c == BJ_INVALID { 0.0 } else { self.constraints.alloc((w, c)) } + if c == BJ_INVALID { + 0.0 + } else { + self.constraints.alloc((w, c)) + } } #[allow(clippy::too_many_arguments)] - pub fn constraint_point(&mut self, body_a: f64, body_b: f64, ax: f32, ay: f32, az: f32, bx: f32, by: f32, bz: f32, world_space: bool) -> f64 { - let (w, anchors) = match self.make_anchors(body_a, body_b, ax, ay, az, bx, by, bz, world_space) { Some(v) => v, None => return 0.0 }; + pub fn constraint_point( + &mut self, + body_a: f64, + body_b: f64, + ax: f32, + ay: f32, + az: f32, + bx: f32, + by: f32, + bz: f32, + world_space: bool, + ) -> f64 { + let (w, anchors) = + match self.make_anchors(body_a, body_b, ax, ay, az, bx, by, bz, world_space) { + Some(v) => v, + None => return 0.0, + }; let c = unsafe { bj_constraint_point(w, &anchors) }; - if c == BJ_INVALID { 0.0 } else { self.constraints.alloc((w, c)) } + if c == BJ_INVALID { + 0.0 + } else { + self.constraints.alloc((w, c)) + } } /// EN-025 — six-DOF, which is what a ragdoll joint actually is: translation @@ -949,20 +1751,32 @@ impl JoltPhysics { /// `rot_limits` is [xmin, xmax, ymin, ymax, zmin, zmax] in radians. #[allow(clippy::too_many_arguments)] pub fn constraint_six_dof_locked_translation( - &mut self, body_a: f64, body_b: f64, - ax: f32, ay: f32, az: f32, bx: f32, by: f32, bz: f32, - rot_limits: [f32; 6], world_space: bool, + &mut self, + body_a: f64, + body_b: f64, + ax: f32, + ay: f32, + az: f32, + bx: f32, + by: f32, + bz: f32, + rot_limits: [f32; 6], + world_space: bool, ) -> f64 { - let (w, anchors) = match self.make_anchors(body_a, body_b, ax, ay, az, bx, by, bz, world_space) { - Some(v) => v, None => return 0.0, - }; + let (w, anchors) = + match self.make_anchors(body_a, body_b, ax, ay, az, bx, by, bz, world_space) { + Some(v) => v, + None => return 0.0, + }; // min >= max means LOCKED (see the shim header), so this pins all three // translation axes. let trans: [f32; 6] = [1.0, -1.0, 1.0, -1.0, 1.0, -1.0]; - let c = unsafe { - bj_constraint_six_dof(w, &anchors, trans.as_ptr(), rot_limits.as_ptr()) - }; - if c == BJ_INVALID { 0.0 } else { self.constraints.alloc((w, c)) } + let c = unsafe { bj_constraint_six_dof(w, &anchors, trans.as_ptr(), rot_limits.as_ptr()) }; + if c == BJ_INVALID { + 0.0 + } else { + self.constraints.alloc((w, c)) + } } /// Body world transform as (position, quaternion) — the ragdoll needs the @@ -984,112 +1798,255 @@ impl JoltPhysics { #[allow(clippy::too_many_arguments)] pub fn constraint_hinge( - &mut self, body_a: f64, body_b: f64, - ax: f32, ay: f32, az: f32, bx: f32, by: f32, bz: f32, - axis_x: f32, axis_y: f32, axis_z: f32, - limit_min: f32, limit_max: f32, world_space: bool, + &mut self, + body_a: f64, + body_b: f64, + ax: f32, + ay: f32, + az: f32, + bx: f32, + by: f32, + bz: f32, + axis_x: f32, + axis_y: f32, + axis_z: f32, + limit_min: f32, + limit_max: f32, + world_space: bool, ) -> f64 { - let (w, anchors) = match self.make_anchors(body_a, body_b, ax, ay, az, bx, by, bz, world_space) { Some(v) => v, None => return 0.0 }; + let (w, anchors) = + match self.make_anchors(body_a, body_b, ax, ay, az, bx, by, bz, world_space) { + Some(v) => v, + None => return 0.0, + }; let c = unsafe { - bj_constraint_hinge(w, &anchors, BjVec3 { x: axis_x, y: axis_y, z: axis_z }, limit_min, limit_max) + bj_constraint_hinge( + w, + &anchors, + BjVec3 { + x: axis_x, + y: axis_y, + z: axis_z, + }, + limit_min, + limit_max, + ) }; - if c == BJ_INVALID { 0.0 } else { self.constraints.alloc((w, c)) } + if c == BJ_INVALID { + 0.0 + } else { + self.constraints.alloc((w, c)) + } } #[allow(clippy::too_many_arguments)] pub fn constraint_slider( - &mut self, body_a: f64, body_b: f64, - ax: f32, ay: f32, az: f32, bx: f32, by: f32, bz: f32, - axis_x: f32, axis_y: f32, axis_z: f32, - limit_min: f32, limit_max: f32, world_space: bool, + &mut self, + body_a: f64, + body_b: f64, + ax: f32, + ay: f32, + az: f32, + bx: f32, + by: f32, + bz: f32, + axis_x: f32, + axis_y: f32, + axis_z: f32, + limit_min: f32, + limit_max: f32, + world_space: bool, ) -> f64 { - let (w, anchors) = match self.make_anchors(body_a, body_b, ax, ay, az, bx, by, bz, world_space) { Some(v) => v, None => return 0.0 }; + let (w, anchors) = + match self.make_anchors(body_a, body_b, ax, ay, az, bx, by, bz, world_space) { + Some(v) => v, + None => return 0.0, + }; let c = unsafe { - bj_constraint_slider(w, &anchors, BjVec3 { x: axis_x, y: axis_y, z: axis_z }, limit_min, limit_max) + bj_constraint_slider( + w, + &anchors, + BjVec3 { + x: axis_x, + y: axis_y, + z: axis_z, + }, + limit_min, + limit_max, + ) }; - if c == BJ_INVALID { 0.0 } else { self.constraints.alloc((w, c)) } + if c == BJ_INVALID { + 0.0 + } else { + self.constraints.alloc((w, c)) + } } #[allow(clippy::too_many_arguments)] pub fn constraint_distance( - &mut self, body_a: f64, body_b: f64, - ax: f32, ay: f32, az: f32, bx: f32, by: f32, bz: f32, - min_distance: f32, max_distance: f32, world_space: bool, + &mut self, + body_a: f64, + body_b: f64, + ax: f32, + ay: f32, + az: f32, + bx: f32, + by: f32, + bz: f32, + min_distance: f32, + max_distance: f32, + world_space: bool, ) -> f64 { - let (w, anchors) = match self.make_anchors(body_a, body_b, ax, ay, az, bx, by, bz, world_space) { Some(v) => v, None => return 0.0 }; + let (w, anchors) = + match self.make_anchors(body_a, body_b, ax, ay, az, bx, by, bz, world_space) { + Some(v) => v, + None => return 0.0, + }; let c = unsafe { bj_constraint_distance(w, &anchors, min_distance, max_distance) }; - if c == BJ_INVALID { 0.0 } else { self.constraints.alloc((w, c)) } + if c == BJ_INVALID { + 0.0 + } else { + self.constraints.alloc((w, c)) + } } pub fn constraint_destroy(&mut self, handle: f64) { if let Some((w, c)) = self.constraints.free(handle) { - unsafe { bj_constraint_destroy(w, c); } + unsafe { + bj_constraint_destroy(w, c); + } } } pub fn constraint_set_enabled(&self, handle: f64, enabled: bool) { if let Some(&(w, c)) = self.constraints.get(handle) { - unsafe { bj_constraint_set_enabled(w, c, enabled as u8); } + unsafe { + bj_constraint_set_enabled(w, c, enabled as u8); + } } } // -------------------------------------------------------- Contacts - pub fn contact_count(&self) -> u32 { self.contact_cache.events.len() as u32 } + pub fn contact_count(&self) -> u32 { + self.contact_cache.events.len() as u32 + } /// field: 0=event, 1=bodyA, 2=bodyB, 3..5=pointA.xyz, 6..8=pointB.xyz, /// 9..11=normal.xyz, 12=depth, 13=friction, 14=restitution pub fn contact_field(&self, i: usize, field: u32) -> f64 { - let c = match self.contact_cache.events.get(i) { Some(v) => v, None => return 0.0 }; + let c = match self.contact_cache.events.get(i) { + Some(v) => v, + None => return 0.0, + }; let bj_to_bloom = |bj: bj_body| -> f64 { - self.bodies.iter().find(|(_, (_, b))| *b == bj).map(|(h, _)| h).unwrap_or(0.0) + self.bodies + .iter() + .find(|(_, (_, b))| *b == bj) + .map(|(h, _)| h) + .unwrap_or(0.0) }; match field { - 0 => c.event as u32 as f64, - 1 => bj_to_bloom(c.body_a), - 2 => bj_to_bloom(c.body_b), - 3 => c.point_a.x as f64, 4 => c.point_a.y as f64, 5 => c.point_a.z as f64, - 6 => c.point_b.x as f64, 7 => c.point_b.y as f64, 8 => c.point_b.z as f64, - 9 => c.normal.x as f64, 10=> c.normal.y as f64, 11=> c.normal.z as f64, - 12 => c.penetration_depth as f64, - 13 => c.combined_friction as f64, + 0 => c.event as u32 as f64, + 1 => bj_to_bloom(c.body_a), + 2 => bj_to_bloom(c.body_b), + 3 => c.point_a.x as f64, + 4 => c.point_a.y as f64, + 5 => c.point_a.z as f64, + 6 => c.point_b.x as f64, + 7 => c.point_b.y as f64, + 8 => c.point_b.z as f64, + 9 => c.normal.x as f64, + 10 => c.normal.y as f64, + 11 => c.normal.z as f64, + 12 => c.penetration_depth as f64, + 13 => c.combined_friction as f64, 14 => c.combined_restitution as f64, - _ => 0.0, + _ => 0.0, } } - pub fn clear_contacts(&mut self) { self.contact_cache.events.clear(); } + pub fn clear_contacts(&mut self) { + self.contact_cache.events.clear(); + } // -------------------------------------------------------- Character controller #[allow(clippy::too_many_arguments)] pub fn character_create( - &mut self, world_h: f64, shape_h: f64, - up_x: f32, up_y: f32, up_z: f32, - max_slope_angle: f32, character_padding: f32, - penetration_recovery_speed: f32, predictive_contact_distance: f32, - max_strength: f32, mass: f32, object_layer: u32, - px: f32, py: f32, pz: f32, - rx: f32, ry: f32, rz: f32, rw: f32, + &mut self, + world_h: f64, + shape_h: f64, + up_x: f32, + up_y: f32, + up_z: f32, + max_slope_angle: f32, + character_padding: f32, + penetration_recovery_speed: f32, + predictive_contact_distance: f32, + max_strength: f32, + mass: f32, + object_layer: u32, + px: f32, + py: f32, + pz: f32, + rx: f32, + ry: f32, + rz: f32, + rw: f32, ) -> f64 { - let world = match self.worlds.get(world_h) { Some(&w) => w, None => return 0.0 }; - let shape = match self.shapes.get(shape_h) { Some(&s) => s, None => return 0.0 }; + let world = match self.worlds.get(world_h) { + Some(&w) => w, + None => return 0.0, + }; + let shape = match self.shapes.get(shape_h) { + Some(&s) => s, + None => return 0.0, + }; let desc = BjCharacterDesc { - up: BjVec3 { x: up_x, y: up_y, z: up_z }, - max_slope_angle, character_padding, penetration_recovery_speed, - predictive_contact_distance, max_strength, mass, object_layer, + up: BjVec3 { + x: up_x, + y: up_y, + z: up_z, + }, + max_slope_angle, + character_padding, + penetration_recovery_speed, + predictive_contact_distance, + max_strength, + mass, + object_layer, }; let c = unsafe { - bj_character_create(world, shape, &desc, - BjVec3 { x: px, y: py, z: pz }, - BjQuat { x: rx, y: ry, z: rz, w: rw }) + bj_character_create( + world, + shape, + &desc, + BjVec3 { + x: px, + y: py, + z: pz, + }, + BjQuat { + x: rx, + y: ry, + z: rz, + w: rw, + }, + ) }; - if c == BJ_INVALID { 0.0 } else { self.characters.alloc((world, c)) } + if c == BJ_INVALID { + 0.0 + } else { + self.characters.alloc((world, c)) + } } pub fn character_destroy(&mut self, handle: f64) { if let Some((w, c)) = self.characters.free(handle) { - unsafe { bj_character_destroy(w, c); } + unsafe { + bj_character_destroy(w, c); + } } } @@ -1099,47 +2056,86 @@ impl JoltPhysics { pub fn character_update(&self, h: f64, dt: f32, gx: f32, gy: f32, gz: f32) { if let Some((w, c)) = self.resolve_character(h) { - unsafe { bj_character_update(w, c, dt, BjVec3 { x: gx, y: gy, z: gz }); } + unsafe { + bj_character_update( + w, + c, + dt, + BjVec3 { + x: gx, + y: gy, + z: gz, + }, + ); + } } } pub fn character_get_position_axis(&self, h: f64, axis: u32) -> f64 { if let Some((w, c)) = self.resolve_character(h) { let mut v = BjVec3::default(); - unsafe { bj_character_get_position(w, c, &mut v); } - return match axis { 0 => v.x, 1 => v.y, 2 => v.z, _ => 0.0 } as f64; + unsafe { + bj_character_get_position(w, c, &mut v); + } + return match axis { + 0 => v.x, + 1 => v.y, + 2 => v.z, + _ => 0.0, + } as f64; } 0.0 } pub fn character_get_rotation_axis(&self, h: f64, axis: u32) -> f64 { if let Some((w, c)) = self.resolve_character(h) { let mut q = BjQuat::default(); - unsafe { bj_character_get_rotation(w, c, &mut q); } - return match axis { 0 => q.x, 1 => q.y, 2 => q.z, 3 => q.w, _ => 0.0 } as f64; + unsafe { + bj_character_get_rotation(w, c, &mut q); + } + return match axis { + 0 => q.x, + 1 => q.y, + 2 => q.z, + 3 => q.w, + _ => 0.0, + } as f64; } 0.0 } pub fn character_set_position(&self, h: f64, x: f32, y: f32, z: f32) { if let Some((w, c)) = self.resolve_character(h) { - unsafe { bj_character_set_position(w, c, BjVec3 { x, y, z }); } + unsafe { + bj_character_set_position(w, c, BjVec3 { x, y, z }); + } } } pub fn character_set_rotation(&self, h: f64, x: f32, y: f32, z: f32, ww: f32) { if let Some((w, c)) = self.resolve_character(h) { - unsafe { bj_character_set_rotation(w, c, BjQuat { x, y, z, w: ww }); } + unsafe { + bj_character_set_rotation(w, c, BjQuat { x, y, z, w: ww }); + } } } pub fn character_get_linear_velocity_axis(&self, h: f64, axis: u32) -> f64 { if let Some((w, c)) = self.resolve_character(h) { let mut v = BjVec3::default(); - unsafe { bj_character_get_linear_velocity(w, c, &mut v); } - return match axis { 0 => v.x, 1 => v.y, 2 => v.z, _ => 0.0 } as f64; + unsafe { + bj_character_get_linear_velocity(w, c, &mut v); + } + return match axis { + 0 => v.x, + 1 => v.y, + 2 => v.z, + _ => 0.0, + } as f64; } 0.0 } pub fn character_set_linear_velocity(&self, h: f64, x: f32, y: f32, z: f32) { if let Some((w, c)) = self.resolve_character(h) { - unsafe { bj_character_set_linear_velocity(w, c, BjVec3 { x, y, z }); } + unsafe { + bj_character_set_linear_velocity(w, c, BjVec3 { x, y, z }); + } } } @@ -1152,24 +2148,42 @@ impl JoltPhysics { pub fn character_get_ground_normal_axis(&self, h: f64, axis: u32) -> f64 { if let Some((w, c)) = self.resolve_character(h) { let mut v = BjVec3::default(); - unsafe { bj_character_get_ground_normal(w, c, &mut v); } - return match axis { 0 => v.x, 1 => v.y, 2 => v.z, _ => 0.0 } as f64; + unsafe { + bj_character_get_ground_normal(w, c, &mut v); + } + return match axis { + 0 => v.x, + 1 => v.y, + 2 => v.z, + _ => 0.0, + } as f64; } 0.0 } pub fn character_get_ground_position_axis(&self, h: f64, axis: u32) -> f64 { if let Some((w, c)) = self.resolve_character(h) { let mut v = BjVec3::default(); - unsafe { bj_character_get_ground_position(w, c, &mut v); } - return match axis { 0 => v.x, 1 => v.y, 2 => v.z, _ => 0.0 } as f64; + unsafe { + bj_character_get_ground_position(w, c, &mut v); + } + return match axis { + 0 => v.x, + 1 => v.y, + 2 => v.z, + _ => 0.0, + } as f64; } 0.0 } pub fn character_get_ground_body(&self, h: f64) -> f64 { if let Some((w, c)) = self.resolve_character(h) { let bj = unsafe { bj_character_get_ground_body(w, c) }; - if bj == BJ_INVALID { return 0.0; } - return self.bodies.iter() + if bj == BJ_INVALID { + return 0.0; + } + return self + .bodies + .iter() .find(|(_, (_, b))| *b == bj) .map(|(h, _)| h) .unwrap_or(0.0); @@ -1178,7 +2192,9 @@ impl JoltPhysics { } pub fn character_set_shape(&self, h: f64, shape_h: f64) { if let (Some((w, c)), Some(&s)) = (self.resolve_character(h), self.shapes.get(shape_h)) { - unsafe { bj_character_set_shape(w, c, s); } + unsafe { + bj_character_set_shape(w, c, s); + } } } @@ -1190,31 +2206,66 @@ impl JoltPhysics { /// inv_mass == 0 pins the vertex. #[allow(clippy::too_many_arguments)] pub fn soft_body_create_from_scratch( - &mut self, world_h: f64, - vertex_count: u32, triangle_count: u32, - px: f32, py: f32, pz: f32, rx: f32, ry: f32, rz: f32, rw: f32, + &mut self, + world_h: f64, + vertex_count: u32, + triangle_count: u32, + px: f32, + py: f32, + pz: f32, + rx: f32, + ry: f32, + rz: f32, + rw: f32, object_layer: u32, - edge_compliance: f32, gravity_factor: f32, linear_damping: f32, pressure: f32, + edge_compliance: f32, + gravity_factor: f32, + linear_damping: f32, + pressure: f32, ) -> f64 { let need_f = (vertex_count * 4) as usize; let need_u = (triangle_count * 3) as usize; if self.scratch_f32.len() < need_f || self.scratch_u32.len() < need_u { return 0.0; } - if vertex_count < 3 || triangle_count == 0 { return 0.0; } - let world = match self.worlds.get(world_h) { Some(&w) => w, None => return 0.0 }; + if vertex_count < 3 || triangle_count == 0 { + return 0.0; + } + let world = match self.worlds.get(world_h) { + Some(&w) => w, + None => return 0.0, + }; let body = unsafe { bj_soft_body_create( world, - self.scratch_f32.as_ptr(), vertex_count, - self.scratch_u32.as_ptr(), triangle_count, - BjVec3 { x: px, y: py, z: pz }, - BjQuat { x: rx, y: ry, z: rz, w: rw }, - object_layer, edge_compliance, gravity_factor, linear_damping, pressure, + self.scratch_f32.as_ptr(), + vertex_count, + self.scratch_u32.as_ptr(), + triangle_count, + BjVec3 { + x: px, + y: py, + z: pz, + }, + BjQuat { + x: rx, + y: ry, + z: rz, + w: rw, + }, + object_layer, + edge_compliance, + gravity_factor, + linear_damping, + pressure, ) }; - if body == BJ_INVALID { 0.0 } else { self.bodies.alloc((world, body)) } + if body == BJ_INVALID { + 0.0 + } else { + self.bodies.alloc((world, body)) + } } pub fn soft_body_vertex_count(&self, body_h: f64) -> u32 { @@ -1225,19 +2276,30 @@ impl JoltPhysics { pub fn soft_body_get_vertex_axis(&self, body_h: f64, idx: u32, axis: u32) -> f64 { if let Some((w, b)) = self.resolve_body(body_h) { let mut v = BjVec3::default(); - unsafe { bj_soft_body_get_vertex(w, b, idx, &mut v); } - return match axis { 0 => v.x, 1 => v.y, 2 => v.z, _ => 0.0 } as f64; + unsafe { + bj_soft_body_get_vertex(w, b, idx, &mut v); + } + return match axis { + 0 => v.x, + 1 => v.y, + 2 => v.z, + _ => 0.0, + } as f64; } 0.0 } pub fn soft_body_set_vertex(&self, body_h: f64, idx: u32, x: f32, y: f32, z: f32) { if let Some((w, b)) = self.resolve_body(body_h) { - unsafe { bj_soft_body_set_vertex(w, b, idx, BjVec3 { x, y, z }); } + unsafe { + bj_soft_body_set_vertex(w, b, idx, BjVec3 { x, y, z }); + } } } pub fn soft_body_set_vertex_inv_mass(&self, body_h: f64, idx: u32, inv_mass: f32) { if let Some((w, b)) = self.resolve_body(body_h) { - unsafe { bj_soft_body_set_vertex_inv_mass(w, b, idx, inv_mass); } + unsafe { + bj_soft_body_set_vertex_inv_mass(w, b, idx, inv_mass); + } } } @@ -1245,50 +2307,125 @@ impl JoltPhysics { #[allow(clippy::too_many_arguments)] pub fn vehicle_create( - &mut self, world_h: f64, chassis_shape_h: f64, + &mut self, + world_h: f64, + chassis_shape_h: f64, // Axes - up_x: f32, up_y: f32, up_z: f32, - fw_x: f32, fw_y: f32, fw_z: f32, + up_x: f32, + up_y: f32, + up_z: f32, + fw_x: f32, + fw_y: f32, + fw_z: f32, // 4 wheel positions (flat array of 12 f32s: fl.xyz, fr.xyz, rl.xyz, rr.xyz) - w0x: f32, w0y: f32, w0z: f32, - w1x: f32, w1y: f32, w1z: f32, - w2x: f32, w2y: f32, w2z: f32, - w3x: f32, w3y: f32, w3z: f32, + w0x: f32, + w0y: f32, + w0z: f32, + w1x: f32, + w1y: f32, + w1z: f32, + w2x: f32, + w2y: f32, + w2z: f32, + w3x: f32, + w3y: f32, + w3z: f32, // Shared wheel + engine parameters - wheel_radius: f32, wheel_width: f32, - suspension_min: f32, suspension_max: f32, - max_steer_angle: f32, max_brake_torque: f32, max_handbrake_torque: f32, - engine_max_torque: f32, max_pitch_roll_angle: f32, + wheel_radius: f32, + wheel_width: f32, + suspension_min: f32, + suspension_max: f32, + max_steer_angle: f32, + max_brake_torque: f32, + max_handbrake_torque: f32, + engine_max_torque: f32, + max_pitch_roll_angle: f32, object_layer: u32, // Pose - px: f32, py: f32, pz: f32, rx: f32, ry: f32, rz: f32, rw: f32, + px: f32, + py: f32, + pz: f32, + rx: f32, + ry: f32, + rz: f32, + rw: f32, ) -> (f64, f64) { // Returns (vehicle_handle, chassis_body_handle). - let world = match self.worlds.get(world_h) { Some(&w) => w, None => return (0.0, 0.0) }; - let shape = match self.shapes.get(chassis_shape_h) { Some(&s) => s, None => return (0.0, 0.0) }; + let world = match self.worlds.get(world_h) { + Some(&w) => w, + None => return (0.0, 0.0), + }; + let shape = match self.shapes.get(chassis_shape_h) { + Some(&s) => s, + None => return (0.0, 0.0), + }; let desc = BjVehicleDesc { - up: BjVec3 { x: up_x, y: up_y, z: up_z }, - forward: BjVec3 { x: fw_x, y: fw_y, z: fw_z }, + up: BjVec3 { + x: up_x, + y: up_y, + z: up_z, + }, + forward: BjVec3 { + x: fw_x, + y: fw_y, + z: fw_z, + }, wheel_positions: [ - BjVec3 { x: w0x, y: w0y, z: w0z }, - BjVec3 { x: w1x, y: w1y, z: w1z }, - BjVec3 { x: w2x, y: w2y, z: w2z }, - BjVec3 { x: w3x, y: w3y, z: w3z }, + BjVec3 { + x: w0x, + y: w0y, + z: w0z, + }, + BjVec3 { + x: w1x, + y: w1y, + z: w1z, + }, + BjVec3 { + x: w2x, + y: w2y, + z: w2z, + }, + BjVec3 { + x: w3x, + y: w3y, + z: w3z, + }, ], - wheel_radius, wheel_width, + wheel_radius, + wheel_width, suspension_min_length: suspension_min, suspension_max_length: suspension_max, - max_steer_angle, max_brake_torque, max_handbrake_torque, - engine_max_torque, max_pitch_roll_angle, object_layer, + max_steer_angle, + max_brake_torque, + max_handbrake_torque, + engine_max_torque, + max_pitch_roll_angle, + object_layer, }; let vehicle = unsafe { - bj_vehicle_create(world, shape, &desc, - BjVec3 { x: px, y: py, z: pz }, - BjQuat { x: rx, y: ry, z: rz, w: rw }) + bj_vehicle_create( + world, + shape, + &desc, + BjVec3 { + x: px, + y: py, + z: pz, + }, + BjQuat { + x: rx, + y: ry, + z: rz, + w: rw, + }, + ) }; - if vehicle == BJ_INVALID { return (0.0, 0.0); } + if vehicle == BJ_INVALID { + return (0.0, 0.0); + } // Register the chassis as a regular body so TS can query its transform // via normal body APIs. @@ -1300,7 +2437,9 @@ impl JoltPhysics { pub fn vehicle_destroy(&mut self, handle: f64) { if let Some((w, v, chassis_h)) = self.vehicles.free(handle) { - unsafe { bj_vehicle_destroy(w, v); } + unsafe { + bj_vehicle_destroy(w, v); + } // Chassis was removed by vehicle_destroy; free its Rust handle too. let _ = self.bodies.free(chassis_h); } @@ -1314,9 +2453,18 @@ impl JoltPhysics { self.vehicles.get(h).map(|&(w, v, _)| (w, v)) } - pub fn vehicle_set_input(&self, handle: f64, forward: f32, right: f32, brake: f32, handbrake: f32) { + pub fn vehicle_set_input( + &self, + handle: f64, + forward: f32, + right: f32, + brake: f32, + handbrake: f32, + ) { if let Some((w, v)) = self.resolve_vehicle(handle) { - unsafe { bj_vehicle_set_input(w, v, forward, right, brake, handbrake); } + unsafe { + bj_vehicle_set_input(w, v, forward, right, brake, handbrake); + } } } @@ -1359,366 +2507,1086 @@ macro_rules! define_physics_ffi { () => { // --- World --- - #[no_mangle] pub extern "C" fn bloom_physics_create_world(gx: f64, gy: f64, gz: f64, max_bodies: f64, num_threads: f64) -> f64 { - bloom_jolt_ffi_physics().create_world(gx as f32, gy as f32, gz as f32, max_bodies as u32, num_threads as u32) + #[no_mangle] + pub extern "C" fn bloom_physics_create_world( + gx: f64, + gy: f64, + gz: f64, + max_bodies: f64, + num_threads: f64, + ) -> f64 { + bloom_jolt_ffi_physics().create_world( + gx as f32, + gy as f32, + gz as f32, + max_bodies as u32, + num_threads as u32, + ) + } + #[no_mangle] + pub extern "C" fn bloom_physics_destroy_world(world: f64) { + bloom_jolt_ffi_physics().destroy_world(world); } - #[no_mangle] pub extern "C" fn bloom_physics_destroy_world(world: f64) { bloom_jolt_ffi_physics().destroy_world(world); } - #[no_mangle] pub extern "C" fn bloom_physics_set_gravity(world: f64, gx: f64, gy: f64, gz: f64) { + #[no_mangle] + pub extern "C" fn bloom_physics_set_gravity(world: f64, gx: f64, gy: f64, gz: f64) { bloom_jolt_ffi_physics().set_gravity(world, gx as f32, gy as f32, gz as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_get_gravity(world: f64, axis: f64) -> f64 { bloom_jolt_ffi_physics().get_gravity_axis(world, axis as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_optimize_broadphase(world: f64) { bloom_jolt_ffi_physics().optimize_broadphase(world); } - #[no_mangle] pub extern "C" fn bloom_physics_step(world: f64, dt: f64, collision_steps: f64) { + #[no_mangle] + pub extern "C" fn bloom_physics_get_gravity(world: f64, axis: f64) -> f64 { + bloom_jolt_ffi_physics().get_gravity_axis(world, axis as u32) + } + #[no_mangle] + pub extern "C" fn bloom_physics_optimize_broadphase(world: f64) { + bloom_jolt_ffi_physics().optimize_broadphase(world); + } + #[no_mangle] + pub extern "C" fn bloom_physics_step(world: f64, dt: f64, collision_steps: f64) { bloom_jolt_ffi_physics().step(world, dt as f32, collision_steps as u32); } - #[no_mangle] pub extern "C" fn bloom_physics_step_fixed(world: f64, dt: f64, collision_steps: f64) -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_step_fixed( + world: f64, + dt: f64, + collision_steps: f64, + ) -> f64 { bloom_jolt_ffi_physics().step_fixed(world, dt as f32, collision_steps as u32) as f64 } - #[no_mangle] pub extern "C" fn bloom_physics_set_fixed_timestep(world: f64, hz: f64, max_steps: f64) { + #[no_mangle] + pub extern "C" fn bloom_physics_set_fixed_timestep(world: f64, hz: f64, max_steps: f64) { bloom_jolt_ffi_physics().set_fixed_timestep(world, hz as f32, max_steps as u32); } - #[no_mangle] pub extern "C" fn bloom_physics_set_interpolation(world: f64, on: f64) { + #[no_mangle] + pub extern "C" fn bloom_physics_set_interpolation(world: f64, on: f64) { bloom_jolt_ffi_physics().set_interpolation(world, on != 0.0); } - #[no_mangle] pub extern "C" fn bloom_physics_get_step_alpha(world: f64) -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_get_step_alpha(world: f64) -> f64 { bloom_jolt_ffi_physics().step_alpha(world) as f64 } - #[no_mangle] pub extern "C" fn bloom_physics_set_layer_collides(world: f64, a: f64, b: f64, collides: f64) { + #[no_mangle] + pub extern "C" fn bloom_physics_set_layer_collides( + world: f64, + a: f64, + b: f64, + collides: f64, + ) { bloom_jolt_ffi_physics().set_layer_collides(world, a as u32, b as u32, collides != 0.0); } - #[no_mangle] pub extern "C" fn bloom_physics_get_layer_collides(world: f64, a: f64, b: f64) -> f64 { - if bloom_jolt_ffi_physics().get_layer_collides(world, a as u32, b as u32) { 1.0 } else { 0.0 } + #[no_mangle] + pub extern "C" fn bloom_physics_get_layer_collides(world: f64, a: f64, b: f64) -> f64 { + if bloom_jolt_ffi_physics().get_layer_collides(world, a as u32, b as u32) { + 1.0 + } else { + 0.0 + } + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_count(world: f64) -> f64 { + bloom_jolt_ffi_physics().body_count(world) as f64 + } + #[no_mangle] + pub extern "C" fn bloom_physics_active_body_count(world: f64) -> f64 { + bloom_jolt_ffi_physics().active_body_count(world) as f64 } - #[no_mangle] pub extern "C" fn bloom_physics_body_count(world: f64) -> f64 { bloom_jolt_ffi_physics().body_count(world) as f64 } - #[no_mangle] pub extern "C" fn bloom_physics_active_body_count(world: f64) -> f64 { bloom_jolt_ffi_physics().active_body_count(world) as f64 } // --- Shapes --- - #[no_mangle] pub extern "C" fn bloom_physics_shape_box(hx: f64, hy: f64, hz: f64, convex_radius: f64) -> f64 { - bloom_jolt_ffi_physics().create_box_shape(hx as f32, hy as f32, hz as f32, convex_radius as f32) + #[no_mangle] + pub extern "C" fn bloom_physics_shape_box( + hx: f64, + hy: f64, + hz: f64, + convex_radius: f64, + ) -> f64 { + bloom_jolt_ffi_physics().create_box_shape( + hx as f32, + hy as f32, + hz as f32, + convex_radius as f32, + ) + } + #[no_mangle] + pub extern "C" fn bloom_physics_shape_sphere(r: f64) -> f64 { + bloom_jolt_ffi_physics().create_sphere_shape(r as f32) + } + #[no_mangle] + pub extern "C" fn bloom_physics_shape_capsule(h: f64, r: f64) -> f64 { + bloom_jolt_ffi_physics().create_capsule_shape(h as f32, r as f32) } - #[no_mangle] pub extern "C" fn bloom_physics_shape_sphere(r: f64) -> f64 { bloom_jolt_ffi_physics().create_sphere_shape(r as f32) } - #[no_mangle] pub extern "C" fn bloom_physics_shape_capsule(h: f64, r: f64) -> f64 { bloom_jolt_ffi_physics().create_capsule_shape(h as f32, r as f32) } - #[no_mangle] pub extern "C" fn bloom_physics_shape_cylinder(h: f64, r: f64, cr: f64) -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_shape_cylinder(h: f64, r: f64, cr: f64) -> f64 { bloom_jolt_ffi_physics().create_cylinder_shape(h as f32, r as f32, cr as f32) } - #[no_mangle] pub extern "C" fn bloom_physics_shape_scaled(base: f64, sx: f64, sy: f64, sz: f64) -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_shape_scaled(base: f64, sx: f64, sy: f64, sz: f64) -> f64 { bloom_jolt_ffi_physics().create_scaled_shape(base, sx as f32, sy as f32, sz as f32) } - #[no_mangle] pub extern "C" fn bloom_physics_shape_offset_com(base: f64, ox: f64, oy: f64, oz: f64) -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_shape_offset_com( + base: f64, + ox: f64, + oy: f64, + oz: f64, + ) -> f64 { bloom_jolt_ffi_physics().create_offset_com_shape(base, ox as f32, oy as f32, oz as f32) } - #[no_mangle] pub extern "C" fn bloom_physics_shape_release(shape: f64) { bloom_jolt_ffi_physics().release_shape(shape); } + #[no_mangle] + pub extern "C" fn bloom_physics_shape_release(shape: f64) { + bloom_jolt_ffi_physics().release_shape(shape); + } // Scratch streams for variable-size shape inputs. - #[no_mangle] pub extern "C" fn bloom_physics_scratch_reset() { bloom_jolt_ffi_physics().scratch_reset(); } - #[no_mangle] pub extern "C" fn bloom_physics_scratch_push_f32(v: f64) { bloom_jolt_ffi_physics().scratch_push_f32(v as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_scratch_push_u32(v: f64) { bloom_jolt_ffi_physics().scratch_push_u32(v as u32); } - - // Complex shape factories — consume scratch streams populated by the caller. - #[no_mangle] pub extern "C" fn bloom_physics_shape_convex_hull(num_points: f64, convex_radius: f64) -> f64 { - bloom_jolt_ffi_physics().shape_convex_hull_from_scratch(num_points as u32, convex_radius as f32) + #[no_mangle] + pub extern "C" fn bloom_physics_scratch_reset() { + bloom_jolt_ffi_physics().scratch_reset(); } - #[no_mangle] pub extern "C" fn bloom_physics_shape_mesh(vertex_count: f64, triangle_count: f64) -> f64 { - bloom_jolt_ffi_physics().shape_mesh_from_scratch(vertex_count as u32, triangle_count as u32) + #[no_mangle] + pub extern "C" fn bloom_physics_scratch_push_f32(v: f64) { + bloom_jolt_ffi_physics().scratch_push_f32(v as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_shape_heightfield( - sample_count: f64, ox: f64, oy: f64, oz: f64, sx: f64, sy: f64, sz: f64, block_size: f64, + #[no_mangle] + pub extern "C" fn bloom_physics_scratch_push_u32(v: f64) { + bloom_jolt_ffi_physics().scratch_push_u32(v as u32); + } + + // Complex shape factories — consume scratch streams populated by the caller. + #[no_mangle] + pub extern "C" fn bloom_physics_shape_convex_hull( + num_points: f64, + convex_radius: f64, + ) -> f64 { + bloom_jolt_ffi_physics() + .shape_convex_hull_from_scratch(num_points as u32, convex_radius as f32) + } + #[no_mangle] + pub extern "C" fn bloom_physics_shape_mesh(vertex_count: f64, triangle_count: f64) -> f64 { + bloom_jolt_ffi_physics() + .shape_mesh_from_scratch(vertex_count as u32, triangle_count as u32) + } + #[no_mangle] + pub extern "C" fn bloom_physics_shape_heightfield( + sample_count: f64, + ox: f64, + oy: f64, + oz: f64, + sx: f64, + sy: f64, + sz: f64, + block_size: f64, ) -> f64 { bloom_jolt_ffi_physics().shape_heightfield_from_scratch( - sample_count as u32, ox as f32, oy as f32, oz as f32, - sx as f32, sy as f32, sz as f32, block_size as u32, + sample_count as u32, + ox as f32, + oy as f32, + oz as f32, + sx as f32, + sy as f32, + sz as f32, + block_size as u32, ) } // Compound shape builder — begin / add_child (repeat) / end. - #[no_mangle] pub extern "C" fn bloom_physics_compound_begin() { bloom_jolt_ffi_physics().compound_begin(); } - #[no_mangle] pub extern "C" fn bloom_physics_compound_add_child( - shape: f64, px: f64, py: f64, pz: f64, rx: f64, ry: f64, rz: f64, rw: f64, + #[no_mangle] + pub extern "C" fn bloom_physics_compound_begin() { + bloom_jolt_ffi_physics().compound_begin(); + } + #[no_mangle] + pub extern "C" fn bloom_physics_compound_add_child( + shape: f64, + px: f64, + py: f64, + pz: f64, + rx: f64, + ry: f64, + rz: f64, + rw: f64, ) { bloom_jolt_ffi_physics().compound_add_child( - shape, - px as f32, py as f32, pz as f32, - rx as f32, ry as f32, rz as f32, rw as f32, + shape, px as f32, py as f32, pz as f32, rx as f32, ry as f32, rz as f32, rw as f32, ); } - #[no_mangle] pub extern "C" fn bloom_physics_compound_end() -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_compound_end() -> f64 { bloom_jolt_ffi_physics().compound_end() } - #[no_mangle] pub extern "C" fn bloom_physics_shape_bounds(shape: f64, axis: f64) -> f64 { bloom_jolt_ffi_physics().shape_bounds_axis(shape, axis as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_shape_volume(shape: f64) -> f64 { bloom_jolt_ffi_physics().shape_volume(shape) as f64 } + #[no_mangle] + pub extern "C" fn bloom_physics_shape_bounds(shape: f64, axis: f64) -> f64 { + bloom_jolt_ffi_physics().shape_bounds_axis(shape, axis as u32) + } + #[no_mangle] + pub extern "C" fn bloom_physics_shape_volume(shape: f64) -> f64 { + bloom_jolt_ffi_physics().shape_volume(shape) as f64 + } // --- Bodies --- - #[no_mangle] pub extern "C" fn bloom_physics_body_create( - world: f64, shape: f64, motion_type: f64, - px: f64, py: f64, pz: f64, - rx: f64, ry: f64, rz: f64, rw: f64, + #[no_mangle] + pub extern "C" fn bloom_physics_body_create( + world: f64, + shape: f64, + motion_type: f64, + px: f64, + py: f64, + pz: f64, + rx: f64, + ry: f64, + rz: f64, + rw: f64, layer: f64, ) -> f64 { bloom_jolt_ffi_physics().create_body( - world, shape, motion_type as u32, - px as f32, py as f32, pz as f32, - rx as f32, ry as f32, rz as f32, rw as f32, - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + world, + shape, + motion_type as u32, + px as f32, + py as f32, + pz as f32, + rx as f32, + ry as f32, + rz as f32, + rw as f32, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, layer as u32, - false, true, false, true, - 0.2, 0.0, 0.05, 0.05, 1.0, - 0.0, 0.0, 0.0, 0.0, + false, + true, + false, + true, + 0.2, + 0.0, + 0.05, + 0.05, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, 0, ) } - #[no_mangle] pub extern "C" fn bloom_physics_body_destroy(body: f64) { bloom_jolt_ffi_physics().destroy_body(body); } - #[no_mangle] pub extern "C" fn bloom_physics_body_activate(body: f64) { bloom_jolt_ffi_physics().body_activate(body); } - #[no_mangle] pub extern "C" fn bloom_physics_body_deactivate(body: f64) { bloom_jolt_ffi_physics().body_deactivate(body); } - #[no_mangle] pub extern "C" fn bloom_physics_body_is_active(body: f64) -> f64 { if bloom_jolt_ffi_physics().body_is_active(body) { 1.0 } else { 0.0 } } - #[no_mangle] pub extern "C" fn bloom_physics_body_is_valid(body: f64) -> f64 { if bloom_jolt_ffi_physics().body_is_valid(body) { 1.0 } else { 0.0 } } + #[no_mangle] + pub extern "C" fn bloom_physics_body_destroy(body: f64) { + bloom_jolt_ffi_physics().destroy_body(body); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_activate(body: f64) { + bloom_jolt_ffi_physics().body_activate(body); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_deactivate(body: f64) { + bloom_jolt_ffi_physics().body_deactivate(body); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_is_active(body: f64) -> f64 { + if bloom_jolt_ffi_physics().body_is_active(body) { + 1.0 + } else { + 0.0 + } + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_is_valid(body: f64) -> f64 { + if bloom_jolt_ffi_physics().body_is_valid(body) { + 1.0 + } else { + 0.0 + } + } - #[no_mangle] pub extern "C" fn bloom_physics_body_get_position(body: f64, axis: f64) -> f64 { bloom_jolt_ffi_physics().body_get_position_axis(body, axis as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_body_get_rotation(body: f64, axis: f64) -> f64 { bloom_jolt_ffi_physics().body_get_rotation_axis(body, axis as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_position(body: f64, x: f64, y: f64, z: f64, activate: f64) { - bloom_jolt_ffi_physics().body_set_position(body, x as f32, y as f32, z as f32, activate != 0.0); + #[no_mangle] + pub extern "C" fn bloom_physics_body_get_position(body: f64, axis: f64) -> f64 { + bloom_jolt_ffi_physics().body_get_position_axis(body, axis as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_rotation(body: f64, x: f64, y: f64, z: f64, w: f64, activate: f64) { - bloom_jolt_ffi_physics().body_set_rotation(body, x as f32, y as f32, z as f32, w as f32, activate != 0.0); + #[no_mangle] + pub extern "C" fn bloom_physics_body_get_rotation(body: f64, axis: f64) -> f64 { + bloom_jolt_ffi_physics().body_get_rotation_axis(body, axis as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_transform( - body: f64, px: f64, py: f64, pz: f64, rx: f64, ry: f64, rz: f64, rw: f64, activate: f64, + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_position( + body: f64, + x: f64, + y: f64, + z: f64, + activate: f64, ) { - bloom_jolt_ffi_physics().body_set_transform(body, px as f32, py as f32, pz as f32, rx as f32, ry as f32, rz as f32, rw as f32, activate != 0.0); + bloom_jolt_ffi_physics().body_set_position( + body, + x as f32, + y as f32, + z as f32, + activate != 0.0, + ); } - #[no_mangle] pub extern "C" fn bloom_physics_body_move_kinematic( - body: f64, px: f64, py: f64, pz: f64, rx: f64, ry: f64, rz: f64, rw: f64, dt: f64, + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_rotation( + body: f64, + x: f64, + y: f64, + z: f64, + w: f64, + activate: f64, ) { - bloom_jolt_ffi_physics().body_move_kinematic(body, px as f32, py as f32, pz as f32, rx as f32, ry as f32, rz as f32, rw as f32, dt as f32); + bloom_jolt_ffi_physics().body_set_rotation( + body, + x as f32, + y as f32, + z as f32, + w as f32, + activate != 0.0, + ); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_transform( + body: f64, + px: f64, + py: f64, + pz: f64, + rx: f64, + ry: f64, + rz: f64, + rw: f64, + activate: f64, + ) { + bloom_jolt_ffi_physics().body_set_transform( + body, + px as f32, + py as f32, + pz as f32, + rx as f32, + ry as f32, + rz as f32, + rw as f32, + activate != 0.0, + ); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_move_kinematic( + body: f64, + px: f64, + py: f64, + pz: f64, + rx: f64, + ry: f64, + rz: f64, + rw: f64, + dt: f64, + ) { + bloom_jolt_ffi_physics().body_move_kinematic( + body, px as f32, py as f32, pz as f32, rx as f32, ry as f32, rz as f32, rw as f32, + dt as f32, + ); } - #[no_mangle] pub extern "C" fn bloom_physics_body_get_linear_velocity(body: f64, axis: f64) -> f64 { bloom_jolt_ffi_physics().body_get_linear_velocity_axis(body, axis as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_body_get_angular_velocity(body: f64, axis: f64) -> f64 { bloom_jolt_ffi_physics().body_get_angular_velocity_axis(body, axis as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_body_get_point_velocity(body: f64, px: f64, py: f64, pz: f64, axis: f64) -> f64 { - bloom_jolt_ffi_physics().body_get_point_velocity_axis(body, px as f32, py as f32, pz as f32, axis as u32) + #[no_mangle] + pub extern "C" fn bloom_physics_body_get_linear_velocity(body: f64, axis: f64) -> f64 { + bloom_jolt_ffi_physics().body_get_linear_velocity_axis(body, axis as u32) + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_get_angular_velocity(body: f64, axis: f64) -> f64 { + bloom_jolt_ffi_physics().body_get_angular_velocity_axis(body, axis as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_linear_velocity(body: f64, x: f64, y: f64, z: f64) { + #[no_mangle] + pub extern "C" fn bloom_physics_body_get_point_velocity( + body: f64, + px: f64, + py: f64, + pz: f64, + axis: f64, + ) -> f64 { + bloom_jolt_ffi_physics().body_get_point_velocity_axis( + body, + px as f32, + py as f32, + pz as f32, + axis as u32, + ) + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_linear_velocity( + body: f64, + x: f64, + y: f64, + z: f64, + ) { bloom_jolt_ffi_physics().body_set_linear_velocity(body, x as f32, y as f32, z as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_angular_velocity(body: f64, x: f64, y: f64, z: f64) { + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_angular_velocity( + body: f64, + x: f64, + y: f64, + z: f64, + ) { bloom_jolt_ffi_physics().body_set_angular_velocity(body, x as f32, y as f32, z as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_body_add_force(body: f64, x: f64, y: f64, z: f64) { bloom_jolt_ffi_physics().body_add_force(body, x as f32, y as f32, z as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_body_add_impulse(body: f64, x: f64, y: f64, z: f64) { bloom_jolt_ffi_physics().body_add_impulse(body, x as f32, y as f32, z as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_body_add_torque(body: f64, x: f64, y: f64, z: f64) { bloom_jolt_ffi_physics().body_add_torque(body, x as f32, y as f32, z as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_body_add_angular_impulse(body: f64, x: f64, y: f64, z: f64) { bloom_jolt_ffi_physics().body_add_angular_impulse(body, x as f32, y as f32, z as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_body_add_force_at(body: f64, fx: f64, fy: f64, fz: f64, px: f64, py: f64, pz: f64) { - bloom_jolt_ffi_physics().body_add_force_at(body, fx as f32, fy as f32, fz as f32, px as f32, py as f32, pz as f32); + #[no_mangle] + pub extern "C" fn bloom_physics_body_add_force(body: f64, x: f64, y: f64, z: f64) { + bloom_jolt_ffi_physics().body_add_force(body, x as f32, y as f32, z as f32); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_add_impulse(body: f64, x: f64, y: f64, z: f64) { + bloom_jolt_ffi_physics().body_add_impulse(body, x as f32, y as f32, z as f32); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_add_torque(body: f64, x: f64, y: f64, z: f64) { + bloom_jolt_ffi_physics().body_add_torque(body, x as f32, y as f32, z as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_body_add_impulse_at(body: f64, ix: f64, iy: f64, iz: f64, px: f64, py: f64, pz: f64) { - bloom_jolt_ffi_physics().body_add_impulse_at(body, ix as f32, iy as f32, iz as f32, px as f32, py as f32, pz as f32); + #[no_mangle] + pub extern "C" fn bloom_physics_body_add_angular_impulse( + body: f64, + x: f64, + y: f64, + z: f64, + ) { + bloom_jolt_ffi_physics().body_add_angular_impulse(body, x as f32, y as f32, z as f32); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_add_force_at( + body: f64, + fx: f64, + fy: f64, + fz: f64, + px: f64, + py: f64, + pz: f64, + ) { + bloom_jolt_ffi_physics().body_add_force_at( + body, fx as f32, fy as f32, fz as f32, px as f32, py as f32, pz as f32, + ); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_add_impulse_at( + body: f64, + ix: f64, + iy: f64, + iz: f64, + px: f64, + py: f64, + pz: f64, + ) { + bloom_jolt_ffi_physics().body_add_impulse_at( + body, ix as f32, iy as f32, iz as f32, px as f32, py as f32, pz as f32, + ); } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_friction(body: f64, v: f64) { bloom_jolt_ffi_physics().body_set_friction(body, v as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_restitution(body: f64, v: f64) { bloom_jolt_ffi_physics().body_set_restitution(body, v as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_linear_damping(body: f64, v: f64) { bloom_jolt_ffi_physics().body_set_linear_damping(body, v as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_angular_damping(body: f64, v: f64) { bloom_jolt_ffi_physics().body_set_angular_damping(body, v as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_gravity_factor(body: f64, v: f64) { bloom_jolt_ffi_physics().body_set_gravity_factor(body, v as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_ccd(body: f64, enabled: f64) { bloom_jolt_ffi_physics().body_set_ccd(body, enabled != 0.0); } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_motion_type(body: f64, t: f64, activate: f64) { bloom_jolt_ffi_physics().body_set_motion_type(body, t as u32, activate != 0.0); } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_object_layer(body: f64, layer: f64) { bloom_jolt_ffi_physics().body_set_object_layer(body, layer as u32); } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_is_sensor(body: f64, enabled: f64) { bloom_jolt_ffi_physics().body_set_is_sensor(body, enabled != 0.0); } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_allow_sleeping(body: f64, enabled: f64) { bloom_jolt_ffi_physics().body_set_allow_sleeping(body, enabled != 0.0); } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_shape(body: f64, shape: f64, update_mass: f64, activate: f64) { - bloom_jolt_ffi_physics().body_set_shape(body, shape, update_mass != 0.0, activate != 0.0); + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_friction(body: f64, v: f64) { + bloom_jolt_ffi_physics().body_set_friction(body, v as f32); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_restitution(body: f64, v: f64) { + bloom_jolt_ffi_physics().body_set_restitution(body, v as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_body_lock_rotation_axes(body: f64, x: f64, y: f64, z: f64) { + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_linear_damping(body: f64, v: f64) { + bloom_jolt_ffi_physics().body_set_linear_damping(body, v as f32); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_angular_damping(body: f64, v: f64) { + bloom_jolt_ffi_physics().body_set_angular_damping(body, v as f32); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_gravity_factor(body: f64, v: f64) { + bloom_jolt_ffi_physics().body_set_gravity_factor(body, v as f32); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_ccd(body: f64, enabled: f64) { + bloom_jolt_ffi_physics().body_set_ccd(body, enabled != 0.0); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_motion_type(body: f64, t: f64, activate: f64) { + bloom_jolt_ffi_physics().body_set_motion_type(body, t as u32, activate != 0.0); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_object_layer(body: f64, layer: f64) { + bloom_jolt_ffi_physics().body_set_object_layer(body, layer as u32); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_is_sensor(body: f64, enabled: f64) { + bloom_jolt_ffi_physics().body_set_is_sensor(body, enabled != 0.0); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_allow_sleeping(body: f64, enabled: f64) { + bloom_jolt_ffi_physics().body_set_allow_sleeping(body, enabled != 0.0); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_shape( + body: f64, + shape: f64, + update_mass: f64, + activate: f64, + ) { + bloom_jolt_ffi_physics().body_set_shape( + body, + shape, + update_mass != 0.0, + activate != 0.0, + ); + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_lock_rotation_axes(body: f64, x: f64, y: f64, z: f64) { bloom_jolt_ffi_physics().body_lock_rotation_axes(body, x != 0.0, y != 0.0, z != 0.0); } - #[no_mangle] pub extern "C" fn bloom_physics_body_lock_translation_axes(body: f64, x: f64, y: f64, z: f64) { + #[no_mangle] + pub extern "C" fn bloom_physics_body_lock_translation_axes( + body: f64, + x: f64, + y: f64, + z: f64, + ) { bloom_jolt_ffi_physics().body_lock_translation_axes(body, x != 0.0, y != 0.0, z != 0.0); } - #[no_mangle] pub extern "C" fn bloom_physics_body_get_mass(body: f64) -> f64 { bloom_jolt_ffi_physics().body_get_mass(body) as f64 } - #[no_mangle] pub extern "C" fn bloom_physics_body_get_friction(body: f64) -> f64 { bloom_jolt_ffi_physics().body_get_friction(body) as f64 } - #[no_mangle] pub extern "C" fn bloom_physics_body_get_restitution(body: f64) -> f64 { bloom_jolt_ffi_physics().body_get_restitution(body) as f64 } - #[no_mangle] pub extern "C" fn bloom_physics_body_get_object_layer(body: f64) -> f64 { bloom_jolt_ffi_physics().body_get_object_layer(body) as f64 } - #[no_mangle] pub extern "C" fn bloom_physics_body_set_user_data(body: f64, lo: f64, hi: f64) { + #[no_mangle] + pub extern "C" fn bloom_physics_body_get_mass(body: f64) -> f64 { + bloom_jolt_ffi_physics().body_get_mass(body) as f64 + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_get_friction(body: f64) -> f64 { + bloom_jolt_ffi_physics().body_get_friction(body) as f64 + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_get_restitution(body: f64) -> f64 { + bloom_jolt_ffi_physics().body_get_restitution(body) as f64 + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_get_object_layer(body: f64) -> f64 { + bloom_jolt_ffi_physics().body_get_object_layer(body) as f64 + } + #[no_mangle] + pub extern "C" fn bloom_physics_body_set_user_data(body: f64, lo: f64, hi: f64) { let user = (lo as u64) | ((hi as u64) << 32); bloom_jolt_ffi_physics().body_set_user_data(body, user); } - #[no_mangle] pub extern "C" fn bloom_physics_body_get_user_data(body: f64, part: f64) -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_body_get_user_data(body: f64, part: f64) -> f64 { let u = bloom_jolt_ffi_physics().body_get_user_data(body); - (if part as u32 == 1 { (u >> 32) as u32 } else { u as u32 }) as f64 + (if part as u32 == 1 { + (u >> 32) as u32 + } else { + u as u32 + }) as f64 } // --- Queries --- - #[no_mangle] pub extern "C" fn bloom_physics_raycast( - world: f64, ox: f64, oy: f64, oz: f64, - dx: f64, dy: f64, dz: f64, max_dist: f64, layer_mask: f64, + #[no_mangle] + pub extern "C" fn bloom_physics_raycast( + world: f64, + ox: f64, + oy: f64, + oz: f64, + dx: f64, + dy: f64, + dz: f64, + max_dist: f64, + layer_mask: f64, ) -> f64 { - if bloom_jolt_ffi_physics().raycast_closest(world, ox as f32, oy as f32, oz as f32, dx as f32, dy as f32, dz as f32, max_dist as f32, layer_mask as u32) { 1.0 } else { 0.0 } + if bloom_jolt_ffi_physics().raycast_closest( + world, + ox as f32, + oy as f32, + oz as f32, + dx as f32, + dy as f32, + dz as f32, + max_dist as f32, + layer_mask as u32, + ) { + 1.0 + } else { + 0.0 + } } - #[no_mangle] pub extern "C" fn bloom_physics_raycast_all( - world: f64, ox: f64, oy: f64, oz: f64, - dx: f64, dy: f64, dz: f64, max_dist: f64, layer_mask: f64, max_hits: f64, + #[no_mangle] + pub extern "C" fn bloom_physics_raycast_all( + world: f64, + ox: f64, + oy: f64, + oz: f64, + dx: f64, + dy: f64, + dz: f64, + max_dist: f64, + layer_mask: f64, + max_hits: f64, ) -> f64 { - bloom_jolt_ffi_physics().raycast_all(world, ox as f32, oy as f32, oz as f32, dx as f32, dy as f32, dz as f32, max_dist as f32, layer_mask as u32, max_hits as u32) as f64 - } - #[no_mangle] pub extern "C" fn bloom_physics_ray_hit_count() -> f64 { bloom_jolt_ffi_physics().ray_hit_count() as f64 } - #[no_mangle] pub extern "C" fn bloom_physics_ray_hit_body(i: f64) -> f64 { bloom_jolt_ffi_physics().ray_hit_body(i as usize) } - #[no_mangle] pub extern "C" fn bloom_physics_ray_hit_axis(i: f64, field: f64) -> f64 { bloom_jolt_ffi_physics().ray_hit_axis(i as usize, field as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_ray_hit_fraction(i: f64) -> f64 { bloom_jolt_ffi_physics().ray_hit_fraction(i as usize) as f64 } - #[no_mangle] pub extern "C" fn bloom_physics_ray_hit_sub_shape(i: f64) -> f64 { bloom_jolt_ffi_physics().ray_hit_sub_shape(i as usize) as f64 } - - #[no_mangle] pub extern "C" fn bloom_physics_overlap_sphere(world: f64, cx: f64, cy: f64, cz: f64, r: f64, layer_mask: f64, max_results: f64) -> f64 { - bloom_jolt_ffi_physics().overlap_sphere(world, cx as f32, cy as f32, cz as f32, r as f32, layer_mask as u32, max_results as u32) as f64 - } - #[no_mangle] pub extern "C" fn bloom_physics_overlap_point(world: f64, px: f64, py: f64, pz: f64, layer_mask: f64, max_results: f64) -> f64 { - bloom_jolt_ffi_physics().overlap_point(world, px as f32, py as f32, pz as f32, layer_mask as u32, max_results as u32) as f64 - } - #[no_mangle] pub extern "C" fn bloom_physics_overlap_box( - world: f64, px: f64, py: f64, pz: f64, rx: f64, ry: f64, rz: f64, rw: f64, - hx: f64, hy: f64, hz: f64, layer_mask: f64, max_results: f64, + bloom_jolt_ffi_physics().raycast_all( + world, + ox as f32, + oy as f32, + oz as f32, + dx as f32, + dy as f32, + dz as f32, + max_dist as f32, + layer_mask as u32, + max_hits as u32, + ) as f64 + } + #[no_mangle] + pub extern "C" fn bloom_physics_ray_hit_count() -> f64 { + bloom_jolt_ffi_physics().ray_hit_count() as f64 + } + #[no_mangle] + pub extern "C" fn bloom_physics_ray_hit_body(i: f64) -> f64 { + bloom_jolt_ffi_physics().ray_hit_body(i as usize) + } + #[no_mangle] + pub extern "C" fn bloom_physics_ray_hit_axis(i: f64, field: f64) -> f64 { + bloom_jolt_ffi_physics().ray_hit_axis(i as usize, field as u32) + } + #[no_mangle] + pub extern "C" fn bloom_physics_ray_hit_fraction(i: f64) -> f64 { + bloom_jolt_ffi_physics().ray_hit_fraction(i as usize) as f64 + } + #[no_mangle] + pub extern "C" fn bloom_physics_ray_hit_sub_shape(i: f64) -> f64 { + bloom_jolt_ffi_physics().ray_hit_sub_shape(i as usize) as f64 + } + + #[no_mangle] + pub extern "C" fn bloom_physics_overlap_sphere( + world: f64, + cx: f64, + cy: f64, + cz: f64, + r: f64, + layer_mask: f64, + max_results: f64, + ) -> f64 { + bloom_jolt_ffi_physics().overlap_sphere( + world, + cx as f32, + cy as f32, + cz as f32, + r as f32, + layer_mask as u32, + max_results as u32, + ) as f64 + } + #[no_mangle] + pub extern "C" fn bloom_physics_overlap_point( + world: f64, + px: f64, + py: f64, + pz: f64, + layer_mask: f64, + max_results: f64, ) -> f64 { - bloom_jolt_ffi_physics().overlap_box(world, px as f32, py as f32, pz as f32, rx as f32, ry as f32, rz as f32, rw as f32, hx as f32, hy as f32, hz as f32, layer_mask as u32, max_results as u32) as f64 + bloom_jolt_ffi_physics().overlap_point( + world, + px as f32, + py as f32, + pz as f32, + layer_mask as u32, + max_results as u32, + ) as f64 + } + #[no_mangle] + pub extern "C" fn bloom_physics_overlap_box( + world: f64, + px: f64, + py: f64, + pz: f64, + rx: f64, + ry: f64, + rz: f64, + rw: f64, + hx: f64, + hy: f64, + hz: f64, + layer_mask: f64, + max_results: f64, + ) -> f64 { + bloom_jolt_ffi_physics().overlap_box( + world, + px as f32, + py as f32, + pz as f32, + rx as f32, + ry as f32, + rz as f32, + rw as f32, + hx as f32, + hy as f32, + hz as f32, + layer_mask as u32, + max_results as u32, + ) as f64 + } + #[no_mangle] + pub extern "C" fn bloom_physics_overlap_body(i: f64) -> f64 { + bloom_jolt_ffi_physics().overlap_body(i as usize) } - #[no_mangle] pub extern "C" fn bloom_physics_overlap_body(i: f64) -> f64 { bloom_jolt_ffi_physics().overlap_body(i as usize) } // --- Constraints --- - #[no_mangle] pub extern "C" fn bloom_physics_constraint_fixed( - body_a: f64, body_b: f64, ax: f64, ay: f64, az: f64, bx: f64, by: f64, bz: f64, world_space: f64, + #[no_mangle] + pub extern "C" fn bloom_physics_constraint_fixed( + body_a: f64, + body_b: f64, + ax: f64, + ay: f64, + az: f64, + bx: f64, + by: f64, + bz: f64, + world_space: f64, ) -> f64 { - bloom_jolt_ffi_physics().constraint_fixed(body_a, body_b, ax as f32, ay as f32, az as f32, bx as f32, by as f32, bz as f32, world_space != 0.0) + bloom_jolt_ffi_physics().constraint_fixed( + body_a, + body_b, + ax as f32, + ay as f32, + az as f32, + bx as f32, + by as f32, + bz as f32, + world_space != 0.0, + ) } - #[no_mangle] pub extern "C" fn bloom_physics_constraint_point( - body_a: f64, body_b: f64, ax: f64, ay: f64, az: f64, bx: f64, by: f64, bz: f64, world_space: f64, + #[no_mangle] + pub extern "C" fn bloom_physics_constraint_point( + body_a: f64, + body_b: f64, + ax: f64, + ay: f64, + az: f64, + bx: f64, + by: f64, + bz: f64, + world_space: f64, ) -> f64 { - bloom_jolt_ffi_physics().constraint_point(body_a, body_b, ax as f32, ay as f32, az as f32, bx as f32, by as f32, bz as f32, world_space != 0.0) + bloom_jolt_ffi_physics().constraint_point( + body_a, + body_b, + ax as f32, + ay as f32, + az as f32, + bx as f32, + by as f32, + bz as f32, + world_space != 0.0, + ) } - #[no_mangle] pub extern "C" fn bloom_physics_constraint_hinge( - body_a: f64, body_b: f64, ax: f64, ay: f64, az: f64, bx: f64, by: f64, bz: f64, - axx: f64, axy: f64, axz: f64, lmin: f64, lmax: f64, world_space: f64, + #[no_mangle] + pub extern "C" fn bloom_physics_constraint_hinge( + body_a: f64, + body_b: f64, + ax: f64, + ay: f64, + az: f64, + bx: f64, + by: f64, + bz: f64, + axx: f64, + axy: f64, + axz: f64, + lmin: f64, + lmax: f64, + world_space: f64, ) -> f64 { bloom_jolt_ffi_physics().constraint_hinge( - body_a, body_b, - ax as f32, ay as f32, az as f32, bx as f32, by as f32, bz as f32, - axx as f32, axy as f32, axz as f32, - lmin as f32, lmax as f32, world_space != 0.0, + body_a, + body_b, + ax as f32, + ay as f32, + az as f32, + bx as f32, + by as f32, + bz as f32, + axx as f32, + axy as f32, + axz as f32, + lmin as f32, + lmax as f32, + world_space != 0.0, ) } - #[no_mangle] pub extern "C" fn bloom_physics_constraint_slider( - body_a: f64, body_b: f64, ax: f64, ay: f64, az: f64, bx: f64, by: f64, bz: f64, - axx: f64, axy: f64, axz: f64, lmin: f64, lmax: f64, world_space: f64, + #[no_mangle] + pub extern "C" fn bloom_physics_constraint_slider( + body_a: f64, + body_b: f64, + ax: f64, + ay: f64, + az: f64, + bx: f64, + by: f64, + bz: f64, + axx: f64, + axy: f64, + axz: f64, + lmin: f64, + lmax: f64, + world_space: f64, ) -> f64 { bloom_jolt_ffi_physics().constraint_slider( - body_a, body_b, - ax as f32, ay as f32, az as f32, bx as f32, by as f32, bz as f32, - axx as f32, axy as f32, axz as f32, - lmin as f32, lmax as f32, world_space != 0.0, + body_a, + body_b, + ax as f32, + ay as f32, + az as f32, + bx as f32, + by as f32, + bz as f32, + axx as f32, + axy as f32, + axz as f32, + lmin as f32, + lmax as f32, + world_space != 0.0, ) } - #[no_mangle] pub extern "C" fn bloom_physics_constraint_distance( - body_a: f64, body_b: f64, ax: f64, ay: f64, az: f64, bx: f64, by: f64, bz: f64, - min_d: f64, max_d: f64, world_space: f64, + #[no_mangle] + pub extern "C" fn bloom_physics_constraint_distance( + body_a: f64, + body_b: f64, + ax: f64, + ay: f64, + az: f64, + bx: f64, + by: f64, + bz: f64, + min_d: f64, + max_d: f64, + world_space: f64, ) -> f64 { - bloom_jolt_ffi_physics().constraint_distance(body_a, body_b, ax as f32, ay as f32, az as f32, bx as f32, by as f32, bz as f32, min_d as f32, max_d as f32, world_space != 0.0) + bloom_jolt_ffi_physics().constraint_distance( + body_a, + body_b, + ax as f32, + ay as f32, + az as f32, + bx as f32, + by as f32, + bz as f32, + min_d as f32, + max_d as f32, + world_space != 0.0, + ) + } + #[no_mangle] + pub extern "C" fn bloom_physics_constraint_destroy(c: f64) { + bloom_jolt_ffi_physics().constraint_destroy(c); + } + #[no_mangle] + pub extern "C" fn bloom_physics_constraint_set_enabled(c: f64, enabled: f64) { + bloom_jolt_ffi_physics().constraint_set_enabled(c, enabled != 0.0); } - #[no_mangle] pub extern "C" fn bloom_physics_constraint_destroy(c: f64) { bloom_jolt_ffi_physics().constraint_destroy(c); } - #[no_mangle] pub extern "C" fn bloom_physics_constraint_set_enabled(c: f64, enabled: f64) { bloom_jolt_ffi_physics().constraint_set_enabled(c, enabled != 0.0); } // --- Contact events --- - #[no_mangle] pub extern "C" fn bloom_physics_contact_count() -> f64 { bloom_jolt_ffi_physics().contact_count() as f64 } - #[no_mangle] pub extern "C" fn bloom_physics_contact_field(i: f64, field: f64) -> f64 { bloom_jolt_ffi_physics().contact_field(i as usize, field as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_clear_contacts(world: f64) { let _ = world; bloom_jolt_ffi_physics().clear_contacts(); } + #[no_mangle] + pub extern "C" fn bloom_physics_contact_count() -> f64 { + bloom_jolt_ffi_physics().contact_count() as f64 + } + #[no_mangle] + pub extern "C" fn bloom_physics_contact_field(i: f64, field: f64) -> f64 { + bloom_jolt_ffi_physics().contact_field(i as usize, field as u32) + } + #[no_mangle] + pub extern "C" fn bloom_physics_clear_contacts(world: f64) { + let _ = world; + bloom_jolt_ffi_physics().clear_contacts(); + } // --- Character controller (Tier 2) --- - #[no_mangle] pub extern "C" fn bloom_physics_character_create( - world: f64, shape: f64, - up_x: f64, up_y: f64, up_z: f64, - max_slope_angle: f64, character_padding: f64, - penetration_recovery_speed: f64, predictive_contact_distance: f64, - max_strength: f64, mass: f64, object_layer: f64, - px: f64, py: f64, pz: f64, - rx: f64, ry: f64, rz: f64, rw: f64, + #[no_mangle] + pub extern "C" fn bloom_physics_character_create( + world: f64, + shape: f64, + up_x: f64, + up_y: f64, + up_z: f64, + max_slope_angle: f64, + character_padding: f64, + penetration_recovery_speed: f64, + predictive_contact_distance: f64, + max_strength: f64, + mass: f64, + object_layer: f64, + px: f64, + py: f64, + pz: f64, + rx: f64, + ry: f64, + rz: f64, + rw: f64, ) -> f64 { bloom_jolt_ffi_physics().character_create( - world, shape, - up_x as f32, up_y as f32, up_z as f32, - max_slope_angle as f32, character_padding as f32, - penetration_recovery_speed as f32, predictive_contact_distance as f32, - max_strength as f32, mass as f32, object_layer as u32, - px as f32, py as f32, pz as f32, - rx as f32, ry as f32, rz as f32, rw as f32, + world, + shape, + up_x as f32, + up_y as f32, + up_z as f32, + max_slope_angle as f32, + character_padding as f32, + penetration_recovery_speed as f32, + predictive_contact_distance as f32, + max_strength as f32, + mass as f32, + object_layer as u32, + px as f32, + py as f32, + pz as f32, + rx as f32, + ry as f32, + rz as f32, + rw as f32, ) } - #[no_mangle] pub extern "C" fn bloom_physics_character_destroy(c: f64) { bloom_jolt_ffi_physics().character_destroy(c); } - #[no_mangle] pub extern "C" fn bloom_physics_character_update(c: f64, dt: f64, gx: f64, gy: f64, gz: f64) { - bloom_jolt_ffi_physics().character_update(c, dt as f32, gx as f32, gy as f32, gz as f32); + #[no_mangle] + pub extern "C" fn bloom_physics_character_destroy(c: f64) { + bloom_jolt_ffi_physics().character_destroy(c); + } + #[no_mangle] + pub extern "C" fn bloom_physics_character_update( + c: f64, + dt: f64, + gx: f64, + gy: f64, + gz: f64, + ) { + bloom_jolt_ffi_physics() + .character_update(c, dt as f32, gx as f32, gy as f32, gz as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_character_get_position(c: f64, axis: f64) -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_character_get_position(c: f64, axis: f64) -> f64 { bloom_jolt_ffi_physics().character_get_position_axis(c, axis as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_character_get_rotation(c: f64, axis: f64) -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_character_get_rotation(c: f64, axis: f64) -> f64 { bloom_jolt_ffi_physics().character_get_rotation_axis(c, axis as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_character_set_position(c: f64, x: f64, y: f64, z: f64) { + #[no_mangle] + pub extern "C" fn bloom_physics_character_set_position(c: f64, x: f64, y: f64, z: f64) { bloom_jolt_ffi_physics().character_set_position(c, x as f32, y as f32, z as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_character_set_rotation(c: f64, x: f64, y: f64, z: f64, w: f64) { - bloom_jolt_ffi_physics().character_set_rotation(c, x as f32, y as f32, z as f32, w as f32); + #[no_mangle] + pub extern "C" fn bloom_physics_character_set_rotation( + c: f64, + x: f64, + y: f64, + z: f64, + w: f64, + ) { + bloom_jolt_ffi_physics() + .character_set_rotation(c, x as f32, y as f32, z as f32, w as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_character_get_linear_velocity(c: f64, axis: f64) -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_character_get_linear_velocity(c: f64, axis: f64) -> f64 { bloom_jolt_ffi_physics().character_get_linear_velocity_axis(c, axis as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_character_set_linear_velocity(c: f64, x: f64, y: f64, z: f64) { + #[no_mangle] + pub extern "C" fn bloom_physics_character_set_linear_velocity( + c: f64, + x: f64, + y: f64, + z: f64, + ) { bloom_jolt_ffi_physics().character_set_linear_velocity(c, x as f32, y as f32, z as f32); } - #[no_mangle] pub extern "C" fn bloom_physics_character_get_ground_state(c: f64) -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_character_get_ground_state(c: f64) -> f64 { bloom_jolt_ffi_physics().character_get_ground_state(c) as f64 } - #[no_mangle] pub extern "C" fn bloom_physics_character_get_ground_normal(c: f64, axis: f64) -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_character_get_ground_normal(c: f64, axis: f64) -> f64 { bloom_jolt_ffi_physics().character_get_ground_normal_axis(c, axis as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_character_get_ground_position(c: f64, axis: f64) -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_character_get_ground_position(c: f64, axis: f64) -> f64 { bloom_jolt_ffi_physics().character_get_ground_position_axis(c, axis as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_character_get_ground_body(c: f64) -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_character_get_ground_body(c: f64) -> f64 { bloom_jolt_ffi_physics().character_get_ground_body(c) } - #[no_mangle] pub extern "C" fn bloom_physics_character_set_shape(c: f64, shape: f64) { + #[no_mangle] + pub extern "C" fn bloom_physics_character_set_shape(c: f64, shape: f64) { bloom_jolt_ffi_physics().character_set_shape(c, shape); } // --- Soft bodies (Tier 2) --- - #[no_mangle] pub extern "C" fn bloom_physics_soft_body_create( - world: f64, vertex_count: f64, triangle_count: f64, - px: f64, py: f64, pz: f64, rx: f64, ry: f64, rz: f64, rw: f64, + #[no_mangle] + pub extern "C" fn bloom_physics_soft_body_create( + world: f64, + vertex_count: f64, + triangle_count: f64, + px: f64, + py: f64, + pz: f64, + rx: f64, + ry: f64, + rz: f64, + rw: f64, object_layer: f64, - edge_compliance: f64, gravity_factor: f64, linear_damping: f64, pressure: f64, + edge_compliance: f64, + gravity_factor: f64, + linear_damping: f64, + pressure: f64, ) -> f64 { bloom_jolt_ffi_physics().soft_body_create_from_scratch( - world, vertex_count as u32, triangle_count as u32, - px as f32, py as f32, pz as f32, - rx as f32, ry as f32, rz as f32, rw as f32, + world, + vertex_count as u32, + triangle_count as u32, + px as f32, + py as f32, + pz as f32, + rx as f32, + ry as f32, + rz as f32, + rw as f32, object_layer as u32, - edge_compliance as f32, gravity_factor as f32, - linear_damping as f32, pressure as f32, + edge_compliance as f32, + gravity_factor as f32, + linear_damping as f32, + pressure as f32, ) } - #[no_mangle] pub extern "C" fn bloom_physics_soft_body_vertex_count(body: f64) -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_soft_body_vertex_count(body: f64) -> f64 { bloom_jolt_ffi_physics().soft_body_vertex_count(body) as f64 } - #[no_mangle] pub extern "C" fn bloom_physics_soft_body_get_vertex(body: f64, idx: f64, axis: f64) -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_soft_body_get_vertex( + body: f64, + idx: f64, + axis: f64, + ) -> f64 { bloom_jolt_ffi_physics().soft_body_get_vertex_axis(body, idx as u32, axis as u32) } - #[no_mangle] pub extern "C" fn bloom_physics_soft_body_set_vertex(body: f64, idx: f64, x: f64, y: f64, z: f64) { - bloom_jolt_ffi_physics().soft_body_set_vertex(body, idx as u32, x as f32, y as f32, z as f32); - } - #[no_mangle] pub extern "C" fn bloom_physics_soft_body_set_vertex_inv_mass(body: f64, idx: f64, inv_mass: f64) { - bloom_jolt_ffi_physics().soft_body_set_vertex_inv_mass(body, idx as u32, inv_mass as f32); + #[no_mangle] + pub extern "C" fn bloom_physics_soft_body_set_vertex( + body: f64, + idx: f64, + x: f64, + y: f64, + z: f64, + ) { + bloom_jolt_ffi_physics() + .soft_body_set_vertex(body, idx as u32, x as f32, y as f32, z as f32); + } + #[no_mangle] + pub extern "C" fn bloom_physics_soft_body_set_vertex_inv_mass( + body: f64, + idx: f64, + inv_mass: f64, + ) { + bloom_jolt_ffi_physics().soft_body_set_vertex_inv_mass( + body, + idx as u32, + inv_mass as f32, + ); } // --- Wheeled vehicles (Tier 2) --- @@ -1728,52 +3596,131 @@ macro_rules! define_physics_ffi { // the FFI scalar-only while letting TS unpack both. Handles stay <2^20 // in practice for any realistic scene. - #[no_mangle] pub extern "C" fn bloom_physics_vehicle_create( - world: f64, chassis_shape: f64, - up_x: f64, up_y: f64, up_z: f64, - fw_x: f64, fw_y: f64, fw_z: f64, - w0x: f64, w0y: f64, w0z: f64, - w1x: f64, w1y: f64, w1z: f64, - w2x: f64, w2y: f64, w2z: f64, - w3x: f64, w3y: f64, w3z: f64, - wheel_radius: f64, wheel_width: f64, - suspension_min: f64, suspension_max: f64, - max_steer_angle: f64, max_brake_torque: f64, max_handbrake_torque: f64, - engine_max_torque: f64, max_pitch_roll_angle: f64, + #[no_mangle] + pub extern "C" fn bloom_physics_vehicle_create( + world: f64, + chassis_shape: f64, + up_x: f64, + up_y: f64, + up_z: f64, + fw_x: f64, + fw_y: f64, + fw_z: f64, + w0x: f64, + w0y: f64, + w0z: f64, + w1x: f64, + w1y: f64, + w1z: f64, + w2x: f64, + w2y: f64, + w2z: f64, + w3x: f64, + w3y: f64, + w3z: f64, + wheel_radius: f64, + wheel_width: f64, + suspension_min: f64, + suspension_max: f64, + max_steer_angle: f64, + max_brake_torque: f64, + max_handbrake_torque: f64, + engine_max_torque: f64, + max_pitch_roll_angle: f64, object_layer: f64, - px: f64, py: f64, pz: f64, rx: f64, ry: f64, rz: f64, rw: f64, + px: f64, + py: f64, + pz: f64, + rx: f64, + ry: f64, + rz: f64, + rw: f64, ) -> f64 { let (vh, _) = bloom_jolt_ffi_physics().vehicle_create( - world, chassis_shape, - up_x as f32, up_y as f32, up_z as f32, - fw_x as f32, fw_y as f32, fw_z as f32, - w0x as f32, w0y as f32, w0z as f32, - w1x as f32, w1y as f32, w1z as f32, - w2x as f32, w2y as f32, w2z as f32, - w3x as f32, w3y as f32, w3z as f32, - wheel_radius as f32, wheel_width as f32, - suspension_min as f32, suspension_max as f32, - max_steer_angle as f32, max_brake_torque as f32, max_handbrake_torque as f32, - engine_max_torque as f32, max_pitch_roll_angle as f32, + world, + chassis_shape, + up_x as f32, + up_y as f32, + up_z as f32, + fw_x as f32, + fw_y as f32, + fw_z as f32, + w0x as f32, + w0y as f32, + w0z as f32, + w1x as f32, + w1y as f32, + w1z as f32, + w2x as f32, + w2y as f32, + w2z as f32, + w3x as f32, + w3y as f32, + w3z as f32, + wheel_radius as f32, + wheel_width as f32, + suspension_min as f32, + suspension_max as f32, + max_steer_angle as f32, + max_brake_torque as f32, + max_handbrake_torque as f32, + engine_max_torque as f32, + max_pitch_roll_angle as f32, object_layer as u32, - px as f32, py as f32, pz as f32, - rx as f32, ry as f32, rz as f32, rw as f32, + px as f32, + py as f32, + pz as f32, + rx as f32, + ry as f32, + rz as f32, + rw as f32, ); vh } - #[no_mangle] pub extern "C" fn bloom_physics_vehicle_destroy(v: f64) { bloom_jolt_ffi_physics().vehicle_destroy(v); } - #[no_mangle] pub extern "C" fn bloom_physics_vehicle_get_chassis(v: f64) -> f64 { bloom_jolt_ffi_physics().vehicle_get_chassis(v) } - #[no_mangle] pub extern "C" fn bloom_physics_vehicle_set_input(v: f64, forward: f64, right: f64, brake: f64, handbrake: f64) { - bloom_jolt_ffi_physics().vehicle_set_input(v, forward as f32, right as f32, brake as f32, handbrake as f32); + #[no_mangle] + pub extern "C" fn bloom_physics_vehicle_destroy(v: f64) { + bloom_jolt_ffi_physics().vehicle_destroy(v); + } + #[no_mangle] + pub extern "C" fn bloom_physics_vehicle_get_chassis(v: f64) -> f64 { + bloom_jolt_ffi_physics().vehicle_get_chassis(v) + } + #[no_mangle] + pub extern "C" fn bloom_physics_vehicle_set_input( + v: f64, + forward: f64, + right: f64, + brake: f64, + handbrake: f64, + ) { + bloom_jolt_ffi_physics().vehicle_set_input( + v, + forward as f32, + right as f32, + brake as f32, + handbrake as f32, + ); } - #[no_mangle] pub extern "C" fn bloom_physics_vehicle_get_wheel_transform(v: f64, wheel_index: f64, axis: f64) -> f64 { - bloom_jolt_ffi_physics().vehicle_get_wheel_transform(v, wheel_index as u32, axis as u32) as f64 + #[no_mangle] + pub extern "C" fn bloom_physics_vehicle_get_wheel_transform( + v: f64, + wheel_index: f64, + axis: f64, + ) -> f64 { + bloom_jolt_ffi_physics().vehicle_get_wheel_transform(v, wheel_index as u32, axis as u32) + as f64 } - #[no_mangle] pub extern "C" fn bloom_physics_vehicle_get_engine_rpm(v: f64) -> f64 { + #[no_mangle] + pub extern "C" fn bloom_physics_vehicle_get_engine_rpm(v: f64) -> f64 { bloom_jolt_ffi_physics().vehicle_get_engine_rpm(v) as f64 } - #[no_mangle] pub extern "C" fn bloom_physics_vehicle_get_wheel_angular_velocity(v: f64, wheel_index: f64) -> f64 { - bloom_jolt_ffi_physics().vehicle_get_wheel_angular_velocity(v, wheel_index as u32) as f64 + #[no_mangle] + pub extern "C" fn bloom_physics_vehicle_get_wheel_angular_velocity( + v: f64, + wheel_index: f64, + ) -> f64 { + bloom_jolt_ffi_physics().vehicle_get_wheel_angular_velocity(v, wheel_index as u32) + as f64 } }; } @@ -1830,7 +3777,10 @@ mod step_tests { for dt in frames { alpha = p.step_fixed(wa, dt as f32, 1); } - assert!((0.3..=0.7).contains(&alpha), "expected ~half-step remainder, alpha = {alpha}"); + assert!( + (0.3..=0.7).contains(&alpha), + "expected ~half-step remainder, alpha = {alpha}" + ); // B: 12 manual steps at exactly 1/60. for _ in 0..12 { p.step(wb, 1.0 / 60.0, 1); diff --git a/native/shared/src/picking.rs b/native/shared/src/picking.rs index 70510144..1480ec3a 100644 --- a/native/shared/src/picking.rs +++ b/native/shared/src/picking.rs @@ -9,10 +9,10 @@ use crate::scene::SceneGraph; #[derive(Clone, Debug)] pub struct PickResult { pub hit: bool, - pub handle: f64, // scene node handle that was hit + pub handle: f64, // scene node handle that was hit pub distance: f32, - pub point: [f32; 3], // world-space hit point - pub normal: [f32; 3], // face normal at hit point + pub point: [f32; 3], // world-space hit point + pub normal: [f32; 3], // face normal at hit point } impl PickResult { @@ -33,8 +33,10 @@ impl PickResult { /// inv_vp: inverse view-projection matrix /// camera_pos: camera world position pub fn screen_to_ray( - screen_x: f32, screen_y: f32, - width: f32, height: f32, + screen_x: f32, + screen_y: f32, + width: f32, + height: f32, inv_vp: &[[f32; 4]; 4], _camera_pos: &[f32; 3], ) -> ([f32; 3], [f32; 3]) { @@ -77,11 +79,7 @@ pub fn screen_to_ray( } /// Raycast against all visible scene nodes. Returns the closest hit. -pub fn raycast_scene( - scene: &SceneGraph, - origin: &[f32; 3], - direction: &[f32; 3], -) -> PickResult { +pub fn raycast_scene(scene: &SceneGraph, origin: &[f32; 3], direction: &[f32; 3]) -> PickResult { let mut best = PickResult::miss(); let mut best_dist = f32::MAX; @@ -97,14 +95,19 @@ pub fn raycast_scene( // Test against all triangles for tri in node.indices.chunks(3) { - if tri.len() < 3 { continue; } + if tri.len() < 3 { + continue; + } let v0 = &node.vertices[tri[0] as usize]; let v1 = &node.vertices[tri[1] as usize]; let v2 = &node.vertices[tri[2] as usize]; if let Some((t, u, v)) = ray_triangle_intersection( - &local_origin, &local_dir, - &v0.position, &v1.position, &v2.position, + &local_origin, + &local_dir, + &v0.position, + &v1.position, + &v2.position, ) { if t > 0.0 && t < best_dist { best_dist = t; @@ -124,9 +127,11 @@ pub fn raycast_scene( v0.normal[1] * w + v1.normal[1] * u + v2.normal[1] * v, v0.normal[2] * w + v1.normal[2] * u + v2.normal[2] * v, ]; - let nl = (normal[0]*normal[0] + normal[1]*normal[1] + normal[2]*normal[2]).sqrt(); + let nl = + (normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]) + .sqrt(); let normal = if nl > 1e-6 { - [normal[0]/nl, normal[1]/nl, normal[2]/nl] + [normal[0] / nl, normal[1] / nl, normal[2] / nl] } else { [0.0, 1.0, 0.0] }; @@ -169,14 +174,19 @@ pub fn raycast_scene_all( let mut node_best: Option = None; for tri in node.indices.chunks(3) { - if tri.len() < 3 { continue; } + if tri.len() < 3 { + continue; + } let v0 = &node.vertices[tri[0] as usize]; let v1 = &node.vertices[tri[1] as usize]; let v2 = &node.vertices[tri[2] as usize]; if let Some((t, u, v)) = ray_triangle_intersection( - &local_origin, &local_dir, - &v0.position, &v1.position, &v2.position, + &local_origin, + &local_dir, + &v0.position, + &v1.position, + &v2.position, ) { if t > 0.0 && t < node_best_dist { node_best_dist = t; @@ -192,9 +202,11 @@ pub fn raycast_scene_all( v0.normal[1] * w + v1.normal[1] * u + v2.normal[1] * v, v0.normal[2] * w + v1.normal[2] * u + v2.normal[2] * v, ]; - let nl = (normal[0]*normal[0] + normal[1]*normal[1] + normal[2]*normal[2]).sqrt(); + let nl = + (normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]) + .sqrt(); let normal = if nl > 1e-6 { - [normal[0]/nl, normal[1]/nl, normal[2]/nl] + [normal[0] / nl, normal[1] / nl, normal[2] / nl] } else { [0.0, 1.0, 0.0] }; @@ -215,7 +227,11 @@ pub fn raycast_scene_all( } // Sort by distance (closest first). - results.sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap_or(std::cmp::Ordering::Equal)); + results.sort_by(|a, b| { + a.distance + .partial_cmp(&b.distance) + .unwrap_or(std::cmp::Ordering::Equal) + }); results.truncate(max_results); results } @@ -225,26 +241,35 @@ pub fn raycast_scene_all( // ============================================================ fn ray_triangle_intersection( - origin: &[f32; 3], dir: &[f32; 3], - v0: &[f32; 3], v1: &[f32; 3], v2: &[f32; 3], + origin: &[f32; 3], + dir: &[f32; 3], + v0: &[f32; 3], + v1: &[f32; 3], + v2: &[f32; 3], ) -> Option<(f32, f32, f32)> { const EPSILON: f32 = 1e-7; - let e1 = [v1[0]-v0[0], v1[1]-v0[1], v1[2]-v0[2]]; - let e2 = [v2[0]-v0[0], v2[1]-v0[1], v2[2]-v0[2]]; + let e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]]; + let e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]]; let h = cross(dir, &e2); let a = dot(&e1, &h); - if a.abs() < EPSILON { return None; } + if a.abs() < EPSILON { + return None; + } let f = 1.0 / a; - let s = [origin[0]-v0[0], origin[1]-v0[1], origin[2]-v0[2]]; + let s = [origin[0] - v0[0], origin[1] - v0[1], origin[2] - v0[2]]; let u = f * dot(&s, &h); - if u < 0.0 || u > 1.0 { return None; } + if u < 0.0 || u > 1.0 { + return None; + } let q = cross(&s, &e1); let v = f * dot(dir, &q); - if v < 0.0 || u + v > 1.0 { return None; } + if v < 0.0 || u + v > 1.0 { + return None; + } let t = f * dot(&e2, &q); if t > EPSILON { @@ -256,29 +281,29 @@ fn ray_triangle_intersection( fn cross(a: &[f32; 3], b: &[f32; 3]) -> [f32; 3] { [ - a[1]*b[2] - a[2]*b[1], - a[2]*b[0] - a[0]*b[2], - a[0]*b[1] - a[1]*b[0], + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], ] } fn dot(a: &[f32; 3], b: &[f32; 3]) -> f32 { - a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } fn mat4_mul_vec4(m: &[[f32; 4]; 4], v: &[f32; 4]) -> [f32; 4] { [ - m[0][0]*v[0] + m[1][0]*v[1] + m[2][0]*v[2] + m[3][0]*v[3], - m[0][1]*v[0] + m[1][1]*v[1] + m[2][1]*v[2] + m[3][1]*v[3], - m[0][2]*v[0] + m[1][2]*v[1] + m[2][2]*v[2] + m[3][2]*v[3], - m[0][3]*v[0] + m[1][3]*v[1] + m[2][3]*v[2] + m[3][3]*v[3], + m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2] + m[3][0] * v[3], + m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2] + m[3][1] * v[3], + m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2] + m[3][2] * v[3], + m[0][3] * v[0] + m[1][3] * v[1] + m[2][3] * v[2] + m[3][3] * v[3], ] } fn mat4_transform_point(m: &[[f32; 4]; 4], p: &[f32; 3]) -> [f32; 3] { let v = mat4_mul_vec4(m, &[p[0], p[1], p[2], 1.0]); if v[3].abs() > 1e-8 { - [v[0]/v[3], v[1]/v[3], v[2]/v[3]] + [v[0] / v[3], v[1] / v[3], v[2] / v[3]] } else { [v[0], v[1], v[2]] } @@ -287,9 +312,9 @@ fn mat4_transform_point(m: &[[f32; 4]; 4], p: &[f32; 3]) -> [f32; 3] { fn mat4_transform_dir(m: &[[f32; 4]; 4], d: &[f32; 3]) -> [f32; 3] { // Transform direction (no translation, no perspective divide) [ - m[0][0]*d[0] + m[1][0]*d[1] + m[2][0]*d[2], - m[0][1]*d[0] + m[1][1]*d[1] + m[2][1]*d[2], - m[0][2]*d[0] + m[1][2]*d[1] + m[2][2]*d[2], + m[0][0] * d[0] + m[1][0] * d[1] + m[2][0] * d[2], + m[0][1] * d[0] + m[1][1] * d[1] + m[2][1] * d[2], + m[0][2] * d[0] + m[1][2] * d[1] + m[2][2] * d[2], ] } diff --git a/native/shared/src/postfx.rs b/native/shared/src/postfx.rs index 460c2d44..09567f6e 100644 --- a/native/shared/src/postfx.rs +++ b/native/shared/src/postfx.rs @@ -99,15 +99,15 @@ fn fs_outline(in: VertexOutput) -> @location(0) vec4 { pub struct OutlineParams { pub color_selected: [f32; 4], pub color_hovered: [f32; 4], - pub thickness: [f32; 4], // [thickness, glow, pulse_time, 0] + pub thickness: [f32; 4], // [thickness, glow, pulse_time, 0] pub screen_size: [f32; 4], } impl Default for OutlineParams { fn default() -> Self { Self { - color_selected: [0.2, 0.5, 1.0, 1.0], // blue - color_hovered: [0.8, 0.8, 0.2, 1.0], // yellow + color_selected: [0.2, 0.5, 1.0, 1.0], // blue + color_hovered: [0.8, 0.8, 0.2, 1.0], // yellow thickness: [2.0, 0.3, 0.0, 0.0], screen_size: [1280.0, 720.0, 0.0, 0.0], } @@ -146,9 +146,16 @@ pub struct PostFxPipeline { } impl PostFxPipeline { - pub fn new(device: &wgpu::Device, width: u32, height: u32, surface_format: wgpu::TextureFormat) -> Self { - let (color_texture, color_view) = create_color_target(device, width, height, surface_format); - let (object_id_texture, object_id_view) = create_color_target(device, width, height, wgpu::TextureFormat::R32Float); + pub fn new( + device: &wgpu::Device, + width: u32, + height: u32, + surface_format: wgpu::TextureFormat, + ) -> Self { + let (color_texture, color_view) = + create_color_target(device, width, height, surface_format); + let (object_id_texture, object_id_view) = + create_color_target(device, width, height, wgpu::TextureFormat::R32Float); let (depth_texture, depth_view) = create_depth_target(device, width, height); let sampler = device.create_sampler(&wgpu::SamplerDescriptor { @@ -185,10 +192,22 @@ impl PostFxPipeline { label: Some("outline_bg"), layout: &outline_bg_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&color_view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&object_id_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: outline_params_buffer.as_entire_binding() }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&color_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&object_id_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: outline_params_buffer.as_entire_binding(), + }, ], }); @@ -199,11 +218,12 @@ impl PostFxPipeline { source: wgpu::ShaderSource::Wgsl(outline_shader_src.into()), }); - let outline_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("outline_pipeline_layout"), - bind_group_layouts: &[Some(&outline_bg_layout)], - immediate_size: 0, - }); + let outline_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("outline_pipeline_layout"), + bind_group_layouts: &[Some(&outline_bg_layout)], + immediate_size: 0, + }); let outline_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("outline_pipeline"), @@ -255,8 +275,16 @@ impl PostFxPipeline { } } - pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32, surface_format: wgpu::TextureFormat) { - if width == self.width && height == self.height { return; } + pub fn resize( + &mut self, + device: &wgpu::Device, + width: u32, + height: u32, + surface_format: wgpu::TextureFormat, + ) { + if width == self.width && height == self.height { + return; + } *self = Self::new(device, width, height, surface_format); } @@ -271,7 +299,11 @@ impl PostFxPipeline { pub fn update(&mut self, queue: &wgpu::Queue, dt: f32) { self.time += dt; self.outline_params.thickness[2] = self.time; - queue.write_buffer(&self.outline_params_buffer, 0, bytemuck::bytes_of(&self.outline_params)); + queue.write_buffer( + &self.outline_params_buffer, + 0, + bytemuck::bytes_of(&self.outline_params), + ); } } @@ -279,10 +311,19 @@ impl PostFxPipeline { // Helpers // ============================================================ -fn create_color_target(device: &wgpu::Device, width: u32, height: u32, format: wgpu::TextureFormat) -> (wgpu::Texture, wgpu::TextureView) { +fn create_color_target( + device: &wgpu::Device, + width: u32, + height: u32, + format: wgpu::TextureFormat, +) -> (wgpu::Texture, wgpu::TextureView) { let tex = device.create_texture(&wgpu::TextureDescriptor { label: Some("postfx_color"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -294,10 +335,18 @@ fn create_color_target(device: &wgpu::Device, width: u32, height: u32, format: w (tex, view) } -fn create_depth_target(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::Texture, wgpu::TextureView) { +fn create_depth_target( + device: &wgpu::Device, + width: u32, + height: u32, +) -> (wgpu::Texture, wgpu::TextureView) { let tex = device.create_texture(&wgpu::TextureDescriptor { label: Some("postfx_depth"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, diff --git a/native/shared/src/profiler.rs b/native/shared/src/profiler.rs index 3e416296..e0009cba 100644 --- a/native/shared/src/profiler.rs +++ b/native/shared/src/profiler.rs @@ -19,12 +19,13 @@ //! samples, not a tree) — post-FX passes are already flat and a tree //! would be overkill for a first pass. -#[cfg(feature = "web")] -use web_time::Instant; #[cfg(not(feature = "web"))] use std::time::Instant; +#[cfg(feature = "web")] +use web_time::Instant; use std::collections::HashMap; +use std::fmt::Write as _; const ROLLING_FRAMES: usize = 120; const MAX_GPU_PAIRS: u32 = 32; @@ -82,23 +83,91 @@ struct RollingStats { last_frame: u64, } +#[derive(Clone, Copy)] +struct QualityStats { + mean: f64, + p50: f64, + p95: f64, + max: f64, +} + +fn quality_stats_ms(values_us: impl Iterator) -> QualityStats { + let mut values: Vec = values_us + .map(|v| if v.is_finite() { v / 1000.0 } else { 0.0 }) + .collect(); + if values.is_empty() { + return QualityStats { + mean: 0.0, + p50: 0.0, + p95: 0.0, + max: 0.0, + }; + } + values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let mean = values.iter().sum::() / values.len() as f64; + let p50 = values[((values.len() - 1) as f64 * 0.50).floor() as usize]; + let p95 = values[((values.len() - 1) as f64 * 0.95).ceil() as usize]; + QualityStats { + mean, + p50, + p95, + max: *values.last().unwrap_or(&0.0), + } +} +fn push_json_string(out: &mut String, value: &str) { + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => { + let _ = write!( + out, + "\\u{:04x + }", + c as u32 + ); + } + c => out.push(c), + } + } + out.push('"'); +} + impl RollingStats { fn new() -> Self { - Self { cpu: [0.0; ROLLING_FRAMES], gpu: [0.0; ROLLING_FRAMES], has_gpu: false, idx: 0, filled: 0, last_frame: 0 } + Self { + cpu: [0.0; ROLLING_FRAMES], + gpu: [0.0; ROLLING_FRAMES], + has_gpu: false, + idx: 0, + filled: 0, + last_frame: 0, + } } fn push(&mut self, cpu: f64, gpu: Option) { self.cpu[self.idx] = cpu; - if let Some(g) = gpu { self.gpu[self.idx] = g; self.has_gpu = true; } + if let Some(g) = gpu { + self.gpu[self.idx] = g; + self.has_gpu = true; + } self.idx = (self.idx + 1) % ROLLING_FRAMES; self.filled = (self.filled + 1).min(ROLLING_FRAMES); } fn avg_cpu(&self) -> f64 { - if self.filled == 0 { return 0.0; } + if self.filled == 0 { + return 0.0; + } let sum: f64 = self.cpu.iter().take(self.filled).sum(); sum / self.filled as f64 } fn avg_gpu(&self) -> Option { - if !self.has_gpu || self.filled == 0 { return None; } + if !self.has_gpu || self.filled == 0 { + return None; + } let sum: f64 = self.gpu.iter().take(self.filled).sum(); Some(sum / self.filled as f64) } @@ -172,19 +241,35 @@ impl Profiler { } self.enabled = on; } - pub fn is_enabled(&self) -> bool { self.enabled } - pub fn has_gpu(&self) -> bool { self.gpu_enabled } + pub fn is_enabled(&self) -> bool { + self.enabled + } + pub fn has_gpu(&self) -> bool { + self.gpu_enabled + } pub fn begin(&mut self, label: &'static str) { - if !self.enabled { return; } - self.open_cpu.insert(label, CpuSample { start: Instant::now() }); + if !self.enabled { + return; + } + self.open_cpu.insert( + label, + CpuSample { + start: Instant::now(), + }, + ); } - pub fn end(&mut self, label: &'static str) { - if !self.enabled { return; } + if !self.enabled { + return; + } if let Some(s) = self.open_cpu.remove(label) { let us = s.start.elapsed().as_secs_f64() * 1_000_000.0; - self.frame.push(FrameSample { label, cpu_us: us, gpu_us: None }); + self.frame.push(FrameSample { + label, + cpu_us: us, + gpu_us: None, + }); } } @@ -193,7 +278,9 @@ impl Profiler { /// `RenderPassTimestampWrites` for the pass descriptor. Returns None /// when profiling or GPU queries are disabled, or when no slots remain. pub fn reserve_gpu_pair(&mut self, label: &'static str) -> Option<(u32, u32)> { - if !self.enabled || !self.gpu_enabled { return None; } + if !self.enabled || !self.gpu_enabled { + return None; + } self.query_set.as_ref()?; if self.next_query + 2 > MAX_GPU_PAIRS * 2 { if !self.budget_warned { @@ -219,7 +306,10 @@ impl Profiler { /// Convenience: build a `RenderPassTimestampWrites` for a pass in one call. /// Returns None when GPU queries are disabled — the caller should plug /// the returned `Option` straight into `RenderPassDescriptor.timestamp_writes`. - pub fn pass_timestamp_writes(&mut self, label: &'static str) -> Option> { + pub fn pass_timestamp_writes( + &mut self, + label: &'static str, + ) -> Option> { let (b, e) = self.reserve_gpu_pair(label)?; let qs = self.query_set.as_ref()?; Some(wgpu::RenderPassTimestampWrites { @@ -230,7 +320,10 @@ impl Profiler { } /// Same as `pass_timestamp_writes` but for a compute pass descriptor. - pub fn compute_pass_timestamp_writes(&mut self, label: &'static str) -> Option> { + pub fn compute_pass_timestamp_writes( + &mut self, + label: &'static str, + ) -> Option> { let (b, e) = self.reserve_gpu_pair(label)?; let qs = self.query_set.as_ref()?; Some(wgpu::ComputePassTimestampWrites { @@ -243,8 +336,12 @@ impl Profiler { /// Resolve any pending GPU queries into the readback buffer. Call once /// per frame, after all passes are encoded and before submit. pub fn resolve(&mut self, encoder: &mut wgpu::CommandEncoder) { - if !self.enabled || !self.gpu_enabled || self.next_query == 0 { return; } - let (Some(qs), Some(resolve)) = (&self.query_set, &self.resolve_buffer) else { return; }; + if !self.enabled || !self.gpu_enabled || self.next_query == 0 { + return; + } + let (Some(qs), Some(resolve)) = (&self.query_set, &self.resolve_buffer) else { + return; + }; encoder.resolve_query_set(qs, 0..self.next_query, resolve, 0); if let Some(readback) = &self.readback_buffer { let byte_count = (self.next_query as u64) * 8; @@ -271,7 +368,10 @@ impl Profiler { let byte_count = (self.next_query as u64) * 8; let slice = readback.slice(0..byte_count); slice.map_async(wgpu::MapMode::Read, |_| {}); - let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + let _ = device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); let data = slice.get_mapped_range().to_vec(); readback.unmap(); let period = self.timestamp_period_ns as f64; @@ -279,19 +379,29 @@ impl Profiler { for (label, b, e) in &self.pending_gpu { let bo = (*b as usize) * 8; let eo = (*e as usize) * 8; - if eo + 8 > data.len() { continue; } - let bt = u64::from_le_bytes(data[bo..bo+8].try_into().unwrap()); - let et = u64::from_le_bytes(data[eo..eo+8].try_into().unwrap()); - if et <= bt { continue; } + if eo + 8 > data.len() { + continue; + } + let bt = u64::from_le_bytes(data[bo..bo + 8].try_into().unwrap()); + let et = u64::from_le_bytes(data[eo..eo + 8].try_into().unwrap()); + if et <= bt { + continue; + } let us = (et - bt) as f64 * period / 1000.0; *by_label.entry(*label).or_insert(0.0) += us; } for s in self.frame.iter_mut() { - if let Some(us) = by_label.remove(s.label) { s.gpu_us = Some(us); } + if let Some(us) = by_label.remove(s.label) { + s.gpu_us = Some(us); + } } // GPU samples without a CPU counterpart — record them too. for (label, us) in by_label { - self.frame.push(FrameSample { label, cpu_us: 0.0, gpu_us: Some(us) }); + self.frame.push(FrameSample { + label, + cpu_us: 0.0, + gpu_us: Some(us), + }); } } } @@ -311,7 +421,9 @@ impl Profiler { let mut frame_gpu = 0.0; for s in &self.frame { frame_cpu += s.cpu_us; - if let Some(g) = s.gpu_us { frame_gpu += g; } + if let Some(g) = s.gpu_us { + frame_gpu += g; + } } self.frame_total_cpu_us[self.histogram_idx] = frame_cpu; self.frame_total_gpu_us[self.histogram_idx] = frame_gpu; @@ -320,13 +432,17 @@ impl Profiler { let fc = self.frame_count; for s in self.frame.drain(..) { - let entry = self.rolling.entry(s.label).or_insert_with(RollingStats::new); + let entry = self + .rolling + .entry(s.label) + .or_insert_with(RollingStats::new); entry.push(s.cpu_us, s.gpu_us); entry.last_frame = fc; } // Drop entries that have not reported for several windows so a // disabled feature's passes leave the map instead of lingering. - self.rolling.retain(|_, s| fc.saturating_sub(s.last_frame) <= (4 * ROLLING_FRAMES) as u64); + self.rolling + .retain(|_, s| fc.saturating_sub(s.last_frame) <= (4 * ROLLING_FRAMES) as u64); self.open_cpu.clear(); self.next_query = 0; self.pending_gpu.clear(); @@ -338,8 +454,14 @@ impl Profiler { /// first, exactly `ROLLING_FRAMES.min(filled)` entries long. pub fn frame_history(&self) -> Vec<(f64, f64)> { let n = self.histogram_filled; - if n == 0 { return Vec::new(); } - let start = if n < ROLLING_FRAMES { 0 } else { self.histogram_idx }; + if n == 0 { + return Vec::new(); + } + let start = if n < ROLLING_FRAMES { + 0 + } else { + self.histogram_idx + }; let mut out = Vec::with_capacity(n); for i in 0..n { let idx = (start + i) % ROLLING_FRAMES; @@ -353,22 +475,36 @@ impl Profiler { return String::from("profiler: disabled\n"); } let fc = self.frame_count; - let mut entries: Vec<(&&str, &RollingStats)> = self.rolling.iter() + let mut entries: Vec<(&&str, &RollingStats)> = self + .rolling + .iter() .filter(|(_, s)| fc.saturating_sub(s.last_frame) <= ROLLING_FRAMES as u64) .map(|(k, v)| (k, v)) .collect(); - entries.sort_by(|a, b| b.1.avg_cpu().partial_cmp(&a.1.avg_cpu()).unwrap_or(std::cmp::Ordering::Equal)); + entries.sort_by(|a, b| { + b.1.avg_cpu() + .partial_cmp(&a.1.avg_cpu()) + .unwrap_or(std::cmp::Ordering::Equal) + }); let mut out = String::new(); out.push_str(&format!( "profiler (avg over last {} frames, gpu={}):\n", - ROLLING_FRAMES.min(entries.first().map(|(_,s)| s.filled).unwrap_or(0)), + ROLLING_FRAMES.min(entries.first().map(|(_, s)| s.filled).unwrap_or(0)), if self.gpu_enabled { "yes" } else { "no" }, )); out.push_str(" phase cpu us gpu us\n"); for (label, stats) in entries { - let gpu = stats.avg_gpu().map(|v| format!("{:>9.1}", v)).unwrap_or_else(|| " -".to_string()); - out.push_str(&format!(" {:<28} {:>9.1} {}\n", label, stats.avg_cpu(), gpu)); + let gpu = stats + .avg_gpu() + .map(|v| format!("{:>9.1}", v)) + .unwrap_or_else(|| " -".to_string()); + out.push_str(&format!( + " {:<28} {:>9.1} {}\n", + label, + stats.avg_cpu(), + gpu + )); } out } @@ -377,17 +513,21 @@ impl Profiler { /// all phases). Useful for a single headline number. pub fn avg_frame_cpu_us(&self) -> f64 { let fc = self.frame_count; - self.rolling.values() + self.rolling + .values() .filter(|s| fc.saturating_sub(s.last_frame) <= ROLLING_FRAMES as u64) - .map(|s| s.avg_cpu()).sum() + .map(|s| s.avg_cpu()) + .sum() } /// Average total GPU frame time where available. pub fn avg_frame_gpu_us(&self) -> f64 { let fc = self.frame_count; - self.rolling.values() + self.rolling + .values() .filter(|s| fc.saturating_sub(s.last_frame) <= ROLLING_FRAMES as u64) - .filter_map(|s| s.avg_gpu()).sum() + .filter_map(|s| s.avg_gpu()) + .sum() } /// Snapshot the rolling averages in a stable, CPU-time-descending @@ -396,13 +536,111 @@ impl Profiler { /// order would jitter the overlay otherwise. pub fn snapshot(&mut self) -> Vec<(&'static str, f64, Option)> { let fc = self.frame_count; - let mut v: Vec<(&'static str, f64, Option)> = self.rolling.iter() + let mut v: Vec<(&'static str, f64, Option)> = self + .rolling + .iter() .filter(|(_, s)| fc.saturating_sub(s.last_frame) <= ROLLING_FRAMES as u64) .map(|(k, s)| (*k, s.avg_cpu(), s.avg_gpu())) .collect(); v.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); v } + + /// Serialize the deterministic qualification snapshot natively. + /// + /// Perry 0.5.x is intentionally kept out of this aggregation path: + /// constructing and sorting nested TS arrays after a long render can + /// corrupt a method receiver in its native-call trampoline. The report + /// is written after the measured window, so this has no benchmark cost. + pub fn quality_report_json( + &mut self, + present_mode: u32, + warmup_frames: u32, + measured_frames: u32, + fixed_timestep: f64, + quality_preset: u32, + render_scale: f64, + measurement_wall_ms: f64, + adapter_json: &str, + runtime_paths_json: &str, + ) -> String { + let history = self.frame_history(); + let cpu = quality_stats_ms(history.iter().map(|(cpu, _)| *cpu)); + let gpu = quality_stats_ms(history.iter().map(|(_, gpu)| *gpu)); + let rows = self.snapshot(); + let gpu_timestamps = rows.iter().any(|(_, _, gpu)| gpu.is_some()); + let mode_name = match present_mode { + 0 => "fifo", + 1 => "mailbox", + 2 => "immediate", + 3 => "auto-no-vsync", + 4 => "fifo-relaxed", + 5 => "auto-vsync", + _ => "unknown", + }; + let uncapped = matches!(present_mode, 1 | 2 | 3); + let measured_frames = measured_frames.max(1); + let wall_ms = if measurement_wall_ms.is_finite() { + measurement_wall_ms.max(0.0) + } else { + 0.0 + }; + + let mut out = String::with_capacity(4096); + let _ = writeln!(out, "{{"); + let _ = writeln!(out, " \"schema\":\"bloom-quality-telemetry-v1\","); + let _ = writeln!(out, " \"adapter\":{adapter_json},"); + let _ = writeln!(out, " \"renderer_paths\":{runtime_paths_json},"); + let _ = writeln!(out, " \"present_mode\":\"{mode_name}\","); + let _ = writeln!(out, " \"present_mode_code\":{present_mode},"); + let _ = writeln!(out, " \"uncapped\":{uncapped},"); + let _ = writeln!(out, " \"warmup_excluded\":true,"); + let _ = writeln!(out, " \"shader_compilation_excluded\":true,"); + let _ = writeln!(out, " \"warmup_frames\":{warmup_frames},"); + let _ = writeln!(out, " \"measured_frames\":{measured_frames},"); + let _ = writeln!(out, " \"fixed_timestep\":{fixed_timestep:.9},"); + let _ = writeln!(out, " \"quality_preset\":{quality_preset},"); + let _ = writeln!(out, " \"render_scale\":{render_scale:.6},"); + let _ = writeln!(out, " \"measurement_wall_ms\":{wall_ms:.6},"); + let _ = writeln!( + out, + " \"wall_frame_mean_ms\":{:.6},", + wall_ms / measured_frames as f64 + ); + let _ = writeln!(out, " \"cpu_frame_mean_ms\":{:.6},", cpu.mean); + let _ = writeln!(out, " \"cpu_frame_p50_ms\":{:.6},", cpu.p50); + let _ = writeln!(out, " \"cpu_frame_p95_ms\":{:.6},", cpu.p95); + let _ = writeln!(out, " \"cpu_frame_max_ms\":{:.6},", cpu.max); + let _ = writeln!(out, " \"gpu_frame_mean_ms\":{:.6},", gpu.mean); + let _ = writeln!(out, " \"gpu_frame_p50_ms\":{:.6},", gpu.p50); + let _ = writeln!(out, " \"gpu_frame_p95_ms\":{:.6},", gpu.p95); + let _ = writeln!(out, " \"gpu_frame_max_ms\":{:.6},", gpu.max); + let _ = writeln!(out, " \"gpu_timestamps_available\":{gpu_timestamps},"); + let _ = writeln!(out, " \"vram_peak_mb\":null,"); + out.push_str(" \"passes\":["); + for (i, (label, cpu_us, gpu_us)) in rows.iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push_str("{\"label\":"); + push_json_string(&mut out, label); + let cpu_ms = if cpu_us.is_finite() { + *cpu_us / 1000.0 + } else { + 0.0 + }; + let _ = write!(out, ",\"cpu_mean_ms\":{cpu_ms:.6},\"gpu_mean_ms\":"); + match gpu_us { + Some(gpu_us) if gpu_us.is_finite() => { + let _ = write!(out, "{:.6}", gpu_us / 1000.0); + } + _ => out.push_str("null"), + } + out.push('}'); + } + out.push_str("]\n}\n"); + out + } } #[cfg(test)] @@ -414,7 +652,11 @@ mod tests { /// sample with the given cpu_us so the histogram totals are /// known. fn fake_frame(p: &mut Profiler, cpu_us: f64) { - p.frame.push(FrameSample { label: "fake", cpu_us, gpu_us: None }); + p.frame.push(FrameSample { + label: "fake", + cpu_us, + gpu_us: None, + }); // Skip the gpu readback path (no device available) and go // straight to the CPU-only histogram + drain path. p.frame_end_cpu(); @@ -445,17 +687,28 @@ mod tests { let mut p = Profiler::new(); p.set_enabled(true); // One label reports once, another keeps reporting. - p.frame.push(FrameSample { label: "once", cpu_us: 5.0, gpu_us: None }); + p.frame.push(FrameSample { + label: "once", + cpu_us: 5.0, + gpu_us: None, + }); p.frame_end_cpu(); - for _ in 0..(ROLLING_FRAMES + 1) { fake_frame(&mut p, 1.0); } + for _ in 0..(ROLLING_FRAMES + 1) { + fake_frame(&mut p, 1.0); + } let snap = p.snapshot(); assert!(snap.iter().any(|(l, _, _)| *l == "fake")); assert!( !snap.iter().any(|(l, _, _)| *l == "once"), "a pass that stopped reporting must leave the snapshot" ); - for _ in 0..(4 * ROLLING_FRAMES) { fake_frame(&mut p, 1.0); } - assert!(!p.rolling.contains_key("once"), "stale label must eventually evict"); + for _ in 0..(4 * ROLLING_FRAMES) { + fake_frame(&mut p, 1.0); + } + assert!( + !p.rolling.contains_key("once"), + "stale label must eventually evict" + ); } #[test] @@ -466,8 +719,14 @@ mod tests { assert!(!p.frame_history().is_empty()); p.set_enabled(false); p.set_enabled(true); - assert!(p.frame_history().is_empty(), "histogram must clear on re-enable"); - assert!(p.snapshot().is_empty(), "rolling stats must clear on re-enable"); + assert!( + p.frame_history().is_empty(), + "histogram must clear on re-enable" + ); + assert!( + p.snapshot().is_empty(), + "rolling stats must clear on re-enable" + ); } #[test] @@ -489,4 +748,32 @@ mod tests { assert!(w[0].0 < w[1].0, "history must be in chronological order"); } } + + #[test] + fn quality_report_is_valid_shape_and_uses_milliseconds() { + let mut p = Profiler::new(); + fake_frame(&mut p, 1_000.0); + fake_frame(&mut p, 3_000.0); + + let report = p.quality_report_json( + 3, + 60, + 2, + 1.0 / 60.0, + 3, + 1.0, + 8.0, + "{\"availability\":\"test\"}", + "{\"ssgi_trace_backend\":\"test\"}", + ); + assert!(report.starts_with("{\n")); + assert!(report.ends_with("}\n")); + assert!(report.contains("\"adapter\":{\"availability\":\"test\"}")); + assert!(report.contains("\"present_mode\":\"auto-no-vsync\"")); + assert!(report.contains("\"uncapped\":true")); + assert!(report.contains("\"cpu_frame_mean_ms\":2.000000")); + assert!(report.contains("\"cpu_frame_p95_ms\":3.000000")); + assert!(report.contains("\"wall_frame_mean_ms\":4.000000")); + assert!(report.contains("\"gpu_timestamps_available\":false")); + } } diff --git a/native/shared/src/ragdoll.rs b/native/shared/src/ragdoll.rs index 320cf2af..9e0bd0c7 100644 --- a/native/shared/src/ragdoll.rs +++ b/native/shared/src/ragdoll.rs @@ -86,13 +86,19 @@ impl Ragdoll { /// long enough to be worth simulating, until `max_bodies` is reached. BFS is the /// point: it spends the budget on the spine and the limbs — the things whose /// motion you actually read — and runs out before it reaches the fingers. -fn select_bones(skel: &SkeletonData, min_len: f32, max_bodies: usize, - joint_world: &[[[f32; 4]; 4]]) -> Vec { +fn select_bones( + skel: &SkeletonData, + min_len: f32, + max_bodies: usize, + joint_world: &[[[f32; 4]; 4]], +) -> Vec { let n = skel.joints.len(); let mut parent = vec![usize::MAX; n]; for (i, j) in skel.joints.iter().enumerate() { for &c in &j.children { - if c < n { parent[c] = i; } + if c < n { + parent[c] = i; + } } } @@ -101,15 +107,21 @@ fn select_bones(skel: &SkeletonData, min_len: f32, max_bodies: usize, while let Some(j) = queue.pop_front() { order.push(j); for &c in &skel.joints[j].children { - if c < n { queue.push_back(c); } + if c < n { + queue.push_back(c); + } } } let mut picked = Vec::new(); for &j in &order { - if picked.len() >= max_bodies { break; } + if picked.len() >= max_bodies { + break; + } let p = parent[j]; - if p == usize::MAX { continue; } // roots carry no bone + if p == usize::MAX { + continue; + } // roots carry no bone let a = translation(&joint_world[p]); let b = translation(&joint_world[j]); let len = dist(a, b); @@ -139,24 +151,36 @@ pub struct RagdollBuild { #[allow(clippy::too_many_arguments)] pub fn plan( anim: &ModelAnimation, - scale: f32, pos: [f32; 3], rot: f32, + scale: f32, + pos: [f32; 3], + rot: f32, max_bodies: usize, radius_scale: f32, ) -> Vec { - let Some(skel) = &anim.skeleton else { return Vec::new() }; - if anim.joint_world.len() != skel.joints.len() { return Vec::new() } + let Some(skel) = &anim.skeleton else { + return Vec::new(); + }; + if anim.joint_world.len() != skel.joints.len() { + return Vec::new(); + } let model = compose(scale, pos, rot); let picked = select_bones(skel, 0.06, max_bodies, &anim.joint_world); // joint -> index in `picked`, so a bone can find its parent bone. let mut bone_of = vec![usize::MAX; skel.joints.len()]; - for (bi, &j) in picked.iter().enumerate() { bone_of[j] = bi; } + for (bi, &j) in picked.iter().enumerate() { + bone_of[j] = bi; + } let n = skel.joints.len(); let mut parent = vec![usize::MAX; n]; for (i, j) in skel.joints.iter().enumerate() { - for &c in &j.children { if c < n { parent[c] = i; } } + for &c in &j.children { + if c < n { + parent[c] = i; + } + } } let mut out = Vec::with_capacity(picked.len()); @@ -169,11 +193,17 @@ pub fn plan( let a = xform_point(&model, a_local); let b = xform_point(&model, b_local); let len = dist(a, b); - if len < 1e-4 { continue; } + if len < 1e-4 { + continue; + } // Capsule spans the bone: centre at the midpoint, +Y down its length // (Jolt capsules are Y-aligned), radius a fraction of the length. - let mid = [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5]; + let mid = [ + (a[0] + b[0]) * 0.5, + (a[1] + b[1]) * 0.5, + (a[2] + b[2]) * 0.5, + ]; let dir = normalize([b[0] - a[0], b[1] - a[1], b[2] - a[2]]); let radius = (len * radius_scale).clamp(0.02, len * 0.45); let half_height = (len * 0.5 - radius).max(0.01); @@ -187,7 +217,11 @@ pub fn plan( while anc != usize::MAX && bone_of[anc] == usize::MAX { anc = parent[anc]; } - let parent_bone = if anc == usize::MAX { usize::MAX } else { bone_of[anc] }; + let parent_bone = if anc == usize::MAX { + usize::MAX + } else { + bone_of[anc] + }; out.push(RagdollBuild { joint: j, @@ -205,10 +239,16 @@ pub fn plan( impl Ragdoll { /// Called by the FFI once the bodies exist. `bodies[i]` pairs with /// `builds[i]`. - pub fn attach(&mut self, builds: &[RagdollBuild], bodies: &[f64], - constraints: Vec, - anim: &ModelAnimation, - scale: f32, pos: [f32; 3], rot: f32) { + pub fn attach( + &mut self, + builds: &[RagdollBuild], + bodies: &[f64], + constraints: Vec, + anim: &ModelAnimation, + scale: f32, + pos: [f32; 3], + rot: f32, + ) { self.model = compose(scale, pos, rot); self.inv_model = invert_rigid(&self.model); self.scale = scale; @@ -219,32 +259,48 @@ impl Ragdoll { self.age = 0.0; for (i, b) in builds.iter().enumerate() { - if i >= bodies.len() || bodies[i] == 0.0 { continue; } + if i >= bodies.len() || bodies[i] == 0.0 { + continue; + } // offset = (M⁻¹ · B(0))⁻¹ · jointWorld(0) let body_model = mat4_mul(&self.inv_model, &b.world); let offset = mat4_mul(&invert_rigid(&body_model), &anim.joint_world[b.joint]); - self.bones.push(Bone { joint: b.joint, body: bodies[i], offset }); + self.bones.push(Bone { + joint: b.joint, + body: bodies[i], + offset, + }); } self.active = true; } - pub fn bodies(&self) -> Vec { self.bones.iter().map(|b| b.body).collect() } - pub fn constraint_handles(&self) -> &[f64] { &self.constraints } + pub fn bodies(&self) -> Vec { + self.bones.iter().map(|b| b.body).collect() + } + pub fn constraint_handles(&self) -> &[f64] { + &self.constraints + } - pub fn upload_params(&self) -> (f32, [f32; 3], f32) { (self.scale, self.pos, self.rot) } + pub fn upload_params(&self) -> (f32, [f32; 3], f32) { + (self.scale, self.pos, self.rot) + } /// Rebuild `anim.joint_matrices` from the simulated bodies. /// `body_world[i]` is the world transform of `self.bones[i]`'s body. pub fn apply(&self, anim: &mut ModelAnimation, body_world: &[[[f32; 4]; 4]]) { let Some(skel) = &anim.skeleton else { return }; let n = skel.joints.len(); - if anim.joint_world.len() != n { return } + if anim.joint_world.len() != n { + return; + } // 1. Bodied joints: push the body back through the stored offset. let mut have = vec![false; n]; let mut world = vec![mat4_identity(); n]; for (i, bone) in self.bones.iter().enumerate() { - if i >= body_world.len() { break; } + if i >= body_world.len() { + break; + } let body_model = mat4_mul(&self.inv_model, &body_world[i]); world[bone.joint] = mat4_mul(&body_model, &bone.offset); have[bone.joint] = true; @@ -252,10 +308,15 @@ impl Ragdoll { // 2. Everything else rides its parent at the rest pose. Walk from the // roots so a parent is always resolved before its children. - let mut stack: Vec<(usize, [[f32; 4]; 4])> = - skel.root_joints.iter().map(|&r| (r, mat4_identity())).collect(); + let mut stack: Vec<(usize, [[f32; 4]; 4])> = skel + .root_joints + .iter() + .map(|&r| (r, mat4_identity())) + .collect(); while let Some((j, parent_world)) = stack.pop() { - if j >= n { continue; } + if j >= n { + continue; + } if !have[j] { let local = mat4_from_trs( &skel.joints[j].rest_translation, @@ -283,43 +344,60 @@ pub struct RagdollManager { } impl RagdollManager { - pub fn new() -> Self { Self { slots: Vec::new() } } + pub fn new() -> Self { + Self { slots: Vec::new() } + } pub fn create(&mut self) -> u32 { self.slots.push(Some(Ragdoll::new())); self.slots.len() as u32 } pub fn get_mut(&mut self, h: u32) -> Option<&mut Ragdoll> { - if h == 0 { return None } + if h == 0 { + return None; + } self.slots.get_mut(h as usize - 1)?.as_mut() } pub fn get(&self, h: u32) -> Option<&Ragdoll> { - if h == 0 { return None } + if h == 0 { + return None; + } self.slots.get(h as usize - 1)?.as_ref() } } impl Default for RagdollManager { - fn default() -> Self { Self::new() } + fn default() -> Self { + Self::new() + } } // ---- math -------------------------------------------------------------------- fn mat4_identity() -> [[f32; 4]; 4] { - [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]] + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] } fn mat4_mul(a: &[[f32; 4]; 4], b: &[[f32; 4]; 4]) -> [[f32; 4]; 4] { let mut o = [[0.0f32; 4]; 4]; for col in 0..4 { for row in 0..4 { - o[col][row] = a[0][row] * b[col][0] + a[1][row] * b[col][1] - + a[2][row] * b[col][2] + a[3][row] * b[col][3]; + o[col][row] = a[0][row] * b[col][0] + + a[1][row] * b[col][1] + + a[2][row] * b[col][2] + + a[3][row] * b[col][3]; } } o } -fn translation(m: &[[f32; 4]; 4]) -> [f32; 3] { [m[3][0], m[3][1], m[3][2]] } +fn translation(m: &[[f32; 4]; 4]) -> [f32; 3] { + [m[3][0], m[3][1], m[3][2]] +} fn xform_point(m: &[[f32; 4]; 4], p: [f32; 3]) -> [f32; 3] { [ @@ -336,11 +414,19 @@ fn dist(a: [f32; 3], b: [f32; 3]) -> f32 { fn normalize(v: [f32; 3]) -> [f32; 3] { let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); - if l > 1e-6 { [v[0] / l, v[1] / l, v[2] / l] } else { [0.0, 1.0, 0.0] } + if l > 1e-6 { + [v[0] / l, v[1] / l, v[2] / l] + } else { + [0.0, 1.0, 0.0] + } } fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { - [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] } /// Model transform: translate · rotateY · scale. @@ -358,7 +444,11 @@ pub fn compose(scale: f32, pos: [f32; 3], rot: f32) -> [[f32; 4]; 4] { fn compose_from_dir(p: [f32; 3], dir: [f32; 3]) -> [[f32; 4]; 4] { let up = dir; // Any axis not parallel to `up` works as the seed for the basis. - let seed = if up[1].abs() > 0.99 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] }; + let seed = if up[1].abs() > 0.99 { + [1.0, 0.0, 0.0] + } else { + [0.0, 1.0, 0.0] + }; let right = normalize(cross(seed, up)); let fwd = cross(up, right); [ @@ -379,9 +469,21 @@ fn invert_rigid(m: &[[f32; 4]; 4]) -> [[f32; 4]; 4] { // R⁻¹ = Rᵀ, with the scale divided out twice (once for the transpose's own // scale, once for the inverse scale). let r = [ - [m[0][0] * inv_s * inv_s, m[1][0] * inv_s * inv_s, m[2][0] * inv_s * inv_s], - [m[0][1] * inv_s * inv_s, m[1][1] * inv_s * inv_s, m[2][1] * inv_s * inv_s], - [m[0][2] * inv_s * inv_s, m[1][2] * inv_s * inv_s, m[2][2] * inv_s * inv_s], + [ + m[0][0] * inv_s * inv_s, + m[1][0] * inv_s * inv_s, + m[2][0] * inv_s * inv_s, + ], + [ + m[0][1] * inv_s * inv_s, + m[1][1] * inv_s * inv_s, + m[2][1] * inv_s * inv_s, + ], + [ + m[0][2] * inv_s * inv_s, + m[1][2] * inv_s * inv_s, + m[2][2] * inv_s * inv_s, + ], ]; let t = [m[3][0], m[3][1], m[3][2]]; let it = [ @@ -404,9 +506,24 @@ fn mat4_from_trs(t: &[f32; 3], q: &[f32; 4], s: &[f32; 3]) -> [[f32; 4]; 4] { let (yy, yz, zz) = (y * y2, y * z2, z * z2); let (wx, wy, wz) = (w * x2, w * y2, w * z2); [ - [(1.0 - (yy + zz)) * s[0], (xy + wz) * s[0], (xz - wy) * s[0], 0.0], - [(xy - wz) * s[1], (1.0 - (xx + zz)) * s[1], (yz + wx) * s[1], 0.0], - [(xz + wy) * s[2], (yz - wx) * s[2], (1.0 - (xx + yy)) * s[2], 0.0], + [ + (1.0 - (yy + zz)) * s[0], + (xy + wz) * s[0], + (xz - wy) * s[0], + 0.0, + ], + [ + (xy - wz) * s[1], + (1.0 - (xx + zz)) * s[1], + (yz + wx) * s[1], + 0.0, + ], + [ + (xz + wy) * s[2], + (yz - wx) * s[2], + (1.0 - (xx + yy)) * s[2], + 0.0, + ], [t[0], t[1], t[2], 1.0], ] } @@ -452,8 +569,11 @@ mod tests { for c in 0..4 { for r in 0..4 { let want = if c == r { 1.0 } else { 0.0 }; - assert!((id[c][r] - want).abs() < 1e-3, - "M·M⁻¹ is not identity at [{c}][{r}]: {}", id[c][r]); + assert!( + (id[c][r] - want).abs() < 1e-3, + "M·M⁻¹ is not identity at [{c}][{r}]: {}", + id[c][r] + ); } } } diff --git a/native/shared/src/renderer/alpha_coverage.rs b/native/shared/src/renderer/alpha_coverage.rs new file mode 100644 index 00000000..4068d54a --- /dev/null +++ b/native/shared/src/renderer/alpha_coverage.rs @@ -0,0 +1,358 @@ +//! Coverage-preserving color mip generation for alpha-tested materials. +//! +//! Level zero remains byte-identical to the authored texture. Lower levels +//! store the fraction of source texels that survive the material's effective +//! alpha reference in A, while RGB averages only surviving coverage in linear +//! light. Empty texels receive a nearest visible color so filtering cannot +//! pull transparent border colors into a surviving silhouette. The scene and +//! shadow shaders interpret lower-level alpha as a deterministic subpixel +//! coverage probability. + +use std::collections::VecDeque; + +/// Build an RGBA8 color mip chain. +/// +/// `coverage_reference` is expressed in texture-alpha space. When present, +/// level zero is left untouched and lower mip alpha stores coverage rather +/// than an averaged opacity. When absent, this is the renderer's established +/// four-texel byte average exactly. +pub(super) fn build_color_mip_chain( + width: u32, + height: u32, + data: &[u8], + mip_count: u32, + coverage_reference: Option, +) -> (Vec, Vec) { + let base_len = width as usize * height as usize * 4; + assert!( + data.len() >= base_len, + "RGBA texture upload is shorter than its declared extent" + ); + let mut mip_data = Vec::with_capacity(base_len.saturating_mul(2)); + mip_data.extend_from_slice(&data[..base_len]); + let mut mip_offsets = vec![0usize]; + let mut previous_width = width.max(1); + let mut previous_height = height.max(1); + let reference = coverage_reference.map(|value| value.max(0.0)); + let srgb_decode = + reference.map(|_| std::array::from_fn::<_, 256, _>(|value| srgb_u8_to_linear(value as u8))); + let srgb_encode = reference.map(|_| { + (0..=u16::MAX) + .map(|value| linear_to_srgb_u8(value as f32 / u16::MAX as f32)) + .collect::>() + }); + + for level in 1..mip_count { + let previous_offset = mip_offsets[level as usize - 1]; + let width = (previous_width / 2).max(1); + let height = (previous_height / 2).max(1); + mip_offsets.push(mip_data.len()); + if let Some(reference) = reference { + append_coverage_mip_level( + &mut mip_data, + previous_offset, + previous_width, + previous_height, + width, + height, + reference, + level == 1, + srgb_decode.as_ref().expect("coverage decode table exists"), + srgb_encode.as_ref().expect("coverage encode table exists"), + ); + } else { + append_ordinary_color_mip_level( + &mut mip_data, + previous_offset, + previous_width, + previous_height, + width, + height, + ); + } + previous_width = width; + previous_height = height; + } + + (mip_data, mip_offsets) +} + +fn append_ordinary_color_mip_level( + mip_data: &mut Vec, + previous_offset: usize, + previous_width: u32, + previous_height: u32, + width: u32, + height: u32, +) { + let pw = previous_width as usize; + let ph = previous_height as usize; + let index = |x: usize, y: usize| previous_offset + (y * pw + x) * 4; + + for y in 0..height as usize { + for x in 0..width as usize { + let sx = x * 2; + let sy = y * 2; + let sx1 = (sx + 1).min(pw - 1); + let sy1 = (sy + 1).min(ph - 1); + let children = [ + index(sx, sy), + index(sx1, sy), + index(sx, sy1), + index(sx1, sy1), + ]; + for channel in 0..4 { + let sum: u32 = children + .iter() + .map(|child| mip_data[*child + channel] as u32) + .sum(); + mip_data.push(((sum + 2) / 4) as u8); + } + } + } +} + +#[allow(clippy::too_many_arguments)] +fn append_coverage_mip_level( + mip_data: &mut Vec, + previous_offset: usize, + previous_width: u32, + previous_height: u32, + width: u32, + height: u32, + reference: f32, + previous_alpha_is_authored: bool, + srgb_decode: &[f32; 256], + srgb_encode: &[u8], +) { + let pw = previous_width as usize; + let ph = previous_height as usize; + let width = width as usize; + let height = height as usize; + let level_offset = mip_data.len(); + let index = |x: usize, y: usize| previous_offset + (y * pw + x) * 4; + let scale_x = pw as f32 / width as f32; + let scale_y = ph as f32 / height as f32; + + // Integrate the exact normalized footprint rather than dropping an odd + // source row/column. A halving step touches at most 3x3 source texels. + for y in 0..height { + let y0 = y as f32 * scale_y; + let y1 = (y + 1) as f32 * scale_y; + let source_y_end = y1.ceil().min(ph as f32) as usize; + for x in 0..width { + let x0 = x as f32 * scale_x; + let x1 = (x + 1) as f32 * scale_x; + let source_x_end = x1.ceil().min(pw as f32) as usize; + let mut area_sum = 0.0f32; + let mut visible_area = 0.0f32; + let mut linear_rgb = [0.0f32; 3]; + + for source_y in y0.floor() as usize..source_y_end { + let overlap_y = (y1.min((source_y + 1) as f32) - y0.max(source_y as f32)).max(0.0); + for source_x in x0.floor() as usize..source_x_end { + let overlap_x = + (x1.min((source_x + 1) as f32) - x0.max(source_x as f32)).max(0.0); + let area = overlap_x * overlap_y; + let source = index(source_x.min(pw - 1), source_y.min(ph - 1)); + let alpha = mip_data[source + 3] as f32 / 255.0; + let coverage = if previous_alpha_is_authored { + if alpha >= reference { + 1.0 + } else { + 0.0 + } + } else { + alpha + }; + let visible_weight = area * coverage; + area_sum += area; + visible_area += visible_weight; + for channel in 0..3 { + linear_rgb[channel] += + srgb_decode[mip_data[source + channel] as usize] * visible_weight; + } + } + } + + for value in linear_rgb { + let linear = if visible_area > 1e-8 { + value / visible_area + } else { + 0.0 + }; + let table_index = (linear.clamp(0.0, 1.0) * u16::MAX as f32).round() as usize; + mip_data.push(srgb_encode[table_index]); + } + let coverage = if area_sum > 1e-8 { + visible_area / area_sum + } else { + 0.0 + }; + mip_data.push((coverage * 255.0).round().clamp(0.0, 255.0) as u8); + } + } + + dilate_visible_rgb(mip_data, level_offset, width, height); +} + +fn dilate_visible_rgb(mip_data: &mut [u8], level_offset: usize, width: usize, height: usize) { + let pixel_count = width * height; + let mut visited = vec![false; pixel_count]; + let mut queue = VecDeque::new(); + for pixel in 0..pixel_count { + if mip_data[level_offset + pixel * 4 + 3] != 0 { + visited[pixel] = true; + queue.push_back(pixel); + } + } + if queue.is_empty() { + return; + } + + while let Some(pixel) = queue.pop_front() { + let x = pixel % width; + let y = pixel / width; + let mut visit = |neighbor: usize| { + if visited[neighbor] { + return; + } + let source = level_offset + pixel * 4; + let target = level_offset + neighbor * 4; + let rgb = [mip_data[source], mip_data[source + 1], mip_data[source + 2]]; + mip_data[target..target + 3].copy_from_slice(&rgb); + visited[neighbor] = true; + queue.push_back(neighbor); + }; + if x > 0 { + visit(pixel - 1); + } + if x + 1 < width { + visit(pixel + 1); + } + if y > 0 { + visit(pixel - width); + } + if y + 1 < height { + visit(pixel + width); + } + } +} + +fn srgb_u8_to_linear(value: u8) -> f32 { + let value = value as f32 / 255.0; + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } +} + +fn linear_to_srgb_u8(value: f32) -> u8 { + let value = value.clamp(0.0, 1.0); + let encoded = if value <= 0.0031308 { + value * 12.92 + } else { + 1.055 * value.powf(1.0 / 2.4) - 0.055 + }; + (encoded * 255.0).round().clamp(0.0, 255.0) as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn alpha_mean(level: &[u8]) -> f32 { + level + .chunks_exact(4) + .map(|pixel| pixel[3] as f32 / 255.0) + .sum::() + / (level.len() / 4) as f32 + } + + #[test] + fn ordinary_color_mips_keep_the_established_byte_average() { + let pixels = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]; + let (chain, offsets) = build_color_mip_chain(2, 2, &pixels, 2, None); + assert_eq!(offsets, [0, 16]); + assert_eq!(&chain[16..], &[7, 8, 9, 10]); + } + + #[test] + fn lower_mips_retain_authored_mask_area_and_visible_color() { + let mut pixels = Vec::new(); + for index in 0..64 { + let visible = index % 4 != 0; + pixels.extend_from_slice(if visible { + &[40, 180, 30, 255] + } else { + &[255, 0, 255, 0] + }); + } + let (chain, offsets) = build_color_mip_chain(8, 8, &pixels, 4, Some(0.5)); + let expected_coverage = 0.75; + for (level, extent) in [(1usize, 4usize), (2, 2), (3, 1)] { + let start = offsets[level]; + let end = start + extent * extent * 4; + assert!( + (alpha_mean(&chain[start..end]) - expected_coverage).abs() <= 1.0 / 255.0, + "level {level} lost source coverage" + ); + for pixel in chain[start..end].chunks_exact(4) { + assert!(pixel[1] > pixel[0], "transparent magenta bled into RGB"); + } + } + assert_eq!(&chain[..pixels.len()], pixels.as_slice()); + } + + #[test] + fn odd_extent_coverage_keeps_the_authored_border_area() { + let mut pixels = vec![0u8; 5 * 3 * 4]; + let visible = (2 * 5 + 4) * 4; + pixels[visible..visible + 4].copy_from_slice(&[30, 220, 20, 255]); + let (chain, offsets) = build_color_mip_chain(5, 3, &pixels, 3, Some(0.5)); + let level_one = &chain[offsets[1]..offsets[1] + 2 * 4]; + let level_two = &chain[offsets[2]..offsets[2] + 4]; + let authored = 1.0 / 15.0; + assert!((alpha_mean(level_one) - authored).abs() <= 1.5 / 255.0); + assert!((alpha_mean(level_two) - authored).abs() <= 1.5 / 255.0); + } + + #[test] + fn empty_coverage_texels_receive_visible_edge_color() { + let mut pixels = Vec::new(); + for _y in 0..8 { + for x in 0..8 { + pixels.extend_from_slice(if x < 4 { + &[20, 210, 25, 255] + } else { + &[255, 0, 255, 0] + }); + } + } + let (chain, offsets) = build_color_mip_chain(8, 8, &pixels, 2, Some(0.5)); + for pixel in chain[offsets[1]..].chunks_exact(4) { + assert!( + pixel[1] > pixel[0] && pixel[1] > pixel[2], + "transparent magenta remained available to bilinear filtering" + ); + } + } + + #[test] + fn coverage_rgb_is_filtered_in_linear_light() { + let pixels = [ + 0, 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 255, 255, 255, 255, 255, + ]; + let (chain, offsets) = build_color_mip_chain(2, 2, &pixels, 2, Some(0.5)); + let output = &chain[offsets[1]..]; + assert!( + (output[0] as i16 - 188).abs() <= 1, + "50% linear luminance must encode near sRGB 188, got {}", + output[0] + ); + assert_eq!(output[0], output[1]); + assert_eq!(output[1], output[2]); + assert_eq!(output[3], 255); + } +} diff --git a/native/shared/src/renderer/atmosphere_lut.rs b/native/shared/src/renderer/atmosphere_lut.rs index a1536bca..a99645f9 100644 --- a/native/shared/src/renderer/atmosphere_lut.rs +++ b/native/shared/src/renderer/atmosphere_lut.rs @@ -230,7 +230,9 @@ fn build_transmittance_row(y: u32, w: u32, h: u32) -> Vec { pub fn build_transmittance_lut(w: u32, h: u32) -> Vec { #[cfg(not(target_arch = "wasm32"))] { - let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4); + let nthreads = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); let rows_per_thread = (h as usize + nthreads - 1) / nthreads; let mut all_rows: Vec>>> = (0..nthreads).map(|_| None).collect(); std::thread::scope(|s| { @@ -253,7 +255,9 @@ pub fn build_transmittance_lut(w: u32, h: u32) -> Vec { } #[cfg(target_arch = "wasm32")] { - (0..h).flat_map(|y| build_transmittance_row(y, w, h)).collect() + (0..h) + .flat_map(|y| build_transmittance_row(y, w, h)) + .collect() } } @@ -285,7 +289,11 @@ pub fn sample_transmittance_lut(lut: &[u16], w: u32, h: u32, r: f32, mu: f32) -> let c11 = fetch(x1, y1); let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t; let lerp3 = |a: [f32; 3], b: [f32; 3], t: f32| { - [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)] + [ + lerp(a[0], b[0], t), + lerp(a[1], b[1], t), + lerp(a[2], b[2], t), + ] }; let bottom = lerp3(c00, c10, tx); let top = lerp3(c01, c11, tx); @@ -382,7 +390,13 @@ fn integrate_single_scatter_along_ray( (l_sum, f_sum) } -fn build_multi_scattering_row(transmittance_lut: &[u16], tw: u32, th: u32, y: u32, size: u32) -> Vec { +fn build_multi_scattering_row( + transmittance_lut: &[u16], + tw: u32, + th: u32, + y: u32, + size: u32, +) -> Vec { let v = (y as f32 + 0.5) / size as f32; let mut row = Vec::with_capacity(size as usize * 4); let n_dirs = MULTI_SCATTERING_SQRT_SAMPLES; @@ -444,10 +458,17 @@ fn build_multi_scattering_row(transmittance_lut: &[u16], tw: u32, th: u32, y: u3 /// Build the multi-scattering LUT as `Rgba16Float` texels, row-major. /// Requires the transmittance LUT (must be built first); samples it /// internally to get the sun-to-point transmittance for each integration step. -pub fn build_multi_scattering_lut(transmittance_lut: &[u16], tw: u32, th: u32, size: u32) -> Vec { +pub fn build_multi_scattering_lut( + transmittance_lut: &[u16], + tw: u32, + th: u32, + size: u32, +) -> Vec { #[cfg(not(target_arch = "wasm32"))] { - let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4); + let nthreads = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); let rows_per_thread = (size as usize + nthreads - 1) / nthreads; let mut all_rows: Vec>>> = (0..nthreads).map(|_| None).collect(); std::thread::scope(|s| { @@ -505,11 +526,27 @@ mod tests { let t = lerp_lut_at(&lut, w, h, GROUND_RADIUS, 1.0); // Loose tolerance — exact values depend on integration step // count, ozone profile shape, and bilinear bias near the edge. - assert!((t[0] - 0.94).abs() < 0.05, "R channel: got {}, expected ~0.94", t[0]); - assert!((t[1] - 0.87).abs() < 0.05, "G channel: got {}, expected ~0.87", t[1]); - assert!((t[2] - 0.76).abs() < 0.05, "B channel: got {}, expected ~0.76", t[2]); + assert!( + (t[0] - 0.94).abs() < 0.05, + "R channel: got {}, expected ~0.94", + t[0] + ); + assert!( + (t[1] - 0.87).abs() < 0.05, + "G channel: got {}, expected ~0.87", + t[1] + ); + assert!( + (t[2] - 0.76).abs() < 0.05, + "B channel: got {}, expected ~0.76", + t[2] + ); // R > G > B — Rayleigh scattering scales with 1/λ⁴. - assert!(t[0] > t[1] && t[1] > t[2], "expected R > G > B, got {:?}", t); + assert!( + t[0] > t[1] && t[1] > t[2], + "expected R > G > B, got {:?}", + t + ); } #[test] @@ -547,9 +584,21 @@ mod tests { let h = TRANSMITTANCE_H; let lut = build_transmittance_lut(w, h); let t = lerp_lut_at(&lut, w, h, GROUND_RADIUS, 0.05); - assert!(t[2] < 0.2, "blue should be heavily attenuated at horizon, got {}", t[2]); - assert!(t[0] > t[1] && t[1] > t[2], "horizon transmittance should still preserve R>G>B ordering, got {:?}", t); - assert!(t[0] - t[2] > 0.2, "expected strong red/blue separation at horizon, got R-B={}", t[0] - t[2]); + assert!( + t[2] < 0.2, + "blue should be heavily attenuated at horizon, got {}", + t[2] + ); + assert!( + t[0] > t[1] && t[1] > t[2], + "horizon transmittance should still preserve R>G>B ordering, got {:?}", + t + ); + assert!( + t[0] - t[2] > 0.2, + "expected strong red/blue separation at horizon, got R-B={}", + t[0] - t[2] + ); } #[test] @@ -561,7 +610,10 @@ mod tests { let th = TRANSMITTANCE_H; let t_lut = build_transmittance_lut(tw, th); let ms = build_multi_scattering_lut(&t_lut, tw, th, MULTI_SCATTERING_SIZE); - assert_eq!(ms.len() as u32, MULTI_SCATTERING_SIZE * MULTI_SCATTERING_SIZE * 4); + assert_eq!( + ms.len() as u32, + MULTI_SCATTERING_SIZE * MULTI_SCATTERING_SIZE * 4 + ); for chunk in ms.chunks(4) { for &bits in &chunk[..3] { let v = f16::from_bits(bits).to_f32(); diff --git a/native/shared/src/renderer/brdf_lut.rs b/native/shared/src/renderer/brdf_lut.rs index 3404297f..c5094767 100644 --- a/native/shared/src/renderer/brdf_lut.rs +++ b/native/shared/src/renderer/brdf_lut.rs @@ -47,7 +47,11 @@ fn importance_sample_ggx(xi: (f32, f32), n: [f32; 3], roughness: f32) -> [f32; 3 let h_local = [sin_theta * phi.cos(), sin_theta * phi.sin(), cos_theta]; // Build TBN around N. - let up = if n[2].abs() < 0.999 { [0.0, 0.0, 1.0] } else { [1.0, 0.0, 0.0] }; + let up = if n[2].abs() < 0.999 { + [0.0, 0.0, 1.0] + } else { + [1.0, 0.0, 0.0] + }; let t = normalize3(cross3(up, n)); let b = cross3(n, t); [ @@ -58,7 +62,11 @@ fn importance_sample_ggx(xi: (f32, f32), n: [f32; 3], roughness: f32) -> [f32; 3 } fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { - [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] } fn normalize3(v: [f32; 3]) -> [f32; 3] { @@ -86,11 +94,7 @@ fn build_brdf_lut_row(y: usize, size: usize) -> Vec { let mut row = Vec::with_capacity(size * 2); for x in 0..size { let n_dot_v = ((x as f32) + 0.5) / size as f32; - let v = [ - (1.0 - n_dot_v * n_dot_v).max(0.0).sqrt(), - 0.0, - n_dot_v, - ]; + let v = [(1.0 - n_dot_v * n_dot_v).max(0.0).sqrt(), 0.0, n_dot_v]; let mut a_sum = 0.0_f32; let mut b_sum = 0.0_f32; for i in 0..BRDF_LUT_SAMPLES { @@ -128,7 +132,9 @@ fn build_brdf_lut_row(y: usize, size: usize) -> Vec { pub fn build_brdf_lut(size: usize) -> Vec { #[cfg(not(target_arch = "wasm32"))] { - let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4); + let nthreads = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); let rows_per_thread = (size + nthreads - 1) / nthreads; let mut all_rows: Vec>>> = (0..nthreads).map(|_| None).collect(); std::thread::scope(|s| { @@ -137,7 +143,9 @@ pub fn build_brdf_lut(size: usize) -> Vec { let y_start = t * rows_per_thread; let y_end = ((t + 1) * rows_per_thread).min(size); let h = s.spawn(move || { - (y_start..y_end).map(|y| build_brdf_lut_row(y, size)).collect::>() + (y_start..y_end) + .map(|y| build_brdf_lut_row(y, size)) + .collect::>() }); handles.push(h); } @@ -149,6 +157,8 @@ pub fn build_brdf_lut(size: usize) -> Vec { } #[cfg(target_arch = "wasm32")] { - (0..size).flat_map(|y| build_brdf_lut_row(y, size)).collect() + (0..size) + .flat_map(|y| build_brdf_lut_row(y, size)) + .collect() } } diff --git a/native/shared/src/renderer/capabilities.rs b/native/shared/src/renderer/capabilities.rs new file mode 100644 index 00000000..95bb4c11 --- /dev/null +++ b/native/shared/src/renderer/capabilities.rs @@ -0,0 +1,445 @@ +//! Data-driven renderer capability tiers (#138). +//! +//! Tiers describe the resource-binding model that every renderer subsystem can +//! rely on. Independent optional paths (GPU-driven submission and ray query) +//! remain feature-detected within the selected tier so an adapter never loses a +//! supported fast path merely because it lacks an unrelated feature. + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum RendererCapabilityTier { + Baseline = 1, + Modern = 2, + HighEnd = 3, +} + +impl RendererCapabilityTier { + pub const fn name(self) -> &'static str { + match self { + Self::Baseline => "baseline", + Self::Modern => "modern", + Self::HighEnd => "high-end", + } + } + + pub fn from_override(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "baseline" | "base" | "low" | "1" => Some(Self::Baseline), + "modern" | "medium" | "mid" | "2" => Some(Self::Modern), + "high-end" | "high_end" | "highend" | "high" | "3" => Some(Self::HighEnd), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CapabilityTierDefinition { + pub tier: RendererCapabilityTier, + pub required_features: wgpu::Features, + pub min_binding_array_elements: u32, + pub min_binding_array_samplers: u32, + pub min_texture_array_layers: u32, + pub min_sampled_textures: u32, + pub material_bindings: &'static str, + pub geometry_submission: &'static str, + pub shadows: &'static str, + pub gi: &'static str, + pub reflections: &'static str, + pub anti_aliasing: &'static str, + pub textures: &'static str, + pub path_tracing: &'static str, + pub minimum_contract: &'static str, +} + +/// This is the source of truth for both runtime selection and the public table +/// in `docs/renderer-capability-tiers.md`. +pub const CAPABILITY_TIER_DEFINITIONS: [CapabilityTierDefinition; 3] = [ + CapabilityTierDefinition { + tier: RendererCapabilityTier::Baseline, + required_features: wgpu::Features::empty(), + min_binding_array_elements: 0, + min_binding_array_samplers: 0, + min_texture_array_layers: 0, + min_sampled_textures: 0, + material_bindings: "Tier C per-material bind groups", + geometry_submission: "CPU direct draws", + shadows: "Cascaded/VSM raster paths", + gi: "Software SDF, probes, and SSGI", + reflections: "SSR, planar, and probe fallbacks", + anti_aliasing: "TAA/CAS/FXAA", + textures: "Per-material resident textures", + path_tracing: "Disabled when this tier is forced", + minimum_contract: "Active platform profile", + }, + CapabilityTierDefinition { + tier: RendererCapabilityTier::Modern, + required_features: wgpu::Features::empty(), + min_binding_array_elements: 0, + min_binding_array_samplers: 0, + min_texture_array_layers: 16, + min_sampled_textures: 8, + material_bindings: "Tier B deterministic paged arrays", + geometry_submission: "CPU direct draws", + shadows: "Cascaded/VSM raster paths", + gi: "Software SDF, probes, and SSGI", + reflections: "SSR, planar, and probe fallbacks", + anti_aliasing: "TAA/CAS/FXAA", + textures: "Paged texture arrays/atlases", + path_tracing: "Disabled when this tier is forced", + minimum_contract: "16 texture-array layers; 8 sampled textures/stage", + }, + CapabilityTierDefinition { + tier: RendererCapabilityTier::HighEnd, + required_features: wgpu::Features::TEXTURE_BINDING_ARRAY + .union(wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING), + min_binding_array_elements: 2, + min_binding_array_samplers: 2, + min_texture_array_layers: 0, + min_sampled_textures: 0, + material_bindings: "Tier A descriptor-indexed global tables", + geometry_submission: "GPU indirect when supported; CPU oracle fallback", + shadows: "Cascaded/VSM raster paths", + gi: "Ray query when supported; software SDF/SSGI fallback", + reflections: "Ray query when supported; SSR/planar/probe fallback", + anti_aliasing: "TAA/CAS/FXAA", + textures: "Descriptor-indexed texture/sampler arrays", + path_tracing: "Available only with ray query and required limits", + minimum_contract: "Texture-binding arrays + non-uniform indexing; 2 array elements", + }, +]; + +impl CapabilityTierDefinition { + fn supported_by(self, features: wgpu::Features, limits: &wgpu::Limits) -> bool { + features.contains(self.required_features) + && limits.max_binding_array_elements_per_shader_stage >= self.min_binding_array_elements + && limits.max_binding_array_sampler_elements_per_shader_stage + >= self.min_binding_array_samplers + && limits.max_texture_array_layers >= self.min_texture_array_layers + && limits.max_sampled_textures_per_shader_stage >= self.min_sampled_textures + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RendererCapabilities { + pub detected_tier: RendererCapabilityTier, + pub selected_tier: RendererCapabilityTier, + pub requested_tier: Option, + pub forced_tier: Option, + pub texture_binding_array: bool, + pub non_uniform_indexing: bool, + pub indirect_first_instance: bool, + pub ray_query: bool, + pub max_binding_array_elements: u32, + pub max_binding_array_samplers: u32, + pub max_texture_array_layers: u32, + pub max_sampled_textures: u32, + pub max_samplers: u32, + pub max_bind_groups: u32, + pub max_color_attachments: u32, + pub diagnostic: Option, +} + +impl RendererCapabilities { + pub fn detect(features: wgpu::Features, limits: &wgpu::Limits) -> Self { + let raw_override = std::env::var("BLOOM_FORCE_RENDER_TIER").ok(); + let parsed_override = raw_override + .as_deref() + .and_then(RendererCapabilityTier::from_override); + let mut capabilities = Self::detect_with_override(features, limits, parsed_override); + if let Some(invalid) = raw_override.filter(|_| parsed_override.is_none()) { + capabilities.diagnostic = Some(format!( + "invalid BLOOM_FORCE_RENDER_TIER value {invalid:?}; using detected tier {}", + capabilities.detected_tier.name() + )); + } + capabilities + } + + pub fn detect_with_override( + features: wgpu::Features, + limits: &wgpu::Limits, + forced_tier: Option, + ) -> Self { + let texture_binding_array = features.contains(wgpu::Features::TEXTURE_BINDING_ARRAY); + let non_uniform_indexing = features.contains( + wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING, + ); + let detected_tier = CAPABILITY_TIER_DEFINITIONS + .iter() + .rev() + .copied() + .find(|definition| definition.supported_by(features, limits)) + .map(|definition| definition.tier) + .expect("baseline renderer capability tier is unconditional"); + let (selected_tier, accepted_force, diagnostic) = match forced_tier { + Some(requested) if requested <= detected_tier => (requested, Some(requested), None), + Some(requested) => ( + detected_tier, + None, + Some(format!( + "requested renderer tier {} exceeds detected tier {}; using {}", + requested.name(), + detected_tier.name(), + detected_tier.name() + )), + ), + None => (detected_tier, None, None), + }; + + Self { + detected_tier, + selected_tier, + requested_tier: forced_tier, + forced_tier: accepted_force, + texture_binding_array, + non_uniform_indexing, + indirect_first_instance: features.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE), + ray_query: features.contains(wgpu::Features::EXPERIMENTAL_RAY_QUERY), + max_binding_array_elements: limits.max_binding_array_elements_per_shader_stage, + max_binding_array_samplers: limits.max_binding_array_sampler_elements_per_shader_stage, + max_texture_array_layers: limits.max_texture_array_layers, + max_sampled_textures: limits.max_sampled_textures_per_shader_stage, + max_samplers: limits.max_samplers_per_shader_stage, + max_bind_groups: limits.max_bind_groups, + max_color_attachments: limits.max_color_attachments, + diagnostic, + } + } + + /// Optional paths are not restricted during automatic detection. A forced + /// lower tier is a test/debug contract and must disable paths above it. + pub fn forced_path_allowed(required: RendererCapabilityTier) -> bool { + forced_renderer_tier().is_none_or(|forced| forced >= required) + } + + pub fn report_json(&self) -> String { + let selected_definition = CAPABILITY_TIER_DEFINITIONS + .iter() + .find(|definition| definition.tier == self.selected_tier) + .expect("selected renderer tier has a definition"); + let requested = self + .requested_tier + .map(|tier| format!("\"{}\"", tier.name())) + .unwrap_or_else(|| "null".to_owned()); + let forced = self + .forced_tier + .map(|tier| format!("\"{}\"", tier.name())) + .unwrap_or_else(|| "null".to_owned()); + let diagnostic = self + .diagnostic + .as_deref() + .map(json_string) + .unwrap_or_else(|| "null".to_owned()); + let mut out = format!( + concat!( + "{{\"detected\":\"{}\",\"selected\":\"{}\",\"requested\":{},\"forced\":{},", + "\"diagnostic\":{},\"available\":{{\"features\":{{", + "\"texture_binding_array\":{},\"non_uniform_indexing\":{},", + "\"indirect_first_instance\":{},\"ray_query\":{}}},\"limits\":{{", + "\"max_binding_array_elements_per_shader_stage\":{},", + "\"max_binding_array_sampler_elements_per_shader_stage\":{},", + "\"max_texture_array_layers\":{},", + "\"max_sampled_textures_per_shader_stage\":{},", + "\"max_samplers_per_shader_stage\":{},\"max_bind_groups\":{},", + "\"max_color_attachments\":{}}}}}}}" + ), + self.detected_tier.name(), + self.selected_tier.name(), + requested, + forced, + diagnostic, + self.texture_binding_array, + self.non_uniform_indexing, + self.indirect_first_instance, + self.ray_query, + self.max_binding_array_elements, + self.max_binding_array_samplers, + self.max_texture_array_layers, + self.max_sampled_textures, + self.max_samplers, + self.max_bind_groups, + self.max_color_attachments, + ); + out.pop(); + out.push_str(",\"paths\":{\"materials\":"); + out.push_str(&json_string(selected_definition.material_bindings)); + out.push_str(",\"geometry\":"); + out.push_str(&json_string(selected_definition.geometry_submission)); + out.push_str(",\"shadows\":"); + out.push_str(&json_string(selected_definition.shadows)); + out.push_str(",\"gi\":"); + out.push_str(&json_string(selected_definition.gi)); + out.push_str(",\"reflections\":"); + out.push_str(&json_string(selected_definition.reflections)); + out.push_str(",\"anti_aliasing\":"); + out.push_str(&json_string(selected_definition.anti_aliasing)); + out.push_str(",\"textures\":"); + out.push_str(&json_string(selected_definition.textures)); + out.push_str(",\"path_tracing\":"); + out.push_str(&json_string(selected_definition.path_tracing)); + out.push_str("}}"); + out + } +} + +pub fn forced_renderer_tier() -> Option { + std::env::var("BLOOM_FORCE_RENDER_TIER") + .ok() + .and_then(|value| RendererCapabilityTier::from_override(&value)) +} + +pub fn hardware_ray_query_enabled(features: wgpu::Features) -> bool { + features.contains(wgpu::Features::EXPERIMENTAL_RAY_QUERY) + && RendererCapabilities::forced_path_allowed(RendererCapabilityTier::HighEnd) +} + +pub fn capability_tier_markdown() -> String { + let mut table = String::from( + "| Tier | Materials | Geometry | Shadows | GI | Reflections | AA | Textures | Path tracing | Minimum contract |\n\ + | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n", + ); + for definition in CAPABILITY_TIER_DEFINITIONS { + use std::fmt::Write as _; + let _ = writeln!( + table, + "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |", + definition.tier.name(), + definition.material_bindings, + definition.geometry_submission, + definition.shadows, + definition.gi, + definition.reflections, + definition.anti_aliasing, + definition.textures, + definition.path_tracing, + definition.minimum_contract, + ); + } + table +} + +fn json_string(value: &str) -> String { + let mut out = String::from("\""); + for character in value.chars() { + match character { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c <= '\u{1f}' => { + use std::fmt::Write as _; + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selection_depends_on_features_and_limits_not_platform_names() { + let mut baseline_limits = wgpu::Limits::downlevel_webgl2_defaults(); + baseline_limits.max_texture_array_layers = 1; + baseline_limits.max_sampled_textures_per_shader_stage = 4; + let baseline = RendererCapabilities::detect_with_override( + wgpu::Features::empty(), + &baseline_limits, + None, + ); + assert_eq!(baseline.detected_tier, RendererCapabilityTier::Baseline); + + let mut modern_limits = wgpu::Limits::downlevel_defaults(); + modern_limits.max_texture_array_layers = 16; + modern_limits.max_sampled_textures_per_shader_stage = 8; + let modern = RendererCapabilities::detect_with_override( + wgpu::Features::empty(), + &modern_limits, + None, + ); + assert_eq!(modern.detected_tier, RendererCapabilityTier::Modern); + + modern_limits.max_binding_array_elements_per_shader_stage = 2; + modern_limits.max_binding_array_sampler_elements_per_shader_stage = 2; + let high_end = RendererCapabilities::detect_with_override( + wgpu::Features::TEXTURE_BINDING_ARRAY + | wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING, + &modern_limits, + None, + ); + assert_eq!(high_end.detected_tier, RendererCapabilityTier::HighEnd); + } + + #[test] + fn lower_force_is_selected_and_unsupported_upward_force_is_rejected() { + let mut limits = wgpu::Limits::downlevel_defaults(); + limits.max_texture_array_layers = 16; + limits.max_sampled_textures_per_shader_stage = 8; + let forced_baseline = RendererCapabilities::detect_with_override( + wgpu::Features::empty(), + &limits, + Some(RendererCapabilityTier::Baseline), + ); + assert_eq!( + forced_baseline.selected_tier, + RendererCapabilityTier::Baseline + ); + assert_eq!( + forced_baseline.forced_tier, + Some(RendererCapabilityTier::Baseline) + ); + + let rejected = RendererCapabilities::detect_with_override( + wgpu::Features::empty(), + &limits, + Some(RendererCapabilityTier::HighEnd), + ); + assert_eq!(rejected.selected_tier, RendererCapabilityTier::Modern); + assert_eq!(rejected.forced_tier, None); + assert!(rejected.diagnostic.is_some()); + } + + #[test] + fn checked_documentation_table_matches_runtime_definitions() { + let document = include_str!("../../../../docs/renderer-capability-tiers.md"); + let begin = "\n"; + let end = ""; + let generated = document + .split_once(begin) + .expect("generated tier table start marker") + .1 + .split_once(end) + .expect("generated tier table end marker") + .0; + assert_eq!(generated, capability_tier_markdown()); + } + + #[cfg(feature = "models3d")] + #[test] + fn capability_report_is_valid_json_with_requested_and_available_state() { + let report = RendererCapabilities::detect_with_override( + wgpu::Features::INDIRECT_FIRST_INSTANCE, + &wgpu::Limits::downlevel_defaults(), + Some(RendererCapabilityTier::Baseline), + ); + let json: serde_json::Value = + serde_json::from_str(&report.report_json()).expect("valid capability report JSON"); + assert_eq!(json["selected"], "baseline"); + assert_eq!(json["requested"], "baseline"); + assert!(json["available"]["features"]["indirect_first_instance"] + .as_bool() + .unwrap()); + assert!(json["available"]["limits"]["max_bind_groups"] + .as_u64() + .is_some()); + assert_eq!( + json["paths"]["materials"], + "Tier C per-material bind groups" + ); + assert_eq!(json["paths"]["anti_aliasing"], "TAA/CAS/FXAA"); + } +} diff --git a/native/shared/src/renderer/capability_api.rs b/native/shared/src/renderer/capability_api.rs new file mode 100644 index 00000000..3f09f566 --- /dev/null +++ b/native/shared/src/renderer/capability_api.rs @@ -0,0 +1,171 @@ +//! Read-only renderer capability reporting shared by native and web targets. +//! +//! Reports are built only when the public API is queried, so exposing this +//! module does not add work to renderer initialization or the frame loop. + +use super::capabilities::RendererCapabilities; +use super::Renderer; + +fn json_string(out: &mut String, value: &str) { + out.push('"'); + for character in value.chars() { + match character { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c <= '\u{1f}' => { + use std::fmt::Write as _; + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); +} + +fn enum_name(value: impl std::fmt::Debug) -> String { + format!("{value:?}").to_ascii_lowercase().replace('_', "-") +} + +impl Renderer { + pub fn set_device_negotiation_report(&mut self, report: String) { + self.device_negotiation_report = Some(report); + } + + /// Present mode: 0 = Fifo (vsync), 1 = Mailbox (uncapped, no tearing), + /// 2 = Immediate (uncapped, tearing allowed), 3 = AutoNoVsync (portable + /// uncapped preference with backend fallback). Headless renderers retain + /// the requested mode as qualification metadata without configuring a + /// surface. Returns false only for invalid requests. + pub fn set_present_mode(&mut self, mode: u32) -> bool { + if mode > 3 { + return false; + } + let requested = match mode { + 1 => wgpu::PresentMode::Mailbox, + 2 => wgpu::PresentMode::Immediate, + 3 => wgpu::PresentMode::AutoNoVsync, + _ => wgpu::PresentMode::Fifo, + }; + if self.surface_config.present_mode == requested { + return true; + } + self.surface_config.present_mode = requested; + if let Some(surface) = &self.surface { + surface.configure(&self.device, &self.surface_config); + } + eprintln!("bloom: present mode = {:?}", requested); + true + } + + /// Stable numeric form of the configured present-mode request. Quality + /// qualification records this alongside wall/GPU timings so a vsync cap + /// cannot masquerade as unchanged performance. + pub fn present_mode_code(&self) -> u32 { + match self.surface_config.present_mode { + wgpu::PresentMode::Fifo => 0, + wgpu::PresentMode::Mailbox => 1, + wgpu::PresentMode::Immediate => 2, + wgpu::PresentMode::AutoNoVsync => 3, + wgpu::PresentMode::FifoRelaxed => 4, + wgpu::PresentMode::AutoVsync => 5, + } + } + + pub fn quality_adapter_json(&self) -> String { + let info = self.device.adapter_info(); + let features = self.device.features(); + let renderer_capabilities = RendererCapabilities::detect(features, &self.device.limits()); + let mut semantic_features = Vec::new(); + for (feature, enabled) in [ + ( + "timestamp-query", + features.contains(wgpu::Features::TIMESTAMP_QUERY), + ), + ( + "ray-query", + features.contains(wgpu::Features::EXPERIMENTAL_RAY_QUERY), + ), + ( + "texture-binding-array", + features.contains(wgpu::Features::TEXTURE_BINDING_ARRAY), + ), + ( + "non-uniform-indexing", + features.contains( + wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING, + ), + ), + ( + "texture-compression-bc", + features.contains(wgpu::Features::TEXTURE_COMPRESSION_BC), + ), + ] { + if enabled { + semantic_features.push(feature); + } + } + let mut out = String::from("{\"availability\":\"reported\",\"name\":"); + json_string(&mut out, &info.name); + out.push_str(",\"vendor_id\":"); + out.push_str(&info.vendor.to_string()); + out.push_str(",\"device_id\":"); + out.push_str(&info.device.to_string()); + out.push_str(",\"device_type\":"); + json_string(&mut out, &enum_name(info.device_type)); + out.push_str(",\"driver\":"); + json_string(&mut out, &info.driver); + out.push_str(",\"driver_info\":"); + json_string(&mut out, &info.driver_info); + out.push_str(",\"backend\":"); + json_string(&mut out, &enum_name(info.backend)); + out.push_str(",\"capability_tier\":"); + json_string(&mut out, renderer_capabilities.selected_tier.name()); + out.push_str(",\"renderer_capabilities\":"); + out.push_str(&renderer_capabilities.report_json()); + out.push_str(",\"device_negotiation\":"); + out.push_str(self.device_negotiation_report.as_deref().unwrap_or("null")); + out.push_str(",\"features\":["); + for (index, feature) in semantic_features.iter().enumerate() { + if index > 0 { + out.push(','); + } + json_string(&mut out, feature); + } + out.push_str("]}"); + out + } + + /// Public, read-only renderer capability report. This composes the same + /// adapter/tier evidence used by qualification artifacts with live + /// material capacities and the optional paths actually built at startup. + pub fn renderer_capability_report_json(&self) -> String { + let imported_refraction = match self.imported_refraction_mode_code() { + 1 => "scene-snapshot", + 2 => "environment-fallback", + _ => "disabled-legacy", + }; + let mut out = String::from( + "{\"version\":1,\"availability\":\"available\",\"reason\":null,\"adapter\":", + ); + out.push_str(&self.quality_adapter_json()); + out.push_str(",\"material_binding\":"); + out.push_str(&self.material_binding_report_json()); + out.push_str(",\"runtime_support\":{\"hardware_ray_query\":"); + out.push_str(if self.hw_rt_enabled { "true" } else { "false" }); + out.push_str(",\"path_tracing\":"); + out.push_str(if self.pt_pipeline.is_some() { + "true" + } else { + "false" + }); + out.push_str(",\"gpu_driven\":"); + out.push_str(&self.gpu_driven.report_json()); + out.push_str(",\"imported_refraction\":"); + json_string(&mut out, imported_refraction); + out.push_str(",\"transparency_modes\":[\"sorted\",\"auto\",\"weighted\"]}}"); + out + } +} diff --git a/native/shared/src/renderer/device_negotiation.rs b/native/shared/src/renderer/device_negotiation.rs new file mode 100644 index 00000000..8a01a174 --- /dev/null +++ b/native/shared/src/renderer/device_negotiation.rs @@ -0,0 +1,540 @@ +//! Tier-aware native device requests and documented fallback (#138). + +use super::capabilities::{forced_renderer_tier, RendererCapabilities, RendererCapabilityTier}; +use super::material_indirection::request_tier_a_if_supported; + +const CORE_BIND_GROUPS: u32 = 5; +const CORE_COLOR_ATTACHMENTS: u32 = 4; +const CORE_SAMPLED_TEXTURES: u32 = 19; +const CORE_SAMPLERS: u32 = 16; +const CORE_STORAGE_BUFFERS: u32 = 8; +const CORE_UNIFORM_BUFFER_BINDING_SIZE: u64 = 64 * 1024; +const PATH_TRACING_STORAGE_BUFFERS: u32 = 9; +const FOLDED_MOBILE_BIND_GROUPS: u32 = 4; +const FOLDED_MOBILE_STORAGE_BUFFERS: u32 = 4; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DeviceRequestProfile { + NativeFull, + FoldedMobile, +} + +impl DeviceRequestProfile { + const fn name(self) -> &'static str { + match self { + Self::NativeFull => "native-full", + Self::FoldedMobile => "folded-mobile", + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct DeviceRequestOptions { + pub allow_ray_query: bool, + pub profile: DeviceRequestProfile, +} + +impl Default for DeviceRequestOptions { + fn default() -> Self { + Self { + allow_ray_query: true, + profile: DeviceRequestProfile::NativeFull, + } + } +} + +#[derive(Clone, Debug)] +pub struct DeviceRequestPlan { + pub label: &'static str, + pub tier: RendererCapabilityTier, + pub required_features: wgpu::Features, + pub required_limits: wgpu::Limits, + pub experimental_features: wgpu::ExperimentalFeatures, +} + +impl DeviceRequestPlan { + fn descriptor(&self, trace: wgpu::Trace) -> wgpu::DeviceDescriptor<'static> { + wgpu::DeviceDescriptor { + label: Some(self.label), + required_features: self.required_features, + required_limits: self.required_limits.clone(), + experimental_features: self.experimental_features, + trace, + ..Default::default() + } + } +} + +#[derive(Clone, Debug)] +pub struct DeviceNegotiationReport { + pub profile: DeviceRequestProfile, + pub preferred_tier: RendererCapabilityTier, + pub selected_tier: RendererCapabilityTier, + pub selected_label: &'static str, + pub fallback_cause: Option, + pub required_features: wgpu::Features, + pub required_limits: wgpu::Limits, +} + +impl DeviceNegotiationReport { + pub fn report_json(&self) -> String { + let fallback_cause = self + .fallback_cause + .as_deref() + .map(json_string) + .unwrap_or_else(|| "null".to_owned()); + format!( + concat!( + "{{\"preferred_tier\":\"{}\",\"selected_tier\":\"{}\",", + "\"profile\":\"{}\",\"selected_request\":\"{}\",\"fallback_cause\":{},", + "\"required_features\":{},\"required_limits\":{{", + "\"max_bind_groups\":{},\"max_color_attachments\":{},", + "\"max_sampled_textures_per_shader_stage\":{},", + "\"max_samplers_per_shader_stage\":{},", + "\"max_storage_buffers_per_shader_stage\":{},", + "\"max_uniform_buffer_binding_size\":{},", + "\"max_binding_array_elements_per_shader_stage\":{},", + "\"max_binding_array_sampler_elements_per_shader_stage\":{}}}}}" + ), + self.preferred_tier.name(), + self.selected_tier.name(), + self.profile.name(), + self.selected_label, + fallback_cause, + json_string(&format!("{:?}", self.required_features)), + self.required_limits.max_bind_groups, + self.required_limits.max_color_attachments, + self.required_limits.max_sampled_textures_per_shader_stage, + self.required_limits.max_samplers_per_shader_stage, + self.required_limits.max_storage_buffers_per_shader_stage, + self.required_limits.max_uniform_buffer_binding_size, + self.required_limits + .max_binding_array_elements_per_shader_stage, + self.required_limits + .max_binding_array_sampler_elements_per_shader_stage, + ) + } +} + +pub struct NegotiatedDevice { + pub device: wgpu::Device, + pub queue: wgpu::Queue, + pub report: DeviceNegotiationReport, +} + +pub async fn request_device_with_fallback( + adapter: &wgpu::Adapter, + options: DeviceRequestOptions, +) -> Result { + request_device_with_fallback_and_trace(adapter, options, wgpu::Trace::Off).await +} + +/// Production device negotiation with an optional wgpu API trace. +/// +/// Normal engine startup always calls [`request_device_with_fallback`] and +/// keeps tracing disabled. Qualification tools use this entry point so their +/// traced device has the exact same bounded feature/limit request and fallback +/// report as the engine being measured. +pub async fn request_device_with_fallback_and_trace( + adapter: &wgpu::Adapter, + options: DeviceRequestOptions, + trace: wgpu::Trace, +) -> Result { + let plans = build_device_request_plans(adapter.features(), &adapter.limits(), options)?; + let preferred = &plans[0]; + match adapter + .request_device(&preferred.descriptor(trace.clone())) + .await + { + Ok((device, queue)) => Ok(NegotiatedDevice { + device, + queue, + report: report_for(preferred, options.profile, preferred.tier, None), + }), + Err(preferred_error) => { + let Some(fallback) = plans.get(1) else { + return Err(format!( + "{} device request failed with no distinct fallback: {preferred_error}", + preferred.label + )); + }; + match adapter.request_device(&fallback.descriptor(trace)).await { + Ok((device, queue)) => Ok(NegotiatedDevice { + device, + queue, + report: report_for( + fallback, + options.profile, + preferred.tier, + Some(preferred_error.to_string()), + ), + }), + Err(fallback_error) => Err(format!( + "{} device request failed: {preferred_error}; {} failed: {fallback_error}", + preferred.label, fallback.label + )), + } + } + } +} + +pub fn build_device_request_plans( + supported: wgpu::Features, + adapter_limits: &wgpu::Limits, + options: DeviceRequestOptions, +) -> Result, String> { + build_device_request_plans_with_override( + supported, + adapter_limits, + options, + forced_renderer_tier(), + ) +} + +fn build_device_request_plans_with_override( + supported: wgpu::Features, + adapter_limits: &wgpu::Limits, + options: DeviceRequestOptions, + forced_tier: Option, +) -> Result, String> { + validate_core_contract(adapter_limits, options.profile)?; + let capability = + RendererCapabilities::detect_with_override(supported, adapter_limits, forced_tier); + let high_paths_allowed = forced_tier.is_none_or(|tier| tier >= RendererCapabilityTier::HighEnd); + let mut preferred_features = wgpu::Features::empty(); + for optional in [ + wgpu::Features::TIMESTAMP_QUERY, + wgpu::Features::TEXTURE_COMPRESSION_BC, + ] { + if supported.contains(optional) { + preferred_features |= optional; + } + } + + let mut preferred_limits = core_required_limits(adapter_limits, options.profile); + if high_paths_allowed { + request_tier_a_if_supported( + supported, + adapter_limits, + &mut preferred_features, + &mut preferred_limits, + ); + super::gpu_driven::request_features_if_supported(supported, &mut preferred_features); + } + + let ray_query = wgpu::Features::EXPERIMENTAL_RAY_QUERY; + if options.allow_ray_query && high_paths_allowed && supported.contains(ray_query) { + preferred_features |= ray_query; + preferred_limits.max_storage_buffers_per_shader_stage = PATH_TRACING_STORAGE_BUFFERS; + preferred_limits = preferred_limits.using_minimum_supported_acceleration_structure_values(); + } + let preferred_experimental = if preferred_features.contains(ray_query) { + // The renderer guards every query path and uses the wgpu v29 contract. + unsafe { wgpu::ExperimentalFeatures::enabled() } + } else { + wgpu::ExperimentalFeatures::disabled() + }; + let preferred = DeviceRequestPlan { + label: "bloom_device_preferred", + tier: capability.selected_tier, + required_features: preferred_features, + required_limits: preferred_limits, + experimental_features: preferred_experimental, + }; + + let fallback_limits = core_required_limits(adapter_limits, options.profile); + let fallback_capability = RendererCapabilities::detect_with_override( + wgpu::Features::empty(), + &fallback_limits, + forced_tier, + ); + let fallback = DeviceRequestPlan { + label: "bloom_device_fallback", + tier: fallback_capability.selected_tier, + required_features: wgpu::Features::empty(), + required_limits: fallback_limits, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + }; + if preferred.required_features.is_empty() + && preferred.required_limits == fallback.required_limits + { + Ok(vec![preferred]) + } else { + Ok(vec![preferred, fallback]) + } +} + +fn core_required_limits( + adapter_limits: &wgpu::Limits, + profile: DeviceRequestProfile, +) -> wgpu::Limits { + let mut limits = wgpu::Limits::downlevel_defaults() + .using_resolution(adapter_limits.clone()) + .using_alignment(adapter_limits.clone()); + limits.max_bind_groups = match profile { + DeviceRequestProfile::NativeFull => CORE_BIND_GROUPS, + DeviceRequestProfile::FoldedMobile => FOLDED_MOBILE_BIND_GROUPS, + }; + limits.max_color_attachments = CORE_COLOR_ATTACHMENTS; + limits.max_sampled_textures_per_shader_stage = CORE_SAMPLED_TEXTURES; + limits.max_samplers_per_shader_stage = CORE_SAMPLERS; + limits.max_storage_buffers_per_shader_stage = match profile { + DeviceRequestProfile::NativeFull => CORE_STORAGE_BUFFERS, + DeviceRequestProfile::FoldedMobile => FOLDED_MOBILE_STORAGE_BUFFERS, + }; + limits.max_uniform_buffer_binding_size = CORE_UNIFORM_BUFFER_BINDING_SIZE; + limits +} + +fn validate_core_contract( + limits: &wgpu::Limits, + profile: DeviceRequestProfile, +) -> Result<(), String> { + let required_bind_groups = match profile { + DeviceRequestProfile::NativeFull => CORE_BIND_GROUPS, + DeviceRequestProfile::FoldedMobile => FOLDED_MOBILE_BIND_GROUPS, + }; + let required_storage_buffers = match profile { + DeviceRequestProfile::NativeFull => CORE_STORAGE_BUFFERS, + DeviceRequestProfile::FoldedMobile => FOLDED_MOBILE_STORAGE_BUFFERS, + }; + let requirements = [ + ( + "max_bind_groups", + required_bind_groups, + limits.max_bind_groups, + ), + ( + "max_color_attachments", + CORE_COLOR_ATTACHMENTS, + limits.max_color_attachments, + ), + ( + "max_sampled_textures_per_shader_stage", + CORE_SAMPLED_TEXTURES, + limits.max_sampled_textures_per_shader_stage, + ), + ( + "max_samplers_per_shader_stage", + CORE_SAMPLERS, + limits.max_samplers_per_shader_stage, + ), + ( + "max_storage_buffers_per_shader_stage", + required_storage_buffers, + limits.max_storage_buffers_per_shader_stage, + ), + ]; + let mut missing = requirements + .into_iter() + .filter(|(_, required, available)| available < required) + .map(|(name, required, available)| { + format!("{name} requires {required}, adapter has {available}") + }) + .collect::>(); + if limits.max_uniform_buffer_binding_size < CORE_UNIFORM_BUFFER_BINDING_SIZE { + missing.push(format!( + "max_uniform_buffer_binding_size requires {}, adapter has {}", + CORE_UNIFORM_BUFFER_BINDING_SIZE, limits.max_uniform_buffer_binding_size + )); + } + if missing.is_empty() { + Ok(()) + } else { + Err(format!( + "adapter is below Bloom's active platform renderer contract: {}", + missing.join("; ") + )) + } +} + +fn report_for( + selected: &DeviceRequestPlan, + profile: DeviceRequestProfile, + preferred_tier: RendererCapabilityTier, + fallback_cause: Option, +) -> DeviceNegotiationReport { + DeviceNegotiationReport { + profile, + preferred_tier, + selected_tier: selected.tier, + selected_label: selected.label, + fallback_cause, + required_features: selected.required_features, + required_limits: selected.required_limits.clone(), + } +} + +fn json_string(value: &str) -> String { + let mut out = String::from("\""); + for character in value.chars() { + match character { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c <= '\u{1f}' => { + use std::fmt::Write as _; + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn linux_asahi_limits() -> wgpu::Limits { + let mut limits = wgpu::Limits::default(); + limits.max_color_attachments = 5; + limits.max_bind_groups = 7; + limits.max_sampled_textures_per_shader_stage = 128; + limits.max_samplers_per_shader_stage = 32; + limits.max_storage_buffers_per_shader_stage = 16; + limits + } + + #[test] + fn native_plan_requests_actual_four_mrt_contract_not_webgpu_default_eight() { + let plans = build_device_request_plans_with_override( + wgpu::Features::empty(), + &linux_asahi_limits(), + DeviceRequestOptions::default(), + None, + ) + .unwrap(); + assert_eq!(plans[0].required_limits.max_color_attachments, 4); + assert!(plans[0].required_limits.max_color_attachments <= 5); + } + + #[test] + fn preferred_high_end_request_has_a_featureless_modern_fallback() { + let supported = wgpu::Features::TIMESTAMP_QUERY + | wgpu::Features::TEXTURE_COMPRESSION_BC + | wgpu::Features::TEXTURE_BINDING_ARRAY + | wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING + | wgpu::Features::INDIRECT_FIRST_INSTANCE + | wgpu::Features::EXPERIMENTAL_RAY_QUERY; + let mut limits = linux_asahi_limits(); + limits.max_binding_array_elements_per_shader_stage = 500_000; + limits.max_binding_array_sampler_elements_per_shader_stage = 1_000; + limits = limits.using_minimum_supported_acceleration_structure_values(); + let plans = build_device_request_plans_with_override( + supported, + &limits, + DeviceRequestOptions::default(), + None, + ) + .unwrap(); + assert_eq!(plans.len(), 2); + assert_eq!(plans[0].tier, RendererCapabilityTier::HighEnd); + assert!(plans[0] + .required_features + .contains(wgpu::Features::EXPERIMENTAL_RAY_QUERY)); + assert_eq!( + plans[0] + .required_limits + .max_binding_array_elements_per_shader_stage, + 4_162 + ); + assert_eq!( + plans[0] + .required_limits + .max_storage_buffers_per_shader_stage, + 9 + ); + assert_eq!(plans[1].tier, RendererCapabilityTier::Modern); + assert!(plans[1].required_features.is_empty()); + assert_eq!(plans[1].required_limits.max_color_attachments, 4); + + #[cfg(feature = "models3d")] + serde_json::from_str::( + &report_for( + &plans[0], + DeviceRequestProfile::NativeFull, + plans[0].tier, + None, + ) + .report_json(), + ) + .expect("device negotiation report is valid JSON"); + } + + #[test] + fn forced_baseline_requests_no_high_end_features() { + let supported = wgpu::Features::all(); + let plans = build_device_request_plans_with_override( + supported, + &linux_asahi_limits(), + DeviceRequestOptions::default(), + Some(RendererCapabilityTier::Baseline), + ) + .unwrap(); + assert_eq!(plans.len(), 2); + assert_eq!(plans[0].tier, RendererCapabilityTier::Baseline); + assert!(!plans[0] + .required_features + .intersects(wgpu::Features::EXPERIMENTAL_RAY_QUERY)); + assert!(!plans[0] + .required_features + .intersects(super::super::material_indirection::TIER_A_FEATURES)); + assert!(!plans[0] + .required_features + .intersects(wgpu::Features::INDIRECT_FIRST_INSTANCE)); + } + + #[test] + fn missing_active_profile_limit_fails_before_backend_device_creation() { + let mut limits = linux_asahi_limits(); + limits.max_bind_groups = 4; + let error = build_device_request_plans_with_override( + wgpu::Features::empty(), + &limits, + DeviceRequestOptions::default(), + None, + ) + .unwrap_err(); + assert!(error.contains("max_bind_groups requires 5, adapter has 4")); + } + + #[test] + fn folded_mobile_profile_accepts_four_bind_groups_and_storage_buffers() { + let mut limits = linux_asahi_limits(); + limits.max_bind_groups = 4; + limits.max_storage_buffers_per_shader_stage = 4; + let plans = build_device_request_plans_with_override( + wgpu::Features::empty(), + &limits, + DeviceRequestOptions { + allow_ray_query: false, + profile: DeviceRequestProfile::FoldedMobile, + }, + Some(RendererCapabilityTier::Modern), + ) + .unwrap(); + assert_eq!(plans[0].required_limits.max_bind_groups, 4); + assert_eq!(plans[0].required_limits.max_color_attachments, 4); + assert_eq!( + plans[0] + .required_limits + .max_sampled_textures_per_shader_stage, + 19 + ); + assert_eq!(plans[0].required_limits.max_samplers_per_shader_stage, 16); + assert_eq!( + plans[0] + .required_limits + .max_storage_buffers_per_shader_stage, + 4 + ); + assert_eq!( + plans[0].required_limits.max_uniform_buffer_binding_size, + 64 * 1024 + ); + } +} diff --git a/native/shared/src/renderer/draw2d.rs b/native/shared/src/renderer/draw2d.rs index 186d9099..dbe9be37 100644 --- a/native/shared/src/renderer/draw2d.rs +++ b/native/shared/src/renderer/draw2d.rs @@ -7,22 +7,49 @@ use super::types::Vertex2D; use super::Renderer; impl Renderer { - pub fn draw_rect(&mut self, x: f64, y: f64, w: f64, h: f64, r: f64, g: f64, b: f64, a: f64) { self.ensure_draw_state(0); let color = Self::color_to_f32_srgb(r, g, b, a); let base = self.vertices_2d.len() as u32; let (x, y, w, h) = (x as f32, y as f32, w as f32, h as f32); - self.vertices_2d.push(Vertex2D { position: [x, y], uv: [0.0, 0.0], color }); - self.vertices_2d.push(Vertex2D { position: [x + w, y], uv: [0.0, 0.0], color }); - self.vertices_2d.push(Vertex2D { position: [x + w, y + h], uv: [0.0, 0.0], color }); - self.vertices_2d.push(Vertex2D { position: [x, y + h], uv: [0.0, 0.0], color }); - - self.indices_2d.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); + self.vertices_2d.push(Vertex2D { + position: [x, y], + uv: [0.0, 0.0], + color, + }); + self.vertices_2d.push(Vertex2D { + position: [x + w, y], + uv: [0.0, 0.0], + color, + }); + self.vertices_2d.push(Vertex2D { + position: [x + w, y + h], + uv: [0.0, 0.0], + color, + }); + self.vertices_2d.push(Vertex2D { + position: [x, y + h], + uv: [0.0, 0.0], + color, + }); + + self.indices_2d + .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); } - pub fn draw_rect_lines(&mut self, x: f64, y: f64, w: f64, h: f64, thickness: f64, r: f64, g: f64, b: f64, a: f64) { + pub fn draw_rect_lines( + &mut self, + x: f64, + y: f64, + w: f64, + h: f64, + thickness: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { let t = thickness; self.draw_rect(x, y, w, t, r, g, b, a); self.draw_rect(x, y + h - t, w, t, r, g, b, a); @@ -30,25 +57,55 @@ impl Renderer { self.draw_rect(x + w - t, y + t, t, h - 2.0 * t, r, g, b, a); } - pub fn draw_line(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, thickness: f64, r: f64, g: f64, b: f64, a: f64) { + pub fn draw_line( + &mut self, + x1: f64, + y1: f64, + x2: f64, + y2: f64, + thickness: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { self.ensure_draw_state(0); let color = Self::color_to_f32_srgb(r, g, b, a); let dx = (x2 - x1) as f32; let dy = (y2 - y1) as f32; let len = (dx * dx + dy * dy).sqrt(); - if len == 0.0 { return; } + if len == 0.0 { + return; + } let half_t = (thickness as f32) * 0.5; let nx = -dy / len * half_t; let ny = dx / len * half_t; let (x1, y1, x2, y2) = (x1 as f32, y1 as f32, x2 as f32, y2 as f32); let base = self.vertices_2d.len() as u32; - self.vertices_2d.push(Vertex2D { position: [x1 + nx, y1 + ny], uv: [0.0, 0.0], color }); - self.vertices_2d.push(Vertex2D { position: [x1 - nx, y1 - ny], uv: [0.0, 0.0], color }); - self.vertices_2d.push(Vertex2D { position: [x2 - nx, y2 - ny], uv: [0.0, 0.0], color }); - self.vertices_2d.push(Vertex2D { position: [x2 + nx, y2 + ny], uv: [0.0, 0.0], color }); - - self.indices_2d.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); + self.vertices_2d.push(Vertex2D { + position: [x1 + nx, y1 + ny], + uv: [0.0, 0.0], + color, + }); + self.vertices_2d.push(Vertex2D { + position: [x1 - nx, y1 - ny], + uv: [0.0, 0.0], + color, + }); + self.vertices_2d.push(Vertex2D { + position: [x2 - nx, y2 - ny], + uv: [0.0, 0.0], + color, + }); + self.vertices_2d.push(Vertex2D { + position: [x2 + nx, y2 + ny], + uv: [0.0, 0.0], + color, + }); + + self.indices_2d + .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); } pub fn draw_circle(&mut self, cx: f64, cy: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) { @@ -58,7 +115,11 @@ impl Renderer { let base = self.vertices_2d.len() as u32; let (cx, cy, radius) = (cx as f32, cy as f32, radius as f32); - self.vertices_2d.push(Vertex2D { position: [cx, cy], uv: [0.0, 0.0], color }); + self.vertices_2d.push(Vertex2D { + position: [cx, cy], + uv: [0.0, 0.0], + color, + }); for i in 0..segments { let angle = (i as f32) / (segments as f32) * std::f32::consts::TAU; self.vertices_2d.push(Vertex2D { @@ -69,19 +130,35 @@ impl Renderer { } for i in 0..segments { let next = if i + 1 < segments { i + 1 } else { 0 }; - self.indices_2d.extend_from_slice(&[base, base + 1 + i, base + 1 + next]); + self.indices_2d + .extend_from_slice(&[base, base + 1 + i, base + 1 + next]); } } - pub fn draw_circle_lines(&mut self, cx: f64, cy: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) { + pub fn draw_circle_lines( + &mut self, + cx: f64, + cy: f64, + radius: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { let segments = 36; for i in 0..segments { let a1 = (i as f64) / (segments as f64) * std::f64::consts::TAU; let a2 = ((i + 1) as f64) / (segments as f64) * std::f64::consts::TAU; self.draw_line( - cx + radius * a1.cos(), cy + radius * a1.sin(), - cx + radius * a2.cos(), cy + radius * a2.sin(), - 1.0, r, g, b, a, + cx + radius * a1.cos(), + cy + radius * a1.sin(), + cx + radius * a2.cos(), + cy + radius * a2.sin(), + 1.0, + r, + g, + b, + a, ); } } @@ -140,4 +217,3 @@ impl Renderer { } } } - diff --git a/native/shared/src/renderer/env_prefilter.rs b/native/shared/src/renderer/env_prefilter.rs new file mode 100644 index 00000000..14ef538c --- /dev/null +++ b/native/shared/src/renderer/env_prefilter.rs @@ -0,0 +1,250 @@ +//! CPU-side source mip preparation for environment-map convolution. +//! +//! The GGX prefilter samples these ordinary radiance mips according to each +//! importance sample's solid-angle footprint. This keeps tiny, high-energy HDR +//! emitters (notably the sun) energy-preserving without turning individual +//! Monte Carlo hits into visible fireflies. + +const F16_MAX: f32 = 65_504.0; + +#[derive(Debug)] +pub(super) struct RadianceMip { + pub(super) width: u32, + pub(super) height: u32, + pub(super) rgba16: Vec, +} + +fn finite_f16(value: f32) -> f32 { + if value.is_finite() { + value.clamp(-F16_MAX, F16_MAX) + } else { + 0.0 + } +} + +fn pack_rgba16(pixels: &[[f32; 3]]) -> Vec { + let mut packed = Vec::with_capacity(pixels.len() * 4); + for pixel in pixels { + packed.push(half::f16::from_f32(finite_f16(pixel[0])).to_bits()); + packed.push(half::f16::from_f32(finite_f16(pixel[1])).to_bits()); + packed.push(half::f16::from_f32(finite_f16(pixel[2])).to_bits()); + packed.push(half::f16::from_f32(1.0).to_bits()); + } + packed +} + +fn pack_rgb_f32(rgb_f32: &[f32]) -> Vec { + let mut packed = Vec::with_capacity(rgb_f32.len() / 3 * 4); + for pixel in rgb_f32.chunks_exact(3) { + packed.push(half::f16::from_f32(finite_f16(pixel[0])).to_bits()); + packed.push(half::f16::from_f32(finite_f16(pixel[1])).to_bits()); + packed.push(half::f16::from_f32(finite_f16(pixel[2])).to_bits()); + packed.push(half::f16::from_f32(1.0).to_bits()); + } + packed +} + +fn row_solid_angle_weight(row: u32, height: u32) -> f64 { + let theta0 = std::f64::consts::PI * row as f64 / height as f64; + let theta1 = std::f64::consts::PI * (row + 1) as f64 / height as f64; + (theta0.cos() - theta1.cos()).max(f64::EPSILON) +} + +/// Build an energy-preserving equirectangular radiance pyramid. +/// +/// Vertical reduction uses each source row's exact spherical area rather than +/// an image-space box average. Horizontal texels all span the same azimuth. +/// Odd extents are partitioned so every source texel contributes exactly once. +pub(super) fn build_radiance_mip_chain( + width: u32, + height: u32, + rgb_f32: &[f32], +) -> Vec { + let Some(texel_count) = (width as usize).checked_mul(height as usize) else { + return Vec::new(); + }; + let Some(required_values) = texel_count.checked_mul(3) else { + return Vec::new(); + }; + if width == 0 || height == 0 || rgb_f32.len() < required_values { + return Vec::new(); + } + + let mip_count = u32::BITS - width.max(height).leading_zeros(); + let mut mips = Vec::with_capacity(mip_count as usize); + mips.push(RadianceMip { + width, + height, + rgba16: pack_rgb_f32(&rgb_f32[..required_values]), + }); + if mip_count == 1 { + return mips; + } + + // Keep only reduced float pixels. Mip zero is packed directly from the + // caller's slice, avoiding a second full-resolution f32 panorama. + let mut current: Option> = None; + let mut current_width = width; + let mut current_height = height; + let mut column_weights = vec![1.0_f64; width as usize]; + let mut row_weights = (0..height) + .map(|row| row_solid_angle_weight(row, height)) + .collect::>(); + + for _level in 1..mip_count { + let next_width = (current_width / 2).max(1); + let next_height = (current_height / 2).max(1); + let mut next = Vec::with_capacity((next_width * next_height) as usize); + let mut next_column_weights = Vec::with_capacity(next_width as usize); + for x in 0..next_width { + let source_x0 = x * current_width / next_width; + let source_x1 = ((x + 1) * current_width / next_width).max(source_x0 + 1); + next_column_weights.push( + column_weights[source_x0 as usize..source_x1 as usize] + .iter() + .sum(), + ); + } + let mut next_row_weights = Vec::with_capacity(next_height as usize); + for y in 0..next_height { + let source_y0 = y * current_height / next_height; + let source_y1 = ((y + 1) * current_height / next_height).max(source_y0 + 1); + next_row_weights.push( + row_weights[source_y0 as usize..source_y1 as usize] + .iter() + .sum(), + ); + for x in 0..next_width { + let source_x0 = x * current_width / next_width; + let source_x1 = ((x + 1) * current_width / next_width).max(source_x0 + 1); + let mut sum = [0.0_f64; 3]; + let mut weight_sum = 0.0_f64; + for source_y in source_y0..source_y1 { + for source_x in source_x0..source_x1 { + let source_index = (source_y * current_width + source_x) as usize; + let pixel = if let Some(current) = current.as_ref() { + current[source_index] + } else { + let rgb_index = source_index * 3; + [ + finite_f16(rgb_f32[rgb_index]), + finite_f16(rgb_f32[rgb_index + 1]), + finite_f16(rgb_f32[rgb_index + 2]), + ] + }; + let weight = + row_weights[source_y as usize] * column_weights[source_x as usize]; + sum[0] += pixel[0] as f64 * weight; + sum[1] += pixel[1] as f64 * weight; + sum[2] += pixel[2] as f64 * weight; + weight_sum += weight; + } + } + next.push([ + finite_f16((sum[0] / weight_sum) as f32), + finite_f16((sum[1] / weight_sum) as f32), + finite_f16((sum[2] / weight_sum) as f32), + ]); + } + } + mips.push(RadianceMip { + width: next_width, + height: next_height, + rgba16: pack_rgba16(&next), + }); + current = Some(next); + current_width = next_width; + current_height = next_height; + column_weights = next_column_weights; + row_weights = next_row_weights; + } + + mips +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unpack_rgb(mip: &RadianceMip, index: usize) -> [f32; 3] { + [ + half::f16::from_bits(mip.rgba16[index * 4]).to_f32(), + half::f16::from_bits(mip.rgba16[index * 4 + 1]).to_f32(), + half::f16::from_bits(mip.rgba16[index * 4 + 2]).to_f32(), + ] + } + + #[test] + fn constant_radiance_survives_every_mip() { + let source = vec![4.25_f32; 8 * 4 * 3]; + let mips = build_radiance_mip_chain(8, 4, &source); + assert_eq!( + mips.iter().map(|m| (m.width, m.height)).collect::>(), + [(8, 4), (4, 2), (2, 1), (1, 1)] + ); + for mip in &mips { + for index in 0..(mip.width * mip.height) as usize { + assert_eq!(unpack_rgb(mip, index), [4.25; 3]); + } + } + } + + #[test] + fn spherical_average_preserves_polar_energy() { + let mut source = vec![0.0_f32; 8 * 4 * 3]; + for x in 0..8 { + source[x * 3] = 1_000.0; + } + let mips = build_radiance_mip_chain(8, 4, &source); + let final_rgb = unpack_rgb(mips.last().unwrap(), 0); + let polar_band_fraction = row_solid_angle_weight(0, 4) / row_solid_angle_weight(0, 1); + let expected = 1_000.0 * polar_band_fraction as f32; + assert!((final_rgb[0] - expected).abs() < 0.1); + assert_eq!(final_rgb[1], 0.0); + assert_eq!(final_rgb[2], 0.0); + } + + #[test] + fn odd_extents_cover_all_texels_and_non_finite_values_are_bounded() { + let mut source = vec![2.0_f32; 5 * 3 * 3]; + source[0] = f32::NAN; + source[1] = f32::INFINITY; + source[2] = -f32::INFINITY; + source[3] = 100_000.0; + let mips = build_radiance_mip_chain(5, 3, &source); + assert_eq!( + mips.iter().map(|m| (m.width, m.height)).collect::>(), + [(5, 3), (2, 1), (1, 1)] + ); + for mip in &mips { + for bits in &mip.rgba16 { + assert!(half::f16::from_bits(*bits).to_f32().is_finite()); + } + } + assert_eq!(unpack_rgb(&mips[0], 0), [0.0; 3]); + assert_eq!(unpack_rgb(&mips[0], 1)[0], F16_MAX); + } + + #[test] + fn odd_extents_preserve_the_spherical_average_across_reductions() { + let width = 5; + let height = 5; + let mut source = vec![0.0_f32; width * height * 3]; + let mut weighted_sum = 0.0_f64; + let mut weight_sum = 0.0_f64; + for y in 0..height { + for x in 0..width { + let value = (y * width + x + 1) as f32; + source[(y * width + x) * 3] = value; + let weight = row_solid_angle_weight(y as u32, height as u32); + weighted_sum += value as f64 * weight; + weight_sum += weight; + } + } + + let mips = build_radiance_mip_chain(width as u32, height as u32, &source); + let expected = (weighted_sum / weight_sum) as f32; + let final_value = unpack_rgb(mips.last().unwrap(), 0)[0]; + assert!((final_value - expected).abs() < 0.01); + } +} diff --git a/native/shared/src/renderer/final_pass.rs b/native/shared/src/renderer/final_pass.rs new file mode 100644 index 00000000..ad47be7c --- /dev/null +++ b/native/shared/src/renderer/final_pass.rs @@ -0,0 +1,246 @@ +//! Terminal composite, game post-pass stack, and 2D overlay recording. +//! +//! Kept in one module so the compiled frame graph can execute the complete +//! visible frame without growing `renderer/mod.rs`. + +use super::{CompositeParams, Renderer}; + +impl Renderer { + pub(super) fn record_final_composite_pass( + &mut self, + encoder: &mut wgpu::CommandEncoder, + profiler: &mut crate::profiler::Profiler, + output_view: &wgpu::TextureView, + exposure_dst_idx: usize, + ) { + profiler.begin("post_fx"); + let composite_source = self.composite_source(); + let composite_cache_index = composite_source.bind_group_cache_index(exposure_dst_idx); + let params = CompositeParams { + params: [ + self.tonemap_kind as f32, + if self.auto_exposure && self.exposure_history_valid { + 1.0 + } else { + 0.0 + }, + self.manual_exposure, + self.auto_exposure_key, + ], + filmic: [ + self.chromatic_aberration, + self.vignette_strength, + self.vignette_softness, + self.grain_strength, + ], + misc: [self.taa_frame_index as f32, self.sharpen_strength, 0.0, 0.0], + }; + self.queue.write_buffer( + &self.composite_uniform_buffer, + 0, + bytemuck::bytes_of(¶ms), + ); + + if self.composite_bind_group_cache[composite_cache_index].is_none() { + self.frame_resource_stats.created_bind_group( + super::frame_resource_stats::BindGroupCreationSite::FinalComposite, + ); + let composite_src_view = self.composite_source_view_for(composite_source); + let composite_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("composite_bg"), + layout: &self.composite_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(composite_src_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: self.composite_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView( + &self.exposure_views[exposure_dst_idx], + ), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&self.ssao_blur_rt_view), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + ], + }); + self.composite_bind_group_cache[composite_cache_index] = Some(composite_bg); + } + let composite_target_view = if self.post_passes.is_empty() { + output_view + } else { + self.composite_ldr_rt_a_view.as_ref().unwrap_or(output_view) + }; + { + let timestamp_writes = profiler.pass_timestamp_writes("final_composite_pass"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_composite_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: composite_target_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.composite_pipeline); + pass.set_bind_group( + 0, + self.composite_bind_group_cache[composite_cache_index] + .as_ref() + .expect("final composite bind group was initialized"), + &[], + ); + pass.draw(0..3, 0..1); + } + + let pass_count = self.post_passes.len(); + for index in 0..pass_count { + let input_view = if index % 2 == 0 { + self.composite_ldr_rt_a_view.as_ref().unwrap_or(output_view) + } else { + self.composite_ldr_rt_b_view.as_ref().unwrap_or(output_view) + }; + let is_last = index == pass_count - 1; + let target_view = if is_last { + output_view + } else if index % 2 == 0 { + self.composite_ldr_rt_b_view.as_ref().unwrap_or(output_view) + } else { + self.composite_ldr_rt_a_view.as_ref().unwrap_or(output_view) + }; + let input_slot = index % 2; + if self.post_passes[index].bind_group_cache[input_slot].is_none() { + self.frame_resource_stats.created_bind_group( + super::frame_resource_stats::BindGroupCreationSite::CustomPostPass, + ); + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("post_pass_bg"), + layout: &self.post_passes[index].bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(input_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&self.depth_view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&self.post_pass_depth_sampler), + }, + ], + }); + self.post_passes[index].bind_group_cache[input_slot] = Some(bind_group); + } + let post_pass = &self.post_passes[index]; + let timestamp_writes = profiler.pass_timestamp_writes("post_pass"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_post_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&post_pass.pipeline); + pass.set_bind_group( + 0, + post_pass.bind_group_cache[input_slot] + .as_ref() + .expect("custom post-pass bind group was initialized"), + &[], + ); + pass.draw(0..3, 0..1); + } + profiler.end("post_fx"); + } + + pub(super) fn record_overlay_2d_pass( + &self, + encoder: &mut wgpu::CommandEncoder, + profiler: &mut crate::profiler::Profiler, + output_view: &wgpu::TextureView, + ) { + profiler.begin("overlay_2d"); + if self.vertices_2d.is_empty() { + profiler.end("overlay_2d"); + return; + } + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_2d_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: output_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.pipeline_2d); + pass.set_vertex_buffer(0, self.persistent_vb_2d.slice(..)); + pass.set_index_buffer(self.persistent_ib_2d.slice(..), wgpu::IndexFormat::Uint32); + + let index_count = self.indices_2d.len() as u32; + for (index, call) in self.draw_calls_2d.iter().enumerate() { + let next_start = self + .draw_calls_2d + .get(index + 1) + .map_or(index_count, |next| next.index_start); + if next_start == call.index_start { + continue; + } + pass.set_bind_group(0, &self.uniform_bind_groups[call.uniform_idx as usize], &[]); + if let Some(texture) = self.texture_bind_groups.get(call.texture_idx as usize) { + pass.set_bind_group(1, texture, &[]); + } + pass.draw_indexed(call.index_start..next_start, 0, 0..1); + } + } + profiler.end("overlay_2d"); + } +} diff --git a/native/shared/src/renderer/formats.rs b/native/shared/src/renderer/formats.rs index 78febf3e..f099e8f9 100644 --- a/native/shared/src/renderer/formats.rs +++ b/native/shared/src/renderer/formats.rs @@ -57,10 +57,18 @@ pub(super) const HIZ_MIP_COUNT: u32 = 5; /// color attachment in the HDR pass; motion blur and TAA read it. pub(super) const VELOCITY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rg16Float; -pub(super) fn create_depth_texture(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::Texture, wgpu::TextureView) { +pub(super) fn create_depth_texture( + device: &wgpu::Device, + width: u32, + height: u32, +) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("depth_texture"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -73,18 +81,26 @@ pub(super) fn create_depth_texture(device: &wgpu::Device, width: u32, height: u3 // texture so translucent materials can sample it without // aliasing the pass's own depth-stencil attachment. usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::COPY_SRC, + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); (texture, view) } -pub(super) fn create_hdr_rt(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::Texture, wgpu::TextureView) { +pub(super) fn create_hdr_rt( + device: &wgpu::Device, + width: u32, + height: u32, +) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("hdr_rt"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -96,9 +112,9 @@ pub(super) fn create_hdr_rt(device: &wgpu::Device, width: u32, height: u32) -> ( // STORAGE_BINDING: the path-trace megakernel (PT-1) writes the // traced scene colour directly into hdr_rt from compute. usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::COPY_SRC - | wgpu::TextureUsages::STORAGE_BINDING, + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::STORAGE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -107,19 +123,24 @@ pub(super) fn create_hdr_rt(device: &wgpu::Device, width: u32, height: u32) -> ( /// Create the two ping-pong 1×1 exposure textures. Single fragment /// writes to one, composite samples the other, swap each frame. -pub(super) fn create_exposure_textures(device: &wgpu::Device) -> ([wgpu::Texture; 2], [wgpu::TextureView; 2]) { +pub(super) fn create_exposure_textures( + device: &wgpu::Device, +) -> ([wgpu::Texture; 2], [wgpu::TextureView; 2]) { let make = |label: &str| -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some(label), - size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, // Rg16Float (was R16Float): .r = smoothed exposure, .g = the // anchored AE target (flicker fix — see EXPOSURE_SHADER_WGSL). format: wgpu::TextureFormat::Rg16Float, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -131,16 +152,23 @@ pub(super) fn create_exposure_textures(device: &wgpu::Device) -> ([wgpu::Texture } /// Create the material G-buffer (Rg8Unorm, surface size). -pub(super) fn create_material_rt(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::Texture, wgpu::TextureView) { +pub(super) fn create_material_rt( + device: &wgpu::Device, + width: u32, + height: u32, +) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("material_rt"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: MATERIAL_FORMAT, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -150,16 +178,23 @@ pub(super) fn create_material_rt(device: &wgpu::Device, width: u32, height: u32) /// Create the albedo G-buffer (Rgba8Unorm, surface size). Written by /// the scene pass so post-passes can modulate bounce light by the /// receiving surface's diffuse albedo (SSGI etc.). -pub(super) fn create_albedo_rt(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::Texture, wgpu::TextureView) { +pub(super) fn create_albedo_rt( + device: &wgpu::Device, + width: u32, + height: u32, +) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("albedo_rt"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -172,16 +207,23 @@ pub(super) fn create_albedo_rt(device: &wgpu::Device, width: u32, height: u32) - /// as its "current frame" input) and the TAA-off path (composite /// reads it directly) read from the same buffer, so fog / shafts / /// post-effects stay visible regardless of TAA state. -pub(super) fn create_composed_rt(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::Texture, wgpu::TextureView) { +pub(super) fn create_composed_rt( + device: &wgpu::Device, + width: u32, + height: u32, +) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("composed_rt"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: HDR_FORMAT, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -190,16 +232,23 @@ pub(super) fn create_composed_rt(device: &wgpu::Device, width: u32, height: u32) /// Create the velocity render target (Rg16Float, surface size). /// Per-pixel screen-space velocity for motion blur and TAA. -pub(super) fn create_velocity_rt(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::Texture, wgpu::TextureView) { +pub(super) fn create_velocity_rt( + device: &wgpu::Device, + width: u32, + height: u32, +) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("velocity_rt"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: VELOCITY_FORMAT, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -213,18 +262,27 @@ pub(super) fn create_velocity_rt(device: &wgpu::Device, width: u32, height: u32) /// counts 4× and the bilinear upsample in compose is imperceptible /// because the GGX cone + temporal blend is already wider than one /// quarter-res texel. -pub(super) fn create_ssr_rt(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::Texture, wgpu::TextureView) { +pub(super) fn create_ssr_rt( + device: &wgpu::Device, + width: u32, + height: u32, +) -> (wgpu::Texture, wgpu::TextureView) { let w = (width / 4).max(1); let h = (height / 4).max(1); let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("ssr_rt"), - size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: HDR_FORMAT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -237,20 +295,29 @@ pub(super) fn create_ssr_rt(device: &wgpu::Device, width: u32, height: u32) -> ( /// reprojected previous-frame history so 4–8 frames of accumulation /// converge to a smooth reflection. pub(super) fn create_ssr_history_textures( - device: &wgpu::Device, width: u32, height: u32, + device: &wgpu::Device, + width: u32, + height: u32, ) -> ([wgpu::Texture; 2], [wgpu::TextureView; 2]) { let w = (width / 4).max(1); let h = (height / 4).max(1); let make = || { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("ssr_history"), - size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: HDR_FORMAT, + // COPY_SRC is inert during normal frames and exposes the temporal + // result to opt-in SSR firefly qualification captures. usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -264,18 +331,29 @@ pub(super) fn create_ssr_history_textures( /// Create the SSGI render target (half-res HDR — indirect diffuse bounce light). /// Same half-res HDR strategy as SSR: keeps the per-pixel ray march cheap /// while still providing enough color resolution for colored bounce light. -pub(super) fn create_ssgi_rt(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::Texture, wgpu::TextureView) { +pub(super) fn create_ssgi_rt( + device: &wgpu::Device, + width: u32, + height: u32, +) -> (wgpu::Texture, wgpu::TextureView) { let w = (width / 2).max(1); let h = (height / 2).max(1); let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("ssgi_rt"), - size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: HDR_FORMAT, + // COPY_SRC is inert during normal frames and lets qualification retain + // raw HDR evidence for the probe resolve without another shader pass. usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -301,17 +379,23 @@ pub(super) fn probe_grid_dims(width: u32, height: u32) -> (u32, u32) { /// 64)` — one voxel per probe × octahedral texel. Shared shape for the /// trace output and the ping-pong history textures. fn create_probe_3d_tex( - device: &wgpu::Device, label: &'static str, gw: u32, gh: u32, + device: &wgpu::Device, + label: &'static str, + gw: u32, + gh: u32, ) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some(label), - size: wgpu::Extent3d { width: gw, height: gh, depth_or_array_layers: PROBE_OCT_TEXELS }, + size: wgpu::Extent3d { + width: gw, + height: gh, + depth_or_array_layers: PROBE_OCT_TEXELS, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D3, format: HDR_FORMAT, - usage: wgpu::TextureUsages::STORAGE_BINDING - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -322,7 +406,9 @@ fn create_probe_3d_tex( /// pass reads it as the "current" input. Not ping-pong because its /// contents are fully regenerated every frame. pub(super) fn create_probe_trace_tex( - device: &wgpu::Device, width: u32, height: u32, + device: &wgpu::Device, + width: u32, + height: u32, ) -> (wgpu::Texture, wgpu::TextureView) { let (gw, gh) = probe_grid_dims(width, height); create_probe_3d_tex(device, "probe_trace", gw, gh) @@ -427,9 +513,7 @@ pub(super) const WSRC_CASCADE_COUNT: u32 = 3; pub(super) const WSRC_CASCADE_EXTENTS: [f32; 3] = [30.0, 120.0, 500.0]; pub(super) const WSRC_REBAKE_THRESHOLD: f32 = 0.25; -pub(super) fn create_wsrc_atlas( - device: &wgpu::Device, -) -> (wgpu::Texture, wgpu::TextureView) { +pub(super) fn create_wsrc_atlas(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("wsrc_atlas"), size: wgpu::Extent3d { @@ -441,8 +525,7 @@ pub(super) fn create_wsrc_atlas( sample_count: 1, dimension: wgpu::TextureDimension::D3, format: HDR_FORMAT, - usage: wgpu::TextureUsages::STORAGE_BINDING - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -480,9 +563,9 @@ pub(super) fn create_mesh_sdf_texture( // path on cache miss. Both have zero runtime cost when the // cache isn't used (just usage-flag bits at allocation). usage: wgpu::TextureUsages::STORAGE_BINDING - | wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::COPY_SRC - | wgpu::TextureUsages::COPY_DST, + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -510,9 +593,7 @@ pub(super) const CARD_AXES_PER_MESH: u32 = 6; /// Create the mesh-card albedo atlas. `RENDER_ATTACHMENT` for capture, /// `TEXTURE_BINDING` for both the card-lighting compute input and a /// direct HW-trace fallback. -pub(super) fn create_mesh_card_atlas( - device: &wgpu::Device, -) -> (wgpu::Texture, wgpu::TextureView) { +pub(super) fn create_mesh_card_atlas(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("mesh_card_albedo_atlas"), size: wgpu::Extent3d { @@ -524,8 +605,7 @@ pub(super) fn create_mesh_card_atlas( sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -549,8 +629,7 @@ pub(super) fn create_mesh_card_emissive_atlas( sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -576,8 +655,7 @@ pub(super) fn create_mesh_card_radiance_atlas( sample_count: 1, dimension: wgpu::TextureDimension::D2, format: HDR_FORMAT, - usage: wgpu::TextureUsages::STORAGE_BINDING - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -590,7 +668,9 @@ pub(super) fn create_mesh_card_radiance_atlas( /// samples `[write_idx]`. Separate textures — the temporal pass cannot /// bind the same view as both sampled input and storage-write output. pub(super) fn create_probe_history_textures( - device: &wgpu::Device, width: u32, height: u32, + device: &wgpu::Device, + width: u32, + height: u32, ) -> ([wgpu::Texture; 2], [wgpu::TextureView; 2]) { let (gw, gh) = probe_grid_dims(width, height); let (t0, v0) = create_probe_3d_tex(device, "probe_history_0", gw, gh); @@ -601,16 +681,23 @@ pub(super) fn create_probe_history_textures( /// Create the DoF render target (full-res HDR, same format as TAA output). /// DoF reads the TAA output + depth, writes the blurred result here. /// Composite then reads this instead of the TAA output. -pub(super) fn create_dof_rt(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::Texture, wgpu::TextureView) { +pub(super) fn create_dof_rt( + device: &wgpu::Device, + width: u32, + height: u32, +) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("dof_rt"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: HDR_FORMAT, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -618,16 +705,23 @@ pub(super) fn create_dof_rt(device: &wgpu::Device, width: u32, height: u32) -> ( } /// Create the SSS render target (full-res HDR, same format as DoF/motion-blur). -pub(super) fn create_sss_rt(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::Texture, wgpu::TextureView) { +pub(super) fn create_sss_rt( + device: &wgpu::Device, + width: u32, + height: u32, +) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("sss_rt"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: HDR_FORMAT, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -651,17 +745,24 @@ pub(super) fn halton(mut i: u32, b: u32) -> f32 { } /// Create the two TAA history textures (HDR format, surface size). -pub(super) fn create_taa_textures(device: &wgpu::Device, width: u32, height: u32) -> ([wgpu::Texture; 2], [wgpu::TextureView; 2]) { +pub(super) fn create_taa_textures( + device: &wgpu::Device, + width: u32, + height: u32, +) -> ([wgpu::Texture; 2], [wgpu::TextureView; 2]) { let make = |label: &str| -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some(label), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: HDR_FORMAT, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -675,18 +776,25 @@ pub(super) fn create_taa_textures(device: &wgpu::Device, width: u32, height: u32 /// Create the SSAO render target. Written by the compute GTAO pass /// (via `STORAGE_BINDING`) and sampled by the bilateral blur + /// downstream passes. -pub(super) fn create_ssao_rt(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::Texture, wgpu::TextureView) { +pub(super) fn create_ssao_rt( + device: &wgpu::Device, + width: u32, + height: u32, +) -> (wgpu::Texture, wgpu::TextureView) { let w = (width / 2).max(1); let h = (height / 2).max(1); let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("ssao_rt"), - size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: SSAO_FORMAT, - usage: wgpu::TextureUsages::STORAGE_BINDING - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -709,13 +817,16 @@ pub(super) fn create_ssao_history_textures( let make = |label: &str| -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some(label), - size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: SSAO_FORMAT, - usage: wgpu::TextureUsages::STORAGE_BINDING - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -727,18 +838,25 @@ pub(super) fn create_ssao_history_textures( } /// Create the SSAO bilateral-blur render target (same format/size as ssao_rt). -pub(super) fn create_ssao_blur_rt(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::Texture, wgpu::TextureView) { +pub(super) fn create_ssao_blur_rt( + device: &wgpu::Device, + width: u32, + height: u32, +) -> (wgpu::Texture, wgpu::TextureView) { let w = (width / 2).max(1); let h = (height / 2).max(1); let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("ssao_blur_rt"), - size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: SSAO_FORMAT, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -762,13 +880,16 @@ pub(super) fn create_linear_depth_hiz_chain( let h = ((height / 2) >> i).max(1); let tex = device.create_texture(&wgpu::TextureDescriptor { label: Some("linear_depth_hiz_mip"), - size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: HIZ_FORMAT, - usage: wgpu::TextureUsages::STORAGE_BINDING - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); @@ -796,7 +917,11 @@ pub(super) fn create_bloom_chain( width: u32, height: u32, mip_count: u32, -) -> (Vec, Vec, wgpu::TextureView) { +) -> ( + Vec, + Vec, + wgpu::TextureView, +) { let mut textures = Vec::with_capacity(mip_count as usize); let mut views = Vec::with_capacity(mip_count as usize); for i in 0..mip_count { @@ -804,13 +929,16 @@ pub(super) fn create_bloom_chain( let h = ((height / 2) >> i).max(1); let tex = device.create_texture(&wgpu::TextureDescriptor { label: Some("bloom_mip_tex"), - size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: HDR_FORMAT, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); diff --git a/native/shared/src/renderer/frame_graph_runtime.rs b/native/shared/src/renderer/frame_graph_runtime.rs new file mode 100644 index 00000000..143a44ac --- /dev/null +++ b/native/shared/src/renderer/frame_graph_runtime.rs @@ -0,0 +1,207 @@ +//! Renderer integration for compiled/cached frame topologies. + +use super::{graph, Renderer}; +use std::sync::Arc; + +const FEATURE_SHADOWS: u64 = 1 << 0; +const FEATURE_TAA: u64 = 1 << 3; +const FEATURE_MOTION_BLUR: u64 = 1 << 6; +const FEATURE_SSS: u64 = 1 << 7; +const FEATURE_AUTO_EXPOSURE: u64 = 1 << 8; + +impl Renderer { + pub(super) fn graph_debug_markers_enabled(&self) -> bool { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *ENABLED.get_or_init(|| { + std::env::var_os("BLOOM_GRAPH_DEBUG_MARKERS") + .map(|value| value != "0") + .unwrap_or(false) + }) + } + + pub(super) fn frame_plan_key(&self, render_target_output: bool) -> graph::FramePlanKey { + let mut feature_mask = 0; + let imported_refraction = self.imported_refraction_enabled + && (self.has_refractive_model_draws || self.has_refractive_scene_nodes); + for (enabled, bit) in [ + (self.shadow_map.enabled, FEATURE_SHADOWS), + (self.ssao_enabled, graph::FRAME_FEATURE_SSAO), + (self.bloom_enabled, graph::FRAME_FEATURE_BLOOM), + (self.taa_enabled, FEATURE_TAA), + (self.ssr_enabled, graph::FRAME_FEATURE_SSR), + (self.ssgi_enabled, graph::FRAME_FEATURE_SSGI), + (self.motion_blur_enabled, FEATURE_MOTION_BLUR), + (self.sss_enabled, FEATURE_SSS), + (self.auto_exposure, FEATURE_AUTO_EXPOSURE), + ( + self.material_system + .translucent_commands + .iter() + .any(|command| { + self.material_system + .pipelines + .get(command.material as usize - 1) + .and_then(|pipeline| pipeline.as_ref()) + .map(|pipeline| pipeline.reads_scene) + .unwrap_or(false) + }) + || (cfg!(not(fold_scene_inputs)) && imported_refraction), + graph::FRAME_FEATURE_SCENE_SNAPSHOTS, + ), + ( + imported_refraction, + graph::FRAME_FEATURE_IMPORTED_REFRACTION, + ), + ( + self.weighted_transparency_active, + graph::FRAME_FEATURE_WEIGHTED_TRANSPARENCY, + ), + ( + self.temporal_reactive_active, + graph::FRAME_FEATURE_TEMPORAL_REACTIVE, + ), + ( + self.transmitted_shadows_active, + graph::FRAME_FEATURE_TRANSMITTED_SHADOWS, + ), + ] { + if enabled { + feature_mask |= bit; + } + } + #[cfg(not(target_arch = "wasm32"))] + if !render_target_output + && (self.screenshot_requested || self.pending_quality_capture_dir.is_some()) + { + feature_mask |= graph::FRAME_FEATURE_CAPTURE_OUTPUT; + if self.pending_quality_capture_dir.is_some() { + feature_mask |= graph::FRAME_FEATURE_CAPTURE_QUALITY; + } + } + + let quality_tier = if feature_mask + & (FEATURE_SHADOWS + | graph::FRAME_FEATURE_SSAO + | graph::FRAME_FEATURE_BLOOM + | FEATURE_TAA + | graph::FRAME_FEATURE_SSR + | graph::FRAME_FEATURE_SSGI) + == 0 + { + 0 + } else if feature_mask + & (graph::FRAME_FEATURE_SSAO + | FEATURE_TAA + | graph::FRAME_FEATURE_SSR + | graph::FRAME_FEATURE_SSGI) + == 0 + { + 1 + } else if feature_mask & (graph::FRAME_FEATURE_SSR | graph::FRAME_FEATURE_SSGI) == 0 { + 2 + } else if feature_mask & (FEATURE_MOTION_BLUR | FEATURE_SSS) == 0 { + 3 + } else { + 4 + }; + + let capability = if cfg!(lean_mrt) { + graph::CapabilityTier::Constrained + } else if self.hw_rt_enabled && self.pt_texture_arrays_enabled { + graph::CapabilityTier::HardwareRayQueryTextureArrays + } else if self.hw_rt_enabled { + graph::CapabilityTier::HardwareRayQuery + } else { + graph::CapabilityTier::Raster + }; + + graph::FramePlanKey { + resolution: graph::ResolutionClass::from_extent( + self.surface_config.width, + self.surface_config.height, + ), + quality_tier, + feature_mask, + capability, + path_tracing: graph::PathTracingMode::from_u32(if self.pt_active() { + self.pt_mode + } else { + 0 + }), + post_pass_count: self.post_passes.len().min(u16::MAX as usize) as u16, + render_target_output, + } + } + + pub(super) fn compiled_frame_plan( + &mut self, + render_target_output: bool, + ) -> Result, graph::CompileError> { + let key = self.frame_plan_key(render_target_output); + let output_format = self.output_format; + let compile_count_before = self.frame_plan_cache.stats().compile_count; + let plan = self.frame_plan_cache.get_or_compile( + key, + // The allocator is conservative: exact descriptor/alias-class + // match plus strictly non-overlapping lifetimes. Persistent and + // temporal resources are imports and can never enter this set. + graph::CompileOptions::CONSERVATIVE_ALIASING, + || graph::build_renderer_frame_plan(key, output_format), + )?; + let compile_count_after = self.frame_plan_cache.stats().compile_count; + self.frame_resource_stats + .created_graph_compiles(compile_count_after.saturating_sub(compile_count_before)); + self.maybe_dump_frame_plan(&plan); + self.last_frame_plan = Some(Arc::clone(&plan)); + Ok(plan) + } + + pub fn render_graph_cache_stats(&self) -> graph::PlanCacheStats { + self.frame_plan_cache.stats() + } + + pub fn render_graph_json(&self) -> Option { + self.last_frame_plan.as_ref().map(|plan| plan.to_json()) + } + + #[cfg(not(target_arch = "wasm32"))] + fn maybe_dump_frame_plan(&mut self, plan: &graph::CompiledGraph) { + if self.dumped_frame_plans.contains(&plan.plan_id) { + return; + } + let Some(directory) = std::env::var_os("BLOOM_GRAPH_DUMP_DIR") else { + return; + }; + let directory = std::path::PathBuf::from(directory); + if let Err(error) = std::fs::create_dir_all(&directory) { + eprintln!( + "bloom graph: cannot create dump directory '{}': {error}", + directory.display() + ); + return; + } + let stem = format!("bloom-frame-{:016x}", plan.plan_id); + let json_path = directory.join(format!("{stem}.json")); + let dot_path = directory.join(format!("{stem}.dot")); + if let Err(error) = std::fs::write(&json_path, plan.to_json()) { + eprintln!( + "bloom graph: cannot write '{}': {error}", + json_path.display() + ); + return; + } + if let Err(error) = std::fs::write(&dot_path, plan.to_dot()) { + eprintln!( + "bloom graph: cannot write '{}': {error}", + dot_path.display() + ); + return; + } + self.dumped_frame_plans.insert(plan.plan_id); + } + + #[cfg(target_arch = "wasm32")] + fn maybe_dump_frame_plan(&mut self, plan: &graph::CompiledGraph) { + self.dumped_frame_plans.insert(plan.plan_id); + } +} diff --git a/native/shared/src/renderer/frame_resource_stats.rs b/native/shared/src/renderer/frame_resource_stats.rs new file mode 100644 index 00000000..4f727072 --- /dev/null +++ b/native/shared/src/renderer/frame_resource_stats.rs @@ -0,0 +1,218 @@ +//! Allocation-free counters for GPU objects created while recording a frame. +//! +//! The renderer deliberately uses a fixed array instead of a map so measuring +//! steady-state object creation cannot itself introduce heap churn. Sites are +//! named and stable in quality telemetry, which lets qualification distinguish +//! a recurring allocation from a bounded rebuild after a resize or mode change. + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(usize)] +pub(super) enum BindGroupCreationSite { + SceneCompose, + SsrTemporal, + Upscale, + Taa, + TaaReactive, + DepthOfField, + MotionBlur, + SubsurfaceScattering, + ContrastAdaptiveSharpen, + AutoExposure, + FinalComposite, + CustomPostPass, +} + +impl BindGroupCreationSite { + const COUNT: usize = 12; + #[cfg(not(target_arch = "wasm32"))] + const ALL: [Self; Self::COUNT] = [ + Self::SceneCompose, + Self::SsrTemporal, + Self::Upscale, + Self::Taa, + Self::TaaReactive, + Self::DepthOfField, + Self::MotionBlur, + Self::SubsurfaceScattering, + Self::ContrastAdaptiveSharpen, + Self::AutoExposure, + Self::FinalComposite, + Self::CustomPostPass, + ]; + + #[cfg(not(target_arch = "wasm32"))] + pub(super) const fn name(self) -> &'static str { + match self { + Self::SceneCompose => "scene_compose", + Self::SsrTemporal => "ssr_temporal", + Self::Upscale => "upscale", + Self::Taa => "taa", + Self::TaaReactive => "taa_reactive", + Self::DepthOfField => "depth_of_field", + Self::MotionBlur => "motion_blur", + Self::SubsurfaceScattering => "subsurface_scattering", + Self::ContrastAdaptiveSharpen => "contrast_adaptive_sharpen", + Self::AutoExposure => "auto_exposure", + Self::FinalComposite => "final_composite", + Self::CustomPostPass => "custom_post_pass", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct FrameResourceStats { + bind_group_creations: [u32; BindGroupCreationSite::COUNT], + pipeline_creation_count_at_begin: u64, + pipeline_creations: u32, + graph_compiles: u32, + command_encoder_creations: u32, + physical_texture_creations: u32, + physical_buffer_creations: u32, +} + +impl FrameResourceStats { + pub(super) fn begin_frame(&mut self, pipeline_creation_count: u64) { + self.bind_group_creations.fill(0); + self.pipeline_creation_count_at_begin = pipeline_creation_count; + self.pipeline_creations = 0; + self.graph_compiles = 0; + self.command_encoder_creations = 0; + self.physical_texture_creations = 0; + self.physical_buffer_creations = 0; + } + + pub(super) fn created_bind_group(&mut self, site: BindGroupCreationSite) { + let count = &mut self.bind_group_creations[site as usize]; + *count = count.saturating_add(1); + } + + pub(super) fn created_graph_compiles(&mut self, count: u64) { + self.graph_compiles = self + .graph_compiles + .saturating_add(count.min(u32::MAX as u64) as u32); + } + + pub(super) fn finish_pipeline_creations(&mut self, pipeline_creation_count: u64) { + let creations = + pipeline_creation_count.saturating_sub(self.pipeline_creation_count_at_begin); + self.pipeline_creations = creations.min(u32::MAX as u64) as u32; + } + + pub(super) fn created_command_encoder(&mut self) { + self.command_encoder_creations = self.command_encoder_creations.saturating_add(1); + } + + pub(super) fn created_physical_textures(&mut self, count: u32) { + self.physical_texture_creations = self.physical_texture_creations.saturating_add(count); + } + + pub(super) fn created_physical_buffers(&mut self, count: u32) { + self.physical_buffer_creations = self.physical_buffer_creations.saturating_add(count); + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) const fn graph_compiles(&self) -> u32 { + self.graph_compiles + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) const fn pipeline_creations(&self) -> u32 { + self.pipeline_creations + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) const fn command_encoder_creations(&self) -> u32 { + self.command_encoder_creations + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) const fn physical_texture_creations(&self) -> u32 { + self.physical_texture_creations + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) const fn physical_buffer_creations(&self) -> u32 { + self.physical_buffer_creations + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn total_bind_group_creations(&self) -> u32 { + self.bind_group_creations + .iter() + .copied() + .fold(0, u32::saturating_add) + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn bind_group_creations( + &self, + ) -> impl Iterator + '_ { + BindGroupCreationSite::ALL + .into_iter() + .map(|site| (site, self.bind_group_creations[site as usize])) + } +} + +impl super::Renderer { + pub(super) fn created_pipelines(&mut self, count: u64) { + self.pipeline_creation_count = self.pipeline_creation_count.saturating_add(count); + } + + pub(super) fn total_pipeline_creation_count(&self) -> u64 { + self.pipeline_creation_count + .saturating_add(self.material_system.pipeline_creation_count) + } + + pub(super) fn finish_frame_resource_stats(&mut self) { + self.frame_resource_stats + .finish_pipeline_creations(self.total_pipeline_creation_count()); + if cfg!(not(target_arch = "wasm32")) + && (self.screenshot_requested || self.pending_quality_capture_dir.is_some()) + { + return; + } + self.steady_state_frame_resource_stats = self.frame_resource_stats; + } +} + +#[cfg(test)] +mod tests { + use super::{BindGroupCreationSite, FrameResourceStats}; + + #[test] + fn counters_are_named_bounded_and_reset_per_frame() { + let mut stats = FrameResourceStats::default(); + stats.begin_frame(20); + stats.created_bind_group(BindGroupCreationSite::Taa); + stats.created_bind_group(BindGroupCreationSite::CustomPostPass); + stats.created_bind_group(BindGroupCreationSite::CustomPostPass); + stats.created_graph_compiles(1); + stats.created_command_encoder(); + stats.created_physical_textures(2); + stats.created_physical_buffers(3); + stats.finish_pipeline_creations(22); + + assert_eq!(stats.total_bind_group_creations(), 3); + assert_eq!(stats.pipeline_creations(), 2); + assert_eq!(stats.graph_compiles(), 1); + assert_eq!(stats.command_encoder_creations(), 1); + assert_eq!(stats.physical_texture_creations(), 2); + assert_eq!(stats.physical_buffer_creations(), 3); + let custom = stats + .bind_group_creations() + .find(|(site, _)| *site == BindGroupCreationSite::CustomPostPass) + .expect("custom post-pass site is reported"); + assert_eq!(custom.0.name(), "custom_post_pass"); + assert_eq!(custom.1, 2); + + stats.begin_frame(22); + stats.finish_pipeline_creations(22); + assert_eq!(stats.total_bind_group_creations(), 0); + assert!(stats.bind_group_creations().all(|(_, count)| count == 0)); + assert_eq!(stats.graph_compiles(), 0); + assert_eq!(stats.command_encoder_creations(), 0); + assert_eq!(stats.physical_texture_creations(), 0); + assert_eq!(stats.physical_buffer_creations(), 0); + assert_eq!(stats.pipeline_creations(), 0); + } +} diff --git a/native/shared/src/renderer/froxel.rs b/native/shared/src/renderer/froxel.rs index 943b726c..efc01d8d 100644 --- a/native/shared/src/renderer/froxel.rs +++ b/native/shared/src/renderer/froxel.rs @@ -177,12 +177,23 @@ pub(super) fn clustered_scene_shader(source: &str) -> String { .find("// BEGIN-POINT-LIGHT-LOOP") .expect("scene shader missing BEGIN-POINT-LIGHT-LOOP marker"); let end_marker = "// END-POINT-LIGHT-LOOP"; - let end = source.find(end_marker).expect("scene shader missing END marker") + end_marker.len(); + let end = source + .find(end_marker) + .expect("scene shader missing END marker") + + end_marker.len(); + let clustered_loop = if source.contains("fn shade_layered_base_pbr(") { + CLUSTERED_LOOP.replace( + "shade_pbr(n, v,", + "shade_layered_pbr(layered_surface, n, v,", + ) + } else { + CLUSTERED_LOOP.to_owned() + }; format!( "{}{}{}{}", CLUSTERED_BINDINGS, &source[..begin], - CLUSTERED_LOOP, + clustered_loop, &source[end..] ) } @@ -208,8 +219,18 @@ pub(super) fn extra_lighting_layout_entries() -> [wgpu::BindGroupLayoutEntry; 3] }, count: None, }, - wgpu::BindGroupLayoutEntry { binding: 11, visibility: wgpu::ShaderStages::FRAGMENT, ty: storage_ro, count: None }, - wgpu::BindGroupLayoutEntry { binding: 12, visibility: wgpu::ShaderStages::FRAGMENT, ty: storage_ro, count: None }, + wgpu::BindGroupLayoutEntry { + binding: 11, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: storage_ro, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 12, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: storage_ro, + count: None, + }, ] } @@ -330,10 +351,22 @@ impl FroxelPass { label: Some("froxel_assign_bg"), layout: &assign_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: params_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: lights_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: counts_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 3, resource: indices_buffer.as_entire_binding() }, + wgpu::BindGroupEntry { + binding: 0, + resource: params_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: lights_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: counts_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: indices_buffer.as_entire_binding(), + }, ], }); Self { @@ -351,9 +384,18 @@ impl FroxelPass { /// appended to every lighting bind group the renderer builds. pub(super) fn extra_lighting_bind_entries(&self) -> [wgpu::BindGroupEntry<'_>; 3] { [ - wgpu::BindGroupEntry { binding: 10, resource: self.params_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 11, resource: self.counts_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 12, resource: self.indices_buffer.as_entire_binding() }, + wgpu::BindGroupEntry { + binding: 10, + resource: self.params_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: self.counts_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 12, + resource: self.indices_buffer.as_entire_binding(), + }, ] } @@ -394,8 +436,7 @@ impl super::Renderer { // pass runs at render_extent (render_scale-aware), not surface // size. let (rw, rh) = self.render_extent(); - let n = (self.lighting_uniforms.point_light_count[0] as u32) - .min(MAX_LIGHTS_PER_CLUSTER); + let n = (self.lighting_uniforms.point_light_count[0] as u32).min(MAX_LIGHTS_PER_CLUSTER); let params = FroxelParams { view: self.current_view_matrix, grid: [GRID_X, GRID_Y, GRID_Z, n], @@ -408,9 +449,11 @@ impl super::Renderer { ], inv_proj: self.current_inv_proj_matrix, }; - self.queue.write_buffer(&froxel.params_buffer, 0, bytemuck::bytes_of(¶ms)); + self.queue + .write_buffer(&froxel.params_buffer, 0, bytemuck::bytes_of(¶ms)); let count = [n as f32, 0.0, 0.0, 0.0_f32]; - self.queue.write_buffer(&froxel.lights_buffer, 0, bytemuck::bytes_of(&count)); + self.queue + .write_buffer(&froxel.lights_buffer, 0, bytemuck::bytes_of(&count)); self.queue.write_buffer( &froxel.lights_buffer, 16, diff --git a/native/shared/src/renderer/gi_bake.rs b/native/shared/src/renderer/gi_bake.rs index 4c44b03f..cc0bf172 100644 --- a/native/shared/src/renderer/gi_bake.rs +++ b/native/shared/src/renderer/gi_bake.rs @@ -17,6 +17,7 @@ use super::*; // renderer/mod.rs gains no field. struct SdfTriCache { version: u64, + excludes_transmission: bool, vertices: Vec, indices: Vec, tri_count: u32, @@ -74,10 +75,23 @@ impl Renderer { scene: &crate::scene::SceneGraph, encoder: &mut wgpu::CommandEncoder, ) { + let transparent_gi = self.transparent_gi_active; + if self.scene_sdf_clipmap_built + && (self.scene_sdf_clipmap_scene_version != scene.tlas_version + || self.scene_sdf_clipmap_transparent_gi != transparent_gi) + { + self.scene_sdf_clipmap_rebake_needed = true; + } // Continue an in-flight job first: one slice batch per frame. if let Some(job) = self.sdf_clipmap_job.take() { - self.encode_clipmap_bake_slices(job, encoder); - return; + if job.scene_version == scene.tlas_version && job.transparent_gi == transparent_gi { + self.encode_clipmap_bake_slices(job, encoder); + return; + } + // The staging volume is not live until its final atomic copy. + // Dropping an obsolete job is therefore safe and avoids publishing + // geometry/material state that changed while it was amortizing. + self.scene_sdf_clipmap_rebake_needed = true; } if !self.scene_sdf_clipmap_rebake_needed { return; @@ -95,13 +109,14 @@ impl Renderer { let mut cache_guard = sdf_tri_cache().lock().unwrap(); let stale = match &*cache_guard { - Some(c) => c.version != scene.tlas_version, + Some(c) => c.version != scene.tlas_version || c.excludes_transmission != transparent_gi, None => true, }; if stale { - let (v, i, n) = scene.build_world_triangles(); + let (v, i, n) = scene.build_world_triangles_for_gi(transparent_gi); *cache_guard = Some(SdfTriCache { version: scene.tlas_version, + excludes_transmission: transparent_gi, vertices: v, indices: i, tri_count: n, @@ -200,26 +215,34 @@ impl Renderer { } } - let vbuf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("scene_sdf_bake_vbuf"), - contents: bytemuck::cast_slice(vertices.as_slice()), - usage: wgpu::BufferUsages::STORAGE, - }); - let ibuf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("scene_sdf_bake_ibuf"), - contents: bytemuck::cast_slice(indices.as_slice()), - usage: wgpu::BufferUsages::STORAGE, - }); - let cell_offsets_buf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("scene_sdf_bake_cell_offsets"), - contents: bytemuck::cast_slice(&offsets), - usage: wgpu::BufferUsages::STORAGE, - }); - let cell_tris_buf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("scene_sdf_bake_cell_tris"), - contents: bytemuck::cast_slice(&tri_refs), - usage: wgpu::BufferUsages::STORAGE, - }); + let vbuf = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_sdf_bake_vbuf"), + contents: bytemuck::cast_slice(vertices.as_slice()), + usage: wgpu::BufferUsages::STORAGE, + }); + let ibuf = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_sdf_bake_ibuf"), + contents: bytemuck::cast_slice(indices.as_slice()), + usage: wgpu::BufferUsages::STORAGE, + }); + let cell_offsets_buf = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_sdf_bake_cell_offsets"), + contents: bytemuck::cast_slice(&offsets), + usage: wgpu::BufferUsages::STORAGE, + }); + let cell_tris_buf = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_sdf_bake_cell_tris"), + contents: bytemuck::cast_slice(&tri_refs), + usage: wgpu::BufferUsages::STORAGE, + }); // Per-job uniform: sharing sdf_bake_uniform would alias with the // per-mesh bakes — queue.write_buffer applies before any of this // frame's commands, so the last write would win for every pass. @@ -233,12 +256,32 @@ impl Renderer { label: Some("scene_sdf_clipmap_bake_bg"), layout: &self.sdf_clipmap_bake_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: vbuf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: ibuf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.scene_sdf_clipmap_staging_view) }, - wgpu::BindGroupEntry { binding: 4, resource: cell_offsets_buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 5, resource: cell_tris_buf.as_entire_binding() }, + wgpu::BindGroupEntry { + binding: 0, + resource: uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: vbuf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: ibuf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView( + &self.scene_sdf_clipmap_staging_view, + ), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: cell_offsets_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: cell_tris_buf.as_entire_binding(), + }, ], }); @@ -247,6 +290,8 @@ impl Renderer { origin, aabb_min, aabb_max, + scene_version: scene.tlas_version, + transparent_gi, uniform, bind_group, next_z: 0, @@ -273,7 +318,8 @@ impl Renderer { aabb_max: job.aabb_max, counts: [SCENE_SDF_CLIPMAP_BIN_CELLS, res, job.next_z, 0], }; - self.queue.write_buffer(&job.uniform, 0, bytemuck::bytes_of(¶ms)); + self.queue + .write_buffer(&job.uniform, 0, bytemuck::bytes_of(¶ms)); let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("scene_sdf_clipmap_bake_slice"), @@ -297,6 +343,8 @@ impl Renderer { ); self.scene_sdf_clipmap_origin = job.origin; self.scene_sdf_clipmap_built = true; + self.scene_sdf_clipmap_scene_version = job.scene_version; + self.scene_sdf_clipmap_transparent_gi = job.transparent_gi; } else { self.sdf_clipmap_job = Some(job); } @@ -371,6 +419,7 @@ impl Renderer { pub(super) fn bake_wsrc( &mut self, encoder: &mut wgpu::CommandEncoder, + profiler: &mut crate::profiler::Profiler, ) { // V13 — bake only cascades that are marked not-built. Each // cascade snaps to its own cell grid (cell = extent / 16) @@ -393,8 +442,11 @@ impl Renderer { // across all cascades in one frame. Per-cascade differences // come from the origin + extent passed through the uniform. let ld = self.lighting_uniforms.light_dir; - let inv_len = 1.0 / (ld[0]*ld[0] + ld[1]*ld[1] + ld[2]*ld[2]).sqrt().max(1e-4); - let sun_dir_ws = [-ld[0]*inv_len, -ld[1]*inv_len, -ld[2]*inv_len, ld[3]]; + let inv_len = 1.0 + / (ld[0] * ld[0] + ld[1] * ld[1] + ld[2] * ld[2]) + .sqrt() + .max(1e-4); + let sun_dir_ws = [-ld[0] * inv_len, -ld[1] * inv_len, -ld[2] * inv_len, ld[3]]; let lc = self.lighting_uniforms.light_color; let sun_intensity = ld[3].max(0.0); let sun_color = [ @@ -432,36 +484,102 @@ impl Renderer { if self.wsrc_bake_hw_bg_cache.is_none() { let tlas = self.tlas.as_ref().unwrap(); let instance_buf = self.tlas_instance_data_buffer.as_ref().unwrap(); - self.wsrc_bake_hw_bg_cache = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("wsrc_bake_hw_bg"), - layout: self.wsrc_bake_hw_layout.as_ref().unwrap(), + self.wsrc_bake_hw_bg_cache = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("wsrc_bake_hw_bg"), + layout: self.wsrc_bake_hw_layout.as_ref().unwrap(), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.wsrc_bake_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView( + &self.shadow_map.depth_views[0], + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView( + &self.shadow_map.depth_views[1], + ), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView( + &self.shadow_map.depth_views[2], + ), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.shadow_map.sampler), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&self.wsrc_atlas_view), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: tlas.as_binding(), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: instance_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView( + &self.mesh_card_radiance_view, + ), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::Sampler( + &self.mesh_card_atlas_sampler, + ), + }, + ], + })); + } + } else if self.wsrc_bake_bg_cache.is_none() { + self.wsrc_bake_bg_cache = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("wsrc_bake_bg"), + layout: &self.wsrc_bake_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.wsrc_bake_uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[0]) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[1]) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[2]) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.shadow_map.sampler) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&self.wsrc_atlas_view) }, - wgpu::BindGroupEntry { binding: 6, resource: tlas.as_binding() }, - wgpu::BindGroupEntry { binding: 7, resource: instance_buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self.mesh_card_radiance_view) }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&self.mesh_card_atlas_sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: self.wsrc_bake_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView( + &self.shadow_map.depth_views[0], + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView( + &self.shadow_map.depth_views[1], + ), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView( + &self.shadow_map.depth_views[2], + ), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.shadow_map.sampler), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&self.wsrc_atlas_view), + }, ], })); - } - } else if self.wsrc_bake_bg_cache.is_none() { - self.wsrc_bake_bg_cache = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("wsrc_bake_bg"), - layout: &self.wsrc_bake_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.wsrc_bake_uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[0]) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[1]) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[2]) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.shadow_map.sampler) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&self.wsrc_atlas_view) }, - ], - })); } let cam = self.current_camera_world_pos(); @@ -506,18 +624,28 @@ impl Renderer { 0.0, ], }; - self.queue.write_buffer( - &self.wsrc_bake_uniform, - 0, - bytemuck::bytes_of(¶ms), - ); + self.queue + .write_buffer(&self.wsrc_bake_uniform, 0, bytemuck::bytes_of(¶ms)); + let label = if use_hw { + "wsrc_bake_hw_pass" + } else { + "wsrc_bake_pass" + }; + let timestamp_writes = profiler.compute_pass_timestamp_writes(label); let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { - label: Some(if use_hw { "wsrc_bake_hw_pass" } else { "wsrc_bake_pass" }), - timestamp_writes: None, + label: Some(label), + timestamp_writes, }); if use_hw { - pass.set_pipeline(self.wsrc_bake_hw_pipeline.as_ref().unwrap()); + let pipeline = if self.transparent_gi_active { + self.wsrc_bake_hw_transparent_pipeline + .as_ref() + .expect("active transparent GI has a lazy HW WSRC specialization") + } else { + self.wsrc_bake_hw_pipeline.as_ref().unwrap() + }; + pass.set_pipeline(pipeline); pass.set_bind_group(0, self.wsrc_bake_hw_bg_cache.as_ref().unwrap(), &[]); } else { pass.set_pipeline(&self.wsrc_bake_pipeline); @@ -562,11 +690,21 @@ impl Renderer { for handle in pending { let (sdf_tex, sdf_view, vb_ptr, ib_ptr, bmin, bmax, index_count, mesh_hash) = { - let Some(node) = scene.nodes.get(handle) else { continue; }; - let Some(sdf_tex) = node.mesh_sdf.as_ref() else { continue; }; - let Some(sdf_view) = node.mesh_sdf_view.as_ref() else { continue; }; - let Some(vb) = node.gpu_vb.as_ref() else { continue; }; - let Some(ib) = node.gpu_ib.as_ref() else { continue; }; + let Some(node) = scene.nodes.get(handle) else { + continue; + }; + let Some(sdf_tex) = node.mesh_sdf.as_ref() else { + continue; + }; + let Some(sdf_view) = node.mesh_sdf_view.as_ref() else { + continue; + }; + let Some(vb) = node.gpu_vb.as_ref() else { + continue; + }; + let Some(ib) = node.gpu_ib.as_ref() else { + continue; + }; ( sdf_tex.clone(), sdf_view.clone(), @@ -587,19 +725,28 @@ impl Renderer { aabb_max: [bmax[0], bmax[1], bmax[2], 0.0], counts: [tri_count, MESH_SDF_RES, 0, 0], }; - self.queue.write_buffer( - &self.sdf_bake_uniform, - 0, - bytemuck::bytes_of(¶ms), - ); + self.queue + .write_buffer(&self.sdf_bake_uniform, 0, bytemuck::bytes_of(¶ms)); let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("sdf_bake_bg"), layout: &self.sdf_bake_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.sdf_bake_uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: vb_ptr.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: ib_ptr.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&sdf_view) }, + wgpu::BindGroupEntry { + binding: 0, + resource: self.sdf_bake_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: vb_ptr.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: ib_ptr.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&sdf_view), + }, ], }); { diff --git a/native/shared/src/renderer/gpu_driven.rs b/native/shared/src/renderer/gpu_driven.rs new file mode 100644 index 00000000..3c0f9299 --- /dev/null +++ b/native/shared/src/renderer/gpu_driven.rs @@ -0,0 +1,1342 @@ +//! GPU-driven static opaque submission (#28). +//! +//! The compatibility renderer remains the oracle. This path is selected only +//! when the device exposes indirect first-instance and Tier-A global material +//! tables. Static cached meshes live in one geometry arena; a compute pass +//! writes one ordered indirect command per submitted draw and zeros +//! `instance_count` for culled draws. Keeping command slots ordered avoids the +//! alpha/cutout ordering changes caused by atomic append compaction. + +use super::{ + material_indirection::GpuCompletionTracker, MeshDrawRef, Renderer, Uniforms3D, Vertex3D, + DEPTH_FORMAT, HDR_FORMAT, MATERIAL_FORMAT, VELOCITY_FORMAT, +}; +use std::sync::OnceLock; + +pub const GPU_DRIVEN_FEATURES: wgpu::Features = wgpu::Features::INDIRECT_FIRST_INSTANCE; +/// Below this count, compute/indirect setup costs more than the CPU loop on +/// current Metal hardware. Keep small scenes on the lower-overhead oracle. +pub const GPU_DRIVEN_MIN_DRAWS: usize = 32; + +/// Request only portable GPU-driven features the adapter actually exposes. +/// +/// `MULTI_DRAW_INDIRECT_COUNT` is optional and is currently absent on Metal; +/// fixed-count multi-draw remains a one-call GPU submission there. +pub fn request_features_if_supported(supported: wgpu::Features, required: &mut wgpu::Features) { + if supported.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE) { + *required |= wgpu::Features::INDIRECT_FIRST_INSTANCE; + } + if supported.contains(wgpu::Features::MULTI_DRAW_INDIRECT_COUNT) { + *required |= wgpu::Features::MULTI_DRAW_INDIRECT_COUNT; + } +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct GeometrySlice { + pub vertex_offset: u64, + pub vertex_size: u64, + pub index_offset: u64, + pub index_size: u64, + pub first_index: u32, + pub base_vertex: i32, +} + +pub enum MeshGeometry { + Shared(GeometrySlice), + Dedicated { + vertex: wgpu::Buffer, + index: wgpu::Buffer, + }, +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +struct FreeRange { + offset: u64, + size: u64, +} + +struct RetiredGeometry { + epoch: u64, + slice: GeometrySlice, +} + +struct GeometryArena { + vertex: wgpu::Buffer, + index: wgpu::Buffer, + vertex_capacity: u64, + index_capacity: u64, + vertex_end: u64, + index_end: u64, + free_vertices: Vec, + free_indices: Vec, + retired: Vec, + completion: GpuCompletionTracker, +} + +impl GeometryArena { + const INITIAL_CAPACITY: u64 = 64 * 1024; + + fn new(device: &wgpu::Device) -> Self { + Self { + vertex: create_vertex_arena(device, Self::INITIAL_CAPACITY), + index: create_index_arena(device, Self::INITIAL_CAPACITY), + vertex_capacity: Self::INITIAL_CAPACITY, + index_capacity: Self::INITIAL_CAPACITY, + vertex_end: 0, + index_end: 0, + free_vertices: Vec::new(), + free_indices: Vec::new(), + retired: Vec::new(), + completion: GpuCompletionTracker::default(), + } + } + + fn upload( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + vertices: &[Vertex3D], + indices: &[u32], + ) -> GeometrySlice { + self.collect(); + let vertex_size = std::mem::size_of_val(vertices) as u64; + let index_size = std::mem::size_of_val(indices) as u64; + let previous_vertex_end = self.vertex_end; + let previous_index_end = self.index_end; + let vertex_offset = allocate_range( + &mut self.free_vertices, + &mut self.vertex_end, + vertex_size, + std::mem::align_of::() as u64, + ); + let index_offset = + allocate_range(&mut self.free_indices, &mut self.index_end, index_size, 4); + self.grow_if_needed( + device, + queue, + vertex_offset + vertex_size, + index_offset + index_size, + previous_vertex_end, + previous_index_end, + ); + if vertex_size > 0 { + queue.write_buffer(&self.vertex, vertex_offset, bytemuck::cast_slice(vertices)); + } + if index_size > 0 { + queue.write_buffer(&self.index, index_offset, bytemuck::cast_slice(indices)); + } + GeometrySlice { + vertex_offset, + vertex_size, + index_offset, + index_size, + first_index: (index_offset / 4) as u32, + base_vertex: (vertex_offset / std::mem::size_of::() as u64) as i32, + } + } + + fn retire_many( + &mut self, + queue: &wgpu::Queue, + slices: impl IntoIterator, + ) { + let slices: Vec<_> = slices.into_iter().collect(); + if slices.is_empty() { + return; + } + let epoch = self.completion.track_submitted_work(queue); + self.retired.extend( + slices + .into_iter() + .map(|slice| RetiredGeometry { epoch, slice }), + ); + } + + fn collect(&mut self) { + let completed = self.completion.completed_epoch(); + let mut keep = Vec::with_capacity(self.retired.len()); + for retired in self.retired.drain(..) { + if retired.epoch > completed { + keep.push(retired); + continue; + } + if retired.slice.vertex_size > 0 { + self.free_vertices.push(FreeRange { + offset: retired.slice.vertex_offset, + size: retired.slice.vertex_size, + }); + } + if retired.slice.index_size > 0 { + self.free_indices.push(FreeRange { + offset: retired.slice.index_offset, + size: retired.slice.index_size, + }); + } + } + self.retired = keep; + merge_ranges(&mut self.free_vertices); + merge_ranges(&mut self.free_indices); + } + + fn grow_if_needed( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + required_vertex: u64, + required_index: u64, + copy_vertex_end: u64, + copy_index_end: u64, + ) { + let new_vertex_capacity = grown_capacity(self.vertex_capacity, required_vertex); + let new_index_capacity = grown_capacity(self.index_capacity, required_index); + if new_vertex_capacity == self.vertex_capacity && new_index_capacity == self.index_capacity + { + return; + } + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("gpu_geometry_arena_grow"), + }); + if new_vertex_capacity != self.vertex_capacity { + let next = create_vertex_arena(device, new_vertex_capacity); + if copy_vertex_end > 0 { + encoder.copy_buffer_to_buffer(&self.vertex, 0, &next, 0, copy_vertex_end); + } + self.vertex = next; + self.vertex_capacity = new_vertex_capacity; + } + if new_index_capacity != self.index_capacity { + let next = create_index_arena(device, new_index_capacity); + if copy_index_end > 0 { + encoder.copy_buffer_to_buffer(&self.index, 0, &next, 0, copy_index_end); + } + self.index = next; + self.index_capacity = new_index_capacity; + } + queue.submit(std::iter::once(encoder.finish())); + } +} + +fn create_vertex_arena(device: &wgpu::Device, size: u64) -> wgpu::Buffer { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("gpu_driven_shared_vertices"), + size, + usage: wgpu::BufferUsages::VERTEX + | wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_SRC + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) +} + +fn create_index_arena(device: &wgpu::Device, size: u64) -> wgpu::Buffer { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("gpu_driven_shared_indices"), + size, + usage: wgpu::BufferUsages::INDEX + | wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_SRC + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) +} + +fn grown_capacity(current: u64, required: u64) -> u64 { + if required <= current { + current + } else { + required + .next_power_of_two() + .max(GeometryArena::INITIAL_CAPACITY) + } +} + +fn align_up(value: u64, alignment: u64) -> u64 { + value.div_ceil(alignment) * alignment +} + +fn allocate_range(free: &mut Vec, end: &mut u64, size: u64, alignment: u64) -> u64 { + if size == 0 { + return 0; + } + for index in 0..free.len() { + let aligned = align_up(free[index].offset, alignment); + let padding = aligned - free[index].offset; + if padding + size > free[index].size { + continue; + } + let original = free.swap_remove(index); + if padding > 0 { + free.push(FreeRange { + offset: original.offset, + size: padding, + }); + } + let tail_offset = aligned + size; + let tail_size = original.size - padding - size; + if tail_size > 0 { + free.push(FreeRange { + offset: tail_offset, + size: tail_size, + }); + } + return aligned; + } + let offset = align_up(*end, alignment); + *end = offset + size; + offset +} + +fn merge_ranges(ranges: &mut Vec) { + ranges.sort_by_key(|range| range.offset); + let mut write = 0usize; + for read in 0..ranges.len() { + let current = ranges[read]; + if write > 0 { + let previous = &mut ranges[write - 1]; + if previous.offset + previous.size == current.offset { + previous.size += current.size; + continue; + } + } + ranges[write] = current; + write += 1; + } + ranges.truncate(write); +} + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub(crate) struct GpuDrawRecord { + pub(crate) uniforms: Uniforms3D, + pub(crate) bounds_min: [f32; 4], + pub(crate) bounds_max: [f32; 4], + /// x=index count, y=first index, z=bitcast base vertex, w=material ID. + pub(crate) draw: [u32; 4], +} + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct CullParams { + planes: [[f32; 4]; 6], + meta: [u32; 4], +} + +#[derive(Copy, Clone, Debug, Default)] +pub struct SubmissionStats { + pub submitted: u32, + pub compatibility: u32, + pub indirect_calls: u32, + pub frustum_visible_oracle: u32, + pub frustum_culled_oracle: u32, +} + +pub struct GpuDrivenRenderer { + arena: GeometryArena, + enabled: bool, + count_supported: bool, + draw_capacity: usize, + draw_buffer: wgpu::Buffer, + indirect_buffer: wgpu::Buffer, + cull_params: wgpu::Buffer, + counter_buffer: wgpu::Buffer, + cull_layout: wgpu::BindGroupLayout, + cull_bind_group: wgpu::BindGroup, + draw_layout: wgpu::BindGroupLayout, + draw_bind_group: wgpu::BindGroup, + cull_pipeline: Option, + depth_pipeline: Option, + main_pipeline: Option, + main_prepassed_pipeline: Option, + pub(super) draw_scratch: Vec, + pub stats: SubmissionStats, +} + +impl GpuDrivenRenderer { + pub fn new( + device: &wgpu::Device, + lighting_layout: &wgpu::BindGroupLayout, + joint_layout: &wgpu::BindGroupLayout, + global_material_layout: Option<&wgpu::BindGroupLayout>, + scene_source: &str, + ) -> Self { + let draw_capacity = 64; + let draw_buffer = create_draw_buffer(device, draw_capacity); + let indirect_buffer = create_indirect_buffer(device, draw_capacity); + let cull_params = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("gpu_driven_cull_params"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let counter_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("gpu_driven_counters"), + size: 16, + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::INDIRECT + | wgpu::BufferUsages::COPY_DST + | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let cull_layout = create_cull_layout(device); + let cull_bind_group = create_cull_bind_group( + device, + &cull_layout, + &draw_buffer, + &indirect_buffer, + &cull_params, + &counter_buffer, + ); + let draw_layout = create_draw_layout(device); + let draw_bind_group = create_draw_bind_group(device, &draw_layout, &draw_buffer); + let forced_off = gpu_driven_forced_off(); + let feature_ready = device + .features() + .contains(wgpu::Features::INDIRECT_FIRST_INSTANCE); + let tier_ready = global_material_layout.is_some(); + let enabled = + cfg!(not(target_arch = "wasm32")) && feature_ready && tier_ready && !forced_off; + let count_supported = device + .features() + .contains(wgpu::Features::MULTI_DRAW_INDIRECT_COUNT); + + let (cull_pipeline, depth_pipeline, main_pipeline, main_prepassed_pipeline) = + if let Some(global_layout) = global_material_layout.filter(|_| enabled) { + let cull_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("gpu_driven_cull_shader"), + source: wgpu::ShaderSource::Wgsl(CULL_SHADER.into()), + }); + let cull_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("gpu_driven_cull_pipeline_layout"), + bind_group_layouts: &[Some(&cull_layout)], + immediate_size: 0, + }); + let cull_pipeline = + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("gpu_driven_cull_pipeline"), + layout: Some(&cull_pipeline_layout), + module: &cull_shader, + entry_point: Some("cs_cull"), + compilation_options: Default::default(), + cache: None, + }); + + let shader_source = make_gpu_scene_shader(scene_source); + let prepassed_source = strip_prepass_discard(&shader_source); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("gpu_driven_scene_shader"), + source: wgpu::ShaderSource::Wgsl(shader_source.into()), + }); + let prepassed_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("gpu_driven_scene_prepassed_shader"), + source: wgpu::ShaderSource::Wgsl(prepassed_source.into()), + }); + let render_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("gpu_driven_scene_pipeline_layout"), + bind_group_layouts: &[ + Some(&draw_layout), + Some(lighting_layout), + Some(global_layout), + Some(joint_layout), + ], + immediate_size: 0, + }); + ( + Some(cull_pipeline), + Some(create_depth_pipeline(device, &render_layout, &shader)), + Some(create_main_pipeline( + device, + &render_layout, + &shader, + &shader, + false, + )), + Some(create_main_pipeline( + device, + &render_layout, + &shader, + &prepassed_shader, + true, + )), + ) + } else { + (None, None, None, None) + }; + + let renderer = Self { + arena: GeometryArena::new(device), + enabled, + count_supported, + draw_capacity, + draw_buffer, + indirect_buffer, + cull_params, + counter_buffer, + cull_layout, + cull_bind_group, + draw_layout, + draw_bind_group, + cull_pipeline, + depth_pipeline, + main_pipeline, + main_prepassed_pipeline, + draw_scratch: Vec::with_capacity(draw_capacity), + stats: SubmissionStats::default(), + }; + log::info!( + "bloom: gpu-driven submission enabled={} indirect_count={} tier_a={} first_instance={}", + renderer.enabled, + renderer.count_supported, + tier_ready, + feature_ready, + ); + renderer + } + + pub fn enabled(&self) -> bool { + self.enabled + } + + pub fn submitting(&self) -> bool { + self.enabled && !self.draw_scratch.is_empty() + } + + pub fn upload_static( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + vertices: &[Vertex3D], + indices: &[u32], + ) -> GeometrySlice { + self.arena.upload(device, queue, vertices, indices) + } + + pub fn retire_shared( + &mut self, + queue: &wgpu::Queue, + slices: impl IntoIterator, + ) { + self.arena.retire_many(queue, slices); + } + + pub(super) fn mesh_draw<'a>( + &'a self, + geometry: &'a MeshGeometry, + index_count: u32, + ) -> MeshDrawRef<'a> { + match geometry { + MeshGeometry::Shared(slice) => MeshDrawRef { + vertex: &self.arena.vertex, + index: &self.arena.index, + first_index: slice.first_index, + index_count, + base_vertex: slice.base_vertex, + }, + MeshGeometry::Dedicated { vertex, index } => MeshDrawRef { + vertex, + index, + first_index: 0, + index_count, + base_vertex: 0, + }, + } + } + + /// Mesh-local binding window for passes that pair the shared primary + /// arena with a per-mesh sidecar stream. The index values remain + /// primitive-local, so slicing both arena buffers lets every vertex stream + /// use base vertex zero. + pub(super) fn mesh_draw_localized<'a>( + &'a self, + geometry: &'a MeshGeometry, + index_count: u32, + ) -> (MeshDrawRef<'a>, u64, u64) { + match geometry { + MeshGeometry::Shared(slice) => ( + MeshDrawRef { + vertex: &self.arena.vertex, + index: &self.arena.index, + first_index: 0, + index_count, + base_vertex: 0, + }, + slice.vertex_offset, + slice.index_offset, + ), + MeshGeometry::Dedicated { vertex, index } => ( + MeshDrawRef { + vertex, + index, + first_index: 0, + index_count, + base_vertex: 0, + }, + 0, + 0, + ), + } + } + + pub fn prepare( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + planes: [[f32; 4]; 6], + compatibility_count: u32, + frustum_visible_oracle: u32, + frustum_culled_oracle: u32, + ) { + self.stats = SubmissionStats { + submitted: self.draw_scratch.len() as u32, + compatibility: compatibility_count, + indirect_calls: u32::from(!self.draw_scratch.is_empty()), + frustum_visible_oracle, + frustum_culled_oracle, + }; + if !self.enabled || self.draw_scratch.is_empty() { + return; + } + self.ensure_draw_capacity(device, self.draw_scratch.len()); + queue.write_buffer( + &self.draw_buffer, + 0, + bytemuck::cast_slice(&self.draw_scratch), + ); + let params = CullParams { + planes, + meta: [self.draw_scratch.len() as u32, 0, 0, 0], + }; + queue.write_buffer(&self.cull_params, 0, bytemuck::bytes_of(¶ms)); + // x is the ordered command count consumed by the count API; y/z are + // visible/culled telemetry atomics written by compute. + queue.write_buffer( + &self.counter_buffer, + 0, + bytemuck::cast_slice(&[self.draw_scratch.len() as u32, 0u32, 0, 0]), + ); + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("gpu_driven_frustum_cull"), + timestamp_writes: None, + }); + pass.set_pipeline( + self.cull_pipeline + .as_ref() + .expect("enabled gpu-driven path owns cull pipeline"), + ); + pass.set_bind_group(0, &self.cull_bind_group, &[]); + pass.dispatch_workgroups((self.draw_scratch.len() as u32).div_ceil(64), 1, 1); + } + + pub fn report_json(&self) -> String { + let classified = self.stats.frustum_visible_oracle + self.stats.frustum_culled_oracle; + let culled_ratio = if classified == 0 { + 0.0 + } else { + self.stats.frustum_culled_oracle as f64 / classified as f64 + }; + format!( + concat!( + "{{\"enabled\":{},\"indirect_count_supported\":{},", + "\"submitted\":{},\"compatibility\":{},\"indirect_calls\":{},", + "\"frustum_visible_oracle\":{},\"frustum_culled_oracle\":{},", + "\"frustum_culled_ratio\":{:.6},", + "\"classification_source\":\"retained-scene conservative CPU oracle\"}}" + ), + self.enabled, + self.count_supported, + self.stats.submitted, + self.stats.compatibility, + self.stats.indirect_calls, + self.stats.frustum_visible_oracle, + self.stats.frustum_culled_oracle, + culled_ratio, + ) + } + + pub fn draw_depth<'a>( + &'a self, + pass: &mut wgpu::RenderPass<'a>, + lighting: &'a wgpu::BindGroup, + global_materials: &'a wgpu::BindGroup, + joints: &'a wgpu::BindGroup, + ) { + let Some(pipeline) = self.depth_pipeline.as_ref() else { + return; + }; + if self.draw_scratch.is_empty() { + return; + } + pass.set_pipeline(pipeline); + self.bind_and_draw(pass, lighting, global_materials, joints); + } + + pub fn draw_main<'a>( + &'a self, + pass: &mut wgpu::RenderPass<'a>, + lighting: &'a wgpu::BindGroup, + global_materials: &'a wgpu::BindGroup, + joints: &'a wgpu::BindGroup, + prepassed: bool, + ) { + let pipeline = if prepassed { + self.main_prepassed_pipeline.as_ref() + } else { + self.main_pipeline.as_ref() + }; + let Some(pipeline) = pipeline else { + return; + }; + if self.draw_scratch.is_empty() { + return; + } + pass.set_pipeline(pipeline); + self.bind_and_draw(pass, lighting, global_materials, joints); + } + + fn bind_and_draw<'a>( + &'a self, + pass: &mut wgpu::RenderPass<'a>, + lighting: &'a wgpu::BindGroup, + global_materials: &'a wgpu::BindGroup, + joints: &'a wgpu::BindGroup, + ) { + pass.set_bind_group(0, &self.draw_bind_group, &[]); + pass.set_bind_group(1, lighting, &[]); + pass.set_bind_group(2, global_materials, &[]); + pass.set_bind_group(3, joints, &[]); + pass.set_vertex_buffer(0, self.arena.vertex.slice(..)); + pass.set_index_buffer(self.arena.index.slice(..), wgpu::IndexFormat::Uint32); + let count = self.draw_scratch.len() as u32; + if self.count_supported { + pass.multi_draw_indexed_indirect_count( + &self.indirect_buffer, + 0, + &self.counter_buffer, + 0, + count, + ); + } else { + pass.multi_draw_indexed_indirect(&self.indirect_buffer, 0, count); + } + } + + fn ensure_draw_capacity(&mut self, device: &wgpu::Device, required: usize) { + if required <= self.draw_capacity { + return; + } + self.draw_capacity = required.next_power_of_two(); + self.draw_buffer = create_draw_buffer(device, self.draw_capacity); + self.indirect_buffer = create_indirect_buffer(device, self.draw_capacity); + self.cull_bind_group = create_cull_bind_group( + device, + &self.cull_layout, + &self.draw_buffer, + &self.indirect_buffer, + &self.cull_params, + &self.counter_buffer, + ); + self.draw_bind_group = create_draw_bind_group(device, &self.draw_layout, &self.draw_buffer); + self.draw_scratch.reserve( + self.draw_capacity + .saturating_sub(self.draw_scratch.capacity()), + ); + } +} + +impl Renderer { + /// Prepare retained scene resources through both the compatibility and + /// GPU-driven paths. Keeping this borrow split inside Renderer lets the + /// scene use the shared geometry arena without exposing device internals. + pub fn prepare_scene_graph( + &mut self, + scene: &mut crate::scene::SceneGraph, + use_occlusion: bool, + ) { + if self.temporal_camera_cut_active { + scene.reset_motion_history(); + } + let vp = self.current_vp_matrix; + let prev_vp = self.velocity_ref_vp; + let occlusion = use_occlusion.then_some(&self.occlusion); + scene.prepare_with_refraction( + &self.device, + &self.queue, + &vp, + &prev_vp, + &self.uniform_3d_layout, + occlusion, + &mut self.gpu_driven, + self.imported_refraction_enabled, + ); + scene.prepare_materials(self); + } + + pub(crate) fn gpu_driven_enabled(&self) -> bool { + self.gpu_driven.enabled() + } + + pub(crate) fn allocate_scene_gpu_material( + &mut self, + material: &crate::scene::PbrMaterial, + ) -> super::material_indirection::MaterialId { + let texture_id = |index: u32| { + self.global_texture_ids + .get(index as usize) + .copied() + .unwrap_or(super::material_indirection::TextureId::FALLBACK) + }; + let mut record = super::material_indirection::GpuMaterialRecord::default(); + // Per-node colour/opacity stay in Uniforms3D.model_tint, matching the + // compatibility scene shader. The global record carries only factors + // that were previously held by SceneMaterialUniforms. + record.metal_rough = [ + material.metalness, + material.roughness, + (material.metallic_roughness_texture_idx != 0) as u8 as f32, + material + .alpha_mode + .shader_alpha_value(material.alpha_cutoff), + ]; + record.emissive = [ + material.emissive[0], + material.emissive[1], + material.emissive[2], + if material.alpha_coverage_mips { + 1.0 + } else { + 0.0 + }, + ]; + record.texture_ids_0 = [ + texture_id(material.texture_idx).raw(), + texture_id(material.normal_texture_idx).raw(), + texture_id(material.metallic_roughness_texture_idx).raw(), + texture_id(material.emissive_texture_idx).raw(), + ]; + record.texture_ids_1[0] = texture_id(material.occlusion_texture_idx).raw(); + record.sampler_ids_0 = [self.global_linear_sampler_id.raw(); 4]; + record.sampler_ids_1[0] = self.global_linear_sampler_id.raw(); + self.material_system + .indirection + .allocate_material(&self.device, record) + } + + pub(crate) fn retire_scene_gpu_materials( + &mut self, + ids: impl IntoIterator, + ) { + self.material_system + .indirection + .retire_materials(&self.queue, ids); + } +} + +fn gpu_driven_forced_off() -> bool { + static OFF: OnceLock = OnceLock::new(); + *OFF.get_or_init(|| { + let explicitly_disabled = std::env::var("BLOOM_GPU_DRIVEN") + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "0" | "off" | "false" | "disabled" + ) + }) + .unwrap_or(false); + explicitly_disabled + || !super::capabilities::RendererCapabilities::forced_path_allowed( + super::capabilities::RendererCapabilityTier::HighEnd, + ) + }) +} + +fn create_draw_buffer(device: &wgpu::Device, capacity: usize) -> wgpu::Buffer { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("gpu_driven_draw_records"), + size: (capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) +} + +fn create_indirect_buffer(device: &wgpu::Device, capacity: usize) -> wgpu::Buffer { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("gpu_driven_indirect_commands"), + size: (capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDIRECT, + mapped_at_creation: false, + }) +} + +fn create_cull_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + let storage = |binding, read_only, visibility| wgpu::BindGroupLayoutEntry { + binding, + visibility, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }; + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("gpu_driven_cull_layout"), + entries: &[ + storage( + 0, + true, + wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::COMPUTE, + ), + storage(1, false, wgpu::ShaderStages::COMPUTE), + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + storage(3, false, wgpu::ShaderStages::COMPUTE), + ], + }) +} + +fn create_draw_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("gpu_driven_draw_layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }) +} + +fn create_draw_bind_group( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + draws: &wgpu::Buffer, +) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("gpu_driven_draw_bind_group"), + layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: draws.as_entire_binding(), + }], + }) +} + +fn create_cull_bind_group( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + draws: &wgpu::Buffer, + indirect: &wgpu::Buffer, + params: &wgpu::Buffer, + counters: &wgpu::Buffer, +) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("gpu_driven_cull_bind_group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: draws.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: indirect.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: params.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: counters.as_entire_binding(), + }, + ], + }) +} + +fn create_depth_pipeline( + device: &wgpu::Device, + layout: &wgpu::PipelineLayout, + shader: &wgpu::ShaderModule, +) -> wgpu::RenderPipeline { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("gpu_driven_depth_pipeline"), + layout: Some(layout), + vertex: wgpu::VertexState { + module: shader, + entry_point: Some("vs_main_scene"), + buffers: &[Vertex3D::desc()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: shader, + entry_point: Some("fs_depth_prepass"), + targets: &[], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: Default::default(), + multiview_mask: None, + cache: None, + }) +} + +fn create_main_pipeline( + device: &wgpu::Device, + layout: &wgpu::PipelineLayout, + vertex_shader: &wgpu::ShaderModule, + fragment_shader: &wgpu::ShaderModule, + prepassed: bool, +) -> wgpu::RenderPipeline { + #[cfg(lean_mrt)] + let targets = &[ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + }), + None, + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + None, + ]; + #[cfg(not(lean_mrt))] + let targets = &[ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: MATERIAL_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + ]; + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(if prepassed { + "gpu_driven_main_prepassed_pipeline" + } else { + "gpu_driven_main_pipeline" + }), + layout: Some(layout), + vertex: wgpu::VertexState { + module: vertex_shader, + entry_point: Some("vs_main_scene"), + buffers: &[Vertex3D::desc()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: fragment_shader, + entry_point: Some("fs_main_scene"), + targets, + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + // Match the canonical prepassed scene pipeline: the depth pass is + // two-sided, so the Equal-test main pass must shade the same face + // if overlapping geometry made a back face win depth. + cull_mode: if prepassed { + None + } else { + Some(wgpu::Face::Back) + }, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(!prepassed), + depth_compare: Some(if prepassed { + wgpu::CompareFunction::Equal + } else { + wgpu::CompareFunction::Less + }), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: Default::default(), + multiview_mask: None, + cache: None, + }) +} + +fn make_gpu_scene_shader(source: &str) -> String { + const LEGACY_MATERIALS: &str = r#"@group(2) @binding(0) var base_color_tex: texture_2d; +@group(2) @binding(1) var base_color_samp: sampler; +@group(2) @binding(2) var normal_tex: texture_2d; +@group(2) @binding(3) var normal_samp: sampler; +@group(2) @binding(4) var mr_tex: texture_2d; +@group(2) @binding(5) var mr_samp: sampler; +@group(2) @binding(6) var em_tex: texture_2d; +@group(2) @binding(7) var em_samp: sampler; +@group(2) @binding(8) var material: MaterialFactors; +@group(2) @binding(9) var occ_tex: texture_2d; +@group(2) @binding(10) var occ_samp: sampler;"#; + const GPU_DRAWS: &str = r#"struct GpuDrawRecord { + uniforms: Uniforms3D, + bounds_min: vec4, + bounds_max: vec4, + draw: vec4, +}; +struct GpuDrawTable { records: array, }; +@group(0) @binding(0) var gpu_draws: GpuDrawTable;"#; + let mut out = source + .replace("@group(0) @binding(0) var u: Uniforms3D;", GPU_DRAWS) + .replace( + LEGACY_MATERIALS, + include_str!("../../shaders/material_indirection.wgsl"), + ) + .replace( + " @location(6) prev_clip: vec4,\n};", + " @location(6) prev_clip: vec4,\n @location(7) @interpolate(flat) material_id: u32,\n @location(8) @interpolate(flat) draw_flags: u32,\n};", + ) + .replace( + "fn vs_main_scene(in: VertexInputScene) -> VertexOutputScene {", + "fn vs_main_scene(in: VertexInputScene, @builtin(instance_index) draw_index: u32) -> VertexOutputScene {\n let gpu_draw = gpu_draws.records[draw_index];\n let u = gpu_draw.uniforms;\n let material = bloom_material_record(gpu_draw.draw.w);", + ) + .replace( + " return o;", + " o.material_id = gpu_draw.draw.w;\n o.draw_flags = bitcast(gpu_draw.bounds_min.w);\n return o;", + ) + .replace( + " return out;\n}", + " out.material_id = gpu_draw.draw.w;\n out.draw_flags = bitcast(gpu_draw.bounds_min.w);\n return out;\n}", + ) + .replace( + "fn fs_depth_prepass(in: VertexOutputScene) {", + "fn fs_depth_prepass(in: VertexOutputScene, @builtin(front_facing) front_facing: bool) {\n let material = bloom_material_record(in.material_id);\n if ((in.draw_flags & 1u) == 0u && !front_facing) { discard; }", + ) + .replace( + "fn shade_main_scene(in: VertexOutputScene, front_facing: bool) -> SceneOut {", + "fn shade_main_scene(in: VertexOutputScene, front_facing: bool) -> SceneOut {\n let material = bloom_material_record(in.material_id);", + ); + out = out + .replace( + "textureSample(base_color_tex, base_color_samp, in.uv).a", + "bloom_sample_raw(material.texture_ids_0.x, material.sampler_ids_0.x, in.uv).a", + ) + .replace( + "textureSampleBias(normal_tex, normal_samp, in.uv, 1.0 + lod_bias)", + "bloom_sample_normal_raw_bias(material, in.uv, 1.0 + lod_bias)", + ) + .replace( + "textureSampleBias(base_color_tex, base_color_samp, in.uv, lod_bias)", + "bloom_sample_raw_bias(material.texture_ids_0.x, material.sampler_ids_0.x, in.uv, lod_bias)", + ) + .replace( + "textureSampleLevel(base_color_tex, base_color_samp, in.uv, 0.0)", + "bloom_sample_raw_level(material.texture_ids_0.x, material.sampler_ids_0.x, in.uv, 0.0)", + ) + .replace( + "textureSampleLevel(base_color_tex, base_color_samp, in.uv, mask_lod)", + "bloom_sample_raw_level(material.texture_ids_0.x, material.sampler_ids_0.x, in.uv, mask_lod)", + ) + .replace( + "textureSampleLevel(base_color_tex, base_color_samp, in.uv, 1.0)", + "bloom_sample_raw_level(material.texture_ids_0.x, material.sampler_ids_0.x, in.uv, 1.0)", + ) + .replace( + "textureDimensions(base_color_tex)", + "bloom_base_color_dimensions(material)", + ) + .replace( + "textureSample(mr_tex, mr_samp, in.uv)", + "bloom_sample_raw(material.texture_ids_0.z, material.sampler_ids_0.z, in.uv)", + ) + .replace( + "textureSample(em_tex, em_samp, in.uv)", + "bloom_sample_raw_bias(material.texture_ids_0.w, material.sampler_ids_0.w, in.uv, 0.0)", + ) + .replace( + "textureSample(occ_tex, occ_samp, in.uv)", + "bloom_sample_raw(material.texture_ids_1.x, material.sampler_ids_1.x, in.uv)", + ); + assert!( + out.contains("var gpu_draws"), + "scene shader group-0 ABI changed" + ); + assert!( + !out.contains("base_color_tex"), + "legacy material declarations or samples remain in GPU scene shader" + ); + out +} + +fn strip_prepass_discard(source: &str) -> String { + match ( + source.find("//PREPASS_STRIP_BEGIN"), + source.find("//PREPASS_STRIP_END"), + ) { + (Some(begin), Some(end)) if end > begin => { + let suffix = end + "//PREPASS_STRIP_END".len(); + format!("{}{}", &source[..begin], &source[suffix..]) + } + _ => source.to_string(), + } +} + +const CULL_SHADER: &str = r#" +struct Uniforms3D { + mvp: mat4x4, + model: mat4x4, + prev_mvp: mat4x4, + model_tint: vec4, + misc: vec4, +}; +struct GpuDrawRecord { + uniforms: Uniforms3D, + bounds_min: vec4, + bounds_max: vec4, + draw: vec4, +}; +struct GpuDrawTable { records: array, }; +struct DrawIndexedIndirect { + index_count: u32, + instance_count: u32, + first_index: u32, + base_vertex: i32, + first_instance: u32, +}; +struct IndirectTable { commands: array, }; +struct CullParams { + planes: array, 6>, + draw_info: vec4, +}; +struct Counters { + draw_count: u32, + visible: atomic, + culled: atomic, + padding: u32, +}; +@group(0) @binding(0) var draws: GpuDrawTable; +@group(0) @binding(1) var indirect: IndirectTable; +@group(0) @binding(2) var params: CullParams; +@group(0) @binding(3) var counters: Counters; + +fn outside_frustum(bmin: vec3, bmax: vec3) -> bool { + if (bmin.x > bmax.x) { + return false; + } + for (var i = 0u; i < 6u; i++) { + let plane = params.planes[i]; + let p = vec3( + select(bmin.x, bmax.x, plane.x >= 0.0), + select(bmin.y, bmax.y, plane.y >= 0.0), + select(bmin.z, bmax.z, plane.z >= 0.0) + ); + if (dot(plane.xyz, p) + plane.w < 0.0) { + return true; + } + } + return false; +} + +@compute @workgroup_size(64) +fn cs_cull(@builtin(global_invocation_id) gid: vec3) { + let draw_index = gid.x; + if (draw_index >= params.draw_info.x) { + return; + } + let draw = draws.records[draw_index]; + let visible = !outside_frustum(draw.bounds_min.xyz, draw.bounds_max.xyz); + indirect.commands[draw_index] = DrawIndexedIndirect( + draw.draw.x, + select(0u, 1u, visible), + draw.draw.y, + bitcast(draw.draw.z), + draw_index + ); + if (visible) { + atomicAdd(&counters.visible, 1u); + } else { + atomicAdd(&counters.culled, 1u); + } +} +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allocator_reuses_and_merges_ranges() { + let mut free = vec![FreeRange { + offset: 16, + size: 16, + }]; + let mut end = 64; + assert_eq!(allocate_range(&mut free, &mut end, 8, 4), 16); + free.push(FreeRange { + offset: 24, + size: 8, + }); + free.push(FreeRange { + offset: 16, + size: 8, + }); + merge_ranges(&mut free); + assert!(free.iter().any(|range| *range + == FreeRange { + offset: 16, + size: 16 + })); + } + + #[test] + fn gpu_record_matches_wgsl_alignment() { + assert_eq!(std::mem::size_of::(), 224); + assert_eq!(std::mem::size_of::(), 272); + assert_eq!( + std::mem::size_of::(), + 20 + ); + } + + #[test] + fn generated_scene_shader_preserves_legacy_color_decode() { + let generated = make_gpu_scene_shader(super::super::SCENE_SHADER); + wgpu::naga::front::wgsl::parse_str(&generated) + .unwrap_or_else(|error| panic!("GPU-driven scene WGSL failed to parse: {error:?}")); + assert!(generated.contains("bloom_sample_raw_bias(material.texture_ids_0.x")); + assert!(generated.contains("bloom_sample_raw_bias(material.texture_ids_0.w")); + assert!(!generated.contains("bloom_sample_registered_color_bias(material.texture_ids_0")); + assert!(generated.contains("@builtin(front_facing) front_facing: bool")); + assert!(generated.contains("(in.draw_flags & 1u) == 0u")); + } +} diff --git a/native/shared/src/renderer/graph.rs b/native/shared/src/renderer/graph.rs index be805ed6..e795e246 100644 --- a/native/shared/src/renderer/graph.rs +++ b/native/shared/src/renderer/graph.rs @@ -10,6 +10,36 @@ // scheduler, and ordering unit tests; Phase 2b (complete — see the // frame-graph construction in `mod.rs`) ported the existing passes, so // the graph now drives the real frame in `end_frame_with_scene`. +// +// Issue #129 adds the typed compiler and cached executor in the modules +// below. The original scheduler remains as a compatibility/test surface; the +// live retained renderer binds directly to `ExecutableGraph`. + +mod compiler; +mod diagnostics; +mod frame_plan; +mod model; +mod runtime; + +pub use compiler::{ + CompileError, CompileOptions, CompiledAccess, CompiledAccessKind, CompiledGraph, CompiledPass, + CompiledResource, PhysicalAllocation, PhysicalAllocationId, UsageTransition, +}; +pub use frame_plan::{build_renderer_frame_plan, QUALITY_CAPTURE_RESOURCE_NAMES}; +pub use model::{ + AliasClass, BufferDesc, BufferHandle, BufferUsage, ClearColor, Extent, GraphBuilder, + LoadPolicy, Ownership, PassId, QueueClass, ResourceDesc, ResourceId, ResourceOrigin, + ResourceVersion, SideEffects, TextureDesc, TextureDimension, TextureHandle, TextureUsage, + Usage, +}; +pub use runtime::{ + CapabilityTier, ExecutableGraph, ExecutionError, ExecutionRunFn, FramePlanKey, + GraphDebugMarkerContext, PathTracingMode, PlanCache, PlanCacheStats, ResolutionClass, + FRAME_FEATURE_BLOOM, FRAME_FEATURE_CAPTURE_OUTPUT, FRAME_FEATURE_CAPTURE_QUALITY, + FRAME_FEATURE_IMPORTED_REFRACTION, FRAME_FEATURE_SCENE_SNAPSHOTS, FRAME_FEATURE_SSAO, + FRAME_FEATURE_SSGI, FRAME_FEATURE_SSR, FRAME_FEATURE_TEMPORAL_REACTIVE, + FRAME_FEATURE_TRANSMITTED_SHADOWS, FRAME_FEATURE_WEIGHTED_TRANSPARENCY, +}; use std::collections::{HashMap, HashSet}; @@ -75,38 +105,45 @@ pub enum PassOutput { pub type RunFn<'a, Ctx> = Box; pub struct PassNode<'a, Ctx> { - pub name: &'static str, - pub reads: Vec, + pub name: &'static str, + pub reads: Vec, pub writes: Vec, /// Hard "run this node after X" hints — used for ordering that the /// data dependencies alone can't express (e.g. two passes that /// share a read target but where one conceptually follows the /// other for timing reasons). - pub after: Vec<&'static str>, + pub after: Vec<&'static str>, /// Hard "run this node before X" hints. Validator rejects cycles. pub before: Vec<&'static str>, - pub run: RunFn<'a, Ctx>, + pub run: RunFn<'a, Ctx>, } impl<'a, Ctx> PassNode<'a, Ctx> { pub fn new(name: &'static str, run: RunFn<'a, Ctx>) -> Self { Self { - name, reads: Vec::new(), writes: Vec::new(), - after: Vec::new(), before: Vec::new(), + name, + reads: Vec::new(), + writes: Vec::new(), + after: Vec::new(), + before: Vec::new(), run, } } pub fn with_reads(mut self, reads: &[PassInput]) -> Self { - self.reads = reads.to_vec(); self + self.reads = reads.to_vec(); + self } pub fn with_writes(mut self, writes: &[PassOutput]) -> Self { - self.writes = writes.to_vec(); self + self.writes = writes.to_vec(); + self } pub fn with_after(mut self, after: &[&'static str]) -> Self { - self.after = after.to_vec(); self + self.after = after.to_vec(); + self } pub fn with_before(mut self, before: &[&'static str]) -> Self { - self.before = before.to_vec(); self + self.before = before.to_vec(); + self } } @@ -127,9 +164,12 @@ pub enum GraphError { } impl<'a, Ctx> Graph<'a, Ctx> { - pub fn new() -> Self { Self { nodes: Vec::new() } } + pub fn new() -> Self { + Self { nodes: Vec::new() } + } pub fn push(&mut self, node: PassNode<'a, Ctx>) -> &mut Self { - self.nodes.push(node); self + self.nodes.push(node); + self } /// Compute execution order. Returns indices into `self.nodes`. @@ -153,7 +193,9 @@ impl<'a, Ctx> Graph<'a, Ctx> { } impl<'a, Ctx> Default for Graph<'a, Ctx> { - fn default() -> Self { Self::new() } + fn default() -> Self { + Self::new() + } } // ===================================================================== @@ -162,7 +204,9 @@ impl<'a, Ctx> Default for Graph<'a, Ctx> { fn schedule<'a, Ctx>(nodes: &[PassNode<'a, Ctx>]) -> Result, GraphError> { let n = nodes.len(); - if n == 0 { return Ok(Vec::new()); } + if n == 0 { + return Ok(Vec::new()); + } // Name → index lookup for `after` / `before` hint resolution. let mut name_to_idx: HashMap<&'static str, usize> = HashMap::new(); @@ -181,7 +225,9 @@ fn schedule<'a, Ctx>(nodes: &[PassNode<'a, Ctx>]) -> Result, GraphErr for b in 0..n { for read in &nodes[b].reads { for a in 0..n { - if a == b { continue; } + if a == b { + continue; + } for write in &nodes[a].writes { if input_matches_write(read, write) { preds[b].insert(a); @@ -193,21 +239,29 @@ fn schedule<'a, Ctx>(nodes: &[PassNode<'a, Ctx>]) -> Result, GraphErr // 2) Explicit `after` hints: node B.after = [A] → A → B. for (b, node) in nodes.iter().enumerate() { for name in &node.after { - let a = name_to_idx.get(name).ok_or_else(|| GraphError::UnknownNode { - node: node.name.to_string(), - referenced: name.to_string(), - })?; - if *a != b { preds[b].insert(*a); } + let a = name_to_idx + .get(name) + .ok_or_else(|| GraphError::UnknownNode { + node: node.name.to_string(), + referenced: name.to_string(), + })?; + if *a != b { + preds[b].insert(*a); + } } } // 3) Explicit `before` hints: node A.before = [B] → A → B. for (a, node) in nodes.iter().enumerate() { for name in &node.before { - let b = name_to_idx.get(name).ok_or_else(|| GraphError::UnknownNode { - node: node.name.to_string(), - referenced: name.to_string(), - })?; - if *b != a { preds[*b].insert(a); } + let b = name_to_idx + .get(name) + .ok_or_else(|| GraphError::UnknownNode { + node: node.name.to_string(), + referenced: name.to_string(), + })?; + if *b != a { + preds[*b].insert(a); + } } } @@ -223,7 +277,9 @@ fn schedule<'a, Ctx>(nodes: &[PassNode<'a, Ctx>]) -> Result, GraphErr Some(i) => { out.push(i); for j in 0..n { - if preds[j].remove(&i) { in_degree[j] -= 1; } + if preds[j].remove(&i) { + in_degree[j] -= 1; + } } } None => break, @@ -248,10 +304,10 @@ fn schedule<'a, Ctx>(nodes: &[PassNode<'a, Ctx>]) -> Result, GraphErr /// the same resource. fn input_matches_write(input: &PassInput, output: &PassOutput) -> bool { match (input, output) { - (PassInput::SceneColor, PassOutput::HdrColor) => true, - (PassInput::SceneDepth, PassOutput::Depth) => true, - (PassInput::Shadow(i), PassOutput::Shadow(j)) => i == j, - (PassInput::Transient(i), PassOutput::Transient(j)) => i == j, + (PassInput::SceneColor, PassOutput::HdrColor) => true, + (PassInput::SceneDepth, PassOutput::Depth) => true, + (PassInput::Shadow(i), PassOutput::Shadow(j)) => i == j, + (PassInput::Transient(i), PassOutput::Transient(j)) => i == j, // EnvCubemap, MotionVectors, Impulse are produced outside // the render graph for now (env is loaded at startup; motion // vectors are written as a G-buffer target but not individually @@ -297,10 +353,9 @@ mod tests { fn data_dependency_orders_reads_after_writes() { // A writes HdrColor, B reads SceneColor. B must run after A. let mut graph = Graph::new(); - graph.push(PassNode::new("B_reads_scene", record("B")) - .with_reads(&[PassInput::SceneColor])); - graph.push(PassNode::new("A_writes_hdr", record("A")) - .with_writes(&[PassOutput::HdrColor])); + graph + .push(PassNode::new("B_reads_scene", record("B")).with_reads(&[PassInput::SceneColor])); + graph.push(PassNode::new("A_writes_hdr", record("A")).with_writes(&[PassOutput::HdrColor])); let mut ctx = Vec::new(); graph.execute(&mut ctx).unwrap(); assert_eq!(ctx, vec!["A", "B"]); @@ -309,8 +364,7 @@ mod tests { #[test] fn explicit_after_hint_orders_nodes() { let mut graph = Graph::new(); - graph.push(PassNode::new("later", record("later")) - .with_after(&["earlier"])); + graph.push(PassNode::new("later", record("later")).with_after(&["earlier"])); graph.push(PassNode::new("earlier", record("earlier"))); let mut ctx = Vec::new(); graph.execute(&mut ctx).unwrap(); @@ -321,8 +375,7 @@ mod tests { fn explicit_before_hint_orders_nodes() { let mut graph = Graph::new(); graph.push(PassNode::new("second", record("second"))); - graph.push(PassNode::new("first", record("first")) - .with_before(&["second"])); + graph.push(PassNode::new("first", record("first")).with_before(&["second"])); let mut ctx = Vec::new(); graph.execute(&mut ctx).unwrap(); assert_eq!(ctx, vec!["first", "second"]); @@ -332,13 +385,13 @@ mod tests { fn chain_of_three() { // A -> B -> C via data dependencies. let mut graph = Graph::new(); - graph.push(PassNode::new("C", record("C")) - .with_reads(&[PassInput::Transient(1)])); - graph.push(PassNode::new("A", record("A")) - .with_writes(&[PassOutput::HdrColor])); - graph.push(PassNode::new("B", record("B")) - .with_reads(&[PassInput::SceneColor]) - .with_writes(&[PassOutput::Transient(1)])); + graph.push(PassNode::new("C", record("C")).with_reads(&[PassInput::Transient(1)])); + graph.push(PassNode::new("A", record("A")).with_writes(&[PassOutput::HdrColor])); + graph.push( + PassNode::new("B", record("B")) + .with_reads(&[PassInput::SceneColor]) + .with_writes(&[PassOutput::Transient(1)]), + ); let mut ctx = Vec::new(); graph.execute(&mut ctx).unwrap(); assert_eq!(ctx, vec!["A", "B", "C"]); @@ -349,12 +402,9 @@ mod tests { // Both X and Y depend on ROOT, neither depends on the other. // Order should be declaration order among the tied candidates. let mut graph = Graph::new(); - graph.push(PassNode::new("X", record("X")) - .with_reads(&[PassInput::SceneColor])); - graph.push(PassNode::new("Y", record("Y")) - .with_reads(&[PassInput::SceneColor])); - graph.push(PassNode::new("ROOT", record("ROOT")) - .with_writes(&[PassOutput::HdrColor])); + graph.push(PassNode::new("X", record("X")).with_reads(&[PassInput::SceneColor])); + graph.push(PassNode::new("Y", record("Y")).with_reads(&[PassInput::SceneColor])); + graph.push(PassNode::new("ROOT", record("ROOT")).with_writes(&[PassOutput::HdrColor])); let mut ctx = Vec::new(); graph.execute(&mut ctx).unwrap(); assert_eq!(ctx, vec!["ROOT", "X", "Y"]); @@ -363,8 +413,7 @@ mod tests { #[test] fn unknown_after_is_reported() { let mut graph: Graph<'_, TestCtx> = Graph::new(); - graph.push(PassNode::new("a", record("a")) - .with_after(&["does_not_exist"])); + graph.push(PassNode::new("a", record("a")).with_after(&["does_not_exist"])); let err = graph.schedule().unwrap_err(); match err { GraphError::UnknownNode { node, referenced } => { @@ -378,10 +427,8 @@ mod tests { #[test] fn cycle_via_explicit_hints_is_rejected() { let mut graph: Graph<'_, TestCtx> = Graph::new(); - graph.push(PassNode::new("a", record("a")) - .with_after(&["b"])); - graph.push(PassNode::new("b", record("b")) - .with_after(&["a"])); + graph.push(PassNode::new("a", record("a")).with_after(&["b"])); + graph.push(PassNode::new("b", record("b")).with_after(&["a"])); match graph.schedule() { Err(GraphError::Cycle(names)) => { assert!(names.contains(&"a".to_string())); @@ -397,45 +444,60 @@ mod tests { // → main_hdr → ssao → translucent → composite → swapchain — // proves the scheduler produces the expected order. let mut graph: Graph<'_, TestCtx> = Graph::new(); - graph.push(PassNode::new("composite", record("composite")) - .with_reads(&[PassInput::Transient(10)]) - // Composite is the terminal pass — explicit `after` on - // every predecessor so tie-breaking (declaration order) - // doesn't float composite past a sibling that hasn't run. - .with_after(&["bloom", "translucent", "ssao"]) - .with_writes(&[PassOutput::Swapchain])); - graph.push(PassNode::new("bloom", record("bloom")) - .with_reads(&[PassInput::SceneColor]) - .with_writes(&[PassOutput::Transient(10)])); - graph.push(PassNode::new("translucent", record("translucent")) - .with_reads(&[PassInput::SceneColor, PassInput::SceneDepth]) - .with_writes(&[PassOutput::HdrColor]) - .with_after(&["main_hdr"])); // explicit so SceneColor is - // a snapshot, not a write- - // then-read loop - graph.push(PassNode::new("ssao", record("ssao")) - .with_reads(&[PassInput::SceneDepth]) - .with_writes(&[PassOutput::Transient(20)])); - graph.push(PassNode::new("main_hdr", record("main_hdr")) - .with_reads(&[PassInput::Shadow(0)]) - .with_writes(&[PassOutput::HdrColor, PassOutput::Depth, - PassOutput::MaterialRt, PassOutput::VelocityRt, - PassOutput::AlbedoRt])); - graph.push(PassNode::new("shadow_0", record("shadow_0")) - .with_writes(&[PassOutput::Shadow(0)])); + graph.push( + PassNode::new("composite", record("composite")) + .with_reads(&[PassInput::Transient(10)]) + // Composite is the terminal pass — explicit `after` on + // every predecessor so tie-breaking (declaration order) + // doesn't float composite past a sibling that hasn't run. + .with_after(&["bloom", "translucent", "ssao"]) + .with_writes(&[PassOutput::Swapchain]), + ); + graph.push( + PassNode::new("bloom", record("bloom")) + .with_reads(&[PassInput::SceneColor]) + .with_writes(&[PassOutput::Transient(10)]), + ); + graph.push( + PassNode::new("translucent", record("translucent")) + .with_reads(&[PassInput::SceneColor, PassInput::SceneDepth]) + .with_writes(&[PassOutput::HdrColor]) + .with_after(&["main_hdr"]), + ); // explicit so SceneColor is + // a snapshot, not a write- + // then-read loop + graph.push( + PassNode::new("ssao", record("ssao")) + .with_reads(&[PassInput::SceneDepth]) + .with_writes(&[PassOutput::Transient(20)]), + ); + graph.push( + PassNode::new("main_hdr", record("main_hdr")) + .with_reads(&[PassInput::Shadow(0)]) + .with_writes(&[ + PassOutput::HdrColor, + PassOutput::Depth, + PassOutput::MaterialRt, + PassOutput::VelocityRt, + PassOutput::AlbedoRt, + ]), + ); + graph.push( + PassNode::new("shadow_0", record("shadow_0")).with_writes(&[PassOutput::Shadow(0)]), + ); let mut ctx = Vec::new(); graph.execute(&mut ctx).unwrap(); // Invariants — strict total order isn't guaranteed, but these // data / explicit-hint dependencies must hold: let pos = |name: &str| ctx.iter().position(|&n| n == name).unwrap(); - assert!(pos("shadow_0") < pos("main_hdr")); - assert!(pos("main_hdr") < pos("ssao")); - assert!(pos("main_hdr") < pos("translucent")); - assert!(pos("main_hdr") < pos("bloom")); - assert!(pos("bloom") < pos("composite")); + assert!(pos("shadow_0") < pos("main_hdr")); + assert!(pos("main_hdr") < pos("ssao")); + assert!(pos("main_hdr") < pos("translucent")); + assert!(pos("main_hdr") < pos("bloom")); + assert!(pos("bloom") < pos("composite")); assert!(pos("translucent") < pos("composite")); - assert!(pos("ssao") < pos("composite")); + assert!(pos("ssao") < pos("composite")); assert_eq!(pos("composite"), ctx.len() - 1); } @@ -449,12 +511,18 @@ mod tests { let c2 = counter.clone(); let mut graph: Graph<'_, TestCtx> = Graph::new(); - graph.push(PassNode::new("a", Box::new(move |_ctx| { - *c1.lock().unwrap() += 1; - }))); - graph.push(PassNode::new("b", Box::new(move |_ctx| { - *c2.lock().unwrap() += 10; - }))); + graph.push(PassNode::new( + "a", + Box::new(move |_ctx| { + *c1.lock().unwrap() += 1; + }), + )); + graph.push(PassNode::new( + "b", + Box::new(move |_ctx| { + *c2.lock().unwrap() += 10; + }), + )); let mut ctx = Vec::new(); graph.execute(&mut ctx).unwrap(); assert_eq!(*counter.lock().unwrap(), 11); diff --git a/native/shared/src/renderer/graph/compiler.rs b/native/shared/src/renderer/graph/compiler.rs new file mode 100644 index 00000000..03501b98 --- /dev/null +++ b/native/shared/src/renderer/graph/compiler.rs @@ -0,0 +1,1022 @@ +//! Validation, deterministic scheduling, lifetime analysis, and conservative +//! physical allocation planning for [`GraphBuilder`](super::GraphBuilder). + +use super::model::*; +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::fmt; + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct CompileOptions { + /// When false, every transient receives a unique physical allocation. + /// This is the migration/parity mode. + pub aliasing: bool, +} + +impl CompileOptions { + pub const NO_ALIASING: Self = Self { aliasing: false }; + pub const CONSERVATIVE_ALIASING: Self = Self { aliasing: true }; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CompileError { + DuplicatePassName(String), + DuplicateResourceName(String), + UnknownPass(PassId), + UnknownResource(ResourceId), + UnknownVersion { + resource: String, + version: ResourceVersion, + }, + ResourceTypeMismatch { + resource: String, + }, + MissingProducer { + pass: String, + resource: String, + version: ResourceVersion, + }, + MultipleWriters { + resource: String, + version: ResourceVersion, + writers: Vec, + }, + UsageNotDeclared { + pass: String, + resource: String, + usage: Usage, + }, + SelfReadOfWrite { + pass: String, + resource: String, + version: ResourceVersion, + }, + Cycle(Vec), +} + +impl fmt::Display for CompileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicatePassName(name) => write!(f, "duplicate pass name '{name}'"), + Self::DuplicateResourceName(name) => write!(f, "duplicate resource name '{name}'"), + Self::UnknownPass(pass) => write!(f, "unknown pass {}", pass.0), + Self::UnknownResource(resource) => write!(f, "unknown resource {}", resource.0), + Self::UnknownVersion { resource, version } => { + write!(f, "resource '{resource}' has no version {}", version.0) + } + Self::ResourceTypeMismatch { resource } => { + write!(f, "typed handle does not match resource '{resource}'") + } + Self::MissingProducer { + pass, + resource, + version, + } => write!( + f, + "pass '{pass}' reads '{resource}' v{} before any producer", + version.0 + ), + Self::MultipleWriters { + resource, + version, + writers, + } => write!( + f, + "'{resource}' v{} has divergent writers: {}", + version.0, + writers.join(", ") + ), + Self::UsageNotDeclared { + pass, + resource, + usage, + } => write!( + f, + "pass '{pass}' uses '{resource}' as '{}' without descriptor permission", + usage.name() + ), + Self::SelfReadOfWrite { + pass, + resource, + version, + } => write!( + f, + "pass '{pass}' reads its own produced '{resource}' v{}", + version.0 + ), + Self::Cycle(names) => write!(f, "render graph cycle: {}", names.join(" -> ")), + } + } +} + +impl std::error::Error for CompileError {} + +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub enum CompiledAccessKind { + Read, + Write, +} + +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub struct CompiledAccess { + pub resource: ResourceId, + pub version: ResourceVersion, + pub usage: Usage, + pub kind: CompiledAccessKind, +} + +#[derive(Clone, Debug)] +pub struct CompiledPass { + pub id: PassId, + pub name: String, + pub queue: QueueClass, + pub side_effects: SideEffects, + pub dependencies: Vec, + pub accesses: Vec, +} + +#[derive(Copy, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct PhysicalAllocationId(pub u32); + +#[derive(Clone, Debug)] +pub struct CompiledResource { + pub id: ResourceId, + pub name: String, + pub desc: ResourceDesc, + pub origin: ResourceOrigin, + /// Inclusive pass positions in the compiled schedule. + pub first_use: Option, + pub last_use: Option, + pub physical: Option, +} + +#[derive(Clone, Debug)] +pub struct PhysicalAllocation { + pub id: PhysicalAllocationId, + pub desc: ResourceDesc, + pub resources: Vec, + pub first_use: usize, + pub last_use: usize, +} + +#[derive(Clone, Debug)] +pub struct UsageTransition { + pub resource: ResourceId, + pub pass: Option, + pub before: Option, + pub after: Usage, + pub from_queue: Option, + pub to_queue: QueueClass, +} + +#[derive(Clone, Debug)] +pub struct CompiledGraph { + pub label: String, + /// Passes in deterministic execution order. + pub passes: Vec, + pub resources: Vec, + pub allocations: Vec, + pub transitions: Vec, + pub aliasing_enabled: bool, + pub plan_id: u64, + pass_by_name: HashMap, + pass_position: HashMap, + resource_by_name: HashMap, +} + +impl CompiledGraph { + pub fn pass(&self, name: &str) -> Option<&CompiledPass> { + let id = self.pass_by_name.get(name)?; + self.pass_by_id(*id) + } + + pub fn pass_by_id(&self, id: PassId) -> Option<&CompiledPass> { + let position = *self.pass_position.get(&id)?; + self.passes.get(position) + } + + pub fn pass_position(&self, id: PassId) -> Option { + self.pass_position.get(&id).copied() + } + + pub fn resource(&self, name: &str) -> Option<&CompiledResource> { + let id = *self.resource_by_name.get(name)?; + self.resources.get(id.0 as usize) + } + + pub fn resource_by_id(&self, id: ResourceId) -> Option<&CompiledResource> { + self.resources.get(id.0 as usize) + } + + pub fn transient_bytes(&self, render_extent: (u32, u32), output_extent: (u32, u32)) -> u64 { + self.allocations + .iter() + .map(|allocation| descriptor_size_bytes(&allocation.desc, render_extent, output_extent)) + .sum() + } + + pub fn unaliased_transient_bytes( + &self, + render_extent: (u32, u32), + output_extent: (u32, u32), + ) -> u64 { + self.resources + .iter() + .filter(|resource| resource.origin.is_transient() && resource.first_use.is_some()) + .map(|resource| descriptor_size_bytes(&resource.desc, render_extent, output_extent)) + .sum() + } + + pub fn alias_savings_percent( + &self, + render_extent: (u32, u32), + output_extent: (u32, u32), + ) -> f64 { + let unaliased = self.unaliased_transient_bytes(render_extent, output_extent); + if unaliased == 0 { + return 0.0; + } + let allocated = self.transient_bytes(render_extent, output_extent); + 100.0 * (unaliased.saturating_sub(allocated) as f64) / unaliased as f64 + } +} + +impl GraphBuilder { + pub fn compile(self, options: CompileOptions) -> Result { + compile(self, options) + } +} + +fn compile(builder: GraphBuilder, options: CompileOptions) -> Result { + validate_unique_names(&builder)?; + + let pass_count = builder.passes.len(); + let mut predecessors: Vec> = vec![HashSet::new(); pass_count]; + let mut readers: Vec>> = builder + .resources + .iter() + .map(|resource| vec![Vec::new(); resource.versions.len()]) + .collect(); + + // Explicit ordering constraints. + for pass in &builder.passes { + let pass_index = pass.id.0 as usize; + if pass_index >= pass_count { + return Err(CompileError::UnknownPass(pass.id)); + } + for predecessor in &pass.after { + let predecessor_index = predecessor.0 as usize; + if predecessor_index >= pass_count { + return Err(CompileError::UnknownPass(*predecessor)); + } + if predecessor_index != pass_index { + predecessors[pass_index].insert(predecessor_index); + } + } + for successor in &pass.before { + let successor_index = successor.0 as usize; + if successor_index >= pass_count { + return Err(CompileError::UnknownPass(*successor)); + } + if successor_index != pass_index { + predecessors[successor_index].insert(pass_index); + } + } + } + + // Validate every access and add producer -> reader dependencies. + for pass in &builder.passes { + let pass_index = pass.id.0 as usize; + let mut produced_earlier_in_pass = HashSet::new(); + for access in &pass.accesses { + let resource_id = access.handle.resource(); + let resource = builder + .resources + .get(resource_id.0 as usize) + .ok_or(CompileError::UnknownResource(resource_id))?; + validate_handle_type(resource, access.handle)?; + let version = access.handle.version(); + let declaration = resource.versions.get(version.0 as usize).ok_or_else(|| { + CompileError::UnknownVersion { + resource: resource.name.clone(), + version, + } + })?; + if !resource.desc.permits(access.usage) { + return Err(CompileError::UsageNotDeclared { + pass: pass.name.clone(), + resource: resource.name.clone(), + usage: access.usage, + }); + } + match access.kind { + AccessKind::Read => { + if let Some(producer) = declaration.producer { + if producer == pass.id { + // A pass closure is serial. It may copy/write a + // resource and consume that produced version later + // in the same closure (for example the translucent + // scene snapshots), but it may not read the version + // before its declared write. + if !produced_earlier_in_pass.contains(&access.handle) { + return Err(CompileError::SelfReadOfWrite { + pass: pass.name.clone(), + resource: resource.name.clone(), + version, + }); + } + } else { + predecessors[pass_index].insert(producer.0 as usize); + } + } else if !declaration.initialized { + return Err(CompileError::MissingProducer { + pass: pass.name.clone(), + resource: resource.name.clone(), + version, + }); + } + readers[resource_id.0 as usize][version.0 as usize].push(pass.id); + } + AccessKind::Write => { + if declaration.producer != Some(pass.id) { + let writers = declaration + .producer + .into_iter() + .chain(std::iter::once(pass.id)) + .filter_map(|id| builder.passes.get(id.0 as usize)) + .map(|writer| writer.name.clone()) + .collect(); + return Err(CompileError::MultipleWriters { + resource: resource.name.clone(), + version, + writers, + }); + } + produced_earlier_in_pass.insert(access.handle); + } + } + } + } + + // A write from one source version may have only one successor. This catches + // the classic "two passes both overwrite v0" error while still making every + // successful write a distinct version. + for resource in &builder.resources { + let mut children: HashMap> = HashMap::new(); + for version in resource.versions.iter().skip(1) { + if let Some(parent) = version.parent { + children.entry(parent).or_default().push(version); + } + } + for (parent, versions) in children { + if versions.len() > 1 { + let mut writers: Vec = versions + .iter() + .filter_map(|version| version.producer) + .filter_map(|pass| builder.passes.get(pass.0 as usize)) + .map(|pass| pass.name.clone()) + .collect(); + writers.sort(); + writers.dedup(); + return Err(CompileError::MultipleWriters { + resource: resource.name.clone(), + version: parent, + writers, + }); + } + } + } + + // In-place logical versions share physical storage. Preserve WAW and WAR + // hazards between a parent version and its successor. + for resource in &builder.resources { + let resource_index = resource.id.0 as usize; + for version in resource.versions.iter().skip(1) { + let Some(writer) = version.producer else { + continue; + }; + let writer_index = writer.0 as usize; + let Some(parent) = version.parent else { + continue; + }; + let parent_declaration = resource.versions.get(parent.0 as usize).ok_or_else(|| { + CompileError::UnknownVersion { + resource: resource.name.clone(), + version: parent, + } + })?; + if let Some(previous_writer) = parent_declaration.producer { + if previous_writer != writer { + predecessors[writer_index].insert(previous_writer.0 as usize); + } + } + for reader in &readers[resource_index][parent.0 as usize] { + if *reader != writer { + predecessors[writer_index].insert(reader.0 as usize); + } + } + } + } + + let order = deterministic_topological_sort(&builder, &predecessors)?; + let schedule_position: HashMap = order + .iter() + .enumerate() + .map(|(position, pass_index)| (*pass_index, position)) + .collect(); + + let mut compiled_resources: Vec = builder + .resources + .iter() + .map(|resource| CompiledResource { + id: resource.id, + name: resource.name.clone(), + desc: resource.desc.clone(), + origin: resource.origin, + first_use: None, + last_use: None, + physical: None, + }) + .collect(); + + for pass in &builder.passes { + let position = schedule_position[&(pass.id.0 as usize)]; + for access in &pass.accesses { + let resource = &mut compiled_resources[access.handle.resource().0 as usize]; + resource.first_use = Some(resource.first_use.map_or(position, |old| old.min(position))); + resource.last_use = Some(resource.last_use.map_or(position, |old| old.max(position))); + } + } + + let allocations = plan_allocations(&mut compiled_resources, options.aliasing); + let transitions = plan_transitions(&builder, &order); + + let mut compiled_passes = Vec::with_capacity(pass_count); + let mut pass_position = HashMap::with_capacity(pass_count); + for (position, pass_index) in order.iter().copied().enumerate() { + let pass = &builder.passes[pass_index]; + let mut dependencies: Vec = predecessors[pass_index] + .iter() + .copied() + .map(|index| builder.passes[index].id) + .collect(); + dependencies.sort_by_key(|dependency| schedule_position[&(dependency.0 as usize)]); + let accesses = pass + .accesses + .iter() + .map(|access| CompiledAccess { + resource: access.handle.resource(), + version: access.handle.version(), + usage: access.usage, + kind: match access.kind { + AccessKind::Read => CompiledAccessKind::Read, + AccessKind::Write => CompiledAccessKind::Write, + }, + }) + .collect(); + compiled_passes.push(CompiledPass { + id: pass.id, + name: pass.name.clone(), + queue: pass.queue, + side_effects: pass.side_effects, + dependencies, + accesses, + }); + pass_position.insert(pass.id, position); + } + + let pass_by_name = compiled_passes + .iter() + .map(|pass| (pass.name.clone(), pass.id)) + .collect(); + let resource_by_name = compiled_resources + .iter() + .map(|resource| (resource.name.clone(), resource.id)) + .collect(); + let plan_id = stable_plan_id( + &builder.label, + &compiled_passes, + &compiled_resources, + options.aliasing, + ); + + Ok(CompiledGraph { + label: builder.label, + passes: compiled_passes, + resources: compiled_resources, + allocations, + transitions, + aliasing_enabled: options.aliasing, + plan_id, + pass_by_name, + pass_position, + resource_by_name, + }) +} + +fn validate_unique_names(builder: &GraphBuilder) -> Result<(), CompileError> { + let mut pass_names = HashSet::new(); + for pass in &builder.passes { + if !pass_names.insert(pass.name.as_str()) { + return Err(CompileError::DuplicatePassName(pass.name.clone())); + } + } + let mut resource_names = HashSet::new(); + for resource in &builder.resources { + if !resource_names.insert(resource.name.as_str()) { + return Err(CompileError::DuplicateResourceName(resource.name.clone())); + } + } + Ok(()) +} + +fn validate_handle_type( + resource: &ResourceDeclaration, + handle: AnyHandle, +) -> Result<(), CompileError> { + let matches = matches!( + (&resource.desc, handle), + (ResourceDesc::Texture(_), AnyHandle::Texture(_)) + | (ResourceDesc::Buffer(_), AnyHandle::Buffer(_)) + ); + if matches { + Ok(()) + } else { + Err(CompileError::ResourceTypeMismatch { + resource: resource.name.clone(), + }) + } +} + +fn deterministic_topological_sort( + builder: &GraphBuilder, + predecessors: &[HashSet], +) -> Result, CompileError> { + let pass_count = builder.passes.len(); + let mut successors = vec![Vec::new(); pass_count]; + let mut in_degree: Vec = predecessors.iter().map(HashSet::len).collect(); + for (successor, pass_predecessors) in predecessors.iter().enumerate() { + for predecessor in pass_predecessors { + successors[*predecessor].push(successor); + } + } + for list in &mut successors { + list.sort_unstable(); + list.dedup(); + } + + // PassId is declaration order, which is deterministic for a given plan. + let mut ready: BTreeSet = (0..pass_count) + .filter(|index| in_degree[*index] == 0) + .collect(); + let mut order = Vec::with_capacity(pass_count); + while let Some(index) = ready.pop_first() { + order.push(index); + for successor in &successors[index] { + in_degree[*successor] -= 1; + if in_degree[*successor] == 0 { + ready.insert(*successor); + } + } + } + if order.len() != pass_count { + let mut cycle: Vec = (0..pass_count) + .filter(|index| in_degree[*index] != 0) + .map(|index| builder.passes[index].name.clone()) + .collect(); + cycle.sort(); + return Err(CompileError::Cycle(cycle)); + } + Ok(order) +} + +fn plan_allocations(resources: &mut [CompiledResource], aliasing: bool) -> Vec { + let mut candidates: Vec = resources + .iter() + .enumerate() + .filter(|(_, resource)| resource.origin.is_transient() && resource.first_use.is_some()) + .map(|(index, _)| index) + .collect(); + candidates.sort_by_key(|index| { + ( + resources[*index].first_use.unwrap_or(usize::MAX), + resources[*index].id, + ) + }); + + let mut allocations: Vec = Vec::new(); + for resource_index in candidates { + let resource = &resources[resource_index]; + let first_use = resource.first_use.expect("candidate is used"); + let last_use = resource.last_use.expect("candidate is used"); + let compatible = aliasing + .then(|| { + allocations.iter().position(|allocation| { + allocation.last_use < first_use + && descriptors_alias_compatible(&allocation.desc, &resource.desc) + }) + }) + .flatten(); + let allocation_index = if let Some(index) = compatible { + let allocation = &mut allocations[index]; + allocation.resources.push(resource.id); + allocation.last_use = last_use; + index + } else { + let index = allocations.len(); + allocations.push(PhysicalAllocation { + id: PhysicalAllocationId(index as u32), + desc: resource.desc.clone(), + resources: vec![resource.id], + first_use, + last_use, + }); + index + }; + resources[resource_index].physical = Some(allocations[allocation_index].id); + } + allocations +} + +fn descriptors_alias_compatible(a: &ResourceDesc, b: &ResourceDesc) -> bool { + match (a, b) { + (ResourceDesc::Texture(a), ResourceDesc::Texture(b)) => { + a.alias_class != AliasClass::Never + && a.alias_class == b.alias_class + && a.format == b.format + && a.extent == b.extent + && a.dimension == b.dimension + && a.mip_count == b.mip_count + && a.sample_count == b.sample_count + && a.allowed_usage == b.allowed_usage + } + (ResourceDesc::Buffer(a), ResourceDesc::Buffer(b)) => { + a.alias_class != AliasClass::Never + && a.alias_class == b.alias_class + && a.size == b.size + && a.allowed_usage == b.allowed_usage + } + _ => false, + } +} + +fn plan_transitions(builder: &GraphBuilder, order: &[usize]) -> Vec { + let mut transitions = Vec::new(); + let mut usage: Vec> = builder + .resources + .iter() + .map(|resource| resource.origin.initial_usage()) + .collect(); + let mut queue: Vec> = vec![None; builder.resources.len()]; + + for pass_index in order { + let pass = &builder.passes[*pass_index]; + for access in &pass.accesses { + let resource = access.handle.resource(); + let index = resource.0 as usize; + if usage[index] != Some(access.usage) || queue[index] != Some(pass.queue) { + transitions.push(UsageTransition { + resource, + pass: Some(pass.id), + before: usage[index], + after: access.usage, + from_queue: queue[index], + to_queue: pass.queue, + }); + } + usage[index] = Some(access.usage); + queue[index] = Some(pass.queue); + } + } + + for resource in &builder.resources { + let Some(final_usage) = resource.origin.final_usage() else { + continue; + }; + let index = resource.id.0 as usize; + if usage[index] != Some(final_usage) { + transitions.push(UsageTransition { + resource: resource.id, + pass: None, + before: usage[index], + after: final_usage, + from_queue: queue[index], + to_queue: queue[index].unwrap_or(QueueClass::Graphics), + }); + } + } + transitions +} + +fn descriptor_size_bytes( + desc: &ResourceDesc, + render_extent: (u32, u32), + output_extent: (u32, u32), +) -> u64 { + match desc { + ResourceDesc::Buffer(desc) => desc.size, + ResourceDesc::Texture(desc) => { + let (mut width, mut height, mut layers) = + desc.extent.resolve(render_extent, output_extent); + if desc.dimension == TextureDimension::D3 { + layers = layers.max(1); + } + let (block_width, block_height) = desc.format.block_dimensions(); + let block_bytes = desc + .format + .block_copy_size(Some(wgpu::TextureAspect::All)) + .or_else(|| desc.format.block_copy_size(None)) + .unwrap_or(4) as u64; + let mut total = 0u64; + for _ in 0..desc.mip_count.max(1) { + let blocks_w = width.div_ceil(block_width.max(1)) as u64; + let blocks_h = height.div_ceil(block_height.max(1)) as u64; + total = total.saturating_add( + blocks_w + .saturating_mul(blocks_h) + .saturating_mul(layers as u64) + .saturating_mul(block_bytes), + ); + width = (width / 2).max(1); + height = (height / 2).max(1); + if desc.dimension == TextureDimension::D3 { + layers = (layers / 2).max(1); + } + } + total.saturating_mul(desc.sample_count.max(1) as u64) + } + } +} + +fn stable_plan_id( + label: &str, + passes: &[CompiledPass], + resources: &[CompiledResource], + aliasing: bool, +) -> u64 { + // FNV-1a is intentionally simple and specified. `DefaultHasher` is not a + // stable persistence format across Rust releases. + let mut hash = 0xcbf29ce484222325u64; + let mut feed = |bytes: &[u8]| { + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + }; + feed(label.as_bytes()); + feed(&[u8::from(aliasing)]); + for pass in passes { + feed(pass.name.as_bytes()); + feed(format!("{:?}{:?}", pass.queue, pass.side_effects).as_bytes()); + for dependency in &pass.dependencies { + feed(&dependency.0.to_le_bytes()); + } + for access in &pass.accesses { + feed(&access.resource.0.to_le_bytes()); + feed(&access.version.0.to_le_bytes()); + feed(format!("{:?}{:?}", access.kind, access.usage).as_bytes()); + } + } + for resource in resources { + feed(resource.name.as_bytes()); + feed(format!("{:?}{:?}", resource.desc, resource.origin).as_bytes()); + } + hash +} + +#[cfg(test)] +mod tests { + use super::*; + + fn color_desc(format: wgpu::TextureFormat) -> TextureDesc { + TextureDesc::color( + format, + Extent::RenderRelative { + numerator: 1, + denominator: 1, + layers: 1, + }, + TextureUsage::COLOR_ATTACHMENT + .union(TextureUsage::SAMPLED) + .union(TextureUsage::COPY_SRC) + .union(TextureUsage::COPY_DST), + ) + } + + fn imported_origin() -> ResourceOrigin { + ResourceOrigin::Persistent { + initial_usage: Usage::Texture(TextureUsage::SAMPLED), + final_usage: Usage::Texture(TextureUsage::SAMPLED), + ownership: Ownership::Graph, + } + } + + #[test] + fn missing_producer_is_rejected() { + let mut graph = GraphBuilder::new("missing-producer"); + let texture = graph.create_texture( + "uninitialized", + color_desc(wgpu::TextureFormat::Rgba16Float), + ); + let pass = graph.add_pass("reader"); + graph.read_texture(pass, texture, TextureUsage::SAMPLED); + assert!(matches!( + graph.compile(CompileOptions::NO_ALIASING), + Err(CompileError::MissingProducer { .. }) + )); + } + + #[test] + fn divergent_writers_of_one_version_are_rejected() { + let mut graph = GraphBuilder::new("writers"); + let texture = graph.create_texture("color", color_desc(wgpu::TextureFormat::Rgba16Float)); + let first = graph.add_pass("first"); + let second = graph.add_pass("second"); + let _first_version = graph.write_texture(first, texture, TextureUsage::COLOR_ATTACHMENT); + let _second_version = graph.write_texture(second, texture, TextureUsage::COLOR_ATTACHMENT); + match graph.compile(CompileOptions::NO_ALIASING) { + Err(CompileError::MultipleWriters { + resource, + version, + writers, + }) => { + assert_eq!(resource, "color"); + assert_eq!(version, ResourceVersion(0)); + assert_eq!(writers, vec!["first", "second"]); + } + other => panic!("expected MultipleWriters, got {other:?}"), + } + } + + #[test] + fn explicit_cycle_is_rejected_deterministically() { + let mut graph = GraphBuilder::new("cycle"); + let a = graph.add_pass("a"); + let b = graph.add_pass("b"); + graph.after(a, b); + graph.after(b, a); + match graph.compile(CompileOptions::NO_ALIASING) { + Err(CompileError::Cycle(names)) => { + assert_eq!(names, vec!["a".to_string(), "b".to_string()]); + } + other => panic!("expected Cycle, got {other:?}"), + } + } + + #[test] + fn persistent_import_is_initialized_and_gets_final_transition() { + let mut graph = GraphBuilder::new("persistent"); + let history = graph.import_texture( + "taa-history", + color_desc(wgpu::TextureFormat::Rgba16Float), + imported_origin(), + ); + let pass = graph.add_pass("taa"); + graph.read_texture(pass, history, TextureUsage::SAMPLED); + let _output = graph.write_texture(pass, history, TextureUsage::COLOR_ATTACHMENT); + let compiled = graph.compile(CompileOptions::NO_ALIASING).unwrap(); + assert!(compiled.resource("taa-history").unwrap().physical.is_none()); + assert!(compiled.transitions.iter().any(|transition| { + transition.resource == history.resource() + && transition.pass.is_none() + && transition.after == Usage::Texture(TextureUsage::SAMPLED) + })); + } + + #[test] + fn optional_pass_is_selected_before_compilation() { + fn build(enabled: bool) -> CompiledGraph { + let mut graph = GraphBuilder::new("optional"); + graph.add_pass("always"); + graph.add_optional_pass(enabled, "optional"); + graph.compile(CompileOptions::NO_ALIASING).unwrap() + } + assert_eq!(build(false).passes.len(), 1); + assert_eq!(build(true).passes.len(), 2); + } + + #[test] + fn compatible_non_overlapping_textures_alias() { + let mut graph = GraphBuilder::new("alias"); + let desc = color_desc(wgpu::TextureFormat::Rgba16Float); + let first_texture = graph.create_texture("first-texture", desc.clone()); + let first_write = graph.add_pass("first-write"); + let first_texture = + graph.write_texture(first_write, first_texture, TextureUsage::COLOR_ATTACHMENT); + let first_read = graph.add_pass("first-read"); + graph.read_texture(first_read, first_texture, TextureUsage::SAMPLED); + + let second_texture = graph.create_texture("second-texture", desc); + let second_write = graph.add_pass("second-write"); + let second_texture = + graph.write_texture(second_write, second_texture, TextureUsage::COLOR_ATTACHMENT); + let second_read = graph.add_pass("second-read"); + graph.read_texture(second_read, second_texture, TextureUsage::SAMPLED); + + let compiled = graph + .compile(CompileOptions::CONSERVATIVE_ALIASING) + .unwrap(); + assert_eq!(compiled.allocations.len(), 1); + assert_eq!(compiled.allocations[0].resources.len(), 2); + assert_eq!( + compiled.transient_bytes((1920, 1080), (1920, 1080)), + 1920 * 1080 * 8 + ); + assert_eq!( + compiled.unaliased_transient_bytes((1920, 1080), (1920, 1080)), + 2 * 1920 * 1080 * 8 + ); + assert_eq!( + compiled.alias_savings_percent((1920, 1080), (1920, 1080)), + 50.0 + ); + } + + #[test] + fn overlapping_or_incompatible_textures_do_not_alias() { + let mut graph = GraphBuilder::new("no-alias"); + let first = graph.create_texture("rgba16", color_desc(wgpu::TextureFormat::Rgba16Float)); + let second = graph.create_texture("rgba8", color_desc(wgpu::TextureFormat::Rgba8Unorm)); + let write_first = graph.add_pass("write-first"); + let first = graph.write_texture(write_first, first, TextureUsage::COLOR_ATTACHMENT); + let write_second = graph.add_pass("write-second"); + let second = graph.write_texture(write_second, second, TextureUsage::COLOR_ATTACHMENT); + let read_both = graph.add_pass("read-both"); + graph.read_texture(read_both, first, TextureUsage::SAMPLED); + graph.read_texture(read_both, second, TextureUsage::SAMPLED); + let compiled = graph + .compile(CompileOptions::CONSERVATIVE_ALIASING) + .unwrap(); + assert_eq!(compiled.allocations.len(), 2); + } + + #[test] + fn buffer_and_texture_handles_cannot_cross_usage_contracts() { + let mut graph = GraphBuilder::new("typed"); + let buffer = graph.create_buffer( + "indirect", + BufferDesc { + size: 256, + allowed_usage: BufferUsage::STORAGE_WRITE.union(BufferUsage::INDIRECT), + alias_class: AliasClass::Storage, + }, + ); + let produce = graph.add_pass("produce"); + let buffer = graph.write_buffer(produce, buffer, BufferUsage::STORAGE_WRITE); + let draw = graph.add_pass("draw"); + graph.read_buffer(draw, buffer, BufferUsage::INDIRECT); + let compiled = graph.compile(CompileOptions::NO_ALIASING).unwrap(); + assert_eq!(compiled.passes[0].name, "produce"); + assert_eq!(compiled.passes[1].name, "draw"); + } + + #[test] + fn usage_outside_descriptor_is_rejected_before_wgpu_validation() { + let mut graph = GraphBuilder::new("usage"); + let texture = graph.create_texture( + "sample-only", + TextureDesc::color( + wgpu::TextureFormat::Rgba8Unorm, + Extent::Fixed { + width: 1, + height: 1, + layers: 1, + }, + TextureUsage::SAMPLED, + ), + ); + let pass = graph.add_pass("bad-write"); + let _ = graph.write_texture(pass, texture, TextureUsage::COLOR_ATTACHMENT); + assert!(matches!( + graph.compile(CompileOptions::NO_ALIASING), + Err(CompileError::UsageNotDeclared { .. }) + )); + } + + #[test] + fn sorting_and_plan_identity_are_deterministic() { + fn build() -> CompiledGraph { + let mut graph = GraphBuilder::new("deterministic"); + let texture = + graph.create_texture("color", color_desc(wgpu::TextureFormat::Rgba16Float)); + let late = graph.add_pass("late"); + let root = graph.add_pass("root"); + let texture = graph.write_texture(root, texture, TextureUsage::COLOR_ATTACHMENT); + graph.read_texture(late, texture, TextureUsage::SAMPLED); + graph.add_pass("independent"); + graph.compile(CompileOptions::NO_ALIASING).unwrap() + } + let first = build(); + let second = build(); + let names = |graph: &CompiledGraph| { + graph + .passes + .iter() + .map(|pass| pass.name.clone()) + .collect::>() + }; + assert_eq!(names(&first), names(&second)); + assert_eq!(first.plan_id, second.plan_id); + assert_eq!(first.to_json(), second.to_json()); + assert_eq!(first.to_dot(), second.to_dot()); + } +} diff --git a/native/shared/src/renderer/graph/diagnostics.rs b/native/shared/src/renderer/graph/diagnostics.rs new file mode 100644 index 00000000..ec4a9437 --- /dev/null +++ b/native/shared/src/renderer/graph/diagnostics.rs @@ -0,0 +1,292 @@ +//! Deterministic machine- and human-readable render-graph diagnostics. + +use super::{CompiledAccessKind, CompiledGraph, ResourceDesc}; +use std::fmt::Write; + +impl CompiledGraph { + /// JSON dump containing topology, lifetimes, aliases, physical allocation + /// identifiers, declared usages, and queue intent. + pub fn to_json(&self) -> String { + let mut out = String::with_capacity(4096); + let _ = write!( + out, + "{{\"schema_version\":1,\"label\":\"{}\",\"plan_id\":\"{:016x}\",\ + \"aliasing_enabled\":{},\"passes\":[", + escape_json(&self.label), + self.plan_id, + self.aliasing_enabled, + ); + for (index, pass) in self.passes.iter().enumerate() { + if index != 0 { + out.push(','); + } + let _ = write!( + out, + "{{\"id\":{},\"name\":\"{}\",\"queue\":\"{:?}\",\ + \"side_effects\":{},\"dependencies\":[", + pass.id.0, + escape_json(&pass.name), + pass.queue, + pass.side_effects.0, + ); + for (dependency_index, dependency) in pass.dependencies.iter().enumerate() { + if dependency_index != 0 { + out.push(','); + } + let _ = write!(out, "{}", dependency.0); + } + out.push_str("],\"accesses\":["); + for (access_index, access) in pass.accesses.iter().enumerate() { + if access_index != 0 { + out.push(','); + } + let resource = &self.resources[access.resource.0 as usize]; + let _ = write!( + out, + "{{\"resource\":{},\"resource_name\":\"{}\",\"version\":{},\ + \"kind\":\"{}\",\"usage\":\"{}\"}}", + access.resource.0, + escape_json(&resource.name), + access.version.0, + match access.kind { + CompiledAccessKind::Read => "read", + CompiledAccessKind::Write => "write", + }, + access.usage.name(), + ); + } + out.push_str("]}"); + } + out.push_str("],\"resources\":["); + for (index, resource) in self.resources.iter().enumerate() { + if index != 0 { + out.push(','); + } + let (kind, descriptor) = descriptor_json(&resource.desc); + let _ = write!( + out, + "{{\"id\":{},\"name\":\"{}\",\"kind\":\"{}\",\"origin\":\"{:?}\",\ + \"first_use\":{},\"last_use\":{},\"physical\":{},\"descriptor\":{}}}", + resource.id.0, + escape_json(&resource.name), + kind, + resource.origin, + optional_usize(resource.first_use), + optional_usize(resource.last_use), + resource + .physical + .map_or_else(|| "null".to_string(), |id| id.0.to_string()), + descriptor, + ); + } + out.push_str("],\"allocations\":["); + for (index, allocation) in self.allocations.iter().enumerate() { + if index != 0 { + out.push(','); + } + let _ = write!( + out, + "{{\"id\":{},\"first_use\":{},\"last_use\":{},\"resources\":[", + allocation.id.0, allocation.first_use, allocation.last_use, + ); + for (resource_index, resource) in allocation.resources.iter().enumerate() { + if resource_index != 0 { + out.push(','); + } + let _ = write!(out, "{}", resource.0); + } + out.push_str("]}"); + } + out.push_str("],\"transitions\":["); + for (index, transition) in self.transitions.iter().enumerate() { + if index != 0 { + out.push(','); + } + let _ = write!( + out, + "{{\"resource\":{},\"pass\":{},\"before\":{},\"after\":\"{}\",\ + \"from_queue\":{},\"to_queue\":\"{:?}\"}}", + transition.resource.0, + transition + .pass + .map_or_else(|| "null".to_string(), |pass| pass.0.to_string()), + transition.before.map_or_else( + || "null".to_string(), + |usage| format!("\"{}\"", usage.name()) + ), + transition.after.name(), + transition + .from_queue + .map_or_else(|| "null".to_string(), |queue| format!("\"{:?}\"", queue)), + transition.to_queue, + ); + } + out.push_str("]}"); + out + } + + /// Graphviz DOT dump. Pass nodes are boxes, resources are ellipses, and + /// allocation identifiers are included in resource labels. + pub fn to_dot(&self) -> String { + let mut out = String::with_capacity(4096); + let _ = writeln!(out, "digraph \"{}\" {{", escape_dot(&self.label)); + out.push_str(" rankdir=LR;\n"); + for pass in &self.passes { + let _ = writeln!( + out, + " p{} [shape=box,label=\"{}\\n{:?}\"];", + pass.id.0, + escape_dot(&pass.name), + pass.queue, + ); + } + for resource in &self.resources { + let physical = resource + .physical + .map_or_else(|| "external".to_string(), |id| format!("physical {}", id.0)); + let lifetime = match (resource.first_use, resource.last_use) { + (Some(first), Some(last)) => format!("{first}..{last}"), + _ => "unused".to_string(), + }; + let _ = writeln!( + out, + " r{} [shape=ellipse,label=\"{}\\n{}\\nlife {}\"];", + resource.id.0, + escape_dot(&resource.name), + physical, + lifetime, + ); + } + for pass in &self.passes { + for dependency in &pass.dependencies { + let _ = writeln!(out, " p{} -> p{} [color=gray];", dependency.0, pass.id.0); + } + for access in &pass.accesses { + match access.kind { + CompiledAccessKind::Read => { + let _ = writeln!( + out, + " r{} -> p{} [label=\"v{} {}\"];", + access.resource.0, + pass.id.0, + access.version.0, + access.usage.name(), + ); + } + CompiledAccessKind::Write => { + let _ = writeln!( + out, + " p{} -> r{} [label=\"v{} {}\"];", + pass.id.0, + access.resource.0, + access.version.0, + access.usage.name(), + ); + } + } + } + } + out.push_str("}\n"); + out + } +} + +fn optional_usize(value: Option) -> String { + value.map_or_else(|| "null".to_string(), |value| value.to_string()) +} + +fn descriptor_json(desc: &ResourceDesc) -> (&'static str, String) { + match desc { + ResourceDesc::Texture(desc) => ( + "texture", + format!( + "{{\"format\":\"{:?}\",\"extent\":\"{:?}\",\"dimension\":\"{:?}\",\ + \"mips\":{},\"samples\":{},\"usage\":{},\"load\":\"{:?}\",\ + \"alias_class\":\"{:?}\"}}", + desc.format, + desc.extent, + desc.dimension, + desc.mip_count, + desc.sample_count, + desc.allowed_usage.0, + desc.load, + desc.alias_class, + ), + ), + ResourceDesc::Buffer(desc) => ( + "buffer", + format!( + "{{\"size\":{},\"usage\":{},\"alias_class\":\"{:?}\"}}", + desc.size, desc.allowed_usage.0, desc.alias_class, + ), + ), + } +} + +fn escape_json(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '"' => escaped.push_str("\\\""), + '\\' => escaped.push_str("\\\\"), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + character if character.is_control() => { + let _ = write!(escaped, "\\u{:04x}", character as u32); + } + character => escaped.push(character), + } + } + escaped +} + +fn escape_dot(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") +} + +#[cfg(test)] +mod tests { + use crate::renderer::graph::{ + CompileOptions, Extent, GraphBuilder, Ownership, ResourceOrigin, TextureDesc, TextureUsage, + Usage, + }; + + #[test] + fn json_and_dot_escape_names_and_include_physical_ids() { + let usage = TextureUsage::SAMPLED.union(TextureUsage::COLOR_ATTACHMENT); + let desc = TextureDesc::color( + wgpu::TextureFormat::Rgba16Float, + Extent::RenderRelative { + numerator: 1, + denominator: 1, + layers: 1, + }, + usage, + ); + let mut graph = GraphBuilder::new("dump \"test\""); + let imported = graph.import_texture( + "history", + desc.clone(), + ResourceOrigin::Persistent { + initial_usage: Usage::Texture(TextureUsage::SAMPLED), + final_usage: Usage::Texture(TextureUsage::SAMPLED), + ownership: Ownership::Graph, + }, + ); + let transient = graph.create_texture("scratch", desc); + let pass = graph.add_pass("shade"); + graph.read_texture(pass, imported, TextureUsage::SAMPLED); + let _ = graph.write_texture(pass, transient, TextureUsage::COLOR_ATTACHMENT); + let compiled = graph.compile(CompileOptions::NO_ALIASING).unwrap(); + + let json = compiled.to_json(); + assert!(json.contains("\"schema_version\":1")); + assert!(json.contains("\"physical\":0")); + assert!(json.contains("dump \\\"test\\\"")); + assert!(compiled.to_dot().contains("physical 0")); + } +} diff --git a/native/shared/src/renderer/graph/frame_plan.rs b/native/shared/src/renderer/graph/frame_plan.rs new file mode 100644 index 00000000..f3c47b43 --- /dev/null +++ b/native/shared/src/renderer/graph/frame_plan.rs @@ -0,0 +1,1009 @@ +//! Typed declaration of Bloom's current serial renderer topology. +//! +//! The execution order deliberately matches the pre-#129 scheduler exactly. +//! Conservative aliasing is enabled only for exact-compatible, non-overlapping +//! transients; persistent and temporal renderer imports remain isolated. + +use super::{ + AliasClass, BufferDesc, BufferUsage, Extent, FramePlanKey, GraphBuilder, Ownership, + ResourceOrigin, TextureDesc, TextureUsage, Usage, FRAME_FEATURE_CAPTURE_OUTPUT, + FRAME_FEATURE_CAPTURE_QUALITY, FRAME_FEATURE_IMPORTED_REFRACTION, + FRAME_FEATURE_SCENE_SNAPSHOTS, FRAME_FEATURE_TEMPORAL_REACTIVE, + FRAME_FEATURE_TRANSMITTED_SHADOWS, FRAME_FEATURE_WEIGHTED_TRANSPARENCY, +}; + +/// Qualification resources accepted by the internal debug-capture path. +/// These are graph names rather than renderer field names so requests remain +/// stable when a physical texture is replaced or aliased. +pub const QUALITY_CAPTURE_RESOURCE_NAMES: [&str; 7] = [ + "hdr-scene", + "scene-depth", + "ssr", + "ssgi", + "shadow-cascade-0", + "shadow-cascade-1", + "shadow-cascade-2", +]; + +fn persistent_texture(initial_usage: TextureUsage, final_usage: TextureUsage) -> ResourceOrigin { + ResourceOrigin::Persistent { + initial_usage: Usage::Texture(initial_usage), + final_usage: Usage::Texture(final_usage), + ownership: Ownership::Graph, + } +} + +fn texture_desc( + format: wgpu::TextureFormat, + extent: Extent, + usage: TextureUsage, + alias_class: AliasClass, +) -> TextureDesc { + let mut desc = TextureDesc::color(format, extent, usage); + desc.alias_class = alias_class; + desc +} + +/// Build the renderer topology for one [`FramePlanKey`](super::FramePlanKey). +/// +/// Feature methods still gate their own command recording in the parity stage, +/// so all historical pass names remain present. `FramePlanKey` nevertheless +/// prevents one configuration from accidentally reusing a topology after a +/// future migration starts selecting optional nodes at compile time. +pub fn build_renderer_frame_plan( + key: FramePlanKey, + output_format: wgpu::TextureFormat, +) -> GraphBuilder { + let render_full = Extent::RenderRelative { + numerator: 1, + denominator: 1, + layers: 1, + }; + let render_half = Extent::RenderRelative { + numerator: 1, + denominator: 2, + layers: 1, + }; + let output_full = Extent::OutputRelative { + numerator: 1, + denominator: 1, + layers: 1, + }; + let sampled_color = TextureUsage::SAMPLED + .union(TextureUsage::COLOR_ATTACHMENT) + .union(TextureUsage::COPY_SRC) + .union(TextureUsage::COPY_DST) + .union(TextureUsage::STORAGE_READ) + .union(TextureUsage::STORAGE_WRITE); + let sampled_depth = TextureUsage::SAMPLED + .union(TextureUsage::DEPTH_ATTACHMENT_READ) + .union(TextureUsage::DEPTH_ATTACHMENT_WRITE) + .union(TextureUsage::COPY_SRC) + .union(TextureUsage::COPY_DST); + + let mut graph = GraphBuilder::new("bloom-frame"); + let froxel = graph.import_buffer( + "froxel-clusters", + BufferDesc { + // Allocation is persistent and external to this plan; size here is + // descriptive, not used for a physical allocation. + size: 1, + allowed_usage: BufferUsage::STORAGE_READ.union(BufferUsage::STORAGE_WRITE), + alias_class: AliasClass::Never, + }, + ResourceOrigin::Persistent { + initial_usage: Usage::Buffer(BufferUsage::STORAGE_READ), + final_usage: Usage::Buffer(BufferUsage::STORAGE_READ), + ownership: Ownership::Graph, + }, + ); + let shadow_desc = texture_desc( + wgpu::TextureFormat::Depth32Float, + Extent::Fixed { + width: 2048, + height: 2048, + layers: 1, + }, + sampled_depth, + AliasClass::Never, + ); + let mut shadows = [ + graph.import_texture( + "shadow-cascade-0", + shadow_desc.clone(), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ), + graph.import_texture( + "shadow-cascade-1", + shadow_desc.clone(), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ), + graph.import_texture( + "shadow-cascade-2", + shadow_desc, + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ), + ]; + let mut transmitted_shadows = if key.feature_mask & FRAME_FEATURE_TRANSMITTED_SHADOWS != 0 { + let color_desc = texture_desc( + super::super::transmitted_shadows::TRANSMITTED_SHADOW_COLOR_FORMAT, + Extent::Fixed { + width: super::super::transmitted_shadows::TRANSMITTED_SHADOW_MAP_SIZE, + height: super::super::transmitted_shadows::TRANSMITTED_SHADOW_MAP_SIZE, + layers: 1, + }, + TextureUsage::COLOR_ATTACHMENT.union(TextureUsage::SAMPLED), + AliasClass::Never, + ); + let depth_desc = texture_desc( + super::super::transmitted_shadows::TRANSMITTED_SHADOW_DEPTH_FORMAT, + Extent::Fixed { + width: super::super::transmitted_shadows::TRANSMITTED_SHADOW_MAP_SIZE, + height: super::super::transmitted_shadows::TRANSMITTED_SHADOW_MAP_SIZE, + layers: 1, + }, + TextureUsage::DEPTH_ATTACHMENT_WRITE.union(TextureUsage::SAMPLED), + AliasClass::Never, + ); + Some(( + [ + graph.import_texture( + "transmitted-shadow-color-0", + color_desc.clone(), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ), + graph.import_texture( + "transmitted-shadow-color-1", + color_desc.clone(), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ), + graph.import_texture( + "transmitted-shadow-color-2", + color_desc, + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ), + ], + [ + graph.import_texture( + "transmitted-shadow-depth-0", + depth_desc.clone(), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ), + graph.import_texture( + "transmitted-shadow-depth-1", + depth_desc.clone(), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ), + graph.import_texture( + "transmitted-shadow-depth-2", + depth_desc, + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ), + ], + )) + } else { + None + }; + let mut hdr = graph.import_texture( + "hdr-scene", + texture_desc( + wgpu::TextureFormat::Rgba16Float, + render_full, + sampled_color, + AliasClass::Never, + ), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ); + let mut depth = graph.import_texture( + "scene-depth", + texture_desc( + wgpu::TextureFormat::Depth32Float, + render_full, + sampled_depth, + AliasClass::Never, + ), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ); + let mut material = graph.import_texture( + "material-properties", + texture_desc( + wgpu::TextureFormat::Rg8Unorm, + render_full, + sampled_color, + AliasClass::Never, + ), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ); + let mut velocity = graph.import_texture( + "motion-vectors", + texture_desc( + wgpu::TextureFormat::Rg16Float, + render_full, + sampled_color, + AliasClass::Never, + ), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ); + let mut albedo = graph.import_texture( + "albedo", + texture_desc( + wgpu::TextureFormat::Rgba8Unorm, + render_full, + sampled_color, + AliasClass::Never, + ), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ); + let mut hiz = graph.import_texture( + "hiz-pyramid", + texture_desc( + wgpu::TextureFormat::R32Float, + render_half, + sampled_color, + AliasClass::Never, + ), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ); + let mut ssao_raw = graph.import_texture( + "ssao-raw", + texture_desc( + wgpu::TextureFormat::R8Unorm, + render_half, + sampled_color, + AliasClass::Never, + ), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ); + let mut ssao_blur = graph.import_texture( + "ssao-filtered", + texture_desc( + wgpu::TextureFormat::R8Unorm, + render_half, + sampled_color, + AliasClass::Never, + ), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ); + let mut ssr = graph.import_texture( + "ssr", + texture_desc( + wgpu::TextureFormat::Rgba16Float, + render_half, + sampled_color, + AliasClass::Never, + ), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ); + let mut ssgi = graph.import_texture( + "ssgi", + texture_desc( + wgpu::TextureFormat::Rgba16Float, + render_half, + sampled_color, + AliasClass::Never, + ), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ); + let mut bloom = graph.import_texture( + "bloom-chain", + texture_desc( + wgpu::TextureFormat::Rgba16Float, + render_half, + sampled_color, + AliasClass::Never, + ), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ); + let mut composed = graph.import_texture( + "composed-hdr", + texture_desc( + wgpu::TextureFormat::Rgba16Float, + render_full, + sampled_color, + AliasClass::Never, + ), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ); + let mut postfx = graph.import_texture( + "postfx-hdr", + texture_desc( + wgpu::TextureFormat::Rgba16Float, + output_full, + sampled_color, + AliasClass::Never, + ), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ); + let mut exposure = graph.import_texture( + "exposure-history", + texture_desc( + wgpu::TextureFormat::Rg16Float, + Extent::Fixed { + width: 1, + height: 1, + layers: 1, + }, + sampled_color, + AliasClass::Never, + ), + persistent_texture(TextureUsage::SAMPLED, TextureUsage::SAMPLED), + ); + let scene_snapshots = if key.feature_mask & FRAME_FEATURE_SCENE_SNAPSHOTS != 0 { + Some(( + graph.create_texture( + "translucent-scene-color", + texture_desc( + wgpu::TextureFormat::Rgba16Float, + render_full, + TextureUsage::COPY_DST.union(TextureUsage::SAMPLED), + AliasClass::Color, + ), + ), + graph.create_texture( + "translucent-scene-depth", + texture_desc( + wgpu::TextureFormat::Depth32Float, + render_full, + TextureUsage::COPY_DST.union(TextureUsage::SAMPLED), + AliasClass::Depth, + ), + ), + )) + } else { + None + }; + let weighted_transparency = if key.feature_mask & FRAME_FEATURE_WEIGHTED_TRANSPARENCY != 0 { + Some(( + graph.create_texture( + "transparency-accumulation", + texture_desc( + wgpu::TextureFormat::Rgba16Float, + render_full, + TextureUsage::COLOR_ATTACHMENT.union(TextureUsage::SAMPLED), + AliasClass::Color, + ), + ), + graph.create_texture( + "transparency-revealage", + texture_desc( + wgpu::TextureFormat::R16Float, + render_full, + TextureUsage::COLOR_ATTACHMENT.union(TextureUsage::SAMPLED), + AliasClass::Color, + ), + ), + )) + } else { + None + }; + let temporal_reactive = if key.feature_mask & FRAME_FEATURE_TEMPORAL_REACTIVE != 0 { + Some(graph.create_texture( + "transparency-reactive", + texture_desc( + wgpu::TextureFormat::R8Unorm, + render_full, + TextureUsage::COLOR_ATTACHMENT.union(TextureUsage::SAMPLED), + AliasClass::Color, + ), + )) + } else { + None + }; + let mut output = graph.import_texture( + "output", + texture_desc( + output_format, + output_full, + TextureUsage::COLOR_ATTACHMENT + .union(TextureUsage::COPY_SRC) + .union(TextureUsage::PRESENT), + AliasClass::Never, + ), + ResourceOrigin::External { + initial_usage: Usage::Texture(TextureUsage::COLOR_ATTACHMENT), + final_usage: Usage::Texture(TextureUsage::PRESENT), + ownership: Ownership::External, + }, + ); + + let ssgi_preparation = key.feature_mask & super::FRAME_FEATURE_SSGI != 0; + let pt_preparation = key.path_tracing != super::PathTracingMode::Off; + let mut previous_gi = None; + for (name, enabled) in [ + ("accel_rebuild", ssgi_preparation || pt_preparation), + ("card_capture", ssgi_preparation || pt_preparation), + ("sdf_bake", ssgi_preparation), + ("scene_sdf_clipmap", ssgi_preparation), + ("wsrc_bake", ssgi_preparation), + ("card_light", ssgi_preparation), + ] { + if enabled { + let pass = graph.add_pass(name); + if let Some(previous) = previous_gi { + graph.after(pass, previous); + } + graph.set_side_effects(pass, super::SideEffects::EXTERNAL_STATE); + previous_gi = Some(pass); + } + } + + let froxel_assign = graph.add_pass("froxel_assign"); + if let Some(previous) = previous_gi { + graph.after(froxel_assign, previous); + } + let froxel = graph.write_buffer(froxel_assign, froxel, BufferUsage::STORAGE_WRITE); + + let shadow = graph.add_pass("shadow"); + graph.after(shadow, froxel_assign); + for cascade in &mut shadows { + *cascade = graph.write_texture(shadow, *cascade, TextureUsage::DEPTH_ATTACHMENT_WRITE); + } + if let Some((colors, depths)) = transmitted_shadows.as_mut() { + for color in colors { + *color = graph.write_texture(shadow, *color, TextureUsage::COLOR_ATTACHMENT); + } + for depth in depths { + *depth = graph.write_texture(shadow, *depth, TextureUsage::DEPTH_ATTACHMENT_WRITE); + } + } + + let hdr_scene = graph.add_pass("hdr_scene"); + graph.after(hdr_scene, shadow); + for cascade in shadows { + graph.read_texture(hdr_scene, cascade, TextureUsage::SAMPLED); + } + graph.read_buffer(hdr_scene, froxel, BufferUsage::STORAGE_READ); + hdr = graph.write_texture(hdr_scene, hdr, TextureUsage::COLOR_ATTACHMENT); + depth = graph.write_texture(hdr_scene, depth, TextureUsage::DEPTH_ATTACHMENT_WRITE); + material = graph.write_texture(hdr_scene, material, TextureUsage::COLOR_ATTACHMENT); + velocity = graph.write_texture(hdr_scene, velocity, TextureUsage::COLOR_ATTACHMENT); + albedo = graph.write_texture(hdr_scene, albedo, TextureUsage::COLOR_ATTACHMENT); + + let pt = graph.add_pass("pt"); + graph.after(pt, hdr_scene); + graph.read_texture(pt, depth, TextureUsage::SAMPLED); + hdr = graph.read_write_texture( + pt, + hdr, + TextureUsage::STORAGE_READ, + TextureUsage::STORAGE_WRITE, + ); + let mut after_opaque = pt; + if let Some((colors, depths)) = transmitted_shadows { + let resolve = graph.add_pass("transmitted_shadow_resolve"); + graph.after(resolve, pt); + graph.read_texture(resolve, depth, TextureUsage::SAMPLED); + graph.read_texture(resolve, albedo, TextureUsage::SAMPLED); + graph.read_texture(resolve, material, TextureUsage::SAMPLED); + for color in colors { + graph.read_texture(resolve, color, TextureUsage::SAMPLED); + } + for trans_depth in depths { + graph.read_texture(resolve, trans_depth, TextureUsage::SAMPLED); + } + hdr = graph.read_write_texture( + resolve, + hdr, + TextureUsage::COLOR_ATTACHMENT, + TextureUsage::COLOR_ATTACHMENT, + ); + after_opaque = resolve; + } + let translucent = graph.add_pass("translucent"); + graph.after(translucent, after_opaque); + graph.read_texture(translucent, depth, TextureUsage::DEPTH_ATTACHMENT_READ); + if let Some((scene_color, scene_depth)) = scene_snapshots { + let scene_color = graph.write_texture(translucent, scene_color, TextureUsage::COPY_DST); + graph.read_texture(translucent, scene_color, TextureUsage::SAMPLED); + let scene_depth = graph.write_texture(translucent, scene_depth, TextureUsage::COPY_DST); + graph.read_texture(translucent, scene_depth, TextureUsage::SAMPLED); + } + if let Some((accumulation, revealage)) = weighted_transparency { + let accumulation = + graph.write_texture(translucent, accumulation, TextureUsage::COLOR_ATTACHMENT); + graph.read_texture(translucent, accumulation, TextureUsage::SAMPLED); + let revealage = graph.write_texture(translucent, revealage, TextureUsage::COLOR_ATTACHMENT); + graph.read_texture(translucent, revealage, TextureUsage::SAMPLED); + } + let temporal_reactive = temporal_reactive + .map(|reactive| graph.write_texture(translucent, reactive, TextureUsage::COLOR_ATTACHMENT)); + hdr = graph.read_write_texture( + translucent, + hdr, + TextureUsage::SAMPLED, + TextureUsage::COLOR_ATTACHMENT, + ); + if key.feature_mask & FRAME_FEATURE_IMPORTED_REFRACTION != 0 { + velocity = graph.read_write_texture( + translucent, + velocity, + TextureUsage::COLOR_ATTACHMENT, + TextureUsage::COLOR_ATTACHMENT, + ); + } + + let mut previous = translucent; + if key.feature_mask & super::FRAME_FEATURE_SSAO != 0 { + let hiz_build = graph.add_pass("hiz_build"); + graph.after(hiz_build, previous); + graph.read_texture(hiz_build, depth, TextureUsage::SAMPLED); + hiz = graph.write_texture(hiz_build, hiz, TextureUsage::STORAGE_WRITE); + + let occlusion_capture = graph.add_pass("occlusion_capture"); + graph.after(occlusion_capture, hiz_build); + graph.read_texture(occlusion_capture, hiz, TextureUsage::SAMPLED); + + let gtao = graph.add_pass("gtao"); + graph.after(gtao, occlusion_capture); + graph.read_texture(gtao, hiz, TextureUsage::SAMPLED); + ssao_raw = graph.write_texture(gtao, ssao_raw, TextureUsage::STORAGE_WRITE); + previous = gtao; + } + let ssao_blur_pass = graph.add_pass("ssao_blur"); + graph.after(ssao_blur_pass, previous); + graph.read_texture(ssao_blur_pass, ssao_raw, TextureUsage::SAMPLED); + ssao_blur = graph.write_texture(ssao_blur_pass, ssao_blur, TextureUsage::COLOR_ATTACHMENT); + previous = ssao_blur_pass; + + if key.feature_mask & super::FRAME_FEATURE_SSR != 0 { + let ssr_march = graph.add_pass("ssr_march"); + graph.after(ssr_march, previous); + graph.read_texture(ssr_march, hdr, TextureUsage::SAMPLED); + graph.read_texture(ssr_march, depth, TextureUsage::SAMPLED); + graph.read_texture(ssr_march, material, TextureUsage::SAMPLED); + ssr = graph.write_texture(ssr_march, ssr, TextureUsage::COLOR_ATTACHMENT); + + let ssr_temporal = graph.add_pass("ssr_temporal"); + graph.after(ssr_temporal, ssr_march); + ssr = graph.read_write_texture( + ssr_temporal, + ssr, + TextureUsage::SAMPLED, + TextureUsage::COLOR_ATTACHMENT, + ); + previous = ssr_temporal; + } + + if key.feature_mask & super::FRAME_FEATURE_SSGI != 0 { + let ssgi_pass = graph.add_pass("ssgi"); + graph.after(ssgi_pass, previous); + graph.read_texture(ssgi_pass, hdr, TextureUsage::SAMPLED); + graph.read_texture(ssgi_pass, depth, TextureUsage::SAMPLED); + graph.read_texture(ssgi_pass, albedo, TextureUsage::SAMPLED); + ssgi = graph.write_texture(ssgi_pass, ssgi, TextureUsage::STORAGE_WRITE); + previous = ssgi_pass; + } + + if key.feature_mask & super::FRAME_FEATURE_BLOOM != 0 { + let bloom_pass = graph.add_pass("bloom"); + graph.after(bloom_pass, previous); + graph.read_texture(bloom_pass, hdr, TextureUsage::SAMPLED); + bloom = graph.write_texture(bloom_pass, bloom, TextureUsage::COLOR_ATTACHMENT); + previous = bloom_pass; + } + + let compose = graph.add_pass("compose"); + graph.after(compose, previous); + for input in [hdr, ssao_blur, ssr, ssgi, bloom] { + graph.read_texture(compose, input, TextureUsage::SAMPLED); + } + composed = graph.write_texture(compose, composed, TextureUsage::COLOR_ATTACHMENT); + + let postfx_tail = graph.add_pass("postfx_tail"); + graph.after(postfx_tail, compose); + graph.read_texture(postfx_tail, composed, TextureUsage::SAMPLED); + graph.read_texture(postfx_tail, velocity, TextureUsage::SAMPLED); + graph.read_texture(postfx_tail, depth, TextureUsage::SAMPLED); + if let Some(reactive) = temporal_reactive { + graph.read_texture(postfx_tail, reactive, TextureUsage::SAMPLED); + } + postfx = graph.write_texture(postfx_tail, postfx, TextureUsage::COLOR_ATTACHMENT); + + let auto_exposure = graph.add_pass("auto_exposure"); + graph.after(auto_exposure, postfx_tail); + graph.read_texture(auto_exposure, postfx, TextureUsage::SAMPLED); + exposure = graph.write_texture(auto_exposure, exposure, TextureUsage::COLOR_ATTACHMENT); + let final_composite = graph.add_pass("final_composite"); + graph.after(final_composite, auto_exposure); + graph.read_texture(final_composite, postfx, TextureUsage::SAMPLED); + graph.read_texture(final_composite, exposure, TextureUsage::SAMPLED); + graph.read_texture(final_composite, ssao_blur, TextureUsage::SAMPLED); + output = graph.write_texture(final_composite, output, TextureUsage::COLOR_ATTACHMENT); + + let overlay = graph.add_pass("overlay_2d"); + graph.after(overlay, final_composite); + graph.set_side_effects( + overlay, + super::SideEffects::PRESENT.union(super::SideEffects::TIMESTAMP), + ); + output = graph.read_write_texture( + overlay, + output, + TextureUsage::COLOR_ATTACHMENT, + TextureUsage::COLOR_ATTACHMENT, + ); + + if key.feature_mask & FRAME_FEATURE_CAPTURE_OUTPUT != 0 { + let capture = graph.add_pass("capture_readback"); + graph.after(capture, overlay); + graph.set_queue(capture, super::QueueClass::CopyCapable); + graph.set_side_effects(capture, super::SideEffects::READBACK); + graph.read_texture(capture, output, TextureUsage::COPY_SRC); + if key.feature_mask & FRAME_FEATURE_CAPTURE_QUALITY != 0 { + graph.read_texture(capture, hdr, TextureUsage::COPY_SRC); + graph.read_texture(capture, depth, TextureUsage::COPY_SRC); + graph.read_texture(capture, ssr, TextureUsage::COPY_SRC); + graph.read_texture(capture, ssgi, TextureUsage::COPY_SRC); + for cascade in shadows { + graph.read_texture(capture, cascade, TextureUsage::COPY_SRC); + } + } + } + + graph +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::renderer::graph::{ + CapabilityTier, CompileOptions, PathTracingMode, QueueClass, ResolutionClass, SideEffects, + FRAME_FEATURE_BLOOM, FRAME_FEATURE_SSAO, FRAME_FEATURE_SSGI, FRAME_FEATURE_SSR, + }; + + fn key(feature_mask: u64) -> FramePlanKey { + FramePlanKey { + resolution: ResolutionClass::Medium, + quality_tier: 3, + feature_mask, + capability: CapabilityTier::Raster, + path_tracing: PathTracingMode::Off, + post_pass_count: 0, + render_target_output: false, + } + } + + #[test] + fn renderer_plan_preserves_the_legacy_serial_order() { + let all_optional = + FRAME_FEATURE_SSAO | FRAME_FEATURE_SSR | FRAME_FEATURE_SSGI | FRAME_FEATURE_BLOOM; + let plan = + build_renderer_frame_plan(key(all_optional), wgpu::TextureFormat::Bgra8UnormSrgb) + .compile(CompileOptions::NO_ALIASING) + .unwrap(); + let names = plan + .passes + .iter() + .map(|pass| pass.name.as_str()) + .collect::>(); + assert_eq!( + names, + vec![ + "accel_rebuild", + "card_capture", + "sdf_bake", + "scene_sdf_clipmap", + "wsrc_bake", + "card_light", + "froxel_assign", + "shadow", + "hdr_scene", + "pt", + "translucent", + "hiz_build", + "occlusion_capture", + "gtao", + "ssao_blur", + "ssr_march", + "ssr_temporal", + "ssgi", + "bloom", + "compose", + "postfx_tail", + "auto_exposure", + "final_composite", + "overlay_2d", + ] + ); + assert!(plan + .passes + .iter() + .all(|pass| pass.queue == QueueClass::Graphics)); + assert!( + plan.allocations.is_empty(), + "parity plan imports current persistent RTs" + ); + } + + #[test] + fn disabled_quality_passes_are_absent_from_the_compiled_topology() { + let plan = build_renderer_frame_plan(key(0), wgpu::TextureFormat::Bgra8UnormSrgb) + .compile(CompileOptions::CONSERVATIVE_ALIASING) + .unwrap(); + for absent in [ + "accel_rebuild", + "card_capture", + "sdf_bake", + "scene_sdf_clipmap", + "wsrc_bake", + "card_light", + "hiz_build", + "occlusion_capture", + "gtao", + "ssr_march", + "ssr_temporal", + "ssgi", + "bloom", + ] { + assert!( + plan.pass(absent).is_none(), + "{absent} should be selected out" + ); + } + assert!( + plan.pass("ssao_blur").is_some(), + "disabled AO path still clears white" + ); + assert!(plan.pass("compose").is_some()); + assert!(plan.pass("capture_readback").is_none()); + } + + #[test] + fn path_tracing_owns_shared_ray_scene_preparation_without_ssgi_bakes() { + let mut pt_key = key(0); + pt_key.path_tracing = PathTracingMode::Realtime; + pt_key.capability = CapabilityTier::HardwareRayQuery; + let plan = build_renderer_frame_plan(pt_key, wgpu::TextureFormat::Bgra8UnormSrgb) + .compile(CompileOptions::CONSERVATIVE_ALIASING) + .unwrap(); + for present in ["accel_rebuild", "card_capture", "pt"] { + assert!(plan.pass(present).is_some(), "{present} must serve PT"); + } + for absent in [ + "sdf_bake", + "scene_sdf_clipmap", + "wsrc_bake", + "card_light", + "ssgi", + ] { + assert!( + plan.pass(absent).is_none(), + "{absent} must remain SSGI-only" + ); + } + let names = plan + .passes + .iter() + .map(|pass| pass.name.as_str()) + .collect::>(); + assert!( + names + .iter() + .position(|name| *name == "card_capture") + .unwrap() + < names.iter().position(|name| *name == "pt").unwrap() + ); + + let mut combined_key = key(FRAME_FEATURE_SSGI); + combined_key.path_tracing = PathTracingMode::Realtime; + combined_key.capability = CapabilityTier::HardwareRayQuery; + let combined = build_renderer_frame_plan(combined_key, wgpu::TextureFormat::Bgra8UnormSrgb) + .compile(CompileOptions::CONSERVATIVE_ALIASING) + .unwrap(); + for present in [ + "accel_rebuild", + "card_capture", + "sdf_bake", + "scene_sdf_clipmap", + "wsrc_bake", + "card_light", + "pt", + "ssgi", + ] { + assert!( + combined.pass(present).is_some(), + "{present} must serve the combined SSGI+PT topology" + ); + } + } + + #[test] + fn capture_is_a_terminal_copy_pass_over_named_logical_resources() { + let plan = build_renderer_frame_plan( + key(FRAME_FEATURE_CAPTURE_OUTPUT | FRAME_FEATURE_CAPTURE_QUALITY), + wgpu::TextureFormat::Bgra8UnormSrgb, + ) + .compile(CompileOptions::NO_ALIASING) + .unwrap(); + let capture = plan.pass("capture_readback").unwrap(); + assert_eq!(capture.queue, QueueClass::CopyCapable); + assert!(capture.side_effects.contains(SideEffects::READBACK)); + for name in [ + "output", + "hdr-scene", + "scene-depth", + "ssr", + "ssgi", + "shadow-cascade-0", + "shadow-cascade-1", + "shadow-cascade-2", + ] { + let resource = plan.resource(name).unwrap(); + assert!( + capture + .accesses + .iter() + .any(|access| access.resource == resource.id), + "capture must read logical resource {name}" + ); + } + assert_eq!( + plan.passes.last().map(|pass| pass.name.as_str()), + Some("capture_readback") + ); + } + + #[test] + fn scene_reading_materials_add_two_non_aliasable_transients() { + let plan = build_renderer_frame_plan( + key(FRAME_FEATURE_SCENE_SNAPSHOTS), + wgpu::TextureFormat::Bgra8UnormSrgb, + ) + .compile(CompileOptions::CONSERVATIVE_ALIASING) + .unwrap(); + assert_eq!(plan.allocations.len(), 2); + assert_eq!( + plan.unaliased_transient_bytes((1920, 1080), (1920, 1080)), + plan.transient_bytes((1920, 1080), (1920, 1080)), + "color/depth snapshots overlap and have incompatible descriptors" + ); + } + + #[test] + fn imported_refraction_declares_velocity_attachment_write() { + let plan = build_renderer_frame_plan( + key(FRAME_FEATURE_IMPORTED_REFRACTION), + wgpu::TextureFormat::Bgra8UnormSrgb, + ) + .compile(CompileOptions::CONSERVATIVE_ALIASING) + .unwrap(); + let translucent = plan.pass("translucent").unwrap(); + let velocity = plan.resource("motion-vectors").unwrap(); + assert!( + translucent + .accesses + .iter() + .any(|access| access.resource == velocity.id), + "the compiled translucent node must own the refractive velocity write" + ); + assert!( + plan.resource("translucent-scene-color").is_none(), + "folded imported refraction can write velocity without allocating snapshots" + ); + } + + #[test] + fn weighted_transparency_allocates_only_its_two_declared_targets() { + let disabled = build_renderer_frame_plan(key(0), wgpu::TextureFormat::Bgra8UnormSrgb) + .compile(CompileOptions::CONSERVATIVE_ALIASING) + .unwrap(); + assert!(disabled.resource("transparency-accumulation").is_none()); + assert!(disabled.resource("transparency-revealage").is_none()); + + let plan = build_renderer_frame_plan( + key(FRAME_FEATURE_WEIGHTED_TRANSPARENCY), + wgpu::TextureFormat::Bgra8UnormSrgb, + ) + .compile(CompileOptions::CONSERVATIVE_ALIASING) + .unwrap(); + assert_eq!(plan.allocations.len(), 2); + let translucent = plan.pass("translucent").unwrap(); + for name in ["transparency-accumulation", "transparency-revealage"] { + let resource = plan.resource(name).unwrap(); + assert!( + translucent + .accesses + .iter() + .any(|access| access.resource == resource.id), + "translucent pass must own {name}" + ); + } + } + + #[test] + fn temporal_reactive_target_is_lazy_and_connects_translucency_to_postfx() { + let disabled = build_renderer_frame_plan(key(0), wgpu::TextureFormat::Bgra8UnormSrgb) + .compile(CompileOptions::CONSERVATIVE_ALIASING) + .unwrap(); + assert!(disabled.resource("transparency-reactive").is_none()); + + let plan = build_renderer_frame_plan( + key(FRAME_FEATURE_TEMPORAL_REACTIVE), + wgpu::TextureFormat::Bgra8UnormSrgb, + ) + .compile(CompileOptions::CONSERVATIVE_ALIASING) + .unwrap(); + assert_eq!(plan.allocations.len(), 1); + let reactive = plan.resource("transparency-reactive").unwrap(); + assert_eq!( + reactive.desc, + super::super::ResourceDesc::Texture(texture_desc( + wgpu::TextureFormat::R8Unorm, + Extent::RenderRelative { + numerator: 1, + denominator: 1, + layers: 1, + }, + TextureUsage::COLOR_ATTACHMENT.union(TextureUsage::SAMPLED), + AliasClass::Color, + )) + ); + for pass_name in ["translucent", "postfx_tail"] { + let pass = plan.pass(pass_name).unwrap(); + assert!( + pass.accesses + .iter() + .any(|access| access.resource == reactive.id), + "{pass_name} must own temporal reactive coverage" + ); + } + assert_eq!( + plan.transient_bytes((1920, 1080), (1920, 1080)), + 1920 * 1080, + "R8 coverage costs exactly one byte per render pixel" + ); + } + + #[test] + fn transmitted_shadow_cascades_are_lazy_persistent_and_resolved_after_pt() { + let disabled = build_renderer_frame_plan(key(0), wgpu::TextureFormat::Bgra8UnormSrgb) + .compile(CompileOptions::CONSERVATIVE_ALIASING) + .unwrap(); + assert!(disabled.pass("transmitted_shadow_resolve").is_none()); + assert!(disabled.resource("transmitted-shadow-color-0").is_none()); + assert!(disabled.resource("transmitted-shadow-depth-0").is_none()); + + let plan = build_renderer_frame_plan( + key(FRAME_FEATURE_TRANSMITTED_SHADOWS), + wgpu::TextureFormat::Bgra8UnormSrgb, + ) + .compile(CompileOptions::CONSERVATIVE_ALIASING) + .unwrap(); + assert!( + plan.allocations.is_empty(), + "transmitted cascades are persistent lazy imports, not frame transients" + ); + let shadow = plan.pass("shadow").unwrap(); + let resolve = plan.pass("transmitted_shadow_resolve").unwrap(); + for cascade in 0..3 { + for prefix in ["transmitted-shadow-color", "transmitted-shadow-depth"] { + let name = format!("{prefix}-{cascade}"); + let resource = plan.resource(&name).unwrap(); + assert!(shadow + .accesses + .iter() + .any(|access| access.resource == resource.id)); + assert!(resolve + .accesses + .iter() + .any(|access| access.resource == resource.id)); + } + } + let pt_index = plan + .passes + .iter() + .position(|pass| pass.name == "pt") + .unwrap(); + let resolve_index = plan + .passes + .iter() + .position(|pass| pass.name == "transmitted_shadow_resolve") + .unwrap(); + let translucent_index = plan + .passes + .iter() + .position(|pass| pass.name == "translucent") + .unwrap(); + assert!(pt_index < resolve_index && resolve_index < translucent_index); + } +} diff --git a/native/shared/src/renderer/graph/model.rs b/native/shared/src/renderer/graph/model.rs new file mode 100644 index 00000000..cbb590b2 --- /dev/null +++ b/native/shared/src/renderer/graph/model.rs @@ -0,0 +1,705 @@ +//! Declarative render-graph model. +//! +//! This layer intentionally contains no `wgpu::Texture`, `wgpu::Buffer`, or +//! encoder references. A frame plan can therefore be built, validated, cached, +//! and dumped without touching the GPU. Runtime code binds execution closures +//! to the compiled pass identifiers later. + +use std::fmt; + +/// Stable identifier of a logical graph resource. +#[derive(Copy, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ResourceId(pub(crate) u32); + +/// Monotonically increasing version of a logical resource. +#[derive(Copy, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ResourceVersion(pub(crate) u32); + +/// Stable identifier of a declared pass. +#[derive(Copy, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct PassId(pub(crate) u32); + +/// A typed logical texture handle. Writes return a handle with a new version. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub struct TextureHandle { + pub(crate) resource: ResourceId, + pub(crate) version: ResourceVersion, +} + +impl TextureHandle { + pub fn resource(self) -> ResourceId { + self.resource + } + pub fn version(self) -> ResourceVersion { + self.version + } +} + +/// A typed logical buffer handle. Writes return a handle with a new version. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub struct BufferHandle { + pub(crate) resource: ResourceId, + pub(crate) version: ResourceVersion, +} + +impl BufferHandle { + pub fn resource(self) -> ResourceId { + self.resource + } + pub fn version(self) -> ResourceVersion { + self.version + } +} + +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) enum AnyHandle { + Texture(TextureHandle), + Buffer(BufferHandle), +} + +impl AnyHandle { + pub(crate) fn resource(self) -> ResourceId { + match self { + Self::Texture(handle) => handle.resource, + Self::Buffer(handle) => handle.resource, + } + } + + pub(crate) fn version(self) -> ResourceVersion { + match self { + Self::Texture(handle) => handle.version, + Self::Buffer(handle) => handle.version, + } + } +} + +/// Graph-owned texture usage contract. This is deliberately independent of +/// backend barrier APIs; `wgpu` still performs the actual transitions. +#[derive(Copy, Clone, Eq, Hash, PartialEq)] +pub struct TextureUsage(pub(crate) u32); + +impl TextureUsage { + pub const NONE: Self = Self(0); + pub const COPY_SRC: Self = Self(1 << 0); + pub const COPY_DST: Self = Self(1 << 1); + pub const SAMPLED: Self = Self(1 << 2); + pub const STORAGE_READ: Self = Self(1 << 3); + pub const STORAGE_WRITE: Self = Self(1 << 4); + pub const COLOR_ATTACHMENT: Self = Self(1 << 5); + pub const DEPTH_ATTACHMENT_READ: Self = Self(1 << 6); + pub const DEPTH_ATTACHMENT_WRITE: Self = Self(1 << 7); + pub const PRESENT: Self = Self(1 << 8); + + pub const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + pub const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } + pub const fn is_empty(self) -> bool { + self.0 == 0 + } +} + +impl fmt::Debug for TextureUsage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "TextureUsage({:#x})", self.0) + } +} + +/// Graph-owned buffer usage contract. +#[derive(Copy, Clone, Eq, Hash, PartialEq)] +pub struct BufferUsage(pub(crate) u32); + +impl BufferUsage { + pub const NONE: Self = Self(0); + pub const COPY_SRC: Self = Self(1 << 0); + pub const COPY_DST: Self = Self(1 << 1); + pub const UNIFORM: Self = Self(1 << 2); + pub const STORAGE_READ: Self = Self(1 << 3); + pub const STORAGE_WRITE: Self = Self(1 << 4); + pub const VERTEX: Self = Self(1 << 5); + pub const INDEX: Self = Self(1 << 6); + pub const INDIRECT: Self = Self(1 << 7); + + pub const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + pub const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } + pub const fn is_empty(self) -> bool { + self.0 == 0 + } +} + +impl fmt::Debug for BufferUsage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "BufferUsage({:#x})", self.0) + } +} + +/// Usage of a resource at one pass boundary. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub enum Usage { + Texture(TextureUsage), + Buffer(BufferUsage), +} + +impl Usage { + pub fn name(self) -> &'static str { + match self { + Self::Texture(TextureUsage::COPY_SRC) => "copy-src", + Self::Texture(TextureUsage::COPY_DST) => "copy-dst", + Self::Texture(TextureUsage::SAMPLED) => "sampled", + Self::Texture(TextureUsage::STORAGE_READ) => "storage-read", + Self::Texture(TextureUsage::STORAGE_WRITE) => "storage-write", + Self::Texture(TextureUsage::COLOR_ATTACHMENT) => "color-attachment", + Self::Texture(TextureUsage::DEPTH_ATTACHMENT_READ) => "depth-read", + Self::Texture(TextureUsage::DEPTH_ATTACHMENT_WRITE) => "depth-write", + Self::Texture(TextureUsage::PRESENT) => "present", + Self::Texture(_) => "texture-mixed", + Self::Buffer(BufferUsage::COPY_SRC) => "copy-src", + Self::Buffer(BufferUsage::COPY_DST) => "copy-dst", + Self::Buffer(BufferUsage::UNIFORM) => "uniform", + Self::Buffer(BufferUsage::STORAGE_READ) => "storage-read", + Self::Buffer(BufferUsage::STORAGE_WRITE) => "storage-write", + Self::Buffer(BufferUsage::VERTEX) => "vertex", + Self::Buffer(BufferUsage::INDEX) => "index", + Self::Buffer(BufferUsage::INDIRECT) => "indirect", + Self::Buffer(_) => "buffer-mixed", + } + } +} + +/// Texture extent resolved when physical allocations are prepared. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub enum Extent { + /// Exact dimensions, independent of the presentation target. + Fixed { + width: u32, + height: u32, + layers: u32, + }, + /// Rational scale of the renderer's internal render extent. + RenderRelative { + numerator: u16, + denominator: u16, + layers: u32, + }, + /// Rational scale of the final presentation extent. + OutputRelative { + numerator: u16, + denominator: u16, + layers: u32, + }, +} + +impl Extent { + pub fn resolve(self, render: (u32, u32), output: (u32, u32)) -> (u32, u32, u32) { + match self { + Self::Fixed { + width, + height, + layers, + } => (width.max(1), height.max(1), layers.max(1)), + Self::RenderRelative { + numerator, + denominator, + layers, + } => { + let denominator = u32::from(denominator.max(1)); + ( + (render.0.saturating_mul(u32::from(numerator)) / denominator).max(1), + (render.1.saturating_mul(u32::from(numerator)) / denominator).max(1), + layers.max(1), + ) + } + Self::OutputRelative { + numerator, + denominator, + layers, + } => { + let denominator = u32::from(denominator.max(1)); + ( + (output.0.saturating_mul(u32::from(numerator)) / denominator).max(1), + (output.1.saturating_mul(u32::from(numerator)) / denominator).max(1), + layers.max(1), + ) + } + } + } +} + +/// Physical texture dimensionality. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub enum TextureDimension { + D1, + D2, + D3, +} + +/// Clear value stored as IEEE-754 bits so descriptors remain `Eq`. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub struct ClearColor([u32; 4]); + +impl ClearColor { + pub fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self { + Self([r.to_bits(), g.to_bits(), b.to_bits(), a.to_bits()]) + } + + pub fn channels(self) -> [f32; 4] { + self.0.map(f32::from_bits) + } +} + +/// Load policy is part of the declaration contract, not the alias key. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub enum LoadPolicy { + Discard, + Load, + ClearColor(ClearColor), + ClearDepth(u32), +} + +impl LoadPolicy { + pub fn clear_depth(value: f32) -> Self { + Self::ClearDepth(value.to_bits()) + } +} + +/// Conservative aliasing partition. Resources in different classes never +/// share physical storage even if every other descriptor field matches. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub enum AliasClass { + Color, + Depth, + Storage, + Readback, + Never, + Custom(u16), +} + +/// Complete logical texture descriptor used by the compiler. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct TextureDesc { + pub format: wgpu::TextureFormat, + pub extent: Extent, + pub dimension: TextureDimension, + pub mip_count: u32, + pub sample_count: u32, + pub allowed_usage: TextureUsage, + pub load: LoadPolicy, + pub alias_class: AliasClass, +} + +impl TextureDesc { + pub fn color(format: wgpu::TextureFormat, extent: Extent, allowed_usage: TextureUsage) -> Self { + Self { + format, + extent, + dimension: TextureDimension::D2, + mip_count: 1, + sample_count: 1, + allowed_usage, + load: LoadPolicy::Discard, + alias_class: AliasClass::Color, + } + } +} + +/// Complete logical buffer descriptor used by the compiler. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct BufferDesc { + pub size: u64, + pub allowed_usage: BufferUsage, + pub alias_class: AliasClass, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum ResourceDesc { + Texture(TextureDesc), + Buffer(BufferDesc), +} + +impl ResourceDesc { + pub(crate) fn permits(&self, usage: Usage) -> bool { + match (self, usage) { + (Self::Texture(desc), Usage::Texture(usage)) => desc.allowed_usage.contains(usage), + (Self::Buffer(desc), Usage::Buffer(usage)) => desc.allowed_usage.contains(usage), + _ => false, + } + } +} + +/// Ownership of an imported resource at the frame boundary. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub enum Ownership { + Graph, + External, +} + +/// Lifetime class of a logical resource. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub enum ResourceOrigin { + Transient, + Persistent { + initial_usage: Usage, + final_usage: Usage, + ownership: Ownership, + }, + External { + initial_usage: Usage, + final_usage: Usage, + ownership: Ownership, + }, +} + +impl ResourceOrigin { + pub fn is_transient(self) -> bool { + matches!(self, Self::Transient) + } + + pub(crate) fn initial_usage(self) -> Option { + match self { + Self::Transient => None, + Self::Persistent { initial_usage, .. } | Self::External { initial_usage, .. } => { + Some(initial_usage) + } + } + } + + pub(crate) fn final_usage(self) -> Option { + match self { + Self::Transient => None, + Self::Persistent { final_usage, .. } | Self::External { final_usage, .. } => { + Some(final_usage) + } + } + } +} + +/// Queue declaration retained in diagnostics even though all currently +/// supported backends execute a single serial graphics queue. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub enum QueueClass { + Graphics, + ComputeCapable, + CopyCapable, +} + +/// Observable pass behavior not represented by resource dependencies. +#[derive(Copy, Clone, Eq, Hash, PartialEq)] +pub struct SideEffects(pub(crate) u32); + +impl SideEffects { + pub const NONE: Self = Self(0); + pub const PRESENT: Self = Self(1 << 0); + pub const READBACK: Self = Self(1 << 1); + pub const TIMESTAMP: Self = Self(1 << 2); + pub const EXTERNAL_STATE: Self = Self(1 << 3); + + pub const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + pub const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } +} + +impl fmt::Debug for SideEffects { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SideEffects({:#x})", self.0) + } +} + +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) enum AccessKind { + Read, + Write, +} + +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct Access { + pub handle: AnyHandle, + pub usage: Usage, + pub kind: AccessKind, +} + +#[derive(Clone, Debug)] +pub(crate) struct PassDeclaration { + pub id: PassId, + pub name: String, + pub queue: QueueClass, + pub side_effects: SideEffects, + pub accesses: Vec, + pub after: Vec, + pub before: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct VersionDeclaration { + pub producer: Option, + pub parent: Option, + pub initialized: bool, +} + +#[derive(Clone, Debug)] +pub(crate) struct ResourceDeclaration { + pub id: ResourceId, + pub name: String, + pub desc: ResourceDesc, + pub origin: ResourceOrigin, + pub versions: Vec, +} + +/// Pure declaration builder. Errors caused by graph relationships are +/// intentionally deferred to compilation so callers get deterministic, +/// aggregate validation rather than order-dependent panics. +#[derive(Clone, Debug)] +pub struct GraphBuilder { + pub(crate) label: String, + pub(crate) passes: Vec, + pub(crate) resources: Vec, +} + +impl GraphBuilder { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + passes: Vec::new(), + resources: Vec::new(), + } + } + + pub fn add_pass(&mut self, name: impl Into) -> PassId { + let id = PassId(self.passes.len() as u32); + self.passes.push(PassDeclaration { + id, + name: name.into(), + queue: QueueClass::Graphics, + side_effects: SideEffects::NONE, + accesses: Vec::new(), + after: Vec::new(), + before: Vec::new(), + }); + id + } + + pub fn add_optional_pass(&mut self, enabled: bool, name: impl Into) -> Option { + enabled.then(|| self.add_pass(name)) + } + + pub fn set_queue(&mut self, pass: PassId, queue: QueueClass) { + self.pass_mut(pass).queue = queue; + } + + pub fn set_side_effects(&mut self, pass: PassId, side_effects: SideEffects) { + self.pass_mut(pass).side_effects = side_effects; + } + + pub fn after(&mut self, pass: PassId, predecessor: PassId) { + self.pass_mut(pass).after.push(predecessor); + } + + pub fn before(&mut self, pass: PassId, successor: PassId) { + self.pass_mut(pass).before.push(successor); + } + + pub fn create_texture(&mut self, name: impl Into, desc: TextureDesc) -> TextureHandle { + let resource = + self.push_resource(name, ResourceDesc::Texture(desc), ResourceOrigin::Transient); + TextureHandle { + resource, + version: ResourceVersion(0), + } + } + + pub fn create_buffer(&mut self, name: impl Into, desc: BufferDesc) -> BufferHandle { + let resource = + self.push_resource(name, ResourceDesc::Buffer(desc), ResourceOrigin::Transient); + BufferHandle { + resource, + version: ResourceVersion(0), + } + } + + pub fn import_texture( + &mut self, + name: impl Into, + desc: TextureDesc, + origin: ResourceOrigin, + ) -> TextureHandle { + assert!( + !origin.is_transient(), + "imports must be persistent or external" + ); + let resource = self.push_resource(name, ResourceDesc::Texture(desc), origin); + self.resources[resource.0 as usize].versions[0].initialized = true; + TextureHandle { + resource, + version: ResourceVersion(0), + } + } + + pub fn import_buffer( + &mut self, + name: impl Into, + desc: BufferDesc, + origin: ResourceOrigin, + ) -> BufferHandle { + assert!( + !origin.is_transient(), + "imports must be persistent or external" + ); + let resource = self.push_resource(name, ResourceDesc::Buffer(desc), origin); + self.resources[resource.0 as usize].versions[0].initialized = true; + BufferHandle { + resource, + version: ResourceVersion(0), + } + } + + pub fn read_texture(&mut self, pass: PassId, handle: TextureHandle, usage: TextureUsage) { + self.push_access( + pass, + AnyHandle::Texture(handle), + Usage::Texture(usage), + AccessKind::Read, + ); + } + + pub fn read_buffer(&mut self, pass: PassId, handle: BufferHandle, usage: BufferUsage) { + self.push_access( + pass, + AnyHandle::Buffer(handle), + Usage::Buffer(usage), + AccessKind::Read, + ); + } + + pub fn write_texture( + &mut self, + pass: PassId, + previous: TextureHandle, + usage: TextureUsage, + ) -> TextureHandle { + let version = self.push_version(previous.resource, pass, previous.version); + let handle = TextureHandle { + resource: previous.resource, + version, + }; + self.push_access( + pass, + AnyHandle::Texture(handle), + Usage::Texture(usage), + AccessKind::Write, + ); + handle + } + + pub fn write_buffer( + &mut self, + pass: PassId, + previous: BufferHandle, + usage: BufferUsage, + ) -> BufferHandle { + let version = self.push_version(previous.resource, pass, previous.version); + let handle = BufferHandle { + resource: previous.resource, + version, + }; + self.push_access( + pass, + AnyHandle::Buffer(handle), + Usage::Buffer(usage), + AccessKind::Write, + ); + handle + } + + pub fn read_write_texture( + &mut self, + pass: PassId, + previous: TextureHandle, + read_usage: TextureUsage, + write_usage: TextureUsage, + ) -> TextureHandle { + self.read_texture(pass, previous, read_usage); + self.write_texture(pass, previous, write_usage) + } + + pub fn read_write_buffer( + &mut self, + pass: PassId, + previous: BufferHandle, + read_usage: BufferUsage, + write_usage: BufferUsage, + ) -> BufferHandle { + self.read_buffer(pass, previous, read_usage); + self.write_buffer(pass, previous, write_usage) + } + + pub fn pass_name(&self, pass: PassId) -> Option<&str> { + self.passes + .get(pass.0 as usize) + .map(|pass| pass.name.as_str()) + } + + fn push_resource( + &mut self, + name: impl Into, + desc: ResourceDesc, + origin: ResourceOrigin, + ) -> ResourceId { + let id = ResourceId(self.resources.len() as u32); + self.resources.push(ResourceDeclaration { + id, + name: name.into(), + desc, + origin, + versions: vec![VersionDeclaration { + producer: None, + parent: None, + initialized: false, + }], + }); + id + } + + fn push_version( + &mut self, + resource: ResourceId, + producer: PassId, + parent: ResourceVersion, + ) -> ResourceVersion { + let resource = self.resource_mut(resource); + let version = ResourceVersion(resource.versions.len() as u32); + resource.versions.push(VersionDeclaration { + producer: Some(producer), + parent: Some(parent), + initialized: true, + }); + version + } + + fn push_access(&mut self, pass: PassId, handle: AnyHandle, usage: Usage, kind: AccessKind) { + self.pass_mut(pass).accesses.push(Access { + handle, + usage, + kind, + }); + } + + fn pass_mut(&mut self, pass: PassId) -> &mut PassDeclaration { + self.passes + .get_mut(pass.0 as usize) + .unwrap_or_else(|| panic!("unknown graph pass {}", pass.0)) + } + + fn resource_mut(&mut self, resource: ResourceId) -> &mut ResourceDeclaration { + self.resources + .get_mut(resource.0 as usize) + .unwrap_or_else(|| panic!("unknown graph resource {}", resource.0)) + } +} diff --git a/native/shared/src/renderer/graph/runtime.rs b/native/shared/src/renderer/graph/runtime.rs new file mode 100644 index 00000000..1262e85f --- /dev/null +++ b/native/shared/src/renderer/graph/runtime.rs @@ -0,0 +1,379 @@ +//! Compiled-plan cache and frame-local execution binding. + +use super::{CompileError, CompileOptions, CompiledGraph, GraphBuilder, PassId}; +use std::collections::HashMap; +use std::fmt; +use std::hash::Hash; +use std::sync::Arc; + +/// Backend-agnostic hook used by the compiled executor to expose stable pass +/// names to GPU capture tools without coupling graph code to wgpu. +pub trait GraphDebugMarkerContext { + fn push_graph_debug_group(&mut self, label: &str); + fn pop_graph_debug_group(&mut self); +} + +/// Dynamic draw content requires the two scene snapshots used by refractive +/// materials. It changes the allocation contract and therefore belongs in the +/// topology key. +pub const FRAME_FEATURE_SSAO: u64 = 1 << 1; +pub const FRAME_FEATURE_BLOOM: u64 = 1 << 2; +pub const FRAME_FEATURE_SSR: u64 = 1 << 4; +pub const FRAME_FEATURE_SSGI: u64 = 1 << 5; +pub const FRAME_FEATURE_SCENE_SNAPSHOTS: u64 = 1 << 16; +/// The final output must be copied to a CPU-visible staging buffer this frame. +/// Capture is deliberately part of the topology key: ordinary frames pay no +/// execution-node or transition cost, while repeated captures reuse one plan. +pub const FRAME_FEATURE_CAPTURE_OUTPUT: u64 = 1 << 17; +/// Qualification capture additionally reads named HDR/depth graph resources. +pub const FRAME_FEATURE_CAPTURE_QUALITY: u64 = 1 << 18; +/// Imported physical transmission writes per-object velocity in its dedicated +/// forward pass. This versions the persistent velocity target even on folded +/// platforms that do not allocate scene snapshots. +pub const FRAME_FEATURE_IMPORTED_REFRACTION: u64 = 1 << 19; +/// Imported glTF BLEND draws use the bounded weighted-blended OIT path. +/// The two accumulation targets are transient and must not exist in plans +/// for sorted-only or opaque frames. +pub const FRAME_FEATURE_WEIGHTED_TRANSPARENCY: u64 = 1 << 20; +/// A visible imported BLEND/transmission draw feeds TAA this frame. Coverage +/// is a render-resolution transient and must not exist for opaque, custom-only, +/// or TAA-disabled topologies. +pub const FRAME_FEATURE_TEMPORAL_REACTIVE: u64 = 1 << 21; +/// Physical transmission contributes a lazy persistent light-space +/// transmittance/depth cascade and an additive post-opaque sun correction. +pub const FRAME_FEATURE_TRANSMITTED_SHADOWS: u64 = 1 << 22; + +/// Coarse output class used in topology keys. Exact dimensions belong to the +/// allocation/resize generation; topology does not rebuild for every resize. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub enum ResolutionClass { + Tiny, + Small, + Medium, + Large, + Ultra, +} + +impl ResolutionClass { + pub fn from_extent(width: u32, height: u32) -> Self { + match u64::from(width).saturating_mul(u64::from(height)) { + 0..=409_599 => Self::Tiny, + 409_600..=921_599 => Self::Small, + 921_600..=2_073_599 => Self::Medium, + 2_073_600..=4_194_303 => Self::Large, + _ => Self::Ultra, + } + } +} + +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub enum CapabilityTier { + Constrained, + Raster, + HardwareRayQuery, + HardwareRayQueryTextureArrays, +} + +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub enum PathTracingMode { + Off, + Progressive, + Realtime, +} + +impl PathTracingMode { + pub fn from_u32(value: u32) -> Self { + match value { + 0 => Self::Off, + 1 => Self::Progressive, + _ => Self::Realtime, + } + } +} + +/// Hashable topology key. Values that only affect uniforms are deliberately +/// absent. `feature_mask` contains only switches that add/remove passes or +/// change resource contracts. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub struct FramePlanKey { + pub resolution: ResolutionClass, + pub quality_tier: u8, + pub feature_mask: u64, + pub capability: CapabilityTier, + pub path_tracing: PathTracingMode, + pub post_pass_count: u16, + pub render_target_output: bool, +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct PlanCacheStats { + pub compile_count: u64, + pub hit_count: u64, + pub plan_count: usize, +} + +/// Cache of immutable compiled topologies. Physical allocations are cached +/// separately by the transient pool because they also depend on resize +/// generation and exact resolved dimensions. +pub struct PlanCache { + plans: HashMap>, + compile_count: u64, + hit_count: u64, +} + +impl PlanCache +where + K: Copy + Eq + Hash, +{ + pub fn new() -> Self { + Self { + plans: HashMap::new(), + compile_count: 0, + hit_count: 0, + } + } + + pub fn get_or_compile( + &mut self, + key: K, + options: CompileOptions, + build: impl FnOnce() -> GraphBuilder, + ) -> Result, CompileError> { + if let Some(plan) = self.plans.get(&key) { + self.hit_count = self.hit_count.saturating_add(1); + return Ok(Arc::clone(plan)); + } + let plan = Arc::new(build().compile(options)?); + self.compile_count = self.compile_count.saturating_add(1); + self.plans.insert(key, Arc::clone(&plan)); + Ok(plan) + } + + pub fn get(&self, key: &K) -> Option> { + self.plans.get(key).map(Arc::clone) + } + + pub fn clear(&mut self) { + self.plans.clear(); + } + + pub fn stats(&self) -> PlanCacheStats { + PlanCacheStats { + compile_count: self.compile_count, + hit_count: self.hit_count, + plan_count: self.plans.len(), + } + } +} + +impl Default for PlanCache +where + K: Copy + Eq + Hash, +{ + fn default() -> Self { + Self::new() + } +} + +pub type ExecutionRunFn<'a, Ctx> = Box; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ExecutionError { + UnknownPass(String), + DuplicateBinding(String), + MissingBinding(String), +} + +impl fmt::Display for ExecutionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownPass(name) => write!(f, "compiled graph has no pass '{name}'"), + Self::DuplicateBinding(name) => write!(f, "pass '{name}' was bound twice"), + Self::MissingBinding(name) => { + write!(f, "compiled pass '{name}' has no execution closure") + } + } + } +} + +impl std::error::Error for ExecutionError {} + +/// Frame-local closure table backed by an immutable compiled topology. +pub struct ExecutableGraph<'a, Ctx> { + plan: Arc, + runs: Vec>>, +} + +impl<'a, Ctx> ExecutableGraph<'a, Ctx> { + pub fn new(plan: Arc) -> Self { + let runs = (0..plan.passes.len()).map(|_| None).collect(); + Self { plan, runs } + } + + pub fn plan(&self) -> &CompiledGraph { + &self.plan + } + + pub fn contains(&self, name: &str) -> bool { + self.plan.pass(name).is_some() + } + + pub fn bind(&mut self, name: &str, run: ExecutionRunFn<'a, Ctx>) -> Result<(), ExecutionError> { + let pass = self + .plan + .pass(name) + .ok_or_else(|| ExecutionError::UnknownPass(name.to_string()))?; + let position = self + .plan + .pass_position(pass.id) + .expect("compiled pass has a schedule position"); + if self.runs[position].is_some() { + return Err(ExecutionError::DuplicateBinding(name.to_string())); + } + self.runs[position] = Some(run); + Ok(()) + } + + /// Bind only when an optional pass is present in this topology. + pub fn bind_optional(&mut self, name: &str, run: ExecutionRunFn<'a, Ctx>) { + if self.contains(name) { + self.bind(name, run) + .expect("optional pass is bound at most once"); + } + } + + pub fn execute(mut self, context: &mut Ctx) -> Result<(), ExecutionError> { + for (position, pass) in self.plan.passes.iter().enumerate() { + let run = self.runs[position] + .take() + .ok_or_else(|| ExecutionError::MissingBinding(pass.name.clone()))?; + run(context); + } + Ok(()) + } + + pub fn execute_marked(mut self, context: &mut Ctx) -> Result<(), ExecutionError> + where + Ctx: GraphDebugMarkerContext, + { + for (position, pass) in self.plan.passes.iter().enumerate() { + let run = self.runs[position] + .take() + .ok_or_else(|| ExecutionError::MissingBinding(pass.name.clone()))?; + context.push_graph_debug_group(&pass.name); + run(context); + context.pop_graph_debug_group(); + } + Ok(()) + } + + pub fn pass_id(&self, name: &str) -> Option { + self.plan.pass(name).map(|pass| pass.id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::renderer::graph::{Extent, TextureDesc, TextureUsage}; + + #[test] + fn stable_key_compiles_once_and_executes_many_times() { + let key = FramePlanKey { + resolution: ResolutionClass::Medium, + quality_tier: 3, + feature_mask: 7, + capability: CapabilityTier::Raster, + path_tracing: PathTracingMode::Off, + post_pass_count: 0, + render_target_output: false, + }; + let build = || { + let mut graph = GraphBuilder::new("cached"); + let resource = graph.create_texture( + "color", + TextureDesc::color( + wgpu::TextureFormat::Rgba16Float, + Extent::RenderRelative { + numerator: 1, + denominator: 1, + layers: 1, + }, + TextureUsage::COLOR_ATTACHMENT, + ), + ); + let pass = graph.add_pass("draw"); + let _ = graph.write_texture(pass, resource, TextureUsage::COLOR_ATTACHMENT); + graph + }; + let mut cache = PlanCache::new(); + let first = cache + .get_or_compile(key, CompileOptions::NO_ALIASING, build) + .unwrap(); + let second = cache + .get_or_compile(key, CompileOptions::NO_ALIASING, build) + .unwrap(); + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!( + cache.stats(), + PlanCacheStats { + compile_count: 1, + hit_count: 1, + plan_count: 1 + } + ); + + let mut output = Vec::new(); + for plan in [first, second] { + let mut executable = ExecutableGraph::new(plan); + executable + .bind( + "draw", + Box::new(|out: &mut Vec<&'static str>| out.push("draw")), + ) + .unwrap(); + executable.execute(&mut output).unwrap(); + } + assert_eq!(output, vec!["draw", "draw"]); + } + + #[test] + fn missing_closure_is_rejected_before_silent_pass_loss() { + let mut graph = GraphBuilder::new("missing"); + graph.add_pass("required"); + let plan = Arc::new(graph.compile(CompileOptions::NO_ALIASING).unwrap()); + let executable: ExecutableGraph<'_, ()> = ExecutableGraph::new(plan); + assert_eq!( + executable.execute(&mut ()), + Err(ExecutionError::MissingBinding("required".to_string())) + ); + } + + #[test] + fn marked_execution_brackets_the_compiled_pass_name() { + struct Context(Vec); + impl GraphDebugMarkerContext for Context { + fn push_graph_debug_group(&mut self, label: &str) { + self.0.push(format!("push:{label}")); + } + + fn pop_graph_debug_group(&mut self) { + self.0.push("pop".to_string()); + } + } + + let mut graph = GraphBuilder::new("marked"); + graph.add_pass("stable-name"); + let plan = Arc::new(graph.compile(CompileOptions::NO_ALIASING).unwrap()); + let mut executable = ExecutableGraph::new(plan); + executable + .bind( + "stable-name", + Box::new(|context: &mut Context| context.0.push("run".to_string())), + ) + .unwrap(); + let mut context = Context(Vec::new()); + executable.execute_marked(&mut context).unwrap(); + assert_eq!(context.0, ["push:stable-name", "run", "pop"]); + } +} diff --git a/native/shared/src/renderer/hiz.rs b/native/shared/src/renderer/hiz.rs index e5fa3a06..8290f173 100644 --- a/native/shared/src/renderer/hiz.rs +++ b/native/shared/src/renderer/hiz.rs @@ -4,10 +4,10 @@ //! occlusion-culling grid. Split out of renderer/mod.rs (2000-line file //! policy); pipelines and the mip chain stay fields on [`Renderer`]. -use super::formats::HIZ_MIP_COUNT; use super::formats::halton; -use super::{HizDownsampleParams, HizLinearizeParams, SsaoBlurParams, SsaoParams}; +use super::formats::HIZ_MIP_COUNT; use super::Renderer; +use super::{HizDownsampleParams, HizLinearizeParams, SsaoBlurParams, SsaoParams}; impl Renderer { pub(super) fn record_hiz_chain( @@ -24,18 +24,35 @@ impl Renderer { params: [1.0 / half_w as f32, 1.0 / half_h as f32, p22, p32], size: [half_w, half_h, 0, 0], }; - self.queue.write_buffer(&self.hiz_linearize_uniform_buffer, 0, bytemuck::bytes_of(&lin_params)); + self.queue.write_buffer( + &self.hiz_linearize_uniform_buffer, + 0, + bytemuck::bytes_of(&lin_params), + ); if self.hiz_linearize_bg_cache.is_none() { - self.hiz_linearize_bg_cache = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("hiz_linearize_bg"), - layout: &self.hiz_linearize_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.hiz_linearize_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.depth_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.hiz_views[0]) }, - ], - })); + self.hiz_linearize_bg_cache = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("hiz_linearize_bg"), + layout: &self.hiz_linearize_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.hiz_linearize_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&self.depth_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&self.hiz_views[0]), + }, + ], + })); } { let bg = self.hiz_linearize_bg_cache.as_ref().unwrap(); @@ -56,17 +73,34 @@ impl Renderer { let ds_params = HizDownsampleParams { size: [dst_w, dst_h, 0, 0], }; - self.queue.write_buffer(&self.hiz_downsample_uniform_buffers[i], 0, bytemuck::bytes_of(&ds_params)); + self.queue.write_buffer( + &self.hiz_downsample_uniform_buffers[i], + 0, + bytemuck::bytes_of(&ds_params), + ); if self.hiz_downsample_bg_cache[i].is_none() { - self.hiz_downsample_bg_cache[i] = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("hiz_downsample_bg"), - layout: &self.hiz_downsample_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.hiz_downsample_uniform_buffers[i].as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.hiz_views[i]) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.hiz_views[i + 1]) }, - ], - })); + self.hiz_downsample_bg_cache[i] = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("hiz_downsample_bg"), + layout: &self.hiz_downsample_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: + self.hiz_downsample_uniform_buffers[i].as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&self.hiz_views[i]), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView( + &self.hiz_views[i + 1], + ), + }, + ], + })); } let bg = self.hiz_downsample_bg_cache[i].as_ref().unwrap(); let ts_label: &'static str = match i { @@ -84,7 +118,6 @@ impl Renderer { pass.set_bind_group(0, bg, &[]); pass.dispatch_workgroups((dst_w + 7) / 8, (dst_h + 7) / 8, 1); } - } } @@ -99,79 +132,96 @@ impl Renderer { surf_w: u32, surf_h: u32, ) { - // ============================================================ - // SSAO bilateral blur: smooth the noisy GTAO output while - // preserving depth edges (depth-guided bilateral filter). - // Reads ssao_rt → writes ssao_blur_rt. - // ============================================================ - // PT: when the path tracer owns the frame it computes real - // occlusion by tracing — screen-space AO on top double-darkens - // every crevice. Route compose to "no occlusion" (white clear - // below) for those frames. - if self.ssao_enabled && !self.pt_owns_frame() { - // texel_size is the size of one SSAO RT texel (half-res). - let ao_w = (surf_w / 2).max(1) as f32; - let ao_h = (surf_h / 2).max(1) as f32; - let bp = SsaoBlurParams { - params: [1.0 / ao_w, 1.0 / ao_h, 0.05, 0.0], - }; - self.queue.write_buffer(&self.ssao_blur_uniform_buffer, 0, bytemuck::bytes_of(&bp)); + // ============================================================ + // SSAO bilateral blur: smooth the noisy GTAO output while + // preserving depth edges (depth-guided bilateral filter). + // Reads ssao_rt → writes ssao_blur_rt. + // ============================================================ + // PT: when the path tracer owns the frame it computes real + // occlusion by tracing — screen-space AO on top double-darkens + // every crevice. Route compose to "no occlusion" (white clear + // below) for those frames. + if self.ssao_enabled && !self.pt_owns_frame() { + // texel_size is the size of one SSAO RT texel (half-res). + let ao_w = (surf_w / 2).max(1) as f32; + let ao_h = (surf_h / 2).max(1) as f32; + let bp = SsaoBlurParams { + params: [1.0 / ao_w, 1.0 / ao_h, 0.05, 0.0], + }; + self.queue + .write_buffer(&self.ssao_blur_uniform_buffer, 0, bytemuck::bytes_of(&bp)); - if self.ssao_blur_bg_cache.is_none() { - self.ssao_blur_bg_cache = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("ssao_blur_bg"), - layout: &self.ssao_blur_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.ssao_blur_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.ssao_rt_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.depth_view) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler) }, - ], - })); - } - let bg = self.ssao_blur_bg_cache.as_ref().unwrap(); + if self.ssao_blur_bg_cache.is_none() { + self.ssao_blur_bg_cache = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("ssao_blur_bg"), + layout: &self.ssao_blur_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.ssao_blur_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&self.ssao_rt_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&self.depth_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler), + }, + ], + })); + } + let bg = self.ssao_blur_bg_cache.as_ref().unwrap(); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("ssao_blur_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.ssao_blur_rt_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(&self.ssao_blur_pipeline); - pass.set_bind_group(0, bg, &[]); - pass.draw(0..3, 0..1); - } else { - // SSAO disabled — clear the blur RT to WHITE so the - // composite pass samples "no occlusion". Cheaper than a - // full blur pass; the clear is the only GPU work. - encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("ssao_blur_disabled_clear"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.ssao_blur_rt_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("ssao_blur_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.ssao_blur_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.ssao_blur_pipeline); + pass.set_bind_group(0, bg, &[]); + pass.draw(0..3, 0..1); + } else { + // SSAO disabled — clear the blur RT to WHITE so the + // composite pass samples "no occlusion". Cheaper than a + // full blur pass; the clear is the only GPU work. + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("ssao_blur_disabled_clear"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.ssao_blur_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + } } } @@ -197,9 +247,9 @@ impl Renderer { let ld = self.lighting_uniforms.light_dir; let v = &self.current_view_matrix; let light_dir_vs = [ - v[0][0]*ld[0] + v[1][0]*ld[1] + v[2][0]*ld[2], - v[0][1]*ld[0] + v[1][1]*ld[1] + v[2][1]*ld[2], - v[0][2]*ld[0] + v[1][2]*ld[1] + v[2][2]*ld[2], + v[0][0] * ld[0] + v[1][0] * ld[1] + v[2][0] * ld[2], + v[0][1] * ld[0] + v[1][1] * ld[1] + v[2][1] * ld[2], + v[0][2] * ld[0] + v[1][2] * ld[1] + v[2][2] * ld[2], 0.0, ]; // Temporal accumulation: ping-pong history textures. @@ -209,7 +259,11 @@ impl Renderer { let write_idx = self.ssao_history_idx; let read_idx = 1 - write_idx; let frame_phase = self.ssao_history_frame % 4; - let force_refresh = if self.ssao_history_frame < 4 { 1u32 } else { 0u32 }; + let force_refresh = if self.ssao_history_frame < 4 { + 1u32 + } else { + 0u32 + }; // 4-frame EMA: alpha = 1/4 = 0.25 gives equal weight to // each of the 4 phases at steady state. let alpha = 0.25_f32; @@ -229,27 +283,69 @@ impl Renderer { size: [half_w, half_h, frame_phase, force_refresh], temporal: [alpha, halton5, 0.0, 0.0], }; - self.queue.write_buffer(&self.ssao_uniform_buffer, 0, bytemuck::bytes_of(&sp)); + self.queue + .write_buffer(&self.ssao_uniform_buffer, 0, bytemuck::bytes_of(&sp)); if self.ssao_bg_cache[write_idx].is_none() { - self.ssao_bg_cache[write_idx] = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("ssao_bg"), - layout: &self.ssao_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.ssao_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.ssao_rt_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.hiz_sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.hiz_views[0]) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self.hiz_views[1]) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&self.hiz_views[2]) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self.hiz_views[3]) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&self.hiz_views[4]) }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self.velocity_rt_view) }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::TextureView(&self.ssao_history_views[read_idx]) }, - wgpu::BindGroupEntry { binding: 10, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 11, resource: wgpu::BindingResource::TextureView(&self.ssao_history_views[write_idx]) }, - ], - })); + self.ssao_bg_cache[write_idx] = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("ssao_bg"), + layout: &self.ssao_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.ssao_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&self.ssao_rt_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.hiz_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&self.hiz_views[0]), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&self.hiz_views[1]), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&self.hiz_views[2]), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&self.hiz_views[3]), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(&self.hiz_views[4]), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView(&self.velocity_rt_view), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::TextureView( + &self.ssao_history_views[read_idx], + ), + }, + wgpu::BindGroupEntry { + binding: 10, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: wgpu::BindingResource::TextureView( + &self.ssao_history_views[write_idx], + ), + }, + ], + })); } let bg = self.ssao_bg_cache[write_idx].as_ref().unwrap(); diff --git a/native/shared/src/renderer/hot_reload.rs b/native/shared/src/renderer/hot_reload.rs index c2b35ba2..d0950502 100644 --- a/native/shared/src/renderer/hot_reload.rs +++ b/native/shared/src/renderer/hot_reload.rs @@ -29,10 +29,10 @@ use super::material_pipeline::{Bucket, FragmentProfile}; /// rebuild it when the file changes. #[derive(Clone)] pub struct FileMaterialDesc { - pub path: PathBuf, - pub profile: FragmentProfile, - pub bucket: Bucket, - pub reads_scene: bool, + pub path: PathBuf, + pub profile: FragmentProfile, + pub bucket: Bucket, + pub reads_scene: bool, /// EN-001 — preserved across reloads so a file-backed instanced /// material continues to opt into the per-instance vertex layout /// after hot reload. @@ -83,7 +83,10 @@ impl MaterialHotReload { // without the `hot-reload` feature), which drops `notify` // from the dep tree entirely (EN-008) #[cfg(all(not(target_arch = "wasm32"), feature = "hot-reload"))] - let watcher = if std::env::var("BLOOM_NO_HOT_RELOAD").map(|v| v == "1").unwrap_or(false) { + let watcher = if std::env::var("BLOOM_NO_HOT_RELOAD") + .map(|v| v == "1") + .unwrap_or(false) + { None } else { use notify::{Event, EventKind}; @@ -140,7 +143,9 @@ impl MaterialHotReload { paths.push(p); } } - if paths.is_empty() { return Vec::new(); } + if paths.is_empty() { + return Vec::new(); + } let now = Instant::now(); let mut last = self.last_event.lock().expect("hot_reload poisoned"); @@ -152,7 +157,9 @@ impl MaterialHotReload { // returns the realpath, sometimes the symlink path. let p = std::fs::canonicalize(&raw).unwrap_or(raw); if let Some(t) = last.get(&p) { - if now.duration_since(*t) < DEBOUNCE_WINDOW { continue; } + if now.duration_since(*t) < DEBOUNCE_WINDOW { + continue; + } } last.insert(p.clone(), now); if let Some(handles) = self.by_path.get(&p) { @@ -188,7 +195,9 @@ impl MaterialHotReload { None => return, }; let mut watched = self.watched_dirs.lock().expect("hot_reload poisoned"); - if watched.contains(&dir) { return; } + if watched.contains(&dir) { + return; + } if let Some(w) = self._watcher.as_mut() { if let Err(e) = w.watch(&dir, RecursiveMode::NonRecursive) { eprintln!("[hot_reload] failed to watch {dir:?}: {e:?}"); @@ -211,10 +220,10 @@ mod tests { fn desc(path: &str) -> FileMaterialDesc { FileMaterialDesc { - path: PathBuf::from(path), - profile: FragmentProfile::Translucent, - bucket: Bucket::Refractive, - reads_scene: true, + path: PathBuf::from(path), + profile: FragmentProfile::Translucent, + bucket: Bucket::Refractive, + reads_scene: true, wants_instancing: false, } } @@ -223,13 +232,16 @@ mod tests { fn drain_returns_registered_handle_for_event_path() { let mut hr = MaterialHotReload::new(); let p = PathBuf::from("/tmp/bloom_test_water.wgsl"); - hr.register(7, FileMaterialDesc { - path: p.clone(), - profile: FragmentProfile::Translucent, - bucket: Bucket::Refractive, - reads_scene: true, - wants_instancing: false, - }); + hr.register( + 7, + FileMaterialDesc { + path: p.clone(), + profile: FragmentProfile::Translucent, + bucket: Bucket::Refractive, + reads_scene: true, + wants_instancing: false, + }, + ); // canonicalize() in drain_pending falls back to the raw path // when the file doesn't exist, so this resolves to itself. hr.test_inject_event(p.clone()); @@ -308,10 +320,10 @@ mod tests { hr.register( 42, FileMaterialDesc { - path: canonical.clone(), - profile: FragmentProfile::Translucent, - bucket: Bucket::Refractive, - reads_scene: true, + path: canonical.clone(), + profile: FragmentProfile::Translucent, + bucket: Bucket::Refractive, + reads_scene: true, wants_instancing: false, }, ); @@ -322,7 +334,11 @@ mod tests { // canonical, so the two line up. hr.test_inject_event(canonical.clone()); let pending = hr.drain_pending(); - assert_eq!(pending.len(), 1, "registered descriptor surfaced exactly once"); + assert_eq!( + pending.len(), + 1, + "registered descriptor surfaced exactly once" + ); assert_eq!(pending[0].0, 42); assert_eq!(pending[0].1.path, canonical); assert_eq!(pending[0].1.bucket, Bucket::Refractive); @@ -366,13 +382,16 @@ mod tests { let canonical = std::fs::canonicalize(&tmp).expect("canonicalize tmp"); let mut hr = MaterialHotReload::new(); - hr.register(99, FileMaterialDesc { - path: canonical.clone(), - profile: FragmentProfile::Opaque, - bucket: Bucket::Opaque, - reads_scene: false, - wants_instancing: false, - }); + hr.register( + 99, + FileMaterialDesc { + path: canonical.clone(), + profile: FragmentProfile::Opaque, + bucket: Bucket::Opaque, + reads_scene: false, + wants_instancing: false, + }, + ); // Give the OS a moment to register the watch, then rewrite and // wait past the debounce. @@ -383,7 +402,8 @@ mod tests { let pending = hr.drain_pending(); assert!( pending.iter().any(|(h, _)| *h == 99), - "notify should have fired for {:?}", canonical, + "notify should have fired for {:?}", + canonical, ); let _ = std::fs::remove_file(&tmp); } diff --git a/native/shared/src/renderer/immediate_motion.rs b/native/shared/src/renderer/immediate_motion.rs new file mode 100644 index 00000000..7eb944e2 --- /dev/null +++ b/native/shared/src/renderer/immediate_motion.rs @@ -0,0 +1,171 @@ +//! Previous-position ownership for raylib-style immediate 3D primitives. +//! +//! Immediate vertices are rebuilt in world space every frame, so a previous +//! model matrix cannot describe their motion. Stable submission slots own a +//! compact CPU copy of the prior positions instead. The existing tangent lane +//! is unused by `pipeline_3d`; xyz carries the previous world position and w +//! marks that payload for the vertex shader. This changes neither the vertex +//! stride nor GPU upload size. + +use super::{Renderer, Vertex3D}; + +pub(super) const PREVIOUS_POSITION_MARKER: f32 = 2.0; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum PrimitiveKind { + Cube, + CubeWires, + Sphere, + SphereWires, + Cylinder, + Plane, + Grid, + Ray, +} + +#[derive(Clone, Copy, Debug)] +struct Range { + kind: PrimitiveKind, + start: usize, + count: usize, +} + +#[derive(Default)] +pub(super) struct History { + previous_positions: Vec<[f32; 3]>, + current_positions: Vec<[f32; 3]>, + previous_ranges: Vec, + current_ranges: Vec, + next_slot: usize, +} + +impl History { + pub(super) fn begin_frame(&mut self) { + std::mem::swap(&mut self.previous_positions, &mut self.current_positions); + self.current_positions.clear(); + std::mem::swap(&mut self.previous_ranges, &mut self.current_ranges); + self.current_ranges.clear(); + self.next_slot = 0; + } + + pub(super) fn reset(&mut self) { + self.previous_positions.clear(); + self.current_positions.clear(); + self.previous_ranges.clear(); + self.current_ranges.clear(); + self.next_slot = 0; + } + + /// Attach the matching prior-frame positions to one completed primitive. + /// + /// Kind and vertex count are a topology fence. A first appearance, a + /// reordered primitive of another kind, or a changed tessellation seeds + /// previous=current so it cannot create a false motion vector. + pub(super) fn record(&mut self, kind: PrimitiveKind, vertices: &mut [Vertex3D]) { + let previous = self + .previous_ranges + .get(self.next_slot) + .copied() + .filter(|range| { + range.kind == kind + && range.count == vertices.len() + && range.start.saturating_add(range.count) <= self.previous_positions.len() + }); + let current_start = self.current_positions.len(); + for (index, vertex) in vertices.iter_mut().enumerate() { + let prior = previous + .map(|range| self.previous_positions[range.start + index]) + .unwrap_or(vertex.position); + vertex.tangent = [prior[0], prior[1], prior[2], PREVIOUS_POSITION_MARKER]; + self.current_positions.push(vertex.position); + } + self.current_ranges.push(Range { + kind, + start: current_start, + count: vertices.len(), + }); + self.next_slot += 1; + } + + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub(super) fn stats(&self) -> (usize, usize) { + let entries = self.current_ranges.len(); + let bytes = (self.previous_positions.capacity() + self.current_positions.capacity()) + * std::mem::size_of::<[f32; 3]>() + + (self.previous_ranges.capacity() + self.current_ranges.capacity()) + * std::mem::size_of::(); + (entries, bytes) + } +} + +impl Renderer { + pub(super) fn record_immediate_motion(&mut self, kind: PrimitiveKind, vertex_start: usize) { + self.immediate_motion + .record(kind, &mut self.vertices_3d[vertex_start..]); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vertex(position: [f32; 3]) -> Vertex3D { + Vertex3D { + position, + tangent: [9.0; 4], + ..Default::default() + } + } + + fn prior(vertex: &Vertex3D) -> [f32; 3] { + [vertex.tangent[0], vertex.tangent[1], vertex.tangent[2]] + } + + #[test] + fn first_appearance_seeds_zero_motion_then_stable_slot_uses_prior_position() { + let mut history = History::default(); + history.begin_frame(); + let mut first = [vertex([1.0, 2.0, 3.0])]; + history.record(PrimitiveKind::Cube, &mut first); + assert_eq!(prior(&first[0]), first[0].position); + assert_eq!(first[0].tangent[3], PREVIOUS_POSITION_MARKER); + + history.begin_frame(); + let mut moved = [vertex([4.0, 5.0, 6.0])]; + history.record(PrimitiveKind::Cube, &mut moved); + assert_eq!(prior(&moved[0]), [1.0, 2.0, 3.0]); + } + + #[test] + fn kind_and_topology_mismatches_cannot_inherit_unrelated_motion() { + let mut history = History::default(); + history.begin_frame(); + history.record( + PrimitiveKind::Cube, + &mut [vertex([1.0, 0.0, 0.0]), vertex([2.0, 0.0, 0.0])], + ); + + history.begin_frame(); + let mut wrong_kind = [vertex([8.0, 0.0, 0.0]), vertex([9.0, 0.0, 0.0])]; + history.record(PrimitiveKind::Sphere, &mut wrong_kind); + assert_eq!(prior(&wrong_kind[0]), wrong_kind[0].position); + + history.begin_frame(); + let mut wrong_count = [vertex([12.0, 0.0, 0.0])]; + history.record(PrimitiveKind::Sphere, &mut wrong_count); + assert_eq!(prior(&wrong_count[0]), wrong_count[0].position); + } + + #[test] + fn an_empty_frame_breaks_submission_history() { + let mut history = History::default(); + history.begin_frame(); + history.record(PrimitiveKind::Ray, &mut [vertex([1.0, 0.0, 0.0])]); + history.begin_frame(); + history.begin_frame(); + + let mut reappeared = [vertex([7.0, 0.0, 0.0])]; + history.record(PrimitiveKind::Ray, &mut reappeared); + assert_eq!(prior(&reappeared[0]), reappeared[0].position); + } +} diff --git a/native/shared/src/renderer/impulse_field.rs b/native/shared/src/renderer/impulse_field.rs index c671eca9..65b9675d 100644 --- a/native/shared/src/renderer/impulse_field.rs +++ b/native/shared/src/renderer/impulse_field.rs @@ -37,39 +37,39 @@ pub const IMPULSE_DECAY_PER_FRAME: f32 = 0.968; #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] struct SplatData { - pos: [f32; 2], // world xz - radius: f32, + pos: [f32; 2], // world xz + radius: f32, strength: f32, } #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] struct InfoUniforms { - world_min: [f32; 2], // meters - world_size: [f32; 2], // meters - decay: f32, - _pad0: f32, + world_min: [f32; 2], // meters + world_size: [f32; 2], // meters + decay: f32, + _pad0: f32, splat_count: u32, - _pad1: u32, - splats: [SplatData; MAX_SPLATS_PER_FRAME], + _pad1: u32, + splats: [SplatData; MAX_SPLATS_PER_FRAME], } pub struct ImpulseField { - pipeline: wgpu::ComputePipeline, - bg_layout: wgpu::BindGroupLayout, - info_buf: wgpu::Buffer, + pipeline: wgpu::ComputePipeline, + bg_layout: wgpu::BindGroupLayout, + info_buf: wgpu::Buffer, /// Backing textures for view_a/view_b — kept alive so the views /// don't dangle. Sampling and binding go through the views; these /// fields are write-only handles. - _tex_a: wgpu::Texture, - _tex_b: wgpu::Texture, - view_a: wgpu::TextureView, - view_b: wgpu::TextureView, - sampler: wgpu::Sampler, + _tex_a: wgpu::Texture, + _tex_b: wgpu::Texture, + view_a: wgpu::TextureView, + view_b: wgpu::TextureView, + sampler: wgpu::Sampler, /// When true, view_a is the "front" that scene_inputs reads and /// the next compute pass reads-from. After dispatch we swap. front_is_a: bool, - splats: Vec, + splats: Vec, } impl ImpulseField { @@ -79,7 +79,8 @@ impl ImpulseField { entries: &[ // 0: src (previous-frame sampled texture) wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D2, @@ -89,7 +90,8 @@ impl ImpulseField { }, // 1: dst (write-only storage) wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::COMPUTE, + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, format: wgpu::TextureFormat::R32Float, @@ -99,7 +101,8 @@ impl ImpulseField { }, // 2: info UBO (splats + bounds + decay) wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::COMPUTE, + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, @@ -116,7 +119,8 @@ impl ImpulseField { immediate_size: 0, }); - let shader_src = library().fetch("impulse_field.wgsl") + let shader_src = library() + .fetch("impulse_field.wgsl") .expect("impulse_field.wgsl must be present in shader_library") .to_string(); let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -143,15 +147,17 @@ impl ImpulseField { let tex = device.create_texture(&wgpu::TextureDescriptor { label: Some(label), size: wgpu::Extent3d { - width: IMPULSE_SIZE, height: IMPULSE_SIZE, + width: IMPULSE_SIZE, + height: IMPULSE_SIZE, depth_or_array_layers: 1, }, - mip_level_count: 1, sample_count: 1, + mip_level_count: 1, + sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::R32Float, usage: wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::STORAGE_BINDING - | wgpu::TextureUsages::COPY_SRC, + | wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let view = tex.create_view(&Default::default()); @@ -171,8 +177,14 @@ impl ImpulseField { }); Self { - pipeline, bg_layout, info_buf, - _tex_a: tex_a, _tex_b: tex_b, view_a, view_b, sampler, + pipeline, + bg_layout, + info_buf, + _tex_a: tex_a, + _tex_b: tex_b, + view_a, + view_b, + sampler, front_is_a: true, splats: Vec::with_capacity(MAX_SPLATS_PER_FRAME), } @@ -181,9 +193,13 @@ impl ImpulseField { /// Gameplay API — queue a splat at world xz with given radius (m) /// and peak strength. Silently drops overflow. pub fn submit_splat(&mut self, world_x: f32, world_z: f32, radius: f32, strength: f32) { - if self.splats.len() >= MAX_SPLATS_PER_FRAME { return; } + if self.splats.len() >= MAX_SPLATS_PER_FRAME { + return; + } self.splats.push(SplatData { - pos: [world_x, world_z], radius, strength, + pos: [world_x, world_z], + radius, + strength, }); } @@ -192,8 +208,12 @@ impl ImpulseField { /// After this, `front_view()` returns the view containing the new /// field and `update_scene_inputs` should bind it at group 4 /// binding 4. - pub fn update(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, - encoder: &mut wgpu::CommandEncoder) { + pub fn update( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + ) { // Ping-pong: read from the CURRENT front (last frame's output), // write into the other side, then flip `front_is_a`. let (src_view, dst_view) = if self.front_is_a { @@ -204,14 +224,20 @@ impl ImpulseField { // Build info UBO from the queued splats. let mut info = InfoUniforms { - world_min: [-IMPULSE_WORLD_HALF_EXTENT, -IMPULSE_WORLD_HALF_EXTENT], - world_size: [IMPULSE_WORLD_HALF_EXTENT * 2.0, IMPULSE_WORLD_HALF_EXTENT * 2.0], - decay: IMPULSE_DECAY_PER_FRAME, - _pad0: 0.0, + world_min: [-IMPULSE_WORLD_HALF_EXTENT, -IMPULSE_WORLD_HALF_EXTENT], + world_size: [ + IMPULSE_WORLD_HALF_EXTENT * 2.0, + IMPULSE_WORLD_HALF_EXTENT * 2.0, + ], + decay: IMPULSE_DECAY_PER_FRAME, + _pad0: 0.0, splat_count: self.splats.len() as u32, - _pad1: 0, - splats: [SplatData { pos: [0.0; 2], radius: 0.0, strength: 0.0 }; - MAX_SPLATS_PER_FRAME], + _pad1: 0, + splats: [SplatData { + pos: [0.0; 2], + radius: 0.0, + strength: 0.0, + }; MAX_SPLATS_PER_FRAME], }; for (i, s) in self.splats.iter().enumerate() { info.splats[i] = *s; @@ -223,9 +249,18 @@ impl ImpulseField { label: Some("impulse_field_bg"), layout: &self.bg_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(src_view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(dst_view) }, - wgpu::BindGroupEntry { binding: 2, resource: self.info_buf.as_entire_binding() }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(src_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(dst_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: self.info_buf.as_entire_binding(), + }, ], }); @@ -246,10 +281,16 @@ impl ImpulseField { /// The view containing the latest impulse field — what materials /// should sample. Available AFTER `update` ran this frame. pub fn front_view(&self) -> &wgpu::TextureView { - if self.front_is_a { &self.view_a } else { &self.view_b } + if self.front_is_a { + &self.view_a + } else { + &self.view_b + } } - pub fn sampler(&self) -> &wgpu::Sampler { &self.sampler } + pub fn sampler(&self) -> &wgpu::Sampler { + &self.sampler + } } // ===================================================================== @@ -280,21 +321,21 @@ mod tests { power_preference: wgpu::PowerPreference::LowPower, compatible_surface: None, force_fallback_adapter: true, - })).ok()?; + })) + .ok()?; // Only proceed if the adapter advertises R32Float storage support. let needed = wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES; let supported = adapter.features(); if !supported.contains(needed) { return None; } - let (device, queue) = pollster::block_on(adapter.request_device( - &wgpu::DeviceDescriptor { - label: Some("impulse-test-device"), - required_features: needed, - required_limits: wgpu::Limits::downlevel_defaults(), - ..Default::default() - }, - )).ok()?; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("impulse-test-device"), + required_features: needed, + required_limits: wgpu::Limits::downlevel_defaults(), + ..Default::default() + })) + .ok()?; Some((device, queue)) } @@ -315,7 +356,11 @@ mod tests { mapped_at_creation: false, }); // Pull the actual texture handle that backs `front_view`. - let front_tex = if field.front_is_a { &field._tex_a } else { &field._tex_b }; + let front_tex = if field.front_is_a { + &field._tex_a + } else { + &field._tex_b + }; let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("impulse_test_readback"), }); @@ -334,14 +379,25 @@ mod tests { rows_per_image: Some(IMPULSE_SIZE), }, }, - wgpu::Extent3d { width: IMPULSE_SIZE, height: IMPULSE_SIZE, depth_or_array_layers: 1 }, + wgpu::Extent3d { + width: IMPULSE_SIZE, + height: IMPULSE_SIZE, + depth_or_array_layers: 1, + }, ); queue.submit(std::iter::once(encoder.finish())); let slice = staging.slice(..); let (tx, rx) = std::sync::mpsc::channel(); - slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); - let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); - rx.recv().expect("map_async sender dropped").expect("map failed"); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); + rx.recv() + .expect("map_async sender dropped") + .expect("map failed"); let data = slice.get_mapped_range(); let mut out: Vec = Vec::with_capacity((IMPULSE_SIZE * IMPULSE_SIZE) as usize); for row in 0..IMPULSE_SIZE { @@ -376,7 +432,9 @@ mod tests { /// (R32Float storage textures). #[test] fn splat_appears_then_decays_across_frames() { - let Some((device, queue)) = try_create_device() else { return; }; + let Some((device, queue)) = try_create_device() else { + return; + }; let mut field = ImpulseField::new(&device); // Submit a splat at world origin with a generous radius so it @@ -398,7 +456,9 @@ mod tests { assert!( center_v1 > 0.0, "splat should write a positive value at the centre texel ({}, {}); got {}", - tx, ty, center_v1, + tx, + ty, + center_v1, ); // A texel far outside the splat radius (16 m away ≫ 2 m radius) @@ -425,15 +485,23 @@ mod tests { assert!( v < prev, "frame {} centre value {} should be < previous {}", - frame, v, prev, + frame, + v, + prev, + ); + assert!( + v > 0.0, + "frame {} centre value should still be positive (decay, not clear)", + frame ); - assert!(v > 0.0, "frame {} centre value should still be positive (decay, not clear)", frame); // Verify the multiplier is roughly the documented decay. let ratio = v / prev; assert!( (ratio - IMPULSE_DECAY_PER_FRAME).abs() < 1e-3, "frame {} ratio {} should match documented decay {}", - frame, ratio, IMPULSE_DECAY_PER_FRAME, + frame, + ratio, + IMPULSE_DECAY_PER_FRAME, ); prev = v; } @@ -448,7 +516,11 @@ mod tests { let mut splats: Vec = Vec::with_capacity(MAX_SPLATS_PER_FRAME); for _ in 0..(MAX_SPLATS_PER_FRAME + 5) { if splats.len() < MAX_SPLATS_PER_FRAME { - splats.push(SplatData { pos: [0.0, 0.0], radius: 1.0, strength: 1.0 }); + splats.push(SplatData { + pos: [0.0, 0.0], + radius: 1.0, + strength: 1.0, + }); } } assert_eq!(splats.len(), MAX_SPLATS_PER_FRAME); diff --git a/native/shared/src/renderer/layered_pbr.rs b/native/shared/src/renderer/layered_pbr.rs new file mode 100644 index 00000000..0e73558f --- /dev/null +++ b/native/shared/src/renderer/layered_pbr.rs @@ -0,0 +1,126 @@ +//! Packed layered-PBR material metadata shared by the legacy/custom uniform +//! record and the global material-indirection storage record. +//! +//! Version 1 is an ABI-only foundation: every existing material has an empty +//! lobe mask and no shader branches on it. Reserved lanes keep both record +//! sizes and bind-group layouts unchanged. + +use super::material_system::MaterialFactorsUniforms; + +/// Version of the per-material layered-PBR metadata, independent from the +/// broader custom-shader ABI version. +pub(crate) const MATERIAL_RECORD_VERSION: u32 = 1; + +/// The global storage record keeps its version in the high eight bits and its +/// lobe mask in the low 24 bits of `header.y`. +pub(crate) const MATERIAL_RECORD_VERSION_SHIFT: u32 = 24; +pub(crate) const MATERIAL_LOBE_MASK_BITS: u32 = MATERIAL_RECORD_VERSION_SHIFT; +pub(crate) const MATERIAL_LOBE_MASK_MASK: u32 = (1 << MATERIAL_LOBE_MASK_BITS) - 1; + +/// Reserved lobe assignments. No production material enables these in the ABI +/// foundation package; the names pin future import/runtime interoperability. +#[repr(transparent)] +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct MaterialLobeMask(u32); + +// Web does not compile the native quality-telemetry consumer yet. Keep the +// reserved assignments visible to every target without adding wasm-only +// dead-code warnings. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +impl MaterialLobeMask { + pub(crate) const NONE: Self = Self(0); + pub(crate) const CLEARCOAT: Self = Self(1 << 0); + pub(crate) const SHEEN: Self = Self(1 << 1); + pub(crate) const ANISOTROPY: Self = Self(1 << 2); + pub(crate) const IRIDESCENCE: Self = Self(1 << 3); + pub(crate) const SPECULAR_IOR: Self = Self(1 << 4); + pub(crate) const TRANSMISSION: Self = Self(1 << 5); + pub(crate) const KNOWN: Self = Self( + Self::CLEARCOAT.0 + | Self::SHEEN.0 + | Self::ANISOTROPY.0 + | Self::IRIDESCENCE.0 + | Self::SPECULAR_IOR.0 + | Self::TRANSMISSION.0, + ); + + pub(crate) const fn from_bits_truncate(bits: u32) -> Self { + Self(bits & MATERIAL_LOBE_MASK_MASK) + } + + pub(crate) const fn bits(self) -> u32 { + self.0 + } + + pub(crate) const fn is_empty(self) -> bool { + self.0 == 0 + } +} + +pub(crate) const fn pack_global_material_metadata(mask: MaterialLobeMask) -> u32 { + (MATERIAL_RECORD_VERSION << MATERIAL_RECORD_VERSION_SHIFT) + | (mask.bits() & MATERIAL_LOBE_MASK_MASK) +} + +pub(crate) const fn global_material_version(metadata: u32) -> u32 { + metadata >> MATERIAL_RECORD_VERSION_SHIFT +} + +pub(crate) const fn global_material_lobe_mask(metadata: u32) -> MaterialLobeMask { + MaterialLobeMask::from_bits_truncate(metadata) +} + +/// Custom `MaterialFactors` keeps the exact u32 version and mask bit patterns +/// in the previously reserved `foliage_params.zw` f32 lanes. +pub(crate) const fn bound_material_version_lane() -> f32 { + f32::from_bits(MATERIAL_RECORD_VERSION) +} + +pub(crate) const fn bound_material_lobe_mask_lane(mask: MaterialLobeMask) -> f32 { + f32::from_bits(mask.bits()) +} + +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) const fn bound_material_version(lane: f32) -> u32 { + lane.to_bits() +} + +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) const fn bound_material_lobe_mask(lane: f32) -> MaterialLobeMask { + MaterialLobeMask::from_bits_truncate(lane.to_bits()) +} + +impl MaterialFactorsUniforms { + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub(crate) fn layered_pbr_version(&self) -> u32 { + bound_material_version(self.foliage_params[2]) + } + + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub(crate) fn layered_pbr_lobe_mask(&self) -> MaterialLobeMask { + bound_material_lobe_mask(self.foliage_params[3]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn global_metadata_round_trips_version_and_mask_without_overlap() { + let metadata = pack_global_material_metadata(MaterialLobeMask::KNOWN); + assert_eq!(global_material_version(metadata), MATERIAL_RECORD_VERSION); + assert_eq!(global_material_lobe_mask(metadata), MaterialLobeMask::KNOWN); + assert_eq!(MaterialLobeMask::KNOWN.bits() & !MATERIAL_LOBE_MASK_MASK, 0); + } + + #[test] + fn bound_uniform_lanes_preserve_exact_integer_bits() { + assert_eq!( + bound_material_version(bound_material_version_lane()), + MATERIAL_RECORD_VERSION + ); + let mask_lane = bound_material_lobe_mask_lane(MaterialLobeMask::KNOWN); + assert_eq!(bound_material_lobe_mask(mask_lane), MaterialLobeMask::KNOWN); + } +} diff --git a/native/shared/src/renderer/layered_pbr_pt.rs b/native/shared/src/renderer/layered_pbr_pt.rs new file mode 100644 index 00000000..848f0e1a --- /dev/null +++ b/native/shared/src/renderer/layered_pbr_pt.rs @@ -0,0 +1,1958 @@ +//! Lazy path-tracing sidecar for layered PBR material factors. +//! +//! The shared `InstanceGiDataCpu` buffer is also consumed by SSGI and WSRC, +//! so layered path tracing must not grow it. This module keeps a parallel +//! record only for scenes with a contributing layered material and compiles a +//! group-2 PT specialization only when that scene is actually path traced. + +use super::*; + +#[path = "layered_pbr_pt_anisotropy_texture.rs"] +mod anisotropy_texture; +#[path = "layered_pbr_pt_clearcoat_normal.rs"] +mod clearcoat_normal; +#[path = "layered_pbr_pt_clearcoat_texture.rs"] +mod clearcoat_texture; +#[path = "layered_pbr_pt_iridescence_texture.rs"] +mod iridescence_texture; +#[path = "layered_pbr_pt_kernel.rs"] +mod kernel; +#[path = "layered_pbr_pt_resources.rs"] +mod resources; +#[path = "layered_pbr_pt_runtime.rs"] +mod runtime; +#[path = "layered_pbr_pt_sheen_texture.rs"] +mod sheen_texture; +#[path = "layered_pbr_pt_texture.rs"] +mod texture; +pub(super) use anisotropy_texture::PtAnisotropyTextureCpu; +use anisotropy_texture::{ + PT_ANISOTROPY_TEXTURE_BINDINGS_WGSL, PT_ANISOTROPY_TEXTURE_DISABLED_WGSL, + PT_ANISOTROPY_TEXTURE_RECORD_BYTES, +}; +pub(super) use clearcoat_normal::PtClearcoatNormalCpu; +use clearcoat_normal::{ + PT_CLEARCOAT_NORMAL_BINDINGS_WGSL, PT_CLEARCOAT_NORMAL_DISABLED_WGSL, + PT_CLEARCOAT_NORMAL_RECORD_BYTES, +}; +pub(super) use clearcoat_texture::PtClearcoatTextureCpu; +use clearcoat_texture::{ + PT_CLEARCOAT_TEXTURE_BINDINGS_WGSL, PT_CLEARCOAT_TEXTURE_DISABLED_WGSL, + PT_CLEARCOAT_TEXTURE_RECORD_BYTES, +}; +pub(super) use iridescence_texture::PtIridescenceTextureCpu; +use iridescence_texture::{ + PT_IRIDESCENCE_TEXTURE_BINDINGS_WGSL, PT_IRIDESCENCE_TEXTURE_DISABLED_WGSL, + PT_IRIDESCENCE_TEXTURE_RECORD_BYTES, +}; +use kernel::layered_kernel_variant; +use resources::ensure_pt_sidecar; +pub(super) use sheen_texture::PtSheenTextureCpu; +use sheen_texture::{ + PT_SHEEN_TEXTURE_BINDINGS_WGSL, PT_SHEEN_TEXTURE_DISABLED_WGSL, PT_SHEEN_TEXTURE_RECORD_BYTES, +}; +pub(super) use texture::{append_record, PtLayeredRuntimeState, PtLayeredTextureCpu}; +use texture::{ + texture_variant, PT_LAYERED_TEXTURE_BINDINGS_WGSL, PT_LAYERED_TEXTURE_DISABLED_WGSL, + PT_LAYERED_TEXTURE_RECORD_BYTES, PT_LAYERED_UV1_BINDINGS_WGSL, PT_LAYERED_UV1_DISABLED_WGSL, +}; + +pub(super) const PT_LAYERED_RECORD_VERSION: u32 = 1; + +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] +pub(super) struct PtLayeredMaterialCpu { + /// x = ABI version, y = lobe mask, z = texture-bearing lobe mask. + pub(super) header: [u32; 4], + pub(super) clearcoat_ior: [f32; 4], + pub(super) specular: [f32; 4], + pub(super) sheen: [f32; 4], + /// x = strength, yz = cos/sin authored rotation. + pub(super) anisotropy: [f32; 4], + pub(super) iridescence: [f32; 4], +} + +const PT_LAYERED_RECORD_BYTES: u64 = std::mem::size_of::() as u64; + +impl Default for PtLayeredMaterialCpu { + fn default() -> Self { + Self { + header: [PT_LAYERED_RECORD_VERSION, 0, 0, 0], + clearcoat_ior: [0.0, 0.0, 1.0, 1.5], + specular: [1.0, 1.0, 1.0, 1.0], + sheen: [0.0; 4], + anisotropy: [0.0, 1.0, 0.0, 0.0], + iridescence: [0.0, 1.3, 100.0, 400.0], + } + } +} + +impl PtLayeredMaterialCpu { + fn from_material(material: crate::models::MaterialLayeredPbr) -> Self { + fn finite_or(value: f32, fallback: f32) -> f32 { + if value.is_finite() { + value + } else { + fallback + } + } + fn unit(value: f32, fallback: f32) -> f32 { + finite_or(value, fallback).clamp(0.0, 1.0) + } + fn non_negative(value: f32, fallback: f32) -> f32 { + finite_or(value, fallback).max(0.0) + } + + let mut mask = 0; + let mut texture_mask = 0; + if material.has_clearcoat() { + mask |= crate::models::MaterialLayeredPbr::CLEARCOAT_LOBE; + } + if material.clearcoat_texture.is_some() || material.clearcoat_roughness_texture.is_some() { + texture_mask |= crate::models::MaterialLayeredPbr::CLEARCOAT_LOBE; + } + if material.has_sheen() { + mask |= crate::models::MaterialLayeredPbr::SHEEN_LOBE; + } + if material.sheen_color_texture.is_some() || material.sheen_roughness_texture.is_some() { + texture_mask |= crate::models::MaterialLayeredPbr::SHEEN_LOBE; + } + if material.has_anisotropy() { + mask |= crate::models::MaterialLayeredPbr::ANISOTROPY_LOBE; + } + if material.anisotropy_texture.is_some() { + texture_mask |= crate::models::MaterialLayeredPbr::ANISOTROPY_LOBE; + } + if material.has_iridescence() { + mask |= crate::models::MaterialLayeredPbr::IRIDESCENCE_LOBE; + } + if material.iridescence_texture.is_some() + || material.iridescence_thickness_texture.is_some() + { + texture_mask |= crate::models::MaterialLayeredPbr::IRIDESCENCE_LOBE; + } + if material.has_specular_ior() { + mask |= crate::models::MaterialLayeredPbr::SPECULAR_IOR_LOBE; + } + if material.specular_texture.is_some() || material.specular_color_texture.is_some() { + texture_mask |= crate::models::MaterialLayeredPbr::SPECULAR_IOR_LOBE; + } + let rotation = finite_or(material.anisotropy_rotation, 0.0); + let (rotation_sine, rotation_cosine) = rotation.sin_cos(); + Self { + header: [PT_LAYERED_RECORD_VERSION, mask, texture_mask, 0], + clearcoat_ior: [ + unit(material.clearcoat_factor, 0.0), + unit(material.clearcoat_roughness_factor, 0.0), + finite_or(material.clearcoat_normal_scale, 1.0), + non_negative(material.ior, 1.5), + ], + specular: [ + non_negative(material.specular_color_factor[0], 1.0), + non_negative(material.specular_color_factor[1], 1.0), + non_negative(material.specular_color_factor[2], 1.0), + unit(material.specular_factor, 1.0), + ], + sheen: [ + unit(material.sheen_color_factor[0], 0.0), + unit(material.sheen_color_factor[1], 0.0), + unit(material.sheen_color_factor[2], 0.0), + unit(material.sheen_roughness_factor, 0.0), + ], + anisotropy: [ + unit(material.anisotropy_strength, 0.0), + rotation_cosine, + rotation_sine, + 0.0, + ], + iridescence: [ + unit(material.iridescence_factor, 0.0), + non_negative(material.iridescence_ior, 1.3).max(1.0), + non_negative(material.iridescence_thickness_minimum, 100.0), + non_negative(material.iridescence_thickness_maximum, 400.0), + ], + } + } + + pub(super) fn active(self) -> bool { + self.header[1] != 0 + } + + fn has_clearcoat(self) -> bool { + self.header[1] & crate::models::MaterialLayeredPbr::CLEARCOAT_LOBE != 0 + && self.header[2] & crate::models::MaterialLayeredPbr::CLEARCOAT_LOBE == 0 + && self.clearcoat_ior[0] > 0.0 + } + + fn has_specular_ior(self) -> bool { + self.header[1] & crate::models::MaterialLayeredPbr::SPECULAR_IOR_LOBE != 0 + && self.header[2] & crate::models::MaterialLayeredPbr::SPECULAR_IOR_LOBE == 0 + } + + fn has_sheen(self) -> bool { + self.header[1] & crate::models::MaterialLayeredPbr::SHEEN_LOBE != 0 + && self.header[2] & crate::models::MaterialLayeredPbr::SHEEN_LOBE == 0 + && self.sheen[..3].iter().any(|value| *value > 0.0) + } + + fn has_anisotropy(self) -> bool { + self.header[1] & crate::models::MaterialLayeredPbr::ANISOTROPY_LOBE != 0 + && self.header[2] & crate::models::MaterialLayeredPbr::ANISOTROPY_LOBE == 0 + && self.anisotropy[0] > 0.0 + } + + fn has_iridescence(self) -> bool { + self.header[1] & crate::models::MaterialLayeredPbr::IRIDESCENCE_LOBE != 0 + && self.header[2] & crate::models::MaterialLayeredPbr::IRIDESCENCE_LOBE == 0 + && self.iridescence[0] > 0.0 + && self.iridescence[3] > 0.0 + } + + fn has_qualified_transport(self) -> bool { + self.has_clearcoat() + || self.has_specular_ior() + || self.has_sheen() + || self.has_anisotropy() + || self.has_iridescence() + } +} + +const PT_LAYERED_BINDINGS_WGSL: &str = r#" +struct PtLayeredMaterial { + header: vec4, + clearcoat_ior: vec4, + specular: vec4, + sheen: vec4, + anisotropy: vec4, + iridescence: vec4, +}; +@group(2) @binding(0) +var pt_layered_materials: array; +"#; + +const PT_LAYERED_TRANSPORT_WGSL: &str = r#" +const PT_LAYERED_CLEARCOAT_LOBE: u32 = 1u; +const PT_LAYERED_ANISOTROPY_LOBE: u32 = 4u; +const PT_LAYERED_SPECULAR_IOR_LOBE: u32 = 16u; + +struct PtLayeredSurface { + material: PtLayeredMaterial, + tangent: vec4, + clearcoat_normal: vec3, +}; + +fn pt_layered_default() -> PtLayeredMaterial { + return PtLayeredMaterial( + vec4(1u, 0u, 0u, 0u), + vec4(0.0, 0.0, 1.0, 1.5), + vec4(1.0), + vec4(0.0), + vec4(0.0, 1.0, 0.0, 0.0), + vec4(0.0, 1.3, 100.0, 400.0), + ); +} + +fn pt_layered_has_clearcoat(material: PtLayeredMaterial) -> bool { + return material.header.x == 1u + && (material.header.y & PT_LAYERED_CLEARCOAT_LOBE) != 0u + && (material.header.z & PT_LAYERED_CLEARCOAT_LOBE) == 0u + && material.clearcoat_ior.x > 0.0; +} + +fn pt_layered_has_specular_ior(material: PtLayeredMaterial) -> bool { + return material.header.x == 1u + && (material.header.y & PT_LAYERED_SPECULAR_IOR_LOBE) != 0u + && (material.header.z & PT_LAYERED_SPECULAR_IOR_LOBE) == 0u; +} + +fn pt_layered_has_anisotropy(material: PtLayeredMaterial) -> bool { + return PT_HAS_SCALAR_ANISOTROPY + && material.header.x == 1u + && (material.header.y & PT_LAYERED_ANISOTROPY_LOBE) != 0u + && (material.header.z & PT_LAYERED_ANISOTROPY_LOBE) == 0u + && material.anisotropy.x > 0.0; +} + +fn pt_layered_has_transport(material: PtLayeredMaterial) -> bool { + return pt_layered_has_clearcoat(material) + || pt_layered_has_specular_ior(material) + || pt_layered_has_sheen(material) + || pt_layered_has_anisotropy(material) + || pt_layered_has_iridescence(material); +} + +fn pt_ior_f0(ior_value: f32) -> f32 { + if (ior_value == 0.0) { + return 1.0; + } + let ior = max(ior_value, 1.0); + let ratio = (ior - 1.0) / (ior + 1.0); + return ratio * ratio; +} + +fn pt_dielectric_f0(material: PtLayeredMaterial) -> vec3 { + return min( + max(material.specular.xyz, vec3(0.0)) * pt_ior_f0(material.clearcoat_ior.w), + vec3(1.0), + ) * clamp(material.specular.w, 0.0, 1.0); +} + +fn pt_fresnel_f90(cos_theta: f32, f0: vec3, f90: vec3) -> vec3 { + let m = 1.0 - clamp(cos_theta, 0.0, 1.0); + return f0 + (f90 - f0) * (m * m * m * m * m); +} + +fn pt_layered_base_fresnel( + cos_theta: f32, + base_color: vec3, + metallic: f32, + material: PtLayeredMaterial, +) -> vec3 { + var base: vec3; + if (pt_layered_has_specular_ior(material)) { + let dielectric = pt_fresnel_f90( + cos_theta, + pt_dielectric_f0(material), + vec3(clamp(material.specular.w, 0.0, 1.0)), + ); + let conductor = fresnel_schlick3(cos_theta, base_color); + base = mix(dielectric, conductor, metallic); + } else { + base = fresnel_schlick3( + cos_theta, mix(vec3(0.04), base_color, metallic), + ); + } + return pt_apply_iridescence_base_fresnel( + base, cos_theta, base_color, metallic, material, + ); +} + +fn pt_dielectric_transmission(cos_theta: f32, material: PtLayeredMaterial) -> f32 { + let base = pt_fresnel_f90( + cos_theta, + pt_dielectric_f0(material), + vec3(clamp(material.specular.w, 0.0, 1.0)), + ); + let fresnel = pt_apply_iridescence_dielectric_fresnel( + base, cos_theta, material, + ); + return clamp(1.0 - max(fresnel.x, max(fresnel.y, fresnel.z)), 0.0, 1.0); +} + +fn pt_clearcoat_fresnel(cos_theta: f32, material: PtLayeredMaterial) -> f32 { + let schlick = 0.04 + 0.96 * pow(1.0 - clamp(cos_theta, 0.0, 1.0), 5.0); + return clamp(material.clearcoat_ior.x, 0.0, 1.0) * schlick; +} + +fn pt_clearcoat_transmission(cos_theta: f32, material: PtLayeredMaterial) -> f32 { + return 1.0 - pt_clearcoat_fresnel(cos_theta, material); +} + +fn pt_clearcoat_alpha(material: PtLayeredMaterial) -> f32 { + let perceptual_roughness = max(clamp(material.clearcoat_ior.y, 0.0, 1.0), 0.04); + return perceptual_roughness * perceptual_roughness; +} + +fn pt_layered_default_tangent(n: vec3) -> vec4 { + return vec4(onb(n)[0], 1.0); +} + +fn pt_layered_vertex_tangent(slot: u32) -> vec4 { + let offset = slot * PT_VSTRIDE + 20u; + return vec4( + geo_v[offset], + geo_v[offset + 1u], + geo_v[offset + 2u], + geo_v[offset + 3u], + ); +} + +fn pt_layered_hit_tangent( + geo: vec4, + primitive: u32, + barycentrics: vec2, + object_to_world: mat4x3, + n: vec3, +) -> vec4 { + if (geo.z == 0u) { + return pt_layered_default_tangent(n); + } + let base = geo.y + primitive * 3u; + let slot0 = geo.x + geo_i[base]; + let slot1 = geo.x + geo_i[base + 1u]; + let slot2 = geo.x + geo_i[base + 2u]; + let weight0 = 1.0 - barycentrics.x - barycentrics.y; + let tangent_os = weight0 * pt_layered_vertex_tangent(slot0) + + barycentrics.x * pt_layered_vertex_tangent(slot1) + + barycentrics.y * pt_layered_vertex_tangent(slot2); + let tangent_raw = object_to_world * vec4(tangent_os.xyz, 0.0); + let tangent_ortho = tangent_raw - n * dot(n, tangent_raw); + let tangent_length = length(tangent_ortho); + if (tangent_length <= 1e-4) { + return pt_layered_default_tangent(n); + } + let model_handedness = select( + -1.0, + 1.0, + dot( + cross(object_to_world[0], object_to_world[1]), + object_to_world[2], + ) >= 0.0, + ); + let authored_handedness = select(-1.0, 1.0, tangent_os.w >= 0.0); + return vec4( + tangent_ortho / tangent_length, + authored_handedness * model_handedness, + ); +} + +fn pt_layered_anisotropy_basis( + n: vec3, + tangent: vec4, + material: PtLayeredMaterial, +) -> mat3x3 { + let tangent_ortho = tangent.xyz - n * dot(n, tangent.xyz); + let tangent_length = length(tangent_ortho); + if (tangent_length <= 1e-4) { + return onb(n); + } + let mesh_tangent = tangent_ortho / tangent_length; + let mesh_bitangent = normalize(cross(n, mesh_tangent)) * tangent.w; + let rotated_raw = mesh_tangent * material.anisotropy.y + + mesh_bitangent * material.anisotropy.z; + let rotated_tangent = normalize( + rotated_raw - n * dot(n, rotated_raw), + ); + let rotated_bitangent = normalize(cross(n, rotated_tangent)); + return mat3x3(rotated_tangent, rotated_bitangent, n); +} + +fn pt_layered_anisotropy_alpha( + roughness: f32, + material: PtLayeredMaterial, +) -> vec2 { + let alpha = max(roughness * roughness, 1e-3); + let strength = clamp(material.anisotropy.x, 0.0, 1.0); + return vec2( + alpha + (1.0 - alpha) * strength * strength, + alpha, + ); +} + +fn pt_d_ggx_anisotropic( + n_dot_h: f32, + t_dot_h: f32, + b_dot_h: f32, + alpha: vec2, +) -> f32 { + let product = alpha.x * alpha.y; + let projected = vec3( + alpha.y * t_dot_h, + alpha.x * b_dot_h, + product * n_dot_h, + ); + let weight = product / max(dot(projected, projected), 1e-12); + return product * weight * weight / 3.14159265; +} + +fn pt_v_smith_anisotropic( + light_local: vec3, + view_local: vec3, + alpha: vec2, +) -> f32 { + let ggx_view = light_local.z * length(vec3( + alpha.x * view_local.x, + alpha.y * view_local.y, + view_local.z, + )); + let ggx_light = view_local.z * length(vec3( + alpha.x * light_local.x, + alpha.y * light_local.y, + light_local.z, + )); + return clamp(0.5 / (ggx_view + ggx_light + 1e-6), 0.0, 1.0); +} + +fn pt_smith_g1_anisotropic(direction: vec3, alpha: vec2) -> f32 { + let n_dot = max(direction.z, 0.0); + if (n_dot <= 0.0) { + return 0.0; + } + let projected = length(vec3( + alpha.x * direction.x, + alpha.y * direction.y, + n_dot, + )); + return 2.0 * n_dot / (n_dot + projected + 1e-6); +} + +fn pt_sample_ggx_vndf_anisotropic( + view: vec3, + alpha: vec2, + sample: vec2, +) -> vec3 { + let stretched_view = normalize(vec3( + alpha.x * view.x, + alpha.y * view.y, + view.z, + )); + let lensq = stretched_view.x * stretched_view.x + + stretched_view.y * stretched_view.y; + var tangent = vec3(1.0, 0.0, 0.0); + if (lensq > 0.0) { + tangent = vec3( + -stretched_view.y, stretched_view.x, 0.0, + ) / sqrt(lensq); + } + let bitangent = cross(stretched_view, tangent); + let radius = sqrt(sample.x); + let phi = 6.2831853 * sample.y; + let tangent_x = radius * cos(phi); + var tangent_y = radius * sin(phi); + let blend = 0.5 * (1.0 + stretched_view.z); + tangent_y = (1.0 - blend) + * sqrt(max(0.0, 1.0 - tangent_x * tangent_x)) + + blend * tangent_y; + let stretched_normal = tangent_x * tangent + + tangent_y * bitangent + + sqrt(max( + 0.0, 1.0 - tangent_x * tangent_x - tangent_y * tangent_y, + )) * stretched_view; + return normalize(vec3( + alpha.x * stretched_normal.x, + alpha.y * stretched_normal.y, + max(stretched_normal.z, 0.0), + )); +} + +fn pt_layered_primary_surface( + p: vec3, + n: vec3, +) -> PtLayeredSurface { + let to_surface = p - u.cam_pos.xyz; + let distance = length(to_surface); + if (distance <= 1e-4) { + return PtLayeredSurface( + pt_layered_default(), vec4(0.0), n, + ); + } + var query: ray_query; + rayQueryInitialize( + &query, + accel, + RayDesc( + 0u, 0xFFu, 0.001, distance * 1.02 + 0.1, + u.cam_pos.xyz, to_surface / distance, + ), + ); + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&query)) { break; } + } + } + let hit = rayQueryGetCommittedIntersection(&query); + if (hit.kind == RAY_QUERY_INTERSECTION_NONE) { + return PtLayeredSurface( + pt_layered_default(), vec4(0.0), n, + ); + } + var material = pt_layered_materials[hit.instance_custom_data]; + let instance = instance_data[hit.instance_custom_data]; + var primary_uv = vec2(0.0); + var secondary_uv = vec2(0.0); + if (( + PT_HAS_LAYERED_TEXTURES + || PT_HAS_CLEARCOAT_TEXTURES + || PT_HAS_CLEARCOAT_NORMALS + || PT_HAS_SHEEN_TEXTURES + || PT_HAS_IRIDESCENCE_TEXTURES + || PT_HAS_ANISOTROPY_TEXTURES + ) && instance.geo.z > 0u) { + let attributes = fetch_hit_attrs( + instance.geo, hit.primitive_index, hit.barycentrics, + ); + primary_uv = attributes.uv; + secondary_uv = pt_layered_hit_uv1( + instance.geo, hit.primitive_index, hit.barycentrics, + ); + material = pt_layered_apply_textures( + material, hit.instance_custom_data, primary_uv, secondary_uv, + ); + material = pt_layered_apply_clearcoat_textures( + material, hit.instance_custom_data, primary_uv, secondary_uv, + ); + material = pt_layered_apply_sheen_textures( + material, hit.instance_custom_data, primary_uv, secondary_uv, + ); + material = pt_layered_apply_anisotropy_texture( + material, hit.instance_custom_data, primary_uv, secondary_uv, + ); + material = pt_layered_apply_iridescence_textures( + material, hit.instance_custom_data, primary_uv, secondary_uv, + ); + } + var tangent = vec4(0.0); + if ( + pt_layered_has_anisotropy(material) + || ( + PT_HAS_CLEARCOAT_NORMALS + && pt_layered_has_clearcoat_normal(hit.instance_custom_data) + ) + ) { + tangent = pt_layered_hit_tangent( + instance.geo, + hit.primitive_index, + hit.barycentrics, + hit.object_to_world, + n, + ); + } + let coat_sample = pt_layered_apply_clearcoat_normal( + material, + hit.instance_custom_data, + primary_uv, + secondary_uv, + n, + tangent, + ); + return PtLayeredSurface( + coat_sample.material, tangent, coat_sample.normal, + ); +} + +fn pt_layered_base_nee( + n: vec3, + tangent: vec4, + view: vec3, + ldir: vec3, + ndl: f32, + full_alb: vec3, + rough: f32, + metal: f32, + material: PtLayeredMaterial, +) -> vec3 { + if ( + !pt_layered_has_specular_ior(material) + && !pt_layered_has_anisotropy(material) + && !pt_layered_has_iridescence(material) + ) { + return nee_diffuse(n, view, ldir, ndl, full_alb, rough, metal) + + nee_spec(n, view, ldir, ndl, full_alb, rough, metal); + } + let half_raw = view + ldir; + if (dot(half_raw, half_raw) <= 1e-8) { + return vec3(0.0); + } + let half = normalize(half_raw); + let ndv = max(dot(n, view), 1e-4); + let ndh = max(dot(n, half), 0.0); + let vdh = max(dot(view, half), 1e-4); + let alpha = max(rough * rough, 1e-3); + var distribution: f32; + var visibility: f32; + if (pt_layered_has_anisotropy(material)) { + let basis = pt_layered_anisotropy_basis(n, tangent, material); + let view_local = vec3( + dot(view, basis[0]), dot(view, basis[1]), ndv, + ); + let light_local = vec3( + dot(ldir, basis[0]), dot(ldir, basis[1]), ndl, + ); + let anisotropic_alpha = pt_layered_anisotropy_alpha(rough, material); + distribution = pt_d_ggx_anisotropic( + ndh, + dot(half, basis[0]), + dot(half, basis[1]), + anisotropic_alpha, + ); + visibility = pt_v_smith_anisotropic( + light_local, view_local, anisotropic_alpha, + ); + } else { + let a2 = alpha * alpha; + let denominator = ndh * ndh * (a2 - 1.0) + 1.0; + distribution = a2 / (3.14159265 * denominator * denominator); + visibility = v_smith(ndv, ndl, alpha); + } + let specular = pt_layered_base_fresnel(vdh, full_alb, metal, material) + * distribution * visibility * ndl; + let diffuse_albedo = full_alb * (1.0 - metal) + * pt_dielectric_transmission(ndv, material) + * pt_dielectric_transmission(ndl, material); + let diffuse = diffuse_albedo + * burley_diffuse(ndl, ndv, max(dot(ldir, half), 0.0), rough) * ndl; + return diffuse + specular; +} + +fn pt_layered_nee( + n: vec3, + clearcoat_normal: vec3, + tangent: vec4, + view: vec3, + ldir: vec3, + ndl: f32, + full_alb: vec3, + rough: f32, + metal: f32, + material: PtLayeredMaterial, +) -> vec3 { + var undercoat = vec3(0.0); + if (ndl > 0.0) { + undercoat = pt_layered_undercoat_nee( + n, tangent, view, ldir, ndl, full_alb, rough, metal, material, + ); + } + let half_raw = view + ldir; + if (!pt_layered_has_clearcoat(material) || dot(half_raw, half_raw) <= 1e-8) { + return undercoat; + } + let coat_ndl = max(dot(clearcoat_normal, ldir), 0.0); + if (coat_ndl <= 0.0) { + return undercoat; + } + let half = normalize(half_raw); + let ndv = max(dot(clearcoat_normal, view), 1e-4); + let ndh = max(dot(clearcoat_normal, half), 0.0); + let vdh = max(dot(view, half), 1e-4); + let alpha = pt_clearcoat_alpha(material); + let a2 = alpha * alpha; + let denominator = ndh * ndh * (a2 - 1.0) + 1.0; + let distribution = a2 / (3.14159265 * denominator * denominator); + let clearcoat = pt_clearcoat_fresnel(vdh, material) + * distribution * v_smith(ndv, coat_ndl, alpha) * coat_ndl; + let attenuation = pt_clearcoat_transmission(ndv, material) + * pt_clearcoat_transmission(coat_ndl, material); + return undercoat * attenuation + vec3(clearcoat); +} + +fn pt_layered_direct_light( + p: vec3, + n: vec3, + clearcoat_normal: vec3, + tangent: vec4, + sun_r2: vec2, + view: vec3, + full_alb: vec3, + rough: f32, + metal: f32, + with_points: bool, + material: PtLayeredMaterial, +) -> vec3 { + if (!pt_layered_has_transport(material)) { + return direct_light(p, n, sun_r2, view, full_alb, rough, metal, with_points); + } + var result = vec3(0.0); + let sun_ndl = max(dot(n, u.sun_dir.xyz), 0.0); + let sun_coat_ndl = max(dot(clearcoat_normal, u.sun_dir.xyz), 0.0); + if (sun_ndl > 0.0 || sun_coat_ndl > 0.0) { + let visibility = sun_visibility(p, n, sun_r2); + if (visibility > 0.0) { + result += pt_layered_nee( + n, clearcoat_normal, tangent, view, u.sun_dir.xyz, sun_ndl, + full_alb, rough, metal, material, + ) * u.sun_color.rgb * visibility; + } + } + let count = u32(u.cfg.z); + if (count > 0u && with_points) { + let pick = min(u32(rand_f() * f32(count)), count - 1u); + let light = u.lights[pick]; + let to_light = light.pos_range.xyz - p; + let distance = length(to_light); + let range = light.pos_range.w; + if (distance < range && distance > 1e-3) { + let direction = to_light / distance; + let ndl = dot(n, direction); + let coat_ndl = dot(clearcoat_normal, direction); + if ( + (ndl > 0.0 || coat_ndl > 0.0) + && !occluded(p, direction, distance - 0.02) + ) { + let falloff = 1.0 - distance / range; + let incident = light.color_int.rgb * light.color_int.w + * falloff * falloff * f32(count); + result += pt_layered_nee( + n, clearcoat_normal, tangent, view, direction, ndl, + full_alb, rough, metal, material, + ) * incident; + } + } + } + return result; +} + +fn pt_sample_layered_base( + n: vec3, + tangent: vec4, + view: vec3, + base_color: vec3, + roughness: f32, + metallic: f32, + material: PtLayeredMaterial, +) -> BrdfSample { + if ( + !pt_layered_has_specular_ior(material) + && !pt_layered_has_anisotropy(material) + && !pt_layered_has_iridescence(material) + ) { + return sample_brdf(n, view, base_color, roughness, metallic); + } + var out: BrdfSample; + out.valid = false; + let alpha = max(roughness * roughness, 1e-3); + let anisotropic = pt_layered_has_anisotropy(material); + var specular_basis = onb(n); + if (anisotropic) { + specular_basis = pt_layered_anisotropy_basis(n, tangent, material); + } + let view_specular = vec3( + dot(view, specular_basis[0]), + dot(view, specular_basis[1]), + dot(view, n), + ); + if (view_specular.z <= 0.0) { + return out; + } + let n_dot_v = max(view_specular.z, 1e-4); + let fresnel_view = pt_layered_base_fresnel( + n_dot_v, base_color, metallic, material, + ); + let specular_weight = ( + fresnel_view.x + fresnel_view.y + fresnel_view.z + ) / 3.0; + let diffuse_weight = pt_dielectric_transmission(n_dot_v, material) + * (1.0 - metallic); + var specular_probability = specular_weight + / (specular_weight + diffuse_weight + 1e-6); + if (specular_weight > 0.0 && diffuse_weight > 0.0) { + specular_probability = clamp(specular_probability, 0.05, 0.95); + } + let sample = rand_2f(); + if (rand_f() < specular_probability) { + let anisotropic_alpha = pt_layered_anisotropy_alpha( + roughness, material, + ); + var half_specular: vec3; + if (anisotropic) { + half_specular = pt_sample_ggx_vndf_anisotropic( + view_specular, anisotropic_alpha, sample, + ); + } else { + half_specular = sample_ggx_vndf(view_specular, alpha, sample); + } + let light_specular = reflect(-view_specular, half_specular); + if (light_specular.z <= 0.0) { + return out; + } + let n_dot_l = light_specular.z; + let v_dot_h = max(dot(view_specular, half_specular), 1e-4); + var visibility: f32; + var g1_view: f32; + if (anisotropic) { + visibility = pt_v_smith_anisotropic( + light_specular, view_specular, anisotropic_alpha, + ); + g1_view = pt_smith_g1_anisotropic( + view_specular, anisotropic_alpha, + ); + } else { + visibility = v_smith(n_dot_v, n_dot_l, alpha); + g1_view = smith_g1(n_dot_v, alpha); + } + let g2 = visibility * 4.0 * n_dot_v * n_dot_l; + out.dir = specular_basis * light_specular; + out.weight = pt_layered_base_fresnel( + v_dot_h, base_color, metallic, material, + ) * g2 / max(g1_view * specular_probability, 1e-6); + if (u.cfg.x >= 2.0) { + out.weight = min(out.weight, vec3(4.0)); + } + out.valid = true; + return out; + } + let diffuse_basis = onb(n); + let view_diffuse = vec3( + dot(view, diffuse_basis[0]), dot(view, diffuse_basis[1]), dot(view, n), + ); + let radius = sqrt(sample.x); + let phi = 6.2831853 * sample.y; + let light_diffuse = vec3( + radius * cos(phi), + radius * sin(phi), + sqrt(max(0.0, 1.0 - sample.x)), + ); + let n_dot_l = max(light_diffuse.z, 1e-4); + let half_raw = view_diffuse + light_diffuse; + var l_dot_h = 0.0; + if (dot(half_raw, half_raw) > 1e-8) { + l_dot_h = max(dot(light_diffuse, normalize(half_raw)), 0.0); + } + let diffuse_albedo = base_color * (1.0 - metallic) + * pt_dielectric_transmission(n_dot_v, material) + * pt_dielectric_transmission(n_dot_l, material); + out.dir = diffuse_basis * light_diffuse; + out.weight = diffuse_albedo + * burley_diffuse(n_dot_l, n_dot_v, l_dot_h, roughness) + * 3.14159265 / max(1.0 - specular_probability, 1e-6); + if (u.cfg.x >= 2.0) { + out.weight = min(out.weight, vec3(4.0)); + } + out.valid = true; + return out; +} + +fn pt_sample_layered_brdf( + n: vec3, + clearcoat_normal: vec3, + tangent: vec4, + view: vec3, + base_color: vec3, + roughness: f32, + metallic: f32, + material: PtLayeredMaterial, +) -> BrdfSample { + if (!pt_layered_has_transport(material)) { + return sample_brdf(n, view, base_color, roughness, metallic); + } + if (!pt_layered_has_clearcoat(material)) { + return pt_sample_layered_undercoat( + n, tangent, view, base_color, roughness, metallic, material, + ); + } + var out: BrdfSample; + out.valid = false; + let base_ndv = max(dot(n, view), 0.0); + if (base_ndv <= 0.0) { + return out; + } + let base_f = pt_layered_base_fresnel( + base_ndv, base_color, metallic, material, + ); + let base_specular_weight = (base_f.x + base_f.y + base_f.z) / 3.0; + var diffuse_weight = (1.0 - base_specular_weight) * (1.0 - metallic); + if ( + pt_layered_has_specular_ior(material) + || pt_layered_has_anisotropy(material) + || pt_layered_has_iridescence(material) + ) { + diffuse_weight = + pt_dielectric_transmission(base_ndv, material) * (1.0 - metallic); + } + let sheen_weight = pt_layered_sheen_weight(material); + let coat_ndv = max(dot(clearcoat_normal, view), 1e-4); + let clearcoat_weight = pt_clearcoat_fresnel(coat_ndv, material); + let clearcoat_probability = clearcoat_weight + / ( + base_specular_weight + diffuse_weight + sheen_weight + + clearcoat_weight + 1e-6 + ); + + if (rand_f() < clearcoat_probability) { + let basis = onb(clearcoat_normal); + let view_tangent = vec3( + dot(view, basis[0]), + dot(view, basis[1]), + dot(view, clearcoat_normal), + ); + let alpha = pt_clearcoat_alpha(material); + let half_tangent = sample_ggx_vndf(view_tangent, alpha, rand_2f()); + let light_tangent = reflect(-view_tangent, half_tangent); + if (light_tangent.z <= 0.0) { + return out; + } + let n_dot_l = light_tangent.z; + let n_dot_v = max(view_tangent.z, 1e-4); + let v_dot_h = max(dot(view_tangent, half_tangent), 1e-4); + let g2 = v_smith(n_dot_v, n_dot_l, alpha) + * 4.0 * n_dot_v * n_dot_l; + let g1_view = smith_g1(n_dot_v, alpha); + out.dir = basis * light_tangent; + out.weight = vec3( + pt_clearcoat_fresnel(v_dot_h, material) * g2 + / max(g1_view * clearcoat_probability, 1e-6), + ); + if (u.cfg.x >= 2.0) { + out.weight = min(out.weight, vec3(4.0)); + } + out.valid = true; + return out; + } + + out = pt_sample_layered_undercoat( + n, tangent, view, base_color, roughness, metallic, material, + ); + if (!out.valid) { + return out; + } + let coat_n_dot_l = max(dot(clearcoat_normal, out.dir), 0.0); + let attenuation = pt_clearcoat_transmission(coat_ndv, material) + * pt_clearcoat_transmission(coat_n_dot_l, material); + out.weight *= attenuation / max(1.0 - clearcoat_probability, 1e-6); + if (u.cfg.x >= 2.0) { + out.weight = min(out.weight, vec3(4.0)); + } + return out; +} +"#; + +const PT_LAYERED_IRIDESCENCE_DISABLED_WGSL: &str = r#" +fn pt_layered_has_iridescence(material: PtLayeredMaterial) -> bool { + return false; +} + +fn pt_apply_iridescence_base_fresnel( + base: vec3, + cos_theta: f32, + base_color: vec3, + metallic: f32, + material: PtLayeredMaterial, +) -> vec3 { + return base; +} + +fn pt_apply_iridescence_dielectric_fresnel( + base: vec3, + cos_theta: f32, + material: PtLayeredMaterial, +) -> vec3 { + return base; +} +"#; + +const PT_LAYERED_IRIDESCENCE_WGSL: &str = r#" +const PT_LAYERED_IRIDESCENCE_LOBE: u32 = 8u; +const PT_IRIDESCENCE_PI: f32 = 3.14159265; + +fn pt_layered_has_iridescence(material: PtLayeredMaterial) -> bool { + return material.header.x == 1u + && (material.header.y & PT_LAYERED_IRIDESCENCE_LOBE) != 0u + && (material.header.z & PT_LAYERED_IRIDESCENCE_LOBE) == 0u + && material.iridescence.x > 0.0 + && material.iridescence.w > 0.0; +} + +fn pt_fresnel0_to_ior(f0: vec3) -> vec3 { + let root = sqrt(clamp(f0, vec3(0.0), vec3(0.9999))); + return (vec3(1.0) + root) / (vec3(1.0) - root); +} + +fn pt_ior_to_fresnel0( + transmitted_ior: vec3, + incident_ior: f32, +) -> vec3 { + let incident = vec3(incident_ior); + let ratio = (transmitted_ior - incident) / (transmitted_ior + incident); + return ratio * ratio; +} + +fn pt_iridescence_sensitivity( + optical_path_difference_nm: f32, + shift: vec3, +) -> vec3 { + let phase = 2.0 * PT_IRIDESCENCE_PI + * optical_path_difference_nm * 1e-9; + let phase_squared = phase * phase; + let value = vec3(5.4856e-13, 4.4201e-13, 5.2481e-13); + let position = vec3(1.6810e6, 1.7953e6, 2.2084e6); + let variance = vec3(4.3278e9, 9.3046e9, 6.6121e9); + var xyz = value + * sqrt(vec3(2.0 * PT_IRIDESCENCE_PI) * variance) + * cos(position * phase + shift) + * exp(-vec3(phase_squared) * variance); + xyz.x += 9.7470e-14 + * sqrt(2.0 * PT_IRIDESCENCE_PI * 4.5282e9) + * cos(2.2399e6 * phase + shift.x) + * exp(-4.5282e9 * phase_squared); + xyz /= 1.0685e-7; + return vec3( + 3.2404542 * xyz.x - 0.9692660 * xyz.y + 0.0556434 * xyz.z, + -1.5371385 * xyz.x + 1.8760108 * xyz.y - 0.2040259 * xyz.z, + -0.4985314 * xyz.x + 0.0415560 * xyz.y + 1.0572252 * xyz.z, + ); +} + +fn pt_eval_iridescence( + outside_ior: f32, + authored_film_ior: f32, + cos_theta_1: f32, + authored_thickness_nm: f32, + base_f0: vec3, +) -> vec3 { + let safe_outside_ior = max(outside_ior, 1e-4); + let thickness_nm = max(authored_thickness_nm, 0.0); + let film_ior = mix( + safe_outside_ior, + max(authored_film_ior, 1.0), + smoothstep(0.0, 0.03, thickness_nm), + ); + let cosine_1 = clamp(cos_theta_1, 0.0, 1.0); + let sin_theta_2_squared = pow( + safe_outside_ior / film_ior, 2.0, + ) * (1.0 - cosine_1 * cosine_1); + let cos_theta_2_squared = 1.0 - sin_theta_2_squared; + if (cos_theta_2_squared < 0.0) { + return vec3(1.0); + } + let cosine_2 = sqrt(cos_theta_2_squared); + + let r0 = pt_ior_f0(film_ior / safe_outside_ior); + let r12 = pt_fresnel_f90( + cosine_1, vec3(r0), vec3(1.0), + ).x; + let t121 = 1.0 - r12; + let phi12 = select( + 0.0, PT_IRIDESCENCE_PI, film_ior < safe_outside_ior, + ); + let phi21 = PT_IRIDESCENCE_PI - phi12; + + let base_ior = pt_fresnel0_to_ior(base_f0); + let r1 = pt_ior_to_fresnel0(base_ior, film_ior); + let r23 = pt_fresnel_f90(cosine_2, r1, vec3(1.0)); + let phi23 = vec3( + select(0.0, PT_IRIDESCENCE_PI, base_ior.x < film_ior), + select(0.0, PT_IRIDESCENCE_PI, base_ior.y < film_ior), + select(0.0, PT_IRIDESCENCE_PI, base_ior.z < film_ior), + ); + let optical_path_difference = + 2.0 * film_ior * thickness_nm * cosine_2; + let phase_shift = vec3(phi21) + phi23; + let r123 = clamp( + vec3(r12) * r23, + vec3(1e-5), + vec3(0.9999), + ); + let reflected_series = vec3(t121 * t121) + * r23 / (vec3(1.0) - r123); + var result = vec3(r12) + reflected_series; + var coefficient = reflected_series - vec3(t121); + let amplitude = sqrt(r123); + for (var order = 1u; order <= 2u; order += 1u) { + coefficient *= amplitude; + result += coefficient * 2.0 * pt_iridescence_sensitivity( + f32(order) * optical_path_difference, + f32(order) * phase_shift, + ); + } + return clamp(result, vec3(0.0), vec3(1.0)); +} + +fn pt_apply_iridescence_base_fresnel( + base: vec3, + cos_theta: f32, + base_color: vec3, + metallic: f32, + material: PtLayeredMaterial, +) -> vec3 { + if (!pt_layered_has_iridescence(material)) { + return base; + } + let dielectric = pt_eval_iridescence( + 1.0, + material.iridescence.y, + cos_theta, + material.iridescence.w, + pt_dielectric_f0(material), + ); + let conductor = pt_eval_iridescence( + 1.0, + material.iridescence.y, + cos_theta, + material.iridescence.w, + base_color, + ); + let thin_film = mix(dielectric, conductor, metallic); + return mix( + base, thin_film, clamp(material.iridescence.x, 0.0, 1.0), + ); +} + +fn pt_apply_iridescence_dielectric_fresnel( + base: vec3, + cos_theta: f32, + material: PtLayeredMaterial, +) -> vec3 { + if (!pt_layered_has_iridescence(material)) { + return base; + } + let thin_film = pt_eval_iridescence( + 1.0, + material.iridescence.y, + cos_theta, + material.iridescence.w, + pt_dielectric_f0(material), + ); + return mix( + base, thin_film, clamp(material.iridescence.x, 0.0, 1.0), + ); +} +"#; + +const PT_LAYERED_SHEEN_DISABLED_WGSL: &str = r#" +fn pt_layered_has_sheen(material: PtLayeredMaterial) -> bool { + return false; +} + +fn pt_layered_sheen_weight(material: PtLayeredMaterial) -> f32 { + return 0.0; +} + +fn pt_layered_undercoat_nee( + n: vec3, + tangent: vec4, + view: vec3, + ldir: vec3, + ndl: f32, + full_alb: vec3, + rough: f32, + metal: f32, + material: PtLayeredMaterial, +) -> vec3 { + return pt_layered_base_nee( + n, tangent, view, ldir, ndl, full_alb, rough, metal, material, + ); +} + +fn pt_sample_layered_undercoat( + n: vec3, + tangent: vec4, + view: vec3, + base_color: vec3, + roughness: f32, + metallic: f32, + material: PtLayeredMaterial, +) -> BrdfSample { + return pt_sample_layered_base( + n, tangent, view, base_color, roughness, metallic, material, + ); +} +"#; + +const PT_LAYERED_SHEEN_WGSL: &str = r#" +@group(2) @binding(1) +var pt_sheen_albedo_tex: texture_2d; + +fn pt_layered_has_sheen(material: PtLayeredMaterial) -> bool { + return material.header.x == 1u + && (material.header.y & 2u) != 0u + && (material.header.z & 2u) == 0u + && max(material.sheen.x, max(material.sheen.y, material.sheen.z)) > 0.0; +} + +fn pt_layered_sheen_weight(material: PtLayeredMaterial) -> f32 { + if (!pt_layered_has_sheen(material)) { + return 0.0; + } + return (material.sheen.x + material.sheen.y + material.sheen.z) / 3.0; +} + +fn pt_sheen_roughness(material: PtLayeredMaterial) -> f32 { + return max(clamp(material.sheen.w, 0.0, 1.0), 1e-3); +} + +fn pt_sheen_lambda_helper(x: f32, alpha_g: f32) -> f32 { + let one_minus_alpha_sq = (1.0 - alpha_g) * (1.0 - alpha_g); + let a = mix(21.5473, 25.3245, one_minus_alpha_sq); + let b = mix(3.82987, 3.32435, one_minus_alpha_sq); + let c = mix(0.19823, 0.16801, one_minus_alpha_sq); + let d = mix(-1.97760, -1.27393, one_minus_alpha_sq); + let e = mix(-4.32054, -4.85967, one_minus_alpha_sq); + return a / (1.0 + b * pow(max(x, 0.0), c)) + d * x + e; +} + +fn pt_sheen_lambda(cos_theta: f32, alpha_g: f32) -> f32 { + let cosine = clamp(abs(cos_theta), 0.0, 1.0); + if (cosine < 0.5) { + return exp(pt_sheen_lambda_helper(cosine, alpha_g)); + } + return exp( + 2.0 * pt_sheen_lambda_helper(0.5, alpha_g) + - pt_sheen_lambda_helper(1.0 - cosine, alpha_g), + ); +} + +fn pt_sheen_distribution(n_dot_h: f32, roughness: f32) -> f32 { + let alpha_g = max(roughness * roughness, 1e-6); + let inverse_alpha = 1.0 / alpha_g; + let sin2_h = max(1.0 - n_dot_h * n_dot_h, 0.0); + return (2.0 + inverse_alpha) * pow(sin2_h, 0.5 * inverse_alpha) + / 6.2831853; +} + +fn pt_sheen_visibility(n_dot_l: f32, n_dot_v: f32, roughness: f32) -> f32 { + let alpha_g = max(roughness * roughness, 1e-6); + let denominator = ( + 1.0 + pt_sheen_lambda(n_dot_v, alpha_g) + + pt_sheen_lambda(n_dot_l, alpha_g) + ) * (4.0 * n_dot_v * n_dot_l); + return 1.0 / max(denominator, 1e-6); +} + +fn pt_sheen_directional_albedo(n_dot: f32, roughness: f32) -> f32 { + return textureSampleLevel( + pt_sheen_albedo_tex, + card_samp, + vec2(clamp(n_dot, 0.0, 1.0), clamp(roughness, 0.0, 1.0)), + 0.0, + ).r; +} + +fn pt_sheen_scale( + material: PtLayeredMaterial, + n_dot_v: f32, + n_dot_l: f32, +) -> f32 { + let maximum_color = max( + material.sheen.x, max(material.sheen.y, material.sheen.z), + ); + let view_albedo = pt_sheen_directional_albedo( + n_dot_v, pt_sheen_roughness(material), + ); + let light_albedo = pt_sheen_directional_albedo( + n_dot_l, pt_sheen_roughness(material), + ); + return clamp( + 1.0 - maximum_color * max(view_albedo, light_albedo), 0.0, 1.0, + ); +} + +fn pt_layered_undercoat_nee( + n: vec3, + tangent: vec4, + view: vec3, + ldir: vec3, + ndl: f32, + full_alb: vec3, + rough: f32, + metal: f32, + material: PtLayeredMaterial, +) -> vec3 { + let base = pt_layered_base_nee( + n, tangent, view, ldir, ndl, full_alb, rough, metal, material, + ); + if (!pt_layered_has_sheen(material)) { + return base; + } + let half_raw = view + ldir; + if (dot(half_raw, half_raw) <= 1e-8) { + return base; + } + let half = normalize(half_raw); + let n_dot_v = max(dot(n, view), 1e-4); + let n_dot_h = max(dot(n, half), 0.0); + let roughness = pt_sheen_roughness(material); + let sheen = material.sheen.xyz + * pt_sheen_distribution(n_dot_h, roughness) + * pt_sheen_visibility(ndl, n_dot_v, roughness) * ndl; + return base * pt_sheen_scale(material, n_dot_v, ndl) + sheen; +} + +fn pt_sample_charlie_half( + perceptual_roughness: f32, + sample: vec2, +) -> vec3 { + let alpha = max(perceptual_roughness, 1e-3); + let alpha_g = alpha * alpha; + let sin_theta = pow(sample.x, alpha_g / (2.0 * alpha_g + 1.0)); + let cos_theta = sqrt(max(0.0, 1.0 - sin_theta * sin_theta)); + let phi = 6.2831853 * sample.y; + return vec3( + sin_theta * cos(phi), sin_theta * sin(phi), cos_theta, + ); +} + +fn pt_sample_layered_undercoat( + n: vec3, + tangent: vec4, + view: vec3, + base_color: vec3, + roughness: f32, + metallic: f32, + material: PtLayeredMaterial, +) -> BrdfSample { + if (!pt_layered_has_sheen(material)) { + return pt_sample_layered_base( + n, tangent, view, base_color, roughness, metallic, material, + ); + } + var out: BrdfSample; + out.valid = false; + let n_dot_v = max(dot(n, view), 0.0); + if (n_dot_v <= 0.0) { + return out; + } + let base_f = pt_layered_base_fresnel( + n_dot_v, base_color, metallic, material, + ); + let base_specular_weight = (base_f.x + base_f.y + base_f.z) / 3.0; + var diffuse_weight = (1.0 - base_specular_weight) * (1.0 - metallic); + if ( + pt_layered_has_specular_ior(material) + || pt_layered_has_anisotropy(material) + || pt_layered_has_iridescence(material) + ) { + diffuse_weight = pt_dielectric_transmission(n_dot_v, material) + * (1.0 - metallic); + } + let sheen_weight = pt_layered_sheen_weight(material); + let sheen_probability = sheen_weight + / (base_specular_weight + diffuse_weight + sheen_weight + 1e-6); + if (rand_f() < sheen_probability) { + let basis = onb(n); + let view_tangent = vec3( + dot(view, basis[0]), dot(view, basis[1]), dot(view, n), + ); + let half_tangent = pt_sample_charlie_half( + pt_sheen_roughness(material), rand_2f(), + ); + let v_dot_h = max(dot(view_tangent, half_tangent), 0.0); + if (v_dot_h <= 0.0 || half_tangent.z <= 0.0) { + return out; + } + let light_tangent = reflect(-view_tangent, half_tangent); + if (light_tangent.z <= 0.0) { + return out; + } + let visibility = pt_sheen_visibility( + light_tangent.z, n_dot_v, pt_sheen_roughness(material), + ); + out.dir = basis * light_tangent; + out.weight = material.sheen.xyz * visibility * light_tangent.z + * 4.0 * v_dot_h + / max(half_tangent.z * sheen_probability, 1e-6); + if (u.cfg.x >= 2.0) { + out.weight = min(out.weight, vec3(4.0)); + } + out.valid = true; + return out; + } + out = pt_sample_layered_base( + n, tangent, view, base_color, roughness, metallic, material, + ); + if (!out.valid) { + return out; + } + let n_dot_l = max(dot(n, out.dir), 0.0); + out.weight *= pt_sheen_scale(material, n_dot_v, n_dot_l) + / max(1.0 - sheen_probability, 1e-6); + if (u.cfg.x >= 2.0) { + out.weight = min(out.weight, vec3(4.0)); + } + return out; +} +"#; + +impl Renderer { + /// Materialize the specialized pipeline and sidecar on the first frame + /// where active layered instances actually reach the path tracer. + pub(super) fn ensure_pt_layered_resources(&mut self) { + if self.pt_layered.records.is_empty() { + return; + } + let sheen = self.pt_layered_sheen_active(); + let anisotropy = self.pt_layered_anisotropy_active(); + let iridescence = self.pt_layered_iridescence_active(); + let textures = self.pt_texture_arrays_enabled && self.pt_layered_texture_active(); + let clearcoat_textures = + self.pt_texture_arrays_enabled && self.pt_layered_clearcoat_texture_active(); + let clearcoat_normals = + self.pt_texture_arrays_enabled && self.pt_layered_clearcoat_normal_active(); + let sheen_textures = + self.pt_texture_arrays_enabled && self.pt_layered_sheen_texture_active(); + let iridescence_textures = + self.pt_texture_arrays_enabled && self.pt_layered_iridescence_texture_active(); + let anisotropy_textures = + self.pt_texture_arrays_enabled && self.pt_layered_anisotropy_texture_active(); + let any_textures = textures + || clearcoat_textures + || clearcoat_normals + || sheen_textures + || iridescence_textures + || anisotropy_textures; + let uv1 = any_textures && self.pt_layered_uv1_active(); + let resource_variant = sheen as usize + | ((textures as usize) << 1) + | ((uv1 as usize) << 2) + | ((clearcoat_textures as usize) << 3) + | ((sheen_textures as usize) << 4) + | ((iridescence_textures as usize) << 5) + | ((anisotropy_textures as usize) << 6) + | ((clearcoat_normals as usize) << 7); + let pipeline_variant = sheen as usize + | ((anisotropy as usize) << 1) + | ((iridescence as usize) << 2) + | ((textures as usize) << 3) + | ((uv1 as usize) << 4) + | ((clearcoat_textures as usize) << 5) + | ((sheen_textures as usize) << 6) + | ((iridescence_textures as usize) << 7) + | ((anisotropy_textures as usize) << 8) + | ((clearcoat_normals as usize) << 9); + if self.pt_layered.layouts.len() <= resource_variant { + self.pt_layered + .layouts + .resize_with(resource_variant + 1, || None); + self.pt_layered + .bind_groups + .resize_with(resource_variant + 1, || None); + } + if self.pt_layered.pipelines.len() <= pipeline_variant { + self.pt_layered + .pipelines + .resize_with(pipeline_variant + 1, || None); + } + if sheen { + self.ensure_scene_sheen_albedo_lut(); + } + if self.pt_layered.layouts[resource_variant].is_none() { + let mut entries = vec![wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: std::num::NonZeroU64::new(PT_LAYERED_RECORD_BYTES), + }, + count: None, + }]; + if sheen { + entries.push(wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }); + } + if textures { + entries.push(wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: std::num::NonZeroU64::new( + PT_LAYERED_TEXTURE_RECORD_BYTES, + ), + }, + count: None, + }); + } + if uv1 { + entries.push(wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: std::num::NonZeroU64::new(8), + }, + count: None, + }); + } + if clearcoat_textures { + entries.push(wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: std::num::NonZeroU64::new( + PT_CLEARCOAT_TEXTURE_RECORD_BYTES, + ), + }, + count: None, + }); + } + if sheen_textures { + entries.push(wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: std::num::NonZeroU64::new(PT_SHEEN_TEXTURE_RECORD_BYTES), + }, + count: None, + }); + } + if iridescence_textures { + entries.push(wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: std::num::NonZeroU64::new( + PT_IRIDESCENCE_TEXTURE_RECORD_BYTES, + ), + }, + count: None, + }); + } + if anisotropy_textures { + entries.push(wgpu::BindGroupLayoutEntry { + binding: 7, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: std::num::NonZeroU64::new( + PT_ANISOTROPY_TEXTURE_RECORD_BYTES, + ), + }, + count: None, + }); + } + if clearcoat_normals { + entries.push(wgpu::BindGroupLayoutEntry { + binding: 8, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: std::num::NonZeroU64::new( + PT_CLEARCOAT_NORMAL_RECORD_BYTES, + ), + }, + count: None, + }); + } + self.pt_layered.layouts[resource_variant] = Some(self.device.create_bind_group_layout( + &wgpu::BindGroupLayoutDescriptor { + label: Some(if any_textures { + "pt_layered_texture_layout" + } else if sheen { + "pt_layered_sheen_layout" + } else { + "pt_layered_layout" + }), + entries: &entries, + }, + )); + } + if self.pt_layered.pipelines[pipeline_variant].is_none() { + let query_diagnostics = std::env::var("BLOOM_GOLDEN_DIAGNOSTICS") + .map(|value| value == "1" || value.eq_ignore_ascii_case("true")) + .unwrap_or(false) + || std::env::var("BLOOM_PT_DEBUG") + .ok() + .and_then(|value| value.parse::().ok()) + .is_some_and(|view| (6..=19).contains(&view)); + let fault = std::env::var("BLOOM_PT_TEST_FAULT").ok(); + let base_kernel = pt_kernel_variant(query_diagnostics); + let layered_kernel = layered_kernel_variant(base_kernel.as_ref()); + let source = format!( + "enable wgpu_ray_query;\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}", + ray_query_backend_variant(&self.device), + pt_fault_constants(fault.as_deref()), + layered_kernel, + texture_variant(self.pt_texture_arrays_enabled), + PT_LAYERED_BINDINGS_WGSL, + if textures { + PT_LAYERED_TEXTURE_BINDINGS_WGSL + } else { + PT_LAYERED_TEXTURE_DISABLED_WGSL + }, + if clearcoat_textures { + PT_CLEARCOAT_TEXTURE_BINDINGS_WGSL + } else { + PT_CLEARCOAT_TEXTURE_DISABLED_WGSL + }, + if clearcoat_normals { + PT_CLEARCOAT_NORMAL_BINDINGS_WGSL + } else { + PT_CLEARCOAT_NORMAL_DISABLED_WGSL + }, + if sheen_textures { + PT_SHEEN_TEXTURE_BINDINGS_WGSL + } else { + PT_SHEEN_TEXTURE_DISABLED_WGSL + }, + if iridescence_textures { + PT_IRIDESCENCE_TEXTURE_BINDINGS_WGSL + } else { + PT_IRIDESCENCE_TEXTURE_DISABLED_WGSL + }, + if anisotropy_textures { + PT_ANISOTROPY_TEXTURE_BINDINGS_WGSL + } else { + PT_ANISOTROPY_TEXTURE_DISABLED_WGSL + }, + if uv1 { + PT_LAYERED_UV1_BINDINGS_WGSL + } else { + PT_LAYERED_UV1_DISABLED_WGSL + }, + if anisotropy { + "const PT_HAS_SCALAR_ANISOTROPY: bool = true;" + } else { + "const PT_HAS_SCALAR_ANISOTROPY: bool = false;" + }, + PT_LAYERED_TRANSPORT_WGSL, + if iridescence { + PT_LAYERED_IRIDESCENCE_WGSL + } else { + PT_LAYERED_IRIDESCENCE_DISABLED_WGSL + }, + if sheen { + PT_LAYERED_SHEEN_WGSL + } else { + PT_LAYERED_SHEEN_DISABLED_WGSL + }, + ); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(if any_textures { + "pt_layered_texture_shader" + } else if sheen { + "pt_layered_sheen_shader" + } else { + "pt_layered_shader" + }), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + let groups = [ + self.pt_layout.as_ref(), + self.pt_tex_layout.as_ref(), + self.pt_layered.layouts[resource_variant].as_ref(), + ]; + let pipeline_layout = + self.device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(if sheen { + if any_textures { + "pt_layered_texture_pipeline_layout" + } else { + "pt_layered_sheen_pipeline_layout" + } + } else if any_textures { + "pt_layered_texture_pipeline_layout" + } else { + "pt_layered_pipeline_layout" + }), + bind_group_layouts: &groups, + immediate_size: 0, + }); + self.pt_layered.pipelines[pipeline_variant] = Some( + self.device + .create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(if any_textures { + "pt_layered_texture_pipeline" + } else if iridescence { + "pt_layered_iridescence_pipeline" + } else if anisotropy { + "pt_layered_anisotropy_pipeline" + } else if sheen { + "pt_layered_sheen_pipeline" + } else { + "pt_layered_pipeline" + }), + layout: Some(&pipeline_layout), + module: &shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }), + ); + self.created_pipelines(1); + } + + let mut records_recreated = ensure_pt_sidecar( + &self.device, + &self.queue, + &self.pt_layered.records, + &mut self.pt_layered.instance_buffer, + &mut self.pt_layered.dirty, + PT_LAYERED_RECORD_BYTES, + "pt_layered_instances", + ); + if textures { + records_recreated |= ensure_pt_sidecar( + &self.device, + &self.queue, + &self.pt_layered.texture_records, + &mut self.pt_layered.texture_buffer, + &mut self.pt_layered.texture_dirty, + PT_LAYERED_TEXTURE_RECORD_BYTES, + "pt_layered_texture_instances", + ); + } + if clearcoat_textures { + records_recreated |= ensure_pt_sidecar( + &self.device, + &self.queue, + &self.pt_layered.clearcoat_texture_records, + &mut self.pt_layered.clearcoat_texture_buffer, + &mut self.pt_layered.clearcoat_texture_dirty, + PT_CLEARCOAT_TEXTURE_RECORD_BYTES, + "pt_clearcoat_texture_instances", + ); + } + if clearcoat_normals { + records_recreated |= ensure_pt_sidecar( + &self.device, + &self.queue, + &self.pt_layered.clearcoat_normal_records, + &mut self.pt_layered.clearcoat_normal_buffer, + &mut self.pt_layered.clearcoat_normal_dirty, + PT_CLEARCOAT_NORMAL_RECORD_BYTES, + "pt_clearcoat_normal_instances", + ); + } + if sheen_textures { + records_recreated |= ensure_pt_sidecar( + &self.device, + &self.queue, + &self.pt_layered.sheen_texture_records, + &mut self.pt_layered.sheen_texture_buffer, + &mut self.pt_layered.sheen_texture_dirty, + PT_SHEEN_TEXTURE_RECORD_BYTES, + "pt_sheen_texture_instances", + ); + } + if iridescence_textures { + records_recreated |= ensure_pt_sidecar( + &self.device, + &self.queue, + &self.pt_layered.iridescence_texture_records, + &mut self.pt_layered.iridescence_texture_buffer, + &mut self.pt_layered.iridescence_texture_dirty, + PT_IRIDESCENCE_TEXTURE_RECORD_BYTES, + "pt_iridescence_texture_instances", + ); + } + if anisotropy_textures { + records_recreated |= ensure_pt_sidecar( + &self.device, + &self.queue, + &self.pt_layered.anisotropy_texture_records, + &mut self.pt_layered.anisotropy_texture_buffer, + &mut self.pt_layered.anisotropy_texture_dirty, + PT_ANISOTROPY_TEXTURE_RECORD_BYTES, + "pt_anisotropy_texture_instances", + ); + } + if records_recreated { + self.pt_layered + .bind_groups + .iter_mut() + .for_each(|group| *group = None); + } + if self.pt_layered.bind_groups[resource_variant].is_none() { + let mut entries = vec![wgpu::BindGroupEntry { + binding: 0, + resource: self + .pt_layered + .instance_buffer + .as_ref() + .unwrap() + .as_entire_binding(), + }]; + if sheen { + entries.push(wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView( + &self.scene_sheen_albedo_lut.as_ref().unwrap().view, + ), + }); + } + if textures { + entries.push(wgpu::BindGroupEntry { + binding: 2, + resource: self + .pt_layered + .texture_buffer + .as_ref() + .unwrap() + .as_entire_binding(), + }); + } + if uv1 { + entries.push(wgpu::BindGroupEntry { + binding: 3, + resource: self + .pt_layered + .uv1_buffer + .as_ref() + .unwrap() + .as_entire_binding(), + }); + } + if clearcoat_textures { + entries.push(wgpu::BindGroupEntry { + binding: 4, + resource: self + .pt_layered + .clearcoat_texture_buffer + .as_ref() + .unwrap() + .as_entire_binding(), + }); + } + if sheen_textures { + entries.push(wgpu::BindGroupEntry { + binding: 5, + resource: self + .pt_layered + .sheen_texture_buffer + .as_ref() + .unwrap() + .as_entire_binding(), + }); + } + if iridescence_textures { + entries.push(wgpu::BindGroupEntry { + binding: 6, + resource: self + .pt_layered + .iridescence_texture_buffer + .as_ref() + .unwrap() + .as_entire_binding(), + }); + } + if anisotropy_textures { + entries.push(wgpu::BindGroupEntry { + binding: 7, + resource: self + .pt_layered + .anisotropy_texture_buffer + .as_ref() + .unwrap() + .as_entire_binding(), + }); + } + if clearcoat_normals { + entries.push(wgpu::BindGroupEntry { + binding: 8, + resource: self + .pt_layered + .clearcoat_normal_buffer + .as_ref() + .unwrap() + .as_entire_binding(), + }); + } + self.pt_layered.bind_groups[resource_variant] = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(if any_textures { + "pt_layered_texture_bg" + } else if sheen { + "pt_layered_sheen_bg" + } else { + "pt_layered_bg" + }), + layout: self.pt_layered.layouts[resource_variant].as_ref().unwrap(), + entries: &entries, + })); + } + } +} + +#[cfg(test)] +#[path = "layered_pbr_pt_tests.rs"] +mod tests; diff --git a/native/shared/src/renderer/layered_pbr_pt_anisotropy_texture.rs b/native/shared/src/renderer/layered_pbr_pt_anisotropy_texture.rs new file mode 100644 index 00000000..0d641bbc --- /dev/null +++ b/native/shared/src/renderer/layered_pbr_pt_anisotropy_texture.rs @@ -0,0 +1,265 @@ +//! Independently lazy anisotropy direction/strength metadata for path tracing. + +use super::*; + +const PT_ANISOTROPY_TEXTURE_RECORD_VERSION: u32 = 1; +const PT_ANISOTROPY_TEXTURE_UV1: u32 = 1 << 16; + +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] +pub(in crate::renderer) struct PtAnisotropyTextureCpu { + /// x = ABI version, y = qualified lobe + UV flags, + /// z = runtime texture index. + header: [u32; 4], + /// Column-major 2x2 UV matrix after authored scale + rotation. + matrix: [f32; 4], + /// xy = authored UV offset; zw are reserved. + offset: [f32; 4], + reserved: [f32; 4], +} + +pub(super) const PT_ANISOTROPY_TEXTURE_RECORD_BYTES: u64 = + std::mem::size_of::() as u64; + +impl Default for PtAnisotropyTextureCpu { + fn default() -> Self { + Self { + header: [PT_ANISOTROPY_TEXTURE_RECORD_VERSION, 0, 0, 0], + matrix: [1.0, 0.0, 0.0, 1.0], + offset: [0.0; 4], + reserved: [0.0; 4], + } + } +} + +impl PtAnisotropyTextureCpu { + pub(super) fn from_material( + material: crate::models::MaterialLayeredPbr, + runtime_texture_count: usize, + has_secondary_uv: bool, + ) -> Self { + let Some(binding) = material.anisotropy_texture else { + return Self::default(); + }; + let transform = binding.transform; + let usable = material.has_anisotropy() + && binding.runtime_texture_idx.is_some_and(|index| { + index != 0 + && (index as usize) < PT_MAX_TEXTURES + && (index as usize) < runtime_texture_count + }) + && (transform.tex_coord == 0 || (transform.tex_coord == 1 && has_secondary_uv)) + && transform.offset.iter().all(|value| value.is_finite()) + && transform.scale.iter().all(|value| value.is_finite()) + && transform.rotation.is_finite(); + if !usable { + return Self::default(); + } + + let (sine, cosine) = transform.rotation.sin_cos(); + Self { + header: [ + PT_ANISOTROPY_TEXTURE_RECORD_VERSION, + crate::models::MaterialLayeredPbr::ANISOTROPY_LOBE + | if transform.tex_coord == 1 { + PT_ANISOTROPY_TEXTURE_UV1 + } else { + 0 + }, + binding.runtime_texture_idx.unwrap(), + 0, + ], + matrix: [ + cosine * transform.scale[0], + sine * transform.scale[0], + -sine * transform.scale[1], + cosine * transform.scale[1], + ], + offset: [transform.offset[0], transform.offset[1], 0.0, 0.0], + reserved: [0.0; 4], + } + } + + pub(super) fn active(self) -> bool { + self.header[0] == PT_ANISOTROPY_TEXTURE_RECORD_VERSION + && self.header[1] & crate::models::MaterialLayeredPbr::ANISOTROPY_LOBE != 0 + } + + pub(super) fn has_uv1(self) -> bool { + self.active() && self.header[1] & PT_ANISOTROPY_TEXTURE_UV1 != 0 + } +} + +pub(super) const PT_ANISOTROPY_TEXTURE_BINDINGS_WGSL: &str = r#" +const PT_HAS_ANISOTROPY_TEXTURES: bool = true; +const PT_ANISOTROPY_TEXTURE_UV1: u32 = 65536u; + +struct PtAnisotropyTexture { + header: vec4, + matrix: vec4, + offset: vec4, + reserved: vec4, +}; +@group(2) @binding(7) +var pt_anisotropy_textures: array; + +fn pt_anisotropy_transform_uv( + uv: vec2, + matrix: vec4, + offset: vec2, +) -> vec2 { + return vec2( + matrix.x * uv.x + matrix.z * uv.y, + matrix.y * uv.x + matrix.w * uv.y, + ) + offset; +} + +fn pt_layered_apply_anisotropy_texture( + material_in: PtLayeredMaterial, + instance_index: u32, + uv0: vec2, + uv1: vec2, +) -> PtLayeredMaterial { + var material = material_in; + let texture_meta = pt_anisotropy_textures[instance_index]; + if ( + texture_meta.header.x != 1u + || (texture_meta.header.y & PT_LAYERED_ANISOTROPY_LOBE) == 0u + ) { + return material; + } + let uv = pt_anisotropy_transform_uv( + select(uv0, uv1, (texture_meta.header.y & PT_ANISOTROPY_TEXTURE_UV1) != 0u), + texture_meta.matrix, + texture_meta.offset.xy, + ); + let sampled = pt_tex_sample_rgba(texture_meta.header.z, uv); + var direction = sampled.rg * 2.0 - vec2(1.0); + if (dot(direction, direction) <= 1e-6) { + direction = vec2(1.0, 0.0); + } else { + direction = normalize(direction); + } + let rotated_direction = vec2( + material.anisotropy.y * direction.x - material.anisotropy.z * direction.y, + material.anisotropy.z * direction.x + material.anisotropy.y * direction.y, + ); + material.anisotropy = vec4( + clamp(material.anisotropy.x * sampled.b, 0.0, 1.0), + rotated_direction, + 0.0, + ); + material.header = vec4( + material.header.xy, + material.header.z & ~PT_LAYERED_ANISOTROPY_LOBE, + material.header.w, + ); + return material; +} +"#; + +pub(super) const PT_ANISOTROPY_TEXTURE_DISABLED_WGSL: &str = r#" +const PT_HAS_ANISOTROPY_TEXTURES: bool = false; + +fn pt_layered_apply_anisotropy_texture( + material: PtLayeredMaterial, + instance_index: u32, + uv0: vec2, + uv1: vec2, +) -> PtLayeredMaterial { + return material; +} +"#; + +#[cfg(test)] +mod tests { + use super::*; + + fn binding(index: Option, tex_coord: u32) -> crate::models::MaterialTextureBinding { + crate::models::MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: index, + transform: crate::models::MaterialTextureTransform { + tex_coord, + ..Default::default() + }, + } + } + + fn material( + binding: crate::models::MaterialTextureBinding, + ) -> crate::models::MaterialLayeredPbr { + crate::models::MaterialLayeredPbr { + anisotropy_authored: true, + anisotropy_strength: 0.8, + anisotropy_texture: Some(binding), + ..Default::default() + } + } + + #[test] + fn record_is_independently_compact_and_inactive_by_default() { + assert_eq!(PT_ANISOTROPY_TEXTURE_RECORD_BYTES, 64); + assert!(!PtAnisotropyTextureCpu::default().active()); + } + + #[test] + fn qualification_requires_resolved_coordinates() { + assert!( + !PtAnisotropyTextureCpu::from_material(material(binding(Some(2), 1)), 3, false,) + .active() + ); + let record = PtAnisotropyTextureCpu::from_material(material(binding(Some(2), 1)), 3, true); + assert!(record.active() && record.has_uv1()); + assert_eq!(record.header[2], 2); + assert!( + !PtAnisotropyTextureCpu::from_material(material(binding(None, 0)), 3, true,).active() + ); + } + + #[test] + fn first_anisotropy_texture_backfills_parallel_records_and_reports_uv1() { + let mut records = None; + let mut specular_records = None; + let mut clearcoat_records = None; + let mut clearcoat_normal_records = None; + let mut sheen_records = None; + let mut iridescence_records = None; + let mut anisotropy_records = None; + assert!(!super::super::texture::append_record( + &mut records, + &mut specular_records, + &mut clearcoat_records, + &mut clearcoat_normal_records, + &mut sheen_records, + &mut iridescence_records, + &mut anisotropy_records, + 0, + Default::default(), + 3, + true, + )); + assert!(super::super::texture::append_record( + &mut records, + &mut specular_records, + &mut clearcoat_records, + &mut clearcoat_normal_records, + &mut sheen_records, + &mut iridescence_records, + &mut anisotropy_records, + 1, + material(binding(Some(2), 1)), + 3, + true, + )); + assert!(specular_records.is_none()); + assert!(clearcoat_records.is_none()); + assert!(sheen_records.is_none()); + assert!(iridescence_records.is_none()); + let records = anisotropy_records.unwrap(); + assert_eq!(records.len(), 2); + assert!(!records[0].active()); + assert!(records[1].active() && records[1].has_uv1()); + } +} diff --git a/native/shared/src/renderer/layered_pbr_pt_clearcoat_normal.rs b/native/shared/src/renderer/layered_pbr_pt_clearcoat_normal.rs new file mode 100644 index 00000000..063daa9d --- /dev/null +++ b/native/shared/src/renderer/layered_pbr_pt_clearcoat_normal.rs @@ -0,0 +1,328 @@ +//! Independently lazy clearcoat-normal metadata for path tracing. +//! +//! Keeping this record separate preserves the exact 64-byte +//! factor/roughness sidecar for materials that do not author a coat normal. + +use super::*; + +const PT_CLEARCOAT_NORMAL_RECORD_VERSION: u32 = 1; +const PT_CLEARCOAT_NORMAL_UV1: u32 = 1 << 16; + +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] +pub(in crate::renderer) struct PtClearcoatNormalCpu { + /// x = ABI version, y = qualified lobe + UV flags, + /// z = normal runtime texture index, w = reserved. + header: [u32; 4], + /// Column-major 2x2 UV matrix after authored scale + rotation. + matrix: [f32; 4], + /// xy = offset, z = tangent-space normal scale, w = reserved. + params: [f32; 4], +} + +pub(super) const PT_CLEARCOAT_NORMAL_RECORD_BYTES: u64 = + std::mem::size_of::() as u64; + +impl Default for PtClearcoatNormalCpu { + fn default() -> Self { + Self { + header: [PT_CLEARCOAT_NORMAL_RECORD_VERSION, 0, 0, 0], + matrix: [1.0, 0.0, 0.0, 1.0], + params: [0.0, 0.0, 1.0, 0.0], + } + } +} + +impl PtClearcoatNormalCpu { + pub(super) fn from_material( + material: crate::models::MaterialLayeredPbr, + runtime_texture_count: usize, + has_secondary_uv: bool, + ) -> Self { + let Some(binding) = material.clearcoat_normal_texture else { + return Self::default(); + }; + let transform = binding.transform; + let usable = binding.runtime_texture_idx.is_some_and(|index| { + index != 0 + && (index as usize) < PT_MAX_TEXTURES + && (index as usize) < runtime_texture_count + }) && (transform.tex_coord == 0 + || (transform.tex_coord == 1 && has_secondary_uv)) + && transform.offset.iter().all(|value| value.is_finite()) + && transform.scale.iter().all(|value| value.is_finite()) + && transform.rotation.is_finite() + && material.clearcoat_normal_scale.is_finite(); + if !material.has_clearcoat() || !usable { + return Self::default(); + } + + let (sine, cosine) = transform.rotation.sin_cos(); + Self { + header: [ + PT_CLEARCOAT_NORMAL_RECORD_VERSION, + crate::models::MaterialLayeredPbr::CLEARCOAT_LOBE + | if transform.tex_coord == 1 { + PT_CLEARCOAT_NORMAL_UV1 + } else { + 0 + }, + binding.runtime_texture_idx.unwrap_or(0), + 0, + ], + matrix: [ + cosine * transform.scale[0], + sine * transform.scale[0], + -sine * transform.scale[1], + cosine * transform.scale[1], + ], + params: [ + transform.offset[0], + transform.offset[1], + material.clearcoat_normal_scale, + 0.0, + ], + } + } + + pub(super) fn active(self) -> bool { + self.header[0] == PT_CLEARCOAT_NORMAL_RECORD_VERSION + && self.header[1] & crate::models::MaterialLayeredPbr::CLEARCOAT_LOBE != 0 + && self.header[2] != 0 + } + + pub(super) fn has_uv1(self) -> bool { + self.active() && self.header[1] & PT_CLEARCOAT_NORMAL_UV1 != 0 + } +} + +pub(super) const PT_CLEARCOAT_NORMAL_BINDINGS_WGSL: &str = r#" +const PT_HAS_CLEARCOAT_NORMALS: bool = true; +const PT_CLEARCOAT_NORMAL_UV1: u32 = 65536u; + +struct PtClearcoatNormalTexture { + header: vec4, + matrix: vec4, + params: vec4, +}; +struct PtClearcoatNormalSample { + material: PtLayeredMaterial, + normal: vec3, +}; +@group(2) @binding(8) +var pt_clearcoat_normals: array; + +fn pt_layered_has_clearcoat_normal(instance_index: u32) -> bool { + let texture_meta = pt_clearcoat_normals[instance_index]; + return texture_meta.header.x == 1u + && (texture_meta.header.y & PT_LAYERED_CLEARCOAT_LOBE) != 0u + && texture_meta.header.z != 0u; +} + +fn pt_layered_apply_clearcoat_normal( + material_in: PtLayeredMaterial, + instance_index: u32, + uv0: vec2, + uv1: vec2, + base_normal: vec3, + tangent: vec4, +) -> PtClearcoatNormalSample { + var result = PtClearcoatNormalSample(material_in, base_normal); + let texture_meta = pt_clearcoat_normals[instance_index]; + if ( + !pt_layered_has_clearcoat(material_in) + || !pt_layered_has_clearcoat_normal(instance_index) + ) { + return result; + } + let source_uv = select( + uv0, + uv1, + (texture_meta.header.y & PT_CLEARCOAT_NORMAL_UV1) != 0u, + ); + let uv = vec2( + texture_meta.matrix.x * source_uv.x + + texture_meta.matrix.z * source_uv.y, + texture_meta.matrix.y * source_uv.x + + texture_meta.matrix.w * source_uv.y, + ) + texture_meta.params.xy; + let sampled = pt_tex_sample_rgba(texture_meta.header.z, uv); + var tangent_normal = sampled.xyz * 2.0 - vec3(1.0); + tangent_normal.x *= texture_meta.params.z; + tangent_normal.y *= texture_meta.params.z; + let normal_len2 = clamp(dot(tangent_normal, tangent_normal), 0.01, 1.0); + tangent_normal *= inverseSqrt(normal_len2); + + let tangent_ortho = + tangent.xyz - base_normal * dot(base_normal, tangent.xyz); + var basis = onb(base_normal); + if (dot(tangent_ortho, tangent_ortho) > 1e-4) { + let mesh_tangent = normalize(tangent_ortho); + let mesh_bitangent = + normalize(cross(base_normal, mesh_tangent)) * tangent.w; + basis = mat3x3(mesh_tangent, mesh_bitangent, base_normal); + } + let mapped_raw = basis * tangent_normal; + let mapped_len2 = dot(mapped_raw, mapped_raw); + var mapped = base_normal; + if (mapped_len2 > 1e-8) { + mapped = mapped_raw * inverseSqrt(mapped_len2); + } + let hemisphere = dot(mapped, base_normal); + result.normal = normalize( + mapped + base_normal * max(0.05 - hemisphere, 0.0), + ); + + // PT has no screen-space derivatives, but it consumes the same baked + // normal-length/variance channels as realtime shading. This keeps the + // sharp coat stable without imposing derivative work on the compute path. + let sigma2_toksvig = (1.0 - normal_len2) / normal_len2; + let baked_variance = clamp(sampled.a, 0.0, 0.999); + let sigma2_baked = baked_variance / max(1.0 - baked_variance, 0.001); + let roughness2 = min( + material_in.clearcoat_ior.y * material_in.clearcoat_ior.y + + sigma2_toksvig + + sigma2_baked, + 1.0, + ); + result.material.clearcoat_ior = vec4( + material_in.clearcoat_ior.x, + sqrt(roughness2), + material_in.clearcoat_ior.zw, + ); + return result; +} +"#; + +pub(super) const PT_CLEARCOAT_NORMAL_DISABLED_WGSL: &str = r#" +const PT_HAS_CLEARCOAT_NORMALS: bool = false; + +struct PtClearcoatNormalSample { + material: PtLayeredMaterial, + normal: vec3, +}; + +fn pt_layered_has_clearcoat_normal(instance_index: u32) -> bool { + return false; +} + +fn pt_layered_apply_clearcoat_normal( + material: PtLayeredMaterial, + instance_index: u32, + uv0: vec2, + uv1: vec2, + base_normal: vec3, + tangent: vec4, +) -> PtClearcoatNormalSample { + return PtClearcoatNormalSample(material, base_normal); +} +"#; + +#[cfg(test)] +mod tests { + use super::*; + + fn binding(index: Option, tex_coord: u32) -> crate::models::MaterialTextureBinding { + crate::models::MaterialTextureBinding { + source_texture_index: 4, + source_image_index: 5, + runtime_texture_idx: index, + transform: crate::models::MaterialTextureTransform { + offset: [0.25, -0.5], + rotation: std::f32::consts::FRAC_PI_2, + scale: [2.0, 3.0], + tex_coord, + }, + } + } + + #[test] + fn record_is_three_vec4s_and_inactive_by_default() { + assert_eq!(PT_CLEARCOAT_NORMAL_RECORD_BYTES, 48); + assert!(!PtClearcoatNormalCpu::default().active()); + } + + #[test] + fn qualification_preserves_transform_scale_and_uv_selection() { + let material = crate::models::MaterialLayeredPbr { + clearcoat_authored: true, + clearcoat_factor: 0.8, + clearcoat_normal_scale: 0.6, + clearcoat_normal_texture: Some(binding(Some(3), 1)), + ..Default::default() + }; + assert!(!PtClearcoatNormalCpu::from_material(material, 4, false).active()); + let record = PtClearcoatNormalCpu::from_material(material, 4, true); + assert!(record.active() && record.has_uv1()); + assert_eq!(record.header[2], 3); + assert!(record.matrix[0].abs() < 1e-6); + assert!((record.matrix[1] - 2.0).abs() < 1e-6); + assert!((record.matrix[2] + 3.0).abs() < 1e-6); + assert!(record.matrix[3].abs() < 1e-6); + assert_eq!(record.params[..3], [0.25, -0.5, 0.6]); + + let unresolved = crate::models::MaterialLayeredPbr { + clearcoat_normal_texture: Some(binding(None, 0)), + ..material + }; + assert!(!PtClearcoatNormalCpu::from_material(unresolved, 4, true).active()); + + let invalid_scale = crate::models::MaterialLayeredPbr { + clearcoat_normal_scale: f32::NAN, + clearcoat_normal_texture: Some(binding(Some(3), 0)), + ..material + }; + assert!(!PtClearcoatNormalCpu::from_material(invalid_scale, 4, true).active()); + } + + #[test] + fn first_normal_map_backfills_only_its_independent_sidecar() { + let mut records = None; + let mut specular_records = None; + let mut clearcoat_records = None; + let mut clearcoat_normal_records = None; + let mut sheen_records = None; + let mut iridescence_records = None; + let mut anisotropy_records = None; + assert!(!super::super::texture::append_record( + &mut records, + &mut specular_records, + &mut clearcoat_records, + &mut clearcoat_normal_records, + &mut sheen_records, + &mut iridescence_records, + &mut anisotropy_records, + 0, + Default::default(), + 4, + true, + )); + let material = crate::models::MaterialLayeredPbr { + clearcoat_authored: true, + clearcoat_factor: 0.8, + clearcoat_normal_texture: Some(binding(Some(3), 1)), + ..Default::default() + }; + assert!(super::super::texture::append_record( + &mut records, + &mut specular_records, + &mut clearcoat_records, + &mut clearcoat_normal_records, + &mut sheen_records, + &mut iridescence_records, + &mut anisotropy_records, + 1, + material, + 4, + true, + )); + assert_eq!(records.as_ref().map(Vec::len), Some(2)); + assert!(records.unwrap()[1].has_clearcoat()); + assert!(specular_records.is_none()); + assert!(clearcoat_records.is_none()); + assert_eq!(clearcoat_normal_records.unwrap().len(), 2); + assert!(sheen_records.is_none()); + assert!(iridescence_records.is_none()); + assert!(anisotropy_records.is_none()); + } +} diff --git a/native/shared/src/renderer/layered_pbr_pt_clearcoat_texture.rs b/native/shared/src/renderer/layered_pbr_pt_clearcoat_texture.rs new file mode 100644 index 00000000..9afa025d --- /dev/null +++ b/native/shared/src/renderer/layered_pbr_pt_clearcoat_texture.rs @@ -0,0 +1,313 @@ +//! Independently lazy clearcoat factor/roughness metadata for path tracing. + +use super::*; + +const PT_CLEARCOAT_TEXTURE_RECORD_VERSION: u32 = 1; +const PT_CLEARCOAT_FACTOR_UV1: u32 = 1 << 16; +const PT_CLEARCOAT_ROUGHNESS_UV1: u32 = 1 << 17; + +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] +pub(in crate::renderer) struct PtClearcoatTextureCpu { + /// x = ABI version, y = qualified lobe + UV flags, + /// z/w = clearcoat-factor/roughness runtime texture indices. + header: [u32; 4], + /// Column-major 2x2 UV matrices after authored scale + rotation. + factor_matrix: [f32; 4], + roughness_matrix: [f32; 4], + /// xy = factor offset, zw = roughness offset. + offsets: [f32; 4], +} + +pub(super) const PT_CLEARCOAT_TEXTURE_RECORD_BYTES: u64 = + std::mem::size_of::() as u64; + +impl Default for PtClearcoatTextureCpu { + fn default() -> Self { + Self { + header: [PT_CLEARCOAT_TEXTURE_RECORD_VERSION, 0, 0, 0], + factor_matrix: [1.0, 0.0, 0.0, 1.0], + roughness_matrix: [1.0, 0.0, 0.0, 1.0], + offsets: [0.0; 4], + } + } +} + +impl PtClearcoatTextureCpu { + pub(super) fn from_material( + material: crate::models::MaterialLayeredPbr, + runtime_texture_count: usize, + has_secondary_uv: bool, + ) -> Self { + fn usable( + binding: crate::models::MaterialTextureBinding, + runtime_texture_count: usize, + has_secondary_uv: bool, + ) -> bool { + let transform = binding.transform; + binding.runtime_texture_idx.is_some_and(|index| { + index != 0 + && (index as usize) < PT_MAX_TEXTURES + && (index as usize) < runtime_texture_count + }) && (transform.tex_coord == 0 || (transform.tex_coord == 1 && has_secondary_uv)) + && transform.offset.iter().all(|value| value.is_finite()) + && transform.scale.iter().all(|value| value.is_finite()) + && transform.rotation.is_finite() + } + + fn transform( + binding: Option, + ) -> ([f32; 4], [f32; 2]) { + let transform = binding.map(|binding| binding.transform).unwrap_or_default(); + let (sine, cosine) = transform.rotation.sin_cos(); + ( + [ + cosine * transform.scale[0], + sine * transform.scale[0], + -sine * transform.scale[1], + cosine * transform.scale[1], + ], + transform.offset, + ) + } + + let factor = material.clearcoat_texture; + let roughness = material.clearcoat_roughness_texture; + let has_texture = factor.is_some() || roughness.is_some(); + let all_usable = factor + .is_none_or(|binding| usable(binding, runtime_texture_count, has_secondary_uv)) + && roughness + .is_none_or(|binding| usable(binding, runtime_texture_count, has_secondary_uv)); + if !material.has_clearcoat() || !has_texture || !all_usable { + return Self::default(); + } + + let (factor_matrix, factor_offset) = transform(factor); + let (roughness_matrix, roughness_offset) = transform(roughness); + Self { + header: [ + PT_CLEARCOAT_TEXTURE_RECORD_VERSION, + crate::models::MaterialLayeredPbr::CLEARCOAT_LOBE + | if factor.is_some_and(|binding| binding.transform.tex_coord == 1) { + PT_CLEARCOAT_FACTOR_UV1 + } else { + 0 + } + | if roughness.is_some_and(|binding| binding.transform.tex_coord == 1) { + PT_CLEARCOAT_ROUGHNESS_UV1 + } else { + 0 + }, + factor + .and_then(|binding| binding.runtime_texture_idx) + .unwrap_or(0), + roughness + .and_then(|binding| binding.runtime_texture_idx) + .unwrap_or(0), + ], + factor_matrix, + roughness_matrix, + offsets: [ + factor_offset[0], + factor_offset[1], + roughness_offset[0], + roughness_offset[1], + ], + } + } + + pub(super) fn active(self) -> bool { + self.header[0] == PT_CLEARCOAT_TEXTURE_RECORD_VERSION + && self.header[1] & crate::models::MaterialLayeredPbr::CLEARCOAT_LOBE != 0 + } + + pub(super) fn has_uv1(self) -> bool { + self.active() + && self.header[1] & (PT_CLEARCOAT_FACTOR_UV1 | PT_CLEARCOAT_ROUGHNESS_UV1) != 0 + } +} + +pub(super) const PT_CLEARCOAT_TEXTURE_BINDINGS_WGSL: &str = r#" +const PT_HAS_CLEARCOAT_TEXTURES: bool = true; +const PT_CLEARCOAT_FACTOR_UV1: u32 = 65536u; +const PT_CLEARCOAT_ROUGHNESS_UV1: u32 = 131072u; + +struct PtClearcoatTexture { + header: vec4, + factor_matrix: vec4, + roughness_matrix: vec4, + offsets: vec4, +}; +@group(2) @binding(4) +var pt_clearcoat_textures: array; + +fn pt_clearcoat_transform_uv( + uv: vec2, + matrix: vec4, + offset: vec2, +) -> vec2 { + return vec2( + matrix.x * uv.x + matrix.z * uv.y, + matrix.y * uv.x + matrix.w * uv.y, + ) + offset; +} + +fn pt_layered_apply_clearcoat_textures( + material_in: PtLayeredMaterial, + instance_index: u32, + uv0: vec2, + uv1: vec2, +) -> PtLayeredMaterial { + var material = material_in; + let texture_meta = pt_clearcoat_textures[instance_index]; + if ( + texture_meta.header.x != 1u + || (texture_meta.header.y & PT_LAYERED_CLEARCOAT_LOBE) == 0u + ) { + return material; + } + var factor = material.clearcoat_ior.x; + var roughness = material.clearcoat_ior.y; + if (texture_meta.header.z != 0u) { + let factor_uv = pt_clearcoat_transform_uv( + select(uv0, uv1, (texture_meta.header.y & PT_CLEARCOAT_FACTOR_UV1) != 0u), + texture_meta.factor_matrix, + texture_meta.offsets.xy, + ); + factor *= pt_tex_sample_rgba(texture_meta.header.z, factor_uv).r; + } + if (texture_meta.header.w != 0u) { + let roughness_uv = pt_clearcoat_transform_uv( + select(uv0, uv1, (texture_meta.header.y & PT_CLEARCOAT_ROUGHNESS_UV1) != 0u), + texture_meta.roughness_matrix, + texture_meta.offsets.zw, + ); + roughness *= pt_tex_sample_rgba(texture_meta.header.w, roughness_uv).g; + } + material.clearcoat_ior = vec4( + clamp(factor, 0.0, 1.0), + clamp(roughness, 0.0, 1.0), + material.clearcoat_ior.zw, + ); + material.header = vec4( + material.header.xy, + material.header.z & ~PT_LAYERED_CLEARCOAT_LOBE, + material.header.w, + ); + return material; +} +"#; + +pub(super) const PT_CLEARCOAT_TEXTURE_DISABLED_WGSL: &str = r#" +const PT_HAS_CLEARCOAT_TEXTURES: bool = false; + +fn pt_layered_apply_clearcoat_textures( + material: PtLayeredMaterial, + instance_index: u32, + uv0: vec2, + uv1: vec2, +) -> PtLayeredMaterial { + return material; +} +"#; + +#[cfg(test)] +mod tests { + use super::*; + + fn binding(index: Option, tex_coord: u32) -> crate::models::MaterialTextureBinding { + crate::models::MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: index, + transform: crate::models::MaterialTextureTransform { + tex_coord, + ..Default::default() + }, + } + } + + #[test] + fn record_is_independently_compact_and_inactive_by_default() { + assert_eq!(PT_CLEARCOAT_TEXTURE_RECORD_BYTES, 64); + assert!(!PtClearcoatTextureCpu::default().active()); + } + + #[test] + fn qualification_requires_resolved_coordinates() { + let material = crate::models::MaterialLayeredPbr { + clearcoat_authored: true, + clearcoat_factor: 0.8, + clearcoat_texture: Some(binding(Some(2), 0)), + clearcoat_roughness_texture: Some(binding(Some(3), 1)), + ..Default::default() + }; + assert!(!PtClearcoatTextureCpu::from_material(material, 4, false).active()); + let record = PtClearcoatTextureCpu::from_material(material, 4, true); + assert!(record.active() && record.has_uv1()); + assert_eq!(record.header[2..], [2, 3]); + + let unresolved = crate::models::MaterialLayeredPbr { + clearcoat_texture: Some(binding(None, 0)), + ..material + }; + assert!(!PtClearcoatTextureCpu::from_material(unresolved, 4, true).active()); + + let normal_mapped = crate::models::MaterialLayeredPbr { + clearcoat_normal_texture: Some(binding(Some(1), 0)), + ..material + }; + assert!(PtClearcoatTextureCpu::from_material(normal_mapped, 4, true).active()); + } + + #[test] + fn first_clearcoat_texture_backfills_parallel_records_and_reports_uv1() { + let mut records = None; + let mut specular_records = None; + let mut clearcoat_records = None; + let mut clearcoat_normal_records = None; + let mut sheen_records = None; + let mut iridescence_records = None; + let mut anisotropy_records = None; + assert!(!super::super::texture::append_record( + &mut records, + &mut specular_records, + &mut clearcoat_records, + &mut clearcoat_normal_records, + &mut sheen_records, + &mut iridescence_records, + &mut anisotropy_records, + 0, + Default::default(), + 4, + true, + )); + let material = crate::models::MaterialLayeredPbr { + clearcoat_authored: true, + clearcoat_factor: 0.8, + clearcoat_texture: Some(binding(Some(2), 1)), + ..Default::default() + }; + assert!(super::super::texture::append_record( + &mut records, + &mut specular_records, + &mut clearcoat_records, + &mut clearcoat_normal_records, + &mut sheen_records, + &mut iridescence_records, + &mut anisotropy_records, + 1, + material, + 4, + true, + )); + assert!(specular_records.is_none()); + assert!(sheen_records.is_none()); + assert!(iridescence_records.is_none()); + assert!(anisotropy_records.is_none()); + let records = clearcoat_records.unwrap(); + assert_eq!(records.len(), 2); + assert!(!records[0].active()); + assert!(records[1].active() && records[1].has_uv1()); + } +} diff --git a/native/shared/src/renderer/layered_pbr_pt_iridescence_texture.rs b/native/shared/src/renderer/layered_pbr_pt_iridescence_texture.rs new file mode 100644 index 00000000..d7229b83 --- /dev/null +++ b/native/shared/src/renderer/layered_pbr_pt_iridescence_texture.rs @@ -0,0 +1,324 @@ +//! Independently lazy iridescence factor/thickness metadata for path tracing. + +use super::*; + +const PT_IRIDESCENCE_TEXTURE_RECORD_VERSION: u32 = 1; +const PT_IRIDESCENCE_FACTOR_UV1: u32 = 1 << 16; +const PT_IRIDESCENCE_THICKNESS_UV1: u32 = 1 << 17; + +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] +pub(in crate::renderer) struct PtIridescenceTextureCpu { + /// x = ABI version, y = qualified lobe + UV flags, + /// z/w = factor/thickness runtime texture indices. + header: [u32; 4], + /// Column-major 2x2 UV matrices after authored scale + rotation. + factor_matrix: [f32; 4], + thickness_matrix: [f32; 4], + /// xy = factor offset, zw = thickness offset. + offsets: [f32; 4], +} + +pub(super) const PT_IRIDESCENCE_TEXTURE_RECORD_BYTES: u64 = + std::mem::size_of::() as u64; + +impl Default for PtIridescenceTextureCpu { + fn default() -> Self { + Self { + header: [PT_IRIDESCENCE_TEXTURE_RECORD_VERSION, 0, 0, 0], + factor_matrix: [1.0, 0.0, 0.0, 1.0], + thickness_matrix: [1.0, 0.0, 0.0, 1.0], + offsets: [0.0; 4], + } + } +} + +impl PtIridescenceTextureCpu { + pub(super) fn from_material( + material: crate::models::MaterialLayeredPbr, + runtime_texture_count: usize, + has_secondary_uv: bool, + ) -> Self { + fn usable( + binding: crate::models::MaterialTextureBinding, + runtime_texture_count: usize, + has_secondary_uv: bool, + ) -> bool { + let transform = binding.transform; + binding.runtime_texture_idx.is_some_and(|index| { + index != 0 + && (index as usize) < PT_MAX_TEXTURES + && (index as usize) < runtime_texture_count + }) && (transform.tex_coord == 0 || (transform.tex_coord == 1 && has_secondary_uv)) + && transform.offset.iter().all(|value| value.is_finite()) + && transform.scale.iter().all(|value| value.is_finite()) + && transform.rotation.is_finite() + } + + fn transform( + binding: Option, + ) -> ([f32; 4], [f32; 2]) { + let transform = binding.map(|binding| binding.transform).unwrap_or_default(); + let (sine, cosine) = transform.rotation.sin_cos(); + ( + [ + cosine * transform.scale[0], + sine * transform.scale[0], + -sine * transform.scale[1], + cosine * transform.scale[1], + ], + transform.offset, + ) + } + + let factor = material.iridescence_texture; + let thickness = material.iridescence_thickness_texture; + let has_texture = factor.is_some() || thickness.is_some(); + let all_usable = factor + .is_none_or(|binding| usable(binding, runtime_texture_count, has_secondary_uv)) + && thickness + .is_none_or(|binding| usable(binding, runtime_texture_count, has_secondary_uv)); + if !material.has_iridescence() || !has_texture || !all_usable { + return Self::default(); + } + + let (factor_matrix, factor_offset) = transform(factor); + let (thickness_matrix, thickness_offset) = transform(thickness); + Self { + header: [ + PT_IRIDESCENCE_TEXTURE_RECORD_VERSION, + crate::models::MaterialLayeredPbr::IRIDESCENCE_LOBE + | if factor.is_some_and(|binding| binding.transform.tex_coord == 1) { + PT_IRIDESCENCE_FACTOR_UV1 + } else { + 0 + } + | if thickness.is_some_and(|binding| binding.transform.tex_coord == 1) { + PT_IRIDESCENCE_THICKNESS_UV1 + } else { + 0 + }, + factor + .and_then(|binding| binding.runtime_texture_idx) + .unwrap_or(0), + thickness + .and_then(|binding| binding.runtime_texture_idx) + .unwrap_or(0), + ], + factor_matrix, + thickness_matrix, + offsets: [ + factor_offset[0], + factor_offset[1], + thickness_offset[0], + thickness_offset[1], + ], + } + } + + pub(super) fn active(self) -> bool { + self.header[0] == PT_IRIDESCENCE_TEXTURE_RECORD_VERSION + && self.header[1] & crate::models::MaterialLayeredPbr::IRIDESCENCE_LOBE != 0 + } + + pub(super) fn has_uv1(self) -> bool { + self.active() + && self.header[1] & (PT_IRIDESCENCE_FACTOR_UV1 | PT_IRIDESCENCE_THICKNESS_UV1) != 0 + } +} + +pub(super) const PT_IRIDESCENCE_TEXTURE_BINDINGS_WGSL: &str = r#" +const PT_HAS_IRIDESCENCE_TEXTURES: bool = true; +const PT_IRIDESCENCE_FACTOR_UV1: u32 = 65536u; +const PT_IRIDESCENCE_THICKNESS_UV1: u32 = 131072u; + +struct PtIridescenceTexture { + header: vec4, + factor_matrix: vec4, + thickness_matrix: vec4, + offsets: vec4, +}; +@group(2) @binding(6) +var pt_iridescence_textures: array; + +fn pt_iridescence_transform_uv( + uv: vec2, + matrix: vec4, + offset: vec2, +) -> vec2 { + return vec2( + matrix.x * uv.x + matrix.z * uv.y, + matrix.y * uv.x + matrix.w * uv.y, + ) + offset; +} + +fn pt_layered_apply_iridescence_textures( + material_in: PtLayeredMaterial, + instance_index: u32, + uv0: vec2, + uv1: vec2, +) -> PtLayeredMaterial { + var material = material_in; + let texture_meta = pt_iridescence_textures[instance_index]; + if ( + texture_meta.header.x != 1u + || (texture_meta.header.y & PT_LAYERED_IRIDESCENCE_LOBE) == 0u + ) { + return material; + } + var factor = material.iridescence.x; + var thickness = material.iridescence.w; + if (texture_meta.header.z != 0u) { + let factor_uv = pt_iridescence_transform_uv( + select(uv0, uv1, (texture_meta.header.y & PT_IRIDESCENCE_FACTOR_UV1) != 0u), + texture_meta.factor_matrix, + texture_meta.offsets.xy, + ); + factor *= pt_tex_sample_rgba(texture_meta.header.z, factor_uv).r; + } + if (texture_meta.header.w != 0u) { + let thickness_uv = pt_iridescence_transform_uv( + select( + uv0, + uv1, + (texture_meta.header.y & PT_IRIDESCENCE_THICKNESS_UV1) != 0u, + ), + texture_meta.thickness_matrix, + texture_meta.offsets.zw, + ); + let sampled = pt_tex_sample_rgba(texture_meta.header.w, thickness_uv).g; + thickness = mix( + max(material.iridescence.z, 0.0), + max(material.iridescence.w, 0.0), + sampled, + ); + } + if (thickness <= 0.0) { + factor = 0.0; + } + material.iridescence = vec4( + clamp(factor, 0.0, 1.0), + material.iridescence.y, + material.iridescence.z, + max(thickness, 0.0), + ); + material.header = vec4( + material.header.xy, + material.header.z & ~PT_LAYERED_IRIDESCENCE_LOBE, + material.header.w, + ); + return material; +} +"#; + +pub(super) const PT_IRIDESCENCE_TEXTURE_DISABLED_WGSL: &str = r#" +const PT_HAS_IRIDESCENCE_TEXTURES: bool = false; + +fn pt_layered_apply_iridescence_textures( + material: PtLayeredMaterial, + instance_index: u32, + uv0: vec2, + uv1: vec2, +) -> PtLayeredMaterial { + return material; +} +"#; + +#[cfg(test)] +mod tests { + use super::*; + + fn binding(index: Option, tex_coord: u32) -> crate::models::MaterialTextureBinding { + crate::models::MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: index, + transform: crate::models::MaterialTextureTransform { + tex_coord, + ..Default::default() + }, + } + } + + #[test] + fn record_is_independently_compact_and_inactive_by_default() { + assert_eq!(PT_IRIDESCENCE_TEXTURE_RECORD_BYTES, 64); + assert!(!PtIridescenceTextureCpu::default().active()); + } + + #[test] + fn qualification_requires_resolved_coordinates() { + let material = crate::models::MaterialLayeredPbr { + iridescence_authored: true, + iridescence_factor: 1.0, + iridescence_thickness_minimum: 100.0, + iridescence_thickness_maximum: 400.0, + iridescence_texture: Some(binding(Some(2), 0)), + iridescence_thickness_texture: Some(binding(Some(3), 1)), + ..Default::default() + }; + assert!(!PtIridescenceTextureCpu::from_material(material, 4, false).active()); + let record = PtIridescenceTextureCpu::from_material(material, 4, true); + assert!(record.active() && record.has_uv1()); + assert_eq!(record.header[2..], [2, 3]); + + let unresolved = crate::models::MaterialLayeredPbr { + iridescence_texture: Some(binding(None, 0)), + ..material + }; + assert!(!PtIridescenceTextureCpu::from_material(unresolved, 4, true).active()); + } + + #[test] + fn first_iridescence_texture_backfills_parallel_records_and_reports_uv1() { + let mut records = None; + let mut specular_records = None; + let mut clearcoat_records = None; + let mut clearcoat_normal_records = None; + let mut sheen_records = None; + let mut iridescence_records = None; + let mut anisotropy_records = None; + assert!(!super::super::texture::append_record( + &mut records, + &mut specular_records, + &mut clearcoat_records, + &mut clearcoat_normal_records, + &mut sheen_records, + &mut iridescence_records, + &mut anisotropy_records, + 0, + Default::default(), + 4, + true, + )); + let material = crate::models::MaterialLayeredPbr { + iridescence_authored: true, + iridescence_factor: 1.0, + iridescence_thickness_minimum: 100.0, + iridescence_thickness_maximum: 400.0, + iridescence_texture: Some(binding(Some(2), 1)), + ..Default::default() + }; + assert!(super::super::texture::append_record( + &mut records, + &mut specular_records, + &mut clearcoat_records, + &mut clearcoat_normal_records, + &mut sheen_records, + &mut iridescence_records, + &mut anisotropy_records, + 1, + material, + 4, + true, + )); + assert!(specular_records.is_none()); + assert!(clearcoat_records.is_none()); + assert!(sheen_records.is_none()); + assert!(anisotropy_records.is_none()); + let records = iridescence_records.unwrap(); + assert_eq!(records.len(), 2); + assert!(!records[0].active()); + assert!(records[1].active() && records[1].has_uv1()); + } +} diff --git a/native/shared/src/renderer/layered_pbr_pt_kernel.rs b/native/shared/src/renderer/layered_pbr_pt_kernel.rs new file mode 100644 index 00000000..a56b29bc --- /dev/null +++ b/native/shared/src/renderer/layered_pbr_pt_kernel.rs @@ -0,0 +1,133 @@ +//! Exact source specialization for the layered path-tracing kernel. + +fn replace_once(source: &mut String, needle: &str, replacement: &str) { + let count = source.matches(needle).count(); + assert_eq!( + count, 1, + "layered PT specialization expected one source anchor, found {count}: {needle}" + ); + *source = source.replacen(needle, replacement, 1); +} + +pub(super) fn layered_kernel_variant(base: &str) -> String { + let mut source = base.to_owned(); + replace_once( + &mut source, + " var rough_cur = mr0.g;", + " var rough_cur = mr0.g;\n\ + \x20 let layered_primary = pt_layered_primary_surface(p0, n0);\n\ + \x20 var layered_cur = layered_primary.material;\n\ + \x20 var layered_tangent_cur = layered_primary.tangent;\n\ + \x20 var layered_clearcoat_normal_cur = layered_primary.clearcoat_normal;", + ); + replace_once( + &mut source, + " let use_restir = u.ext.w == 1u && u.cfg.x >= 2.0;", + " let use_restir = u.ext.w == 1u && u.cfg.x >= 2.0\n \ + && !pt_layered_has_transport(layered_cur);", + ); + replace_once( + &mut source, + " var radiance = direct_light(\n\ + \x20 p0 + n0 * 0.02, n0, sun_r2, view_cur,\n\ + \x20 albedo0, rough_cur, metal_cur, !use_restir,\n\ + \x20 );", + " var radiance = pt_layered_direct_light(\n\ + \x20 p0 + n0 * 0.02, n0, layered_clearcoat_normal_cur,\n\ + \x20 layered_tangent_cur, sun_r2, view_cur,\n\ + \x20 albedo0, rough_cur, metal_cur, !use_restir, layered_cur,\n\ + \x20 );", + ); + replace_once( + &mut source, + " let s = sample_brdf(n_cur, view_cur, alb_cur, rough_cur, metal_cur);", + " let s = pt_sample_layered_brdf(\n\ + \x20 n_cur, layered_clearcoat_normal_cur,\n\ + \x20 layered_tangent_cur, view_cur,\n\ + \x20 alb_cur, rough_cur, metal_cur, layered_cur,\n\ + \x20 );", + ); + replace_once( + &mut source, + " radiance += throughput * direct_light(\n\ + \x20 hit_p, n_hit, rand_2f(), -dir,\n\ + \x20 alb_hit, inst.mat_params.x, inst.mat_params.y, true,\n\ + \x20 );", + " var layered_hit = pt_layered_materials[hit.instance_custom_data];\n\ + \x20 var layered_primary_uv = vec2(0.0);\n\ + \x20 var layered_secondary_uv = vec2(0.0);\n\ + \x20 if ((\n\ + \x20 PT_HAS_LAYERED_TEXTURES\n\ + \x20 || PT_HAS_CLEARCOAT_TEXTURES\n\ + \x20 || PT_HAS_CLEARCOAT_NORMALS\n\ + \x20 || PT_HAS_SHEEN_TEXTURES\n\ + \x20 || PT_HAS_IRIDESCENCE_TEXTURES\n\ + \x20 || PT_HAS_ANISOTROPY_TEXTURES\n\ + \x20 ) && inst.geo.z > 0u) {\n\ + \x20 let layered_attributes = fetch_hit_attrs(\n\ + \x20 inst.geo, hit.primitive_index, hit.barycentrics,\n\ + \x20 );\n\ + \x20 layered_primary_uv = layered_attributes.uv;\n\ + \x20 layered_secondary_uv = pt_layered_hit_uv1(\n\ + \x20 inst.geo, hit.primitive_index, hit.barycentrics,\n\ + \x20 );\n\ + \x20 layered_hit = pt_layered_apply_textures(\n\ + \x20 layered_hit, hit.instance_custom_data,\n\ + \x20 layered_primary_uv, layered_secondary_uv,\n\ + \x20 );\n\ + \x20 layered_hit = pt_layered_apply_clearcoat_textures(\n\ + \x20 layered_hit, hit.instance_custom_data,\n\ + \x20 layered_primary_uv, layered_secondary_uv,\n\ + \x20 );\n\ + \x20 layered_hit = pt_layered_apply_sheen_textures(\n\ + \x20 layered_hit, hit.instance_custom_data,\n\ + \x20 layered_primary_uv, layered_secondary_uv,\n\ + \x20 );\n\ + \x20 layered_hit = pt_layered_apply_anisotropy_texture(\n\ + \x20 layered_hit, hit.instance_custom_data,\n\ + \x20 layered_primary_uv, layered_secondary_uv,\n\ + \x20 );\n\ + \x20 layered_hit = pt_layered_apply_iridescence_textures(\n\ + \x20 layered_hit, hit.instance_custom_data,\n\ + \x20 layered_primary_uv, layered_secondary_uv,\n\ + \x20 );\n\ + \x20 }\n\ + \x20 var layered_tangent_hit = vec4(0.0);\n\ + \x20 if (\n\ + \x20 pt_layered_has_anisotropy(layered_hit)\n\ + \x20 || (\n\ + \x20 PT_HAS_CLEARCOAT_NORMALS\n\ + \x20 && pt_layered_has_clearcoat_normal(\n\ + \x20 hit.instance_custom_data,\n\ + \x20 )\n\ + \x20 )\n\ + \x20 ) {\n\ + \x20 layered_tangent_hit = pt_layered_hit_tangent(\n\ + \x20 inst.geo, hit.primitive_index, hit.barycentrics,\n\ + \x20 hit.object_to_world, n_hit,\n\ + \x20 );\n\ + \x20 }\n\ + \x20 let layered_coat_sample = pt_layered_apply_clearcoat_normal(\n\ + \x20 layered_hit, hit.instance_custom_data,\n\ + \x20 layered_primary_uv, layered_secondary_uv,\n\ + \x20 n_hit, layered_tangent_hit,\n\ + \x20 );\n\ + \x20 layered_hit = layered_coat_sample.material;\n\ + \x20 let layered_clearcoat_normal_hit = layered_coat_sample.normal;\n\ + \x20 radiance += throughput * pt_layered_direct_light(\n\ + \x20 hit_p, n_hit, layered_clearcoat_normal_hit,\n\ + \x20 layered_tangent_hit, rand_2f(), -dir,\n\ + \x20 alb_hit, inst.mat_params.x, inst.mat_params.y, true, layered_hit,\n\ + \x20 );", + ); + replace_once( + &mut source, + " metal_cur = inst.mat_params.y;\n view_cur = -dir;", + " metal_cur = inst.mat_params.y;\n\ + \x20 layered_cur = layered_hit;\n\ + \x20 layered_tangent_cur = layered_tangent_hit;\n\ + \x20 layered_clearcoat_normal_cur = layered_clearcoat_normal_hit;\n\ + \x20 view_cur = -dir;", + ); + source +} diff --git a/native/shared/src/renderer/layered_pbr_pt_resources.rs b/native/shared/src/renderer/layered_pbr_pt_resources.rs new file mode 100644 index 00000000..816b4f8f --- /dev/null +++ b/native/shared/src/renderer/layered_pbr_pt_resources.rs @@ -0,0 +1,34 @@ +//! Shared allocation/upload primitive for independently lazy PT sidecars. + +pub(super) fn ensure_pt_sidecar( + device: &wgpu::Device, + queue: &wgpu::Queue, + records: &[T], + buffer: &mut Option, + dirty: &mut bool, + record_bytes: u64, + label: &'static str, +) -> bool { + debug_assert!(!records.is_empty()); + let needed = record_bytes * records.len() as u64; + let recreate = buffer.as_ref().is_none_or(|buffer| buffer.size() < needed); + if recreate { + let capacity = records.len().next_power_of_two() as u64; + *buffer = Some(device.create_buffer(&wgpu::BufferDescriptor { + label: Some(label), + size: record_bytes * capacity, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + })); + *dirty = true; + } + if *dirty { + queue.write_buffer( + buffer.as_ref().expect("PT sidecar buffer is initialized"), + 0, + bytemuck::cast_slice(records), + ); + *dirty = false; + } + recreate +} diff --git a/native/shared/src/renderer/layered_pbr_pt_runtime.rs b/native/shared/src/renderer/layered_pbr_pt_runtime.rs new file mode 100644 index 00000000..52528de3 --- /dev/null +++ b/native/shared/src/renderer/layered_pbr_pt_runtime.rs @@ -0,0 +1,219 @@ +//! Runtime qualification and record updates for layered path tracing. + +use super::*; + +impl Renderer { + pub(in crate::renderer) fn pt_layered_transport_active(&self) -> bool { + self.pt_layered + .records + .iter() + .copied() + .any(PtLayeredMaterialCpu::has_qualified_transport) + || (self.pt_texture_arrays_enabled + && (self.pt_layered_texture_active() + || self.pt_layered_clearcoat_texture_active() + || self.pt_layered_clearcoat_normal_active() + || self.pt_layered_sheen_texture_active() + || self.pt_layered_iridescence_texture_active() + || self.pt_layered_anisotropy_texture_active())) + } + + pub(in crate::renderer) fn pt_layered_sheen_active(&self) -> bool { + self.pt_layered + .records + .iter() + .copied() + .any(PtLayeredMaterialCpu::has_sheen) + || (self.pt_texture_arrays_enabled && self.pt_layered_sheen_texture_active()) + } + + pub(in crate::renderer) fn pt_layered_anisotropy_active(&self) -> bool { + self.pt_layered + .records + .iter() + .copied() + .any(PtLayeredMaterialCpu::has_anisotropy) + || (self.pt_texture_arrays_enabled && self.pt_layered_anisotropy_texture_active()) + } + + pub(in crate::renderer) fn pt_layered_iridescence_active(&self) -> bool { + self.pt_layered + .records + .iter() + .copied() + .any(PtLayeredMaterialCpu::has_iridescence) + || (self.pt_texture_arrays_enabled && self.pt_layered_iridescence_texture_active()) + } + + pub(in crate::renderer) fn pt_layered_texture_active(&self) -> bool { + self.pt_layered + .texture_records + .iter() + .copied() + .any(PtLayeredTextureCpu::has_specular_ior) + } + + pub(in crate::renderer) fn pt_layered_clearcoat_texture_active(&self) -> bool { + self.pt_layered + .clearcoat_texture_records + .iter() + .copied() + .any(PtClearcoatTextureCpu::active) + } + + pub(in crate::renderer) fn pt_layered_clearcoat_normal_active(&self) -> bool { + self.pt_layered + .clearcoat_normal_records + .iter() + .copied() + .any(PtClearcoatNormalCpu::active) + } + + pub(in crate::renderer) fn pt_layered_sheen_texture_active(&self) -> bool { + self.pt_layered + .sheen_texture_records + .iter() + .copied() + .any(PtSheenTextureCpu::active) + } + + pub(in crate::renderer) fn pt_layered_iridescence_texture_active(&self) -> bool { + self.pt_layered + .iridescence_texture_records + .iter() + .copied() + .any(PtIridescenceTextureCpu::active) + } + + pub(in crate::renderer) fn pt_layered_anisotropy_texture_active(&self) -> bool { + self.pt_layered + .anisotropy_texture_records + .iter() + .copied() + .any(PtAnisotropyTextureCpu::active) + } + + pub(in crate::renderer) fn pt_layered_uv1_active(&self) -> bool { + self.pt_layered + .texture_records + .iter() + .copied() + .any(PtLayeredTextureCpu::has_uv1) + || self + .pt_layered + .clearcoat_texture_records + .iter() + .copied() + .any(PtClearcoatTextureCpu::has_uv1) + || self + .pt_layered + .clearcoat_normal_records + .iter() + .copied() + .any(PtClearcoatNormalCpu::has_uv1) + || self + .pt_layered + .sheen_texture_records + .iter() + .copied() + .any(PtSheenTextureCpu::has_uv1) + || self + .pt_layered + .iridescence_texture_records + .iter() + .copied() + .any(PtIridescenceTextureCpu::has_uv1) + || self + .pt_layered + .anisotropy_texture_records + .iter() + .copied() + .any(PtAnisotropyTextureCpu::has_uv1) + } + + pub(in crate::renderer) fn set_pt_layered_records( + &mut self, + records: Option>, + texture_records: Option>, + clearcoat_texture_records: Option>, + clearcoat_normal_records: Option>, + sheen_texture_records: Option>, + iridescence_texture_records: Option>, + anisotropy_texture_records: Option>, + instance_count: usize, + ) { + let records = records.unwrap_or_default(); + let texture_records = texture_records.unwrap_or_default(); + let clearcoat_texture_records = clearcoat_texture_records.unwrap_or_default(); + let clearcoat_normal_records = clearcoat_normal_records.unwrap_or_default(); + let sheen_texture_records = sheen_texture_records.unwrap_or_default(); + let iridescence_texture_records = iridescence_texture_records.unwrap_or_default(); + let anisotropy_texture_records = anisotropy_texture_records.unwrap_or_default(); + debug_assert!(records.is_empty() || records.len() == instance_count); + debug_assert!(texture_records.is_empty() || texture_records.len() == instance_count); + debug_assert!( + clearcoat_texture_records.is_empty() + || clearcoat_texture_records.len() == instance_count + ); + debug_assert!( + clearcoat_normal_records.is_empty() || clearcoat_normal_records.len() == instance_count + ); + debug_assert!( + sheen_texture_records.is_empty() || sheen_texture_records.len() == instance_count + ); + debug_assert!( + iridescence_texture_records.is_empty() + || iridescence_texture_records.len() == instance_count + ); + debug_assert!( + anisotropy_texture_records.is_empty() + || anisotropy_texture_records.len() == instance_count + ); + if self.pt_layered.records != records { + self.pt_layered.records = records; + self.pt_layered.dirty = !self.pt_layered.records.is_empty(); + self.pt_accum_count = 0; + self.pt_wrote_frame = false; + } + if self.pt_layered.clearcoat_texture_records != clearcoat_texture_records { + self.pt_layered.clearcoat_texture_records = clearcoat_texture_records; + self.pt_layered.clearcoat_texture_dirty = + !self.pt_layered.clearcoat_texture_records.is_empty(); + self.pt_accum_count = 0; + self.pt_wrote_frame = false; + } + if self.pt_layered.clearcoat_normal_records != clearcoat_normal_records { + self.pt_layered.clearcoat_normal_records = clearcoat_normal_records; + self.pt_layered.clearcoat_normal_dirty = + !self.pt_layered.clearcoat_normal_records.is_empty(); + self.pt_accum_count = 0; + self.pt_wrote_frame = false; + } + if self.pt_layered.sheen_texture_records != sheen_texture_records { + self.pt_layered.sheen_texture_records = sheen_texture_records; + self.pt_layered.sheen_texture_dirty = !self.pt_layered.sheen_texture_records.is_empty(); + self.pt_accum_count = 0; + self.pt_wrote_frame = false; + } + if self.pt_layered.iridescence_texture_records != iridescence_texture_records { + self.pt_layered.iridescence_texture_records = iridescence_texture_records; + self.pt_layered.iridescence_texture_dirty = + !self.pt_layered.iridescence_texture_records.is_empty(); + self.pt_accum_count = 0; + self.pt_wrote_frame = false; + } + if self.pt_layered.anisotropy_texture_records != anisotropy_texture_records { + self.pt_layered.anisotropy_texture_records = anisotropy_texture_records; + self.pt_layered.anisotropy_texture_dirty = + !self.pt_layered.anisotropy_texture_records.is_empty(); + self.pt_accum_count = 0; + self.pt_wrote_frame = false; + } + if self.pt_layered.texture_records != texture_records { + self.pt_layered.texture_records = texture_records; + self.pt_layered.texture_dirty = !self.pt_layered.texture_records.is_empty(); + self.pt_accum_count = 0; + self.pt_wrote_frame = false; + } + } +} diff --git a/native/shared/src/renderer/layered_pbr_pt_sheen_texture.rs b/native/shared/src/renderer/layered_pbr_pt_sheen_texture.rs new file mode 100644 index 00000000..350873cc --- /dev/null +++ b/native/shared/src/renderer/layered_pbr_pt_sheen_texture.rs @@ -0,0 +1,317 @@ +//! Independently lazy sheen color/roughness metadata for path tracing. +//! +//! This record is separate from specular and clearcoat texture transport so +//! each previously qualified path preserves its established ABI and bandwidth. + +use super::*; + +const PT_SHEEN_TEXTURE_RECORD_VERSION: u32 = 1; +const PT_SHEEN_COLOR_UV1: u32 = 1 << 16; +const PT_SHEEN_ROUGHNESS_UV1: u32 = 1 << 17; + +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] +pub(in crate::renderer) struct PtSheenTextureCpu { + /// x = ABI version, y = qualified lobe + UV flags, + /// z/w = sheen-color/roughness runtime texture indices. + header: [u32; 4], + /// Column-major 2x2 UV matrices after authored scale + rotation. + color_matrix: [f32; 4], + roughness_matrix: [f32; 4], + /// xy = color offset, zw = roughness offset. + offsets: [f32; 4], +} + +pub(super) const PT_SHEEN_TEXTURE_RECORD_BYTES: u64 = + std::mem::size_of::() as u64; + +impl Default for PtSheenTextureCpu { + fn default() -> Self { + Self { + header: [PT_SHEEN_TEXTURE_RECORD_VERSION, 0, 0, 0], + color_matrix: [1.0, 0.0, 0.0, 1.0], + roughness_matrix: [1.0, 0.0, 0.0, 1.0], + offsets: [0.0; 4], + } + } +} + +impl PtSheenTextureCpu { + pub(super) fn from_material( + material: crate::models::MaterialLayeredPbr, + runtime_texture_count: usize, + has_secondary_uv: bool, + ) -> Self { + fn usable( + binding: crate::models::MaterialTextureBinding, + runtime_texture_count: usize, + has_secondary_uv: bool, + ) -> bool { + let transform = binding.transform; + binding.runtime_texture_idx.is_some_and(|index| { + index != 0 + && (index as usize) < PT_MAX_TEXTURES + && (index as usize) < runtime_texture_count + }) && (transform.tex_coord == 0 || (transform.tex_coord == 1 && has_secondary_uv)) + && transform.offset.iter().all(|value| value.is_finite()) + && transform.scale.iter().all(|value| value.is_finite()) + && transform.rotation.is_finite() + } + + fn transform( + binding: Option, + ) -> ([f32; 4], [f32; 2]) { + let transform = binding.map(|binding| binding.transform).unwrap_or_default(); + let (sine, cosine) = transform.rotation.sin_cos(); + ( + [ + cosine * transform.scale[0], + sine * transform.scale[0], + -sine * transform.scale[1], + cosine * transform.scale[1], + ], + transform.offset, + ) + } + + let color = material.sheen_color_texture; + let roughness = material.sheen_roughness_texture; + let has_texture = color.is_some() || roughness.is_some(); + let all_usable = color + .is_none_or(|binding| usable(binding, runtime_texture_count, has_secondary_uv)) + && roughness + .is_none_or(|binding| usable(binding, runtime_texture_count, has_secondary_uv)); + if !material.has_sheen() || !has_texture || !all_usable { + return Self::default(); + } + + let (color_matrix, color_offset) = transform(color); + let (roughness_matrix, roughness_offset) = transform(roughness); + Self { + header: [ + PT_SHEEN_TEXTURE_RECORD_VERSION, + crate::models::MaterialLayeredPbr::SHEEN_LOBE + | if color.is_some_and(|binding| binding.transform.tex_coord == 1) { + PT_SHEEN_COLOR_UV1 + } else { + 0 + } + | if roughness.is_some_and(|binding| binding.transform.tex_coord == 1) { + PT_SHEEN_ROUGHNESS_UV1 + } else { + 0 + }, + color + .and_then(|binding| binding.runtime_texture_idx) + .unwrap_or(0), + roughness + .and_then(|binding| binding.runtime_texture_idx) + .unwrap_or(0), + ], + color_matrix, + roughness_matrix, + offsets: [ + color_offset[0], + color_offset[1], + roughness_offset[0], + roughness_offset[1], + ], + } + } + + pub(super) fn active(self) -> bool { + self.header[0] == PT_SHEEN_TEXTURE_RECORD_VERSION + && self.header[1] & crate::models::MaterialLayeredPbr::SHEEN_LOBE != 0 + } + + pub(super) fn has_uv1(self) -> bool { + self.active() && self.header[1] & (PT_SHEEN_COLOR_UV1 | PT_SHEEN_ROUGHNESS_UV1) != 0 + } +} + +pub(super) const PT_SHEEN_TEXTURE_BINDINGS_WGSL: &str = r#" +const PT_HAS_SHEEN_TEXTURES: bool = true; +const PT_LAYERED_SHEEN_LOBE: u32 = 2u; +const PT_SHEEN_COLOR_UV1: u32 = 65536u; +const PT_SHEEN_ROUGHNESS_UV1: u32 = 131072u; + +struct PtSheenTexture { + header: vec4, + color_matrix: vec4, + roughness_matrix: vec4, + offsets: vec4, +}; +@group(2) @binding(5) +var pt_sheen_textures: array; + +fn pt_sheen_transform_uv( + uv: vec2, + matrix: vec4, + offset: vec2, +) -> vec2 { + return vec2( + matrix.x * uv.x + matrix.z * uv.y, + matrix.y * uv.x + matrix.w * uv.y, + ) + offset; +} + +fn pt_sheen_srgb_to_linear(color: vec3) -> vec3 { + let low = color / 12.92; + let high = pow((color + vec3(0.055)) / 1.055, vec3(2.4)); + return select(high, low, color <= vec3(0.04045)); +} + +fn pt_layered_apply_sheen_textures( + material_in: PtLayeredMaterial, + instance_index: u32, + uv0: vec2, + uv1: vec2, +) -> PtLayeredMaterial { + var material = material_in; + let texture_meta = pt_sheen_textures[instance_index]; + if ( + texture_meta.header.x != 1u + || (texture_meta.header.y & PT_LAYERED_SHEEN_LOBE) == 0u + ) { + return material; + } + var color = material.sheen.xyz; + var roughness = material.sheen.w; + if (texture_meta.header.z != 0u) { + let color_uv = pt_sheen_transform_uv( + select(uv0, uv1, (texture_meta.header.y & PT_SHEEN_COLOR_UV1) != 0u), + texture_meta.color_matrix, + texture_meta.offsets.xy, + ); + color *= pt_sheen_srgb_to_linear( + pt_tex_sample_rgba(texture_meta.header.z, color_uv).rgb, + ); + } + if (texture_meta.header.w != 0u) { + let roughness_uv = pt_sheen_transform_uv( + select(uv0, uv1, (texture_meta.header.y & PT_SHEEN_ROUGHNESS_UV1) != 0u), + texture_meta.roughness_matrix, + texture_meta.offsets.zw, + ); + roughness *= pt_tex_sample_rgba(texture_meta.header.w, roughness_uv).a; + } + material.sheen = vec4( + max(color, vec3(0.0)), + clamp(roughness, 0.0, 1.0), + ); + material.header = vec4( + material.header.xy, + material.header.z & ~PT_LAYERED_SHEEN_LOBE, + material.header.w, + ); + return material; +} +"#; + +pub(super) const PT_SHEEN_TEXTURE_DISABLED_WGSL: &str = r#" +const PT_HAS_SHEEN_TEXTURES: bool = false; + +fn pt_layered_apply_sheen_textures( + material: PtLayeredMaterial, + instance_index: u32, + uv0: vec2, + uv1: vec2, +) -> PtLayeredMaterial { + return material; +} +"#; + +#[cfg(test)] +mod tests { + use super::*; + + fn binding(index: Option, tex_coord: u32) -> crate::models::MaterialTextureBinding { + crate::models::MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: index, + transform: crate::models::MaterialTextureTransform { + tex_coord, + ..Default::default() + }, + } + } + + #[test] + fn record_is_independently_compact_and_inactive_by_default() { + assert_eq!(PT_SHEEN_TEXTURE_RECORD_BYTES, 64); + assert!(!PtSheenTextureCpu::default().active()); + } + + #[test] + fn qualification_requires_resolved_coordinates() { + let material = crate::models::MaterialLayeredPbr { + sheen_authored: true, + sheen_color_factor: [0.4, 0.2, 0.1], + sheen_color_texture: Some(binding(Some(2), 0)), + sheen_roughness_texture: Some(binding(Some(3), 1)), + ..Default::default() + }; + assert!(!PtSheenTextureCpu::from_material(material, 4, false).active()); + let record = PtSheenTextureCpu::from_material(material, 4, true); + assert!(record.active() && record.has_uv1()); + assert_eq!(record.header[2..], [2, 3]); + + let unresolved = crate::models::MaterialLayeredPbr { + sheen_color_texture: Some(binding(None, 0)), + ..material + }; + assert!(!PtSheenTextureCpu::from_material(unresolved, 4, true).active()); + } + + #[test] + fn first_sheen_texture_backfills_parallel_records_and_reports_uv1() { + let mut records = None; + let mut specular_records = None; + let mut clearcoat_records = None; + let mut clearcoat_normal_records = None; + let mut sheen_records = None; + let mut iridescence_records = None; + let mut anisotropy_records = None; + assert!(!super::super::texture::append_record( + &mut records, + &mut specular_records, + &mut clearcoat_records, + &mut clearcoat_normal_records, + &mut sheen_records, + &mut iridescence_records, + &mut anisotropy_records, + 0, + Default::default(), + 4, + true, + )); + let material = crate::models::MaterialLayeredPbr { + sheen_authored: true, + sheen_color_factor: [0.4, 0.2, 0.1], + sheen_color_texture: Some(binding(Some(2), 1)), + ..Default::default() + }; + assert!(super::super::texture::append_record( + &mut records, + &mut specular_records, + &mut clearcoat_records, + &mut clearcoat_normal_records, + &mut sheen_records, + &mut iridescence_records, + &mut anisotropy_records, + 1, + material, + 4, + true, + )); + assert!(specular_records.is_none()); + assert!(clearcoat_records.is_none()); + assert!(iridescence_records.is_none()); + assert!(anisotropy_records.is_none()); + let records = sheen_records.unwrap(); + assert_eq!(records.len(), 2); + assert!(!records[0].active()); + assert!(records[1].active() && records[1].has_uv1()); + } +} diff --git a/native/shared/src/renderer/layered_pbr_pt_tests.rs b/native/shared/src/renderer/layered_pbr_pt_tests.rs new file mode 100644 index 00000000..97dce7d7 --- /dev/null +++ b/native/shared/src/renderer/layered_pbr_pt_tests.rs @@ -0,0 +1,205 @@ +use super::*; + +#[test] +fn record_abi_is_six_vec4s_and_default_is_inactive() { + assert_eq!(std::mem::size_of::(), 96); + let record = PtLayeredMaterialCpu::default(); + assert_eq!(record.header, [PT_LAYERED_RECORD_VERSION, 0, 0, 0]); + assert!(!record.active()); +} + +#[test] +fn scalar_record_preserves_every_current_lobe_bit() { + let mask = crate::models::MaterialLayeredPbr::CLEARCOAT_LOBE + | crate::models::MaterialLayeredPbr::SHEEN_LOBE + | crate::models::MaterialLayeredPbr::ANISOTROPY_LOBE + | crate::models::MaterialLayeredPbr::IRIDESCENCE_LOBE + | crate::models::MaterialLayeredPbr::SPECULAR_IOR_LOBE; + let material = crate::models::MaterialLayeredPbr::from_authoring_factors( + mask, + 0.8, + 0.2, + 1.0, + 0.7, + [0.9, 0.8, 0.7], + 1.45, + [0.2, 0.1, 0.05], + 0.4, + 0.6, + 0.3, + 0.75, + 1.3, + 120.0, + 360.0, + ); + let record = PtLayeredMaterialCpu::from_material(material); + assert_eq!(record.header[1], mask); + assert_eq!(record.clearcoat_ior, [0.8, 0.2, 1.0, 1.45]); + assert_eq!(record.iridescence, [0.75, 1.3, 120.0, 360.0]); + assert!(record.has_iridescence() && record.has_qualified_transport()); +} + +#[test] +fn only_qualified_lobes_select_layered_transport() { + let sheen = crate::models::MaterialLayeredPbr::from_authoring_factors( + crate::models::MaterialLayeredPbr::SHEEN_LOBE, + 0.0, + 0.0, + 1.0, + 1.0, + [1.0; 3], + 1.5, + [0.3, 0.1, 0.05], + 0.4, + 0.0, + 0.0, + 0.0, + 1.3, + 100.0, + 400.0, + ); + let clearcoat = crate::models::MaterialLayeredPbr::from_authoring_factors( + crate::models::MaterialLayeredPbr::CLEARCOAT_LOBE, + 0.8, + 0.2, + 1.0, + 1.0, + [1.0; 3], + 1.5, + [0.0; 3], + 0.0, + 0.0, + 0.0, + 0.0, + 1.3, + 100.0, + 400.0, + ); + let specular = crate::models::MaterialLayeredPbr::from_authoring_factors( + crate::models::MaterialLayeredPbr::SPECULAR_IOR_LOBE, + 0.0, + 0.0, + 1.0, + 0.7, + [0.8, 0.6, 0.4], + 1.8, + [0.0; 3], + 0.0, + 0.0, + 0.0, + 0.0, + 1.3, + 100.0, + 400.0, + ); + let anisotropy = crate::models::MaterialLayeredPbr::from_authoring_factors( + crate::models::MaterialLayeredPbr::ANISOTROPY_LOBE, + 0.0, + 0.0, + 1.0, + 1.0, + [1.0; 3], + 1.5, + [0.0; 3], + 0.0, + 0.6, + 0.3, + 0.0, + 1.3, + 100.0, + 400.0, + ); + let texture = crate::models::MaterialTextureBinding { + source_texture_index: 1, + source_image_index: 2, + runtime_texture_idx: Some(3), + transform: Default::default(), + }; + let textured_material = crate::models::MaterialLayeredPbr { + clearcoat_authored: true, + clearcoat_factor: 0.8, + clearcoat_texture: Some(texture), + specular_authored: true, + specular_factor: 0.7, + specular_texture: Some(texture), + sheen_authored: true, + sheen_color_factor: [0.3, 0.1, 0.05], + sheen_color_texture: Some(texture), + anisotropy_authored: true, + anisotropy_strength: 0.6, + anisotropy_texture: Some(texture), + iridescence_authored: true, + iridescence_factor: 0.75, + iridescence_texture: Some(texture), + ..Default::default() + }; + let sheen = PtLayeredMaterialCpu::from_material(sheen); + let clearcoat = PtLayeredMaterialCpu::from_material(clearcoat); + let specular = PtLayeredMaterialCpu::from_material(specular); + let anisotropy = PtLayeredMaterialCpu::from_material(anisotropy); + let textured = PtLayeredMaterialCpu::from_material(textured_material); + let textured_meta = PtLayeredTextureCpu::from_material(textured_material, 4, false); + let clearcoat_meta = PtClearcoatTextureCpu::from_material(textured_material, 4, false); + let sheen_meta = PtSheenTextureCpu::from_material(textured_material, 4, false); + let iridescence_meta = PtIridescenceTextureCpu::from_material(textured_material, 4, false); + let anisotropy_meta = PtAnisotropyTextureCpu::from_material(textured_material, 4, false); + assert!(sheen.has_sheen() && sheen.has_qualified_transport()); + assert!(clearcoat.has_clearcoat() && clearcoat.has_qualified_transport()); + assert!(specular.has_specular_ior() && specular.has_qualified_transport()); + assert!(anisotropy.has_anisotropy() && anisotropy.has_qualified_transport()); + assert!(textured.active() && !textured.has_qualified_transport()); + assert!(textured_meta.has_specular_ior()); + assert!(clearcoat_meta.active()); + assert!(sheen_meta.active()); + assert!(iridescence_meta.active()); + assert!(anisotropy_meta.active()); + assert_eq!( + textured.header[2], + crate::models::MaterialLayeredPbr::CLEARCOAT_LOBE + | crate::models::MaterialLayeredPbr::SPECULAR_IOR_LOBE + | crate::models::MaterialLayeredPbr::SHEEN_LOBE + | crate::models::MaterialLayeredPbr::ANISOTROPY_LOBE + | crate::models::MaterialLayeredPbr::IRIDESCENCE_LOBE + ); +} + +#[test] +fn specialization_uses_separate_group_without_touching_base_kernel() { + assert!(PT_LAYERED_BINDINGS_WGSL.contains("@group(2) @binding(0)")); + assert!(PT_LAYERED_TEXTURE_BINDINGS_WGSL.contains("@group(2) @binding(2)")); + assert!(PT_CLEARCOAT_TEXTURE_BINDINGS_WGSL.contains("@group(2) @binding(4)")); + assert!(PT_CLEARCOAT_NORMAL_BINDINGS_WGSL.contains("@group(2) @binding(8)")); + assert!(PT_SHEEN_TEXTURE_BINDINGS_WGSL.contains("@group(2) @binding(5)")); + assert!(PT_IRIDESCENCE_TEXTURE_BINDINGS_WGSL.contains("@group(2) @binding(6)")); + assert!(PT_ANISOTROPY_TEXTURE_BINDINGS_WGSL.contains("@group(2) @binding(7)")); + assert!(!PT_LAYERED_SHEEN_DISABLED_WGSL.contains("@binding(1)")); + assert!(PT_LAYERED_SHEEN_WGSL.contains("@group(2) @binding(1)")); + assert!(PT_LAYERED_TRANSPORT_WGSL.contains("PT_HAS_SCALAR_ANISOTROPY")); + assert!(PT_LAYERED_TRANSPORT_WGSL.contains("slot * PT_VSTRIDE + 20u")); + assert!(PT_LAYERED_TRANSPORT_WGSL.contains("hit.object_to_world")); + assert!(PT_LAYERED_IRIDESCENCE_WGSL.contains("pt_eval_iridescence")); + assert!(!PT_LAYERED_IRIDESCENCE_DISABLED_WGSL.contains("exp(")); + assert!(!pt_kernel_variant(false).contains("pt_layered_materials")); +} + +#[test] +fn layered_specialization_rewrites_every_transport_vertex() { + for diagnostics in [false, true] { + let base = pt_kernel_variant(diagnostics); + let specialized = layered_kernel_variant(base.as_ref()); + assert!(specialized.contains("let layered_primary = pt_layered_primary_surface(p0, n0);")); + assert_eq!(specialized.matches("pt_sample_layered_brdf(").count(), 1); + assert_eq!( + specialized + .matches("throughput * pt_layered_direct_light(") + .count(), + 1 + ); + assert!(specialized.contains("layered_cur = layered_hit;")); + assert!(specialized.contains("layered_tangent_cur = layered_tangent_hit;")); + assert!( + specialized.contains("layered_clearcoat_normal_cur = layered_clearcoat_normal_hit;") + ); + assert_eq!(base.matches("pt_layered_").count(), 0); + } +} diff --git a/native/shared/src/renderer/layered_pbr_pt_texture.rs b/native/shared/src/renderer/layered_pbr_pt_texture.rs new file mode 100644 index 00000000..e4cea967 --- /dev/null +++ b/native/shared/src/renderer/layered_pbr_pt_texture.rs @@ -0,0 +1,690 @@ +//! Independently lazy texture metadata for layered path tracing. +//! +//! This stays separate from the scalar sidecar so texture transforms never +//! grow or bind the scalar-only ABI. + +use super::*; + +const PT_LAYERED_TEXTURE_RECORD_VERSION: u32 = 1; +const PT_LAYERED_FACTOR_UV1: u32 = 1 << 16; +const PT_LAYERED_COLOR_UV1: u32 = 1 << 17; + +pub(in crate::renderer) struct PtLayeredRuntimeState { + pub(in crate::renderer) pipelines: Vec>, + pub(in crate::renderer) layouts: Vec>, + pub(in crate::renderer) bind_groups: Vec>, + pub(in crate::renderer) instance_buffer: Option, + pub(in crate::renderer) records: Vec, + pub(in crate::renderer) texture_buffer: Option, + pub(in crate::renderer) uv1_buffer: Option, + pub(in crate::renderer) texture_records: Vec, + pub(in crate::renderer) clearcoat_texture_buffer: Option, + pub(in crate::renderer) clearcoat_texture_records: Vec, + pub(in crate::renderer) clearcoat_normal_buffer: Option, + pub(in crate::renderer) clearcoat_normal_records: Vec, + pub(in crate::renderer) sheen_texture_buffer: Option, + pub(in crate::renderer) sheen_texture_records: Vec, + pub(in crate::renderer) iridescence_texture_buffer: Option, + pub(in crate::renderer) iridescence_texture_records: Vec, + pub(in crate::renderer) anisotropy_texture_buffer: Option, + pub(in crate::renderer) anisotropy_texture_records: Vec, + pub(in crate::renderer) dirty: bool, + pub(in crate::renderer) texture_dirty: bool, + pub(in crate::renderer) clearcoat_texture_dirty: bool, + pub(in crate::renderer) clearcoat_normal_dirty: bool, + pub(in crate::renderer) sheen_texture_dirty: bool, + pub(in crate::renderer) iridescence_texture_dirty: bool, + pub(in crate::renderer) anisotropy_texture_dirty: bool, +} + +impl Default for PtLayeredRuntimeState { + fn default() -> Self { + Self { + pipelines: Vec::new(), + layouts: Vec::new(), + bind_groups: Vec::new(), + instance_buffer: None, + records: Vec::new(), + texture_buffer: None, + uv1_buffer: None, + texture_records: Vec::new(), + clearcoat_texture_buffer: None, + clearcoat_texture_records: Vec::new(), + clearcoat_normal_buffer: None, + clearcoat_normal_records: Vec::new(), + sheen_texture_buffer: None, + sheen_texture_records: Vec::new(), + iridescence_texture_buffer: None, + iridescence_texture_records: Vec::new(), + anisotropy_texture_buffer: None, + anisotropy_texture_records: Vec::new(), + dirty: false, + texture_dirty: false, + clearcoat_texture_dirty: false, + clearcoat_normal_dirty: false, + sheen_texture_dirty: false, + iridescence_texture_dirty: false, + anisotropy_texture_dirty: false, + } + } +} + +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] +pub(in crate::renderer) struct PtLayeredTextureCpu { + /// x = ABI version, y = qualified textured-lobe mask, + /// z/w = specular-factor/specular-color runtime texture indices. + header: [u32; 4], + /// Column-major 2x2 UV matrices after authored scale + rotation. + specular_factor_matrix: [f32; 4], + specular_color_matrix: [f32; 4], + /// xy = factor offset, zw = color offset. + specular_offsets: [f32; 4], +} + +pub(super) const PT_LAYERED_TEXTURE_RECORD_BYTES: u64 = + std::mem::size_of::() as u64; + +impl Default for PtLayeredTextureCpu { + fn default() -> Self { + Self { + header: [PT_LAYERED_TEXTURE_RECORD_VERSION, 0, 0, 0], + specular_factor_matrix: [1.0, 0.0, 0.0, 1.0], + specular_color_matrix: [1.0, 0.0, 0.0, 1.0], + specular_offsets: [0.0; 4], + } + } +} + +impl PtLayeredTextureCpu { + pub(super) fn from_material( + material: crate::models::MaterialLayeredPbr, + runtime_texture_count: usize, + has_secondary_uv: bool, + ) -> Self { + fn usable( + binding: crate::models::MaterialTextureBinding, + runtime_texture_count: usize, + has_secondary_uv: bool, + ) -> bool { + let transform = binding.transform; + binding.runtime_texture_idx.is_some_and(|index| { + index != 0 + && (index as usize) < PT_MAX_TEXTURES + && (index as usize) < runtime_texture_count + }) && (transform.tex_coord == 0 || (transform.tex_coord == 1 && has_secondary_uv)) + && transform.offset.iter().all(|value| value.is_finite()) + && transform.scale.iter().all(|value| value.is_finite()) + && transform.rotation.is_finite() + } + + fn transform( + binding: Option, + ) -> ([f32; 4], [f32; 2]) { + let transform = binding.map(|binding| binding.transform).unwrap_or_default(); + let (sine, cosine) = transform.rotation.sin_cos(); + ( + [ + cosine * transform.scale[0], + sine * transform.scale[0], + -sine * transform.scale[1], + cosine * transform.scale[1], + ], + transform.offset, + ) + } + + let factor = material.specular_texture; + let color = material.specular_color_texture; + let has_texture = factor.is_some() || color.is_some(); + let all_usable = factor + .is_none_or(|binding| usable(binding, runtime_texture_count, has_secondary_uv)) + && color.is_none_or(|binding| usable(binding, runtime_texture_count, has_secondary_uv)); + if !material.has_specular_ior() || !has_texture || !all_usable { + return Self::default(); + } + + let (factor_matrix, factor_offset) = transform(factor); + let (color_matrix, color_offset) = transform(color); + Self { + header: [ + PT_LAYERED_TEXTURE_RECORD_VERSION, + crate::models::MaterialLayeredPbr::SPECULAR_IOR_LOBE + | if factor.is_some_and(|binding| binding.transform.tex_coord == 1) { + PT_LAYERED_FACTOR_UV1 + } else { + 0 + } + | if color.is_some_and(|binding| binding.transform.tex_coord == 1) { + PT_LAYERED_COLOR_UV1 + } else { + 0 + }, + factor + .and_then(|binding| binding.runtime_texture_idx) + .unwrap_or(0), + color + .and_then(|binding| binding.runtime_texture_idx) + .unwrap_or(0), + ], + specular_factor_matrix: factor_matrix, + specular_color_matrix: color_matrix, + specular_offsets: [ + factor_offset[0], + factor_offset[1], + color_offset[0], + color_offset[1], + ], + } + } + + pub(super) fn has_specular_ior(self) -> bool { + self.header[0] == PT_LAYERED_TEXTURE_RECORD_VERSION + && self.header[1] & crate::models::MaterialLayeredPbr::SPECULAR_IOR_LOBE != 0 + } + + pub(super) fn has_uv1(self) -> bool { + self.has_specular_ior() + && self.header[1] & (PT_LAYERED_FACTOR_UV1 | PT_LAYERED_COLOR_UV1) != 0 + } +} + +/// Append scalar and texture records parallel to the next TLAS instance. +/// Both vectors remain absent until their first contributing material. +pub(in crate::renderer) fn append_record( + records: &mut Option>, + texture_records: &mut Option>, + clearcoat_texture_records: &mut Option>, + clearcoat_normal_records: &mut Option>, + sheen_texture_records: &mut Option>, + iridescence_texture_records: &mut Option>, + anisotropy_texture_records: &mut Option>, + instance_index: usize, + material: crate::models::MaterialLayeredPbr, + runtime_texture_count: usize, + has_secondary_uv: bool, +) -> bool { + let active = material.is_active(); + if records.is_none() && !active { + return false; + } + if records.is_none() { + *records = Some(vec![PtLayeredMaterialCpu::default(); instance_index]); + } + if let Some(records) = records { + debug_assert_eq!(records.len(), instance_index); + records.push(if active { + PtLayeredMaterialCpu::from_material(material) + } else { + PtLayeredMaterialCpu::default() + }); + } + + let texture_record = + PtLayeredTextureCpu::from_material(material, runtime_texture_count, has_secondary_uv); + let uses_uv1 = texture_record.has_uv1(); + if texture_records.is_none() && texture_record.has_specular_ior() { + *texture_records = Some(vec![PtLayeredTextureCpu::default(); instance_index]); + } + if let Some(texture_records) = texture_records { + debug_assert_eq!(texture_records.len(), instance_index); + texture_records.push(texture_record); + } + + let clearcoat_texture_record = + PtClearcoatTextureCpu::from_material(material, runtime_texture_count, has_secondary_uv); + let uses_uv1 = uses_uv1 || clearcoat_texture_record.has_uv1(); + if clearcoat_texture_records.is_none() && clearcoat_texture_record.active() { + *clearcoat_texture_records = Some(vec![PtClearcoatTextureCpu::default(); instance_index]); + } + if let Some(clearcoat_texture_records) = clearcoat_texture_records { + debug_assert_eq!(clearcoat_texture_records.len(), instance_index); + clearcoat_texture_records.push(clearcoat_texture_record); + } + + let clearcoat_normal_record = + PtClearcoatNormalCpu::from_material(material, runtime_texture_count, has_secondary_uv); + let uses_uv1 = uses_uv1 || clearcoat_normal_record.has_uv1(); + if clearcoat_normal_records.is_none() && clearcoat_normal_record.active() { + *clearcoat_normal_records = Some(vec![PtClearcoatNormalCpu::default(); instance_index]); + } + if let Some(clearcoat_normal_records) = clearcoat_normal_records { + debug_assert_eq!(clearcoat_normal_records.len(), instance_index); + clearcoat_normal_records.push(clearcoat_normal_record); + } + + let sheen_texture_record = + PtSheenTextureCpu::from_material(material, runtime_texture_count, has_secondary_uv); + let uses_uv1 = uses_uv1 || sheen_texture_record.has_uv1(); + if sheen_texture_records.is_none() && sheen_texture_record.active() { + *sheen_texture_records = Some(vec![PtSheenTextureCpu::default(); instance_index]); + } + if let Some(sheen_texture_records) = sheen_texture_records { + debug_assert_eq!(sheen_texture_records.len(), instance_index); + sheen_texture_records.push(sheen_texture_record); + } + + let iridescence_texture_record = + PtIridescenceTextureCpu::from_material(material, runtime_texture_count, has_secondary_uv); + let uses_uv1 = uses_uv1 || iridescence_texture_record.has_uv1(); + if iridescence_texture_records.is_none() && iridescence_texture_record.active() { + *iridescence_texture_records = + Some(vec![PtIridescenceTextureCpu::default(); instance_index]); + } + if let Some(iridescence_texture_records) = iridescence_texture_records { + debug_assert_eq!(iridescence_texture_records.len(), instance_index); + iridescence_texture_records.push(iridescence_texture_record); + } + let anisotropy_texture_record = + PtAnisotropyTextureCpu::from_material(material, runtime_texture_count, has_secondary_uv); + let uses_uv1 = uses_uv1 || anisotropy_texture_record.has_uv1(); + if anisotropy_texture_records.is_none() && anisotropy_texture_record.active() { + *anisotropy_texture_records = Some(vec![PtAnisotropyTextureCpu::default(); instance_index]); + } + if let Some(anisotropy_texture_records) = anisotropy_texture_records { + debug_assert_eq!(anisotropy_texture_records.len(), instance_index); + anisotropy_texture_records.push(anisotropy_texture_record); + } + uses_uv1 +} + +pub(super) fn texture_variant(enabled: bool) -> &'static str { + if enabled { + "const PT_HAS_TEXTURES: bool = true;\n\ + @group(1) @binding(0) var pt_textures: binding_array>;\n\ + fn pt_tex_sample_rgba(idx: u32, uv: vec2) -> vec4 {\n\ + return textureSampleLevel(pt_textures[idx], card_samp, uv, 0.0);\n\ + }\n\ + fn pt_tex_sample(idx: u32, uv: vec2) -> vec3 {\n\ + return pt_tex_sample_rgba(idx, uv).rgb;\n\ + }\n" + } else { + "const PT_HAS_TEXTURES: bool = false;\n\ + fn pt_tex_sample_rgba(idx: u32, uv: vec2) -> vec4 { return vec4(1.0); }\n\ + fn pt_tex_sample(idx: u32, uv: vec2) -> vec3 { return vec3(1.0); }\n" + } +} + +pub(super) const PT_LAYERED_TEXTURE_BINDINGS_WGSL: &str = r#" +const PT_HAS_LAYERED_TEXTURES: bool = true; +const PT_LAYERED_FACTOR_UV1: u32 = 65536u; +const PT_LAYERED_COLOR_UV1: u32 = 131072u; + +struct PtLayeredTexture { + header: vec4, + specular_factor_matrix: vec4, + specular_color_matrix: vec4, + specular_offsets: vec4, +}; +@group(2) @binding(2) +var pt_layered_textures: array; + +fn pt_layered_transform_uv( + uv: vec2, + matrix: vec4, + offset: vec2, +) -> vec2 { + return vec2( + matrix.x * uv.x + matrix.z * uv.y, + matrix.y * uv.x + matrix.w * uv.y, + ) + offset; +} + +fn pt_layered_srgb_to_linear(color: vec3) -> vec3 { + let low = color / 12.92; + let high = pow((color + vec3(0.055)) / 1.055, vec3(2.4)); + return select(high, low, color <= vec3(0.04045)); +} + +fn pt_layered_apply_textures( + material_in: PtLayeredMaterial, + instance_index: u32, + uv0: vec2, + uv1: vec2, +) -> PtLayeredMaterial { + var material = material_in; + let texture_meta = pt_layered_textures[instance_index]; + if ( + texture_meta.header.x != 1u + || (texture_meta.header.y & PT_LAYERED_SPECULAR_IOR_LOBE) == 0u + ) { + return material; + } + var specular_color = material.specular.xyz; + var specular_factor = material.specular.w; + if (texture_meta.header.z != 0u) { + let factor_uv = pt_layered_transform_uv( + select(uv0, uv1, (texture_meta.header.y & PT_LAYERED_FACTOR_UV1) != 0u), + texture_meta.specular_factor_matrix, + texture_meta.specular_offsets.xy, + ); + specular_factor *= pt_tex_sample_rgba( + texture_meta.header.z, factor_uv, + ).a; + } + if (texture_meta.header.w != 0u) { + let color_uv = pt_layered_transform_uv( + select(uv0, uv1, (texture_meta.header.y & PT_LAYERED_COLOR_UV1) != 0u), + texture_meta.specular_color_matrix, + texture_meta.specular_offsets.zw, + ); + specular_color *= pt_layered_srgb_to_linear( + pt_tex_sample_rgba(texture_meta.header.w, color_uv).rgb, + ); + } + material.specular = vec4( + max(specular_color, vec3(0.0)), + clamp(specular_factor, 0.0, 1.0), + ); + material.header = vec4( + material.header.xy, + material.header.z & ~PT_LAYERED_SPECULAR_IOR_LOBE, + material.header.w, + ); + return material; +} +"#; + +pub(super) const PT_LAYERED_TEXTURE_DISABLED_WGSL: &str = r#" +const PT_HAS_LAYERED_TEXTURES: bool = false; + +fn pt_layered_apply_textures( + material: PtLayeredMaterial, + instance_index: u32, + uv0: vec2, + uv1: vec2, +) -> PtLayeredMaterial { + return material; +} +"#; + +pub(super) const PT_LAYERED_UV1_BINDINGS_WGSL: &str = r#" +@group(2) @binding(3) +var pt_layered_uv1_vertices: array>; + +fn pt_layered_hit_uv1( + geo: vec4, + primitive: u32, + barycentrics: vec2, +) -> vec2 { + let base = geo.y + primitive * 3u; + let slot0 = geo.x + geo_i[base]; + let slot1 = geo.x + geo_i[base + 1u]; + let slot2 = geo.x + geo_i[base + 2u]; + let weight0 = 1.0 - barycentrics.x - barycentrics.y; + return weight0 * pt_layered_uv1_vertices[slot0] + + barycentrics.x * pt_layered_uv1_vertices[slot1] + + barycentrics.y * pt_layered_uv1_vertices[slot2]; +} +"#; + +pub(super) const PT_LAYERED_UV1_DISABLED_WGSL: &str = r#" +fn pt_layered_hit_uv1( + geo: vec4, + primitive: u32, + barycentrics: vec2, +) -> vec2 { + return vec2(0.0); +} +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn record_is_four_vec4s_and_default_is_inactive() { + assert_eq!(std::mem::size_of::(), 64); + assert!(!PtLayeredTextureCpu::default().has_specular_ior()); + } + + #[test] + fn specialization_handle_tables_are_allocation_free_until_first_use() { + let state = PtLayeredRuntimeState::default(); + assert!(state.pipelines.is_empty()); + assert!(state.layouts.is_empty()); + assert!(state.bind_groups.is_empty()); + } + + #[test] + fn first_active_record_backfills_base_instances_lazily() { + let mut records = None; + let mut texture_records = None; + let mut clearcoat_texture_records = None; + let mut clearcoat_normal_records = None; + let mut sheen_texture_records = None; + let mut iridescence_texture_records = None; + let mut anisotropy_texture_records = None; + append_record( + &mut records, + &mut texture_records, + &mut clearcoat_texture_records, + &mut clearcoat_normal_records, + &mut sheen_texture_records, + &mut iridescence_texture_records, + &mut anisotropy_texture_records, + 0, + Default::default(), + 1, + false, + ); + append_record( + &mut records, + &mut texture_records, + &mut clearcoat_texture_records, + &mut clearcoat_normal_records, + &mut sheen_texture_records, + &mut iridescence_texture_records, + &mut anisotropy_texture_records, + 1, + Default::default(), + 1, + false, + ); + assert!(records.is_none()); + assert!(texture_records.is_none()); + assert!(clearcoat_texture_records.is_none()); + assert!(sheen_texture_records.is_none()); + assert!(iridescence_texture_records.is_none()); + assert!(anisotropy_texture_records.is_none()); + + let layered = crate::models::MaterialLayeredPbr::from_authoring_factors( + crate::models::MaterialLayeredPbr::CLEARCOAT_LOBE, + 1.0, + 0.2, + 1.0, + 1.0, + [1.0; 3], + 1.5, + [0.0; 3], + 0.0, + 0.0, + 0.0, + 0.0, + 1.3, + 100.0, + 400.0, + ); + append_record( + &mut records, + &mut texture_records, + &mut clearcoat_texture_records, + &mut clearcoat_normal_records, + &mut sheen_texture_records, + &mut iridescence_texture_records, + &mut anisotropy_texture_records, + 2, + layered, + 1, + false, + ); + let records = records.unwrap(); + assert_eq!(records.len(), 3); + assert!(!records[0].active()); + assert!(!records[1].active()); + assert_eq!( + records[2].header[1], + crate::models::MaterialLayeredPbr::CLEARCOAT_LOBE + ); + assert!(texture_records.is_none()); + assert!(clearcoat_texture_records.is_none()); + assert!(sheen_texture_records.is_none()); + assert!(iridescence_texture_records.is_none()); + assert!(anisotropy_texture_records.is_none()); + } + + #[test] + fn qualifies_only_resolved_specular_bindings_with_available_coordinates() { + let binding = crate::models::MaterialTextureBinding { + source_texture_index: 4, + source_image_index: 5, + runtime_texture_idx: Some(3), + transform: crate::models::MaterialTextureTransform { + offset: [0.25, -0.5], + rotation: std::f32::consts::FRAC_PI_2, + scale: [2.0, 3.0], + tex_coord: 0, + }, + }; + let material = crate::models::MaterialLayeredPbr { + specular_authored: true, + specular_factor: 0.8, + specular_texture: Some(binding), + ..Default::default() + }; + let record = PtLayeredTextureCpu::from_material(material, 4, false); + assert!(record.has_specular_ior()); + assert_eq!(record.header[2..], [3, 0]); + assert!(record.specular_factor_matrix[0].abs() < 1e-6); + assert!((record.specular_factor_matrix[1] - 2.0).abs() < 1e-6); + assert!((record.specular_factor_matrix[2] + 3.0).abs() < 1e-6); + assert!(record.specular_factor_matrix[3].abs() < 1e-6); + assert_eq!(record.specular_offsets[..2], [0.25, -0.5]); + + let unresolved = crate::models::MaterialLayeredPbr { + specular_texture: Some(crate::models::MaterialTextureBinding { + runtime_texture_idx: None, + ..binding + }), + ..material + }; + assert!(!PtLayeredTextureCpu::from_material(unresolved, 4, false).has_specular_ior()); + + let uv1 = crate::models::MaterialLayeredPbr { + specular_texture: Some(crate::models::MaterialTextureBinding { + transform: crate::models::MaterialTextureTransform { + tex_coord: 1, + ..binding.transform + }, + ..binding + }), + ..material + }; + assert!(!PtLayeredTextureCpu::from_material(uv1, 4, false).has_specular_ior()); + let uv1_record = PtLayeredTextureCpu::from_material(uv1, 4, true); + assert!(uv1_record.has_specular_ior() && uv1_record.has_uv1()); + } + + #[test] + fn scalar_uv0_and_uv1_specializations_parse() { + for ( + textures, + clearcoat_textures, + clearcoat_normals, + sheen_textures, + iridescence_textures, + anisotropy_textures, + uv1, + ) in [ + (false, false, false, false, false, false, false), + (true, false, false, false, false, false, false), + (true, false, false, false, false, false, true), + (false, true, false, false, false, false, false), + (false, true, false, false, false, false, true), + (false, false, true, false, false, false, false), + (false, false, true, false, false, false, true), + (false, false, false, true, false, false, false), + (false, false, false, true, false, false, true), + (false, false, false, false, true, false, false), + (false, false, false, false, true, false, true), + (false, false, false, false, false, true, false), + (false, false, false, false, false, true, true), + (true, true, true, true, true, true, true), + ] { + let source = format!( + "enable wgpu_ray_query;\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}", + "const BLOOM_RAY_QUERY_NEEDS_PROCEED: bool = false;", + pt_fault_constants(None), + layered_kernel_variant(pt_kernel_variant(false).as_ref()), + texture_variant( + textures + || clearcoat_textures + || clearcoat_normals + || sheen_textures + || iridescence_textures + || anisotropy_textures + ), + PT_LAYERED_BINDINGS_WGSL, + if textures { + PT_LAYERED_TEXTURE_BINDINGS_WGSL + } else { + PT_LAYERED_TEXTURE_DISABLED_WGSL + }, + if clearcoat_textures { + super::super::PT_CLEARCOAT_TEXTURE_BINDINGS_WGSL + } else { + super::super::PT_CLEARCOAT_TEXTURE_DISABLED_WGSL + }, + if clearcoat_normals { + super::super::PT_CLEARCOAT_NORMAL_BINDINGS_WGSL + } else { + super::super::PT_CLEARCOAT_NORMAL_DISABLED_WGSL + }, + if sheen_textures { + super::super::PT_SHEEN_TEXTURE_BINDINGS_WGSL + } else { + super::super::PT_SHEEN_TEXTURE_DISABLED_WGSL + }, + if iridescence_textures { + super::super::PT_IRIDESCENCE_TEXTURE_BINDINGS_WGSL + } else { + super::super::PT_IRIDESCENCE_TEXTURE_DISABLED_WGSL + }, + if anisotropy_textures { + super::super::PT_ANISOTROPY_TEXTURE_BINDINGS_WGSL + } else { + super::super::PT_ANISOTROPY_TEXTURE_DISABLED_WGSL + }, + if uv1 { + PT_LAYERED_UV1_BINDINGS_WGSL + } else { + PT_LAYERED_UV1_DISABLED_WGSL + }, + if anisotropy_textures { + "const PT_HAS_SCALAR_ANISOTROPY: bool = true;" + } else { + "const PT_HAS_SCALAR_ANISOTROPY: bool = false;" + }, + PT_LAYERED_TRANSPORT_WGSL, + if iridescence_textures { + PT_LAYERED_IRIDESCENCE_WGSL + } else { + PT_LAYERED_IRIDESCENCE_DISABLED_WGSL + }, + if sheen_textures { + PT_LAYERED_SHEEN_WGSL + } else { + PT_LAYERED_SHEEN_DISABLED_WGSL + }, + ); + wgpu::naga::front::wgsl::parse_str(&source).unwrap_or_else(|error| { + panic!( + "layered PT WGSL (specular={textures}, clearcoat={clearcoat_textures}, \ + clearcoat_normal={clearcoat_normals}, \ + sheen={sheen_textures}, iridescence={iridescence_textures}, \ + anisotropy={anisotropy_textures}, uv1={uv1}) failed: {error}" + ) + }); + } + } +} diff --git a/native/shared/src/renderer/layered_pbr_refraction.rs b/native/shared/src/renderer/layered_pbr_refraction.rs new file mode 100644 index 00000000..c7a60ad7 --- /dev/null +++ b/native/shared/src/renderer/layered_pbr_refraction.rs @@ -0,0 +1,905 @@ +//! Lazy combined specialization for materials that author both physical +//! transmission and layered-PBR clearcoat/specular/IOR/sheen/anisotropy. + +use super::*; + +const FIRST_LAYERED_BINDING: u32 = 16; + +pub(super) fn scene_layered_refractive_shader_source( + base_scene_shader: &str, + folded_scene_inputs: bool, + screen_space_reflections: bool, + secondary_uv: bool, + reactive: bool, +) -> String { + let layered = layered_pbr_scene::scene_layered_shader_source_with_bindings( + base_scene_shader, + secondary_uv, + FIRST_LAYERED_BINDING, + ); + let source = scene_refractive_shader_source( + &layered, + folded_scene_inputs, + screen_space_reflections, + secondary_uv, + ); + let source = source.replacen( + r#" let f0_scalar = pow((ior - 1.0) / (ior + 1.0), 2.0); + let n_dot_v = clamp(dot(n, v), 0.0, 1.0); + let fresnel = f0_scalar + + (1.0 - f0_scalar) * pow(1.0 - n_dot_v, 5.0);"#, + r#" let layered_surface = evaluate_layered_surface( + in, + n, + 1.0 + lighting.shadow_cascade_splits.w, + ); + let n_dot_v = clamp(dot(n, v), 0.0, 1.0); + let fresnel = layered_dielectric_fresnel(layered_surface, n_dot_v);"#, + 1, + ); + assert!( + source.contains("let layered_surface = evaluate_layered_surface("), + "refractive Fresnel block changed; layered specialization must be updated" + ); + let source = source.replacen( + " let reflected_direction = reflect(-v, n);", + " let reflected_direction = layered_ibl_reflection(\n\ + layered_surface, n, v, roughness,\n\ + );", + 1, + ); + assert!( + source.contains("let reflected_direction = layered_ibl_reflection("), + "refractive reflection direction changed; layered specialization must be updated" + ); + let mut source = source.replacen( + r#" let dielectric_transmission = mix(transmitted, reflected, fresnel); + var hdr = surface.color.rgb * (1.0 - transmission_weight) + + dielectric_transmission * transmission_weight;"#, + r#" let dielectric_transmission = mix(transmitted, reflected, fresnel); + let sheen_n_dot_v = max(dot(n, v), 0.0); + let sheen_attenuation = layered_sheen_ibl_scale( + layered_surface, + sheen_n_dot_v, + ); + let sheen_reflection = layered_sheen_ibl( + layered_surface, + n, + v, + max(f32(textureNumLevels(env_tex)) - 1.0, 0.0), + 1.0, + ); + let dielectric_below_coat = + dielectric_transmission * sheen_attenuation + sheen_reflection; + let coat_n_dot_v = max(dot(layered_surface.clearcoat_normal, v), 0.0); + let coat_fresnel = layered_clearcoat_fresnel(layered_surface, coat_n_dot_v); + let coat_reflection_direction = reflect(-v, layered_surface.clearcoat_normal); + let coat_reflection_raw = env_sample_lod( + coat_reflection_direction, + layered_surface.clearcoat_roughness + * max(f32(textureNumLevels(env_tex)) - 1.0, 0.0), + ) * coat_fresnel; + let coat_luma = dot( + coat_reflection_raw, + vec3(0.2126, 0.7152, 0.0722), + ); + let coat_cap = 1.0 / (1.0 + coat_luma / 0.3); + let layered_dielectric_transmission = + dielectric_below_coat + * layered_clearcoat_ibl_attenuation(layered_surface, v) + + coat_reflection_raw * coat_cap; + var hdr = surface.color.rgb * (1.0 - transmission_weight) + + layered_dielectric_transmission * transmission_weight;"#, + 1, + ); + assert!( + source.contains("let layered_dielectric_transmission ="), + "refractive energy partition changed; layered specialization must be updated" + ); + + if reactive { + source = source + .replacen( + " @location(1) velocity: vec2,\n};", + " @location(1) velocity: vec2,\n\ + @location(2) reactive: f32,\n\ + };", + 1, + ) + .replacen( + " return RefractiveSceneOut(vec4(hdr, 1.0), surface.velocity);", + " let reactive = select(\n\ + transmission_weight,\n\ + clamp(base_alpha, 0.0, 1.0),\n\ + material.metal_rough.w < 0.0,\n\ + );\n\ + return RefractiveSceneOut(\n\ + vec4(hdr, 1.0), surface.velocity, reactive,\n\ + );", + 1, + ); + assert!( + source.contains("@location(2) reactive"), + "layered refractive reactive output changed; specialization must be updated" + ); + } + source +} + +pub(super) struct SceneLayeredRefractiveResources { + material_layout: wgpu::BindGroupLayout, + scalar: wgpu::RenderPipeline, + scalar_double_sided: wgpu::RenderPipeline, + uv1: Option, + uv1_double_sided: Option, + reactive: Option, + reactive_double_sided: Option, + reactive_uv1: Option, + reactive_uv1_double_sided: Option, +} + +impl SceneLayeredRefractiveResources { + pub(super) fn pipeline( + &self, + secondary_uv: bool, + double_sided: bool, + reactive: bool, + ) -> &wgpu::RenderPipeline { + match (reactive, secondary_uv, double_sided) { + (false, false, false) => &self.scalar, + (false, false, true) => &self.scalar_double_sided, + (false, true, false) => self + .uv1 + .as_ref() + .expect("layered refractive UV1 resources are initialized"), + (false, true, true) => self + .uv1_double_sided + .as_ref() + .expect("layered refractive UV1 resources are initialized"), + (true, false, false) => self + .reactive + .as_ref() + .expect("layered refractive reactive resources are initialized"), + (true, false, true) => self + .reactive_double_sided + .as_ref() + .expect("layered refractive reactive resources are initialized"), + (true, true, false) => self + .reactive_uv1 + .as_ref() + .expect("layered refractive reactive UV1 resources are initialized"), + (true, true, true) => self + .reactive_uv1_double_sided + .as_ref() + .expect("layered refractive reactive UV1 resources are initialized"), + } + } +} + +fn create_material_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + let texture = |binding| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }; + let sampler = |binding| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }; + let uniform = |binding, visibility| wgpu::BindGroupLayoutEntry { + binding, + visibility, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }; + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("scene_layered_refractive_material_layout"), + entries: &[ + texture(0), + sampler(1), + texture(2), + sampler(3), + texture(4), + sampler(5), + texture(6), + sampler(7), + uniform(8, wgpu::ShaderStages::VERTEX_FRAGMENT), + texture(9), + sampler(10), + texture(11), + sampler(12), + texture(13), + sampler(14), + uniform(15, wgpu::ShaderStages::FRAGMENT), + texture(16), + texture(17), + texture(18), + texture(19), + texture(20), + texture(21), + texture(22), + texture(23), + texture(24), + texture(25), + texture(26), + sampler(27), + uniform(28, wgpu::ShaderStages::FRAGMENT), + ], + }) +} + +fn pipeline_layout( + renderer: &Renderer, + material_layout: &wgpu::BindGroupLayout, + label: &'static str, +) -> wgpu::PipelineLayout { + #[cfg(fold_scene_inputs)] + let bind_group_layouts = vec![ + Some(&renderer.uniform_3d_layout), + Some(&renderer.lighting_layout), + Some(material_layout), + Some(&renderer.joint_layout), + ]; + #[cfg(not(fold_scene_inputs))] + let bind_group_layouts = vec![ + Some(&renderer.uniform_3d_layout), + Some(&renderer.lighting_layout), + Some(material_layout), + Some(&renderer.joint_layout), + Some( + renderer + .scene_refractive_inputs_layout + .as_ref() + .unwrap_or(&renderer.material_system.layouts.scene_inputs), + ), + ]; + renderer + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(label), + bind_group_layouts: &bind_group_layouts, + immediate_size: 0, + }) +} + +fn create_pipeline( + device: &wgpu::Device, + layout: &wgpu::PipelineLayout, + shader: &wgpu::ShaderModule, + secondary_uv: bool, + double_sided: bool, + reactive: bool, + label: &'static str, +) -> wgpu::RenderPipeline { + let buffers = if secondary_uv { + vec![Vertex3D::desc(), secondary_uv_desc()] + } else { + vec![Vertex3D::desc()] + }; + let mut targets = vec![ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + ]; + if reactive { + targets.push(Some(wgpu::ColorTargetState { + format: temporal_reactive::TEMPORAL_REACTIVE_FORMAT, + blend: Some(temporal_reactive::reactive_union_blend()), + write_mask: wgpu::ColorWrites::RED, + })); + } + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(layout), + vertex: wgpu::VertexState { + module: shader, + entry_point: Some("vs_main_scene"), + buffers: &buffers, + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: shader, + entry_point: Some("fs_refractive_scene"), + targets: &targets, + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: (!double_sided).then_some(wgpu::Face::Back), + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) +} + +impl Renderer { + fn layered_refractive_source(&self, secondary_uv: bool, reactive: bool) -> String { + #[cfg(fold_scene_inputs)] + let screen_space_reflections = false; + #[cfg(not(fold_scene_inputs))] + let screen_space_reflections = self.scene_refractive_inputs_layout.is_some(); + scene_layered_refractive_shader_source( + &specialized_scene_shader_source( + self.froxel.is_some(), + self.shadow_map.virtual_map.requested(), + ), + cfg!(fold_scene_inputs), + screen_space_reflections, + secondary_uv, + reactive, + ) + } + + fn ensure_scene_layered_refraction_resources(&mut self) { + if self.scene_layered_refractive_resources.is_some() { + return; + } + self.ensure_scene_refraction_resources(); + let material_layout = create_material_layout(&self.device); + let layout = pipeline_layout( + self, + &material_layout, + "scene_layered_refractive_pipeline_layout", + ); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_layered_refractive_shader"), + source: wgpu::ShaderSource::Wgsl( + self.layered_refractive_source(false, false).into(), + ), + }); + let scalar = create_pipeline( + &self.device, + &layout, + &shader, + false, + false, + false, + "scene_layered_refractive_pipeline", + ); + let scalar_double_sided = create_pipeline( + &self.device, + &layout, + &shader, + false, + true, + false, + "scene_layered_refractive_double_sided_pipeline", + ); + self.scene_layered_refractive_resources = Some(SceneLayeredRefractiveResources { + material_layout, + scalar, + scalar_double_sided, + uv1: None, + uv1_double_sided: None, + reactive: None, + reactive_double_sided: None, + reactive_uv1: None, + reactive_uv1_double_sided: None, + }); + self.created_pipelines(2); + } + + fn ensure_scene_layered_refraction_uv1_resources(&mut self) { + self.ensure_scene_layered_refraction_resources(); + if self + .scene_layered_refractive_resources + .as_ref() + .is_some_and(|resources| resources.uv1.is_some()) + { + return; + } + let resources = self + .scene_layered_refractive_resources + .as_ref() + .expect("layered refractive resources initialize first"); + let layout = pipeline_layout( + self, + &resources.material_layout, + "scene_layered_refractive_uv1_pipeline_layout", + ); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_layered_refractive_uv1_shader"), + source: wgpu::ShaderSource::Wgsl( + self.layered_refractive_source(true, false).into(), + ), + }); + let uv1 = create_pipeline( + &self.device, + &layout, + &shader, + true, + false, + false, + "scene_layered_refractive_uv1_pipeline", + ); + let uv1_double_sided = create_pipeline( + &self.device, + &layout, + &shader, + true, + true, + false, + "scene_layered_refractive_uv1_double_sided_pipeline", + ); + let resources = self + .scene_layered_refractive_resources + .as_mut() + .expect("layered refractive resources initialize first"); + resources.uv1 = Some(uv1); + resources.uv1_double_sided = Some(uv1_double_sided); + self.created_pipelines(2); + } + + pub(super) fn ensure_scene_layered_refraction_reactive_resources(&mut self) { + let Some(resources) = self.scene_layered_refractive_resources.as_ref() else { + return; + }; + if resources.reactive.is_some() { + return; + } + let layout = pipeline_layout( + self, + &resources.material_layout, + "scene_layered_refractive_reactive_pipeline_layout", + ); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_layered_refractive_reactive_shader"), + source: wgpu::ShaderSource::Wgsl( + self.layered_refractive_source(false, true).into(), + ), + }); + let reactive = create_pipeline( + &self.device, + &layout, + &shader, + false, + false, + true, + "scene_layered_refractive_reactive_pipeline", + ); + let reactive_double_sided = create_pipeline( + &self.device, + &layout, + &shader, + false, + true, + true, + "scene_layered_refractive_reactive_double_sided_pipeline", + ); + let resources = self + .scene_layered_refractive_resources + .as_mut() + .expect("layered refractive resources initialize first"); + resources.reactive = Some(reactive); + resources.reactive_double_sided = Some(reactive_double_sided); + let has_uv1 = resources.uv1.is_some(); + self.created_pipelines(2); + if has_uv1 { + self.ensure_scene_layered_refraction_reactive_uv1_resources(); + } + } + + fn ensure_scene_layered_refraction_reactive_uv1_resources(&mut self) { + self.ensure_scene_layered_refraction_uv1_resources(); + if self + .scene_layered_refractive_resources + .as_ref() + .is_some_and(|resources| resources.reactive_uv1.is_some()) + { + return; + } + let resources = self + .scene_layered_refractive_resources + .as_ref() + .expect("layered refractive resources initialize first"); + let layout = pipeline_layout( + self, + &resources.material_layout, + "scene_layered_refractive_reactive_uv1_pipeline_layout", + ); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_layered_refractive_reactive_uv1_shader"), + source: wgpu::ShaderSource::Wgsl(self.layered_refractive_source(true, true).into()), + }); + let reactive_uv1 = create_pipeline( + &self.device, + &layout, + &shader, + true, + false, + true, + "scene_layered_refractive_reactive_uv1_pipeline", + ); + let reactive_uv1_double_sided = create_pipeline( + &self.device, + &layout, + &shader, + true, + true, + true, + "scene_layered_refractive_reactive_uv1_double_sided_pipeline", + ); + let resources = self + .scene_layered_refractive_resources + .as_mut() + .expect("layered refractive resources initialize first"); + resources.reactive_uv1 = Some(reactive_uv1); + resources.reactive_uv1_double_sided = Some(reactive_uv1_double_sided); + self.created_pipelines(2); + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn create_scene_layered_refractive_material_bg( + &mut self, + base_color_tex_idx: u32, + normal_tex_idx: u32, + metallic_roughness_tex_idx: u32, + emissive_tex_idx: u32, + occlusion_tex_idx: u32, + material_uniform: &wgpu::Buffer, + transmission: crate::models::MaterialTransmission, + layered: crate::models::MaterialLayeredPbr, + has_secondary_tex_coords: bool, + ) -> Option<(wgpu::Buffer, wgpu::Buffer, wgpu::BindGroup, bool)> { + if !self.imported_refraction_enabled || !transmission.is_active() || !layered.is_active() { + return None; + } + self.ensure_scene_layered_refraction_resources(); + + let usable_texture = |binding: Option, + contributes: bool| + -> Option<(u32, u32)> { + if !contributes { + return None; + } + binding.and_then(|binding| { + match binding.transform.tex_coord { + 0 => {} + 1 if has_secondary_tex_coords => {} + 1 => { + log::warn!( + "bloom materials: layered refractive texture requests TEXCOORD_1 \ + but this primitive has no valid secondary UV stream; using its \ + scalar factor" + ); + return None; + } + tex_coord => { + log::warn!( + "bloom materials: layered refractive texture TEXCOORD_{tex_coord} \ + is preserved but only TEXCOORD_0/1 are renderable; using its \ + scalar factor" + ); + return None; + } + } + binding + .runtime_texture_idx + .filter(|index| *index != 0 && (*index as usize) < self.textures.len()) + .map(|index| (index, binding.transform.tex_coord)) + }) + }; + let transmission_texture = usable_texture(transmission.texture, true); + let thickness_texture = usable_texture(transmission.thickness_texture, true); + let layered_bindings = [ + usable_texture(layered.clearcoat_texture, layered.has_clearcoat()), + usable_texture(layered.clearcoat_roughness_texture, layered.has_clearcoat()), + usable_texture(layered.clearcoat_normal_texture, layered.has_clearcoat()), + usable_texture(layered.specular_texture, layered.has_specular_ior()), + usable_texture(layered.specular_color_texture, layered.has_specular_ior()), + usable_texture(layered.sheen_color_texture, layered.has_sheen()), + usable_texture(layered.sheen_roughness_texture, layered.has_sheen()), + usable_texture(layered.anisotropy_texture, layered.has_anisotropy()), + usable_texture(layered.iridescence_texture, layered.has_iridescence()), + usable_texture( + layered.iridescence_thickness_texture, + layered.has_iridescence(), + ), + ]; + let uses_uv1 = transmission_texture + .into_iter() + .chain(thickness_texture) + .chain(layered_bindings.into_iter().flatten()) + .any(|(_, tex_coord)| tex_coord == 1); + if uses_uv1 { + self.ensure_scene_layered_refraction_uv1_resources(); + if self + .scene_layered_refractive_resources + .as_ref() + .is_some_and(|resources| resources.reactive.is_some()) + { + self.ensure_scene_layered_refraction_reactive_uv1_resources(); + } + } + if self.shadow_map.enabled { + self.ensure_transmitted_shadow_resources(); + if uses_uv1 { + self.ensure_transmitted_shadow_uv1_resources(); + } + } + if layered.has_sheen() { + self.ensure_scene_sheen_albedo_lut(); + } + + let layered_texture_usable = layered_bindings.map(|binding| binding.is_some()); + let transmission_factors = SceneTransmissionUniforms::new( + transmission, + transmission_texture.is_some(), + thickness_texture.is_some(), + ); + let layered_factors = + layered_pbr_scene::SceneLayeredPbrUniforms::new(layered, layered_texture_usable); + let transmission_uniform = + self.device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_layered_refractive_transmission_uniform"), + contents: bytemuck::bytes_of(&transmission_factors), + usage: wgpu::BufferUsages::UNIFORM, + }); + let layered_uniform = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_layered_refractive_layer_uniform"), + contents: bytemuck::bytes_of(&layered_factors), + usage: wgpu::BufferUsages::UNIFORM, + }); + + let view_or_white = |index: u32| { + self.textures + .get(index as usize) + .unwrap_or(&self.textures[0]) + .create_view(&wgpu::TextureViewDescriptor::default()) + }; + let base_view = view_or_white(base_color_tex_idx); + let mr_view = view_or_white(metallic_roughness_tex_idx); + let emissive_view = view_or_white(emissive_tex_idx); + let occlusion_view = view_or_white(occlusion_tex_idx); + let transmission_view = + view_or_white(transmission_texture.map(|binding| binding.0).unwrap_or(0)); + let thickness_view = view_or_white(thickness_texture.map(|binding| binding.0).unwrap_or(0)); + let clearcoat_factor_view = + view_or_white(layered_bindings[0].map(|binding| binding.0).unwrap_or(0)); + let clearcoat_roughness_view = + view_or_white(layered_bindings[1].map(|binding| binding.0).unwrap_or(0)); + let specular_factor_view = + view_or_white(layered_bindings[3].map(|binding| binding.0).unwrap_or(0)); + let specular_color_view = + view_or_white(layered_bindings[4].map(|binding| binding.0).unwrap_or(0)); + let sheen_color_view = + view_or_white(layered_bindings[5].map(|binding| binding.0).unwrap_or(0)); + let sheen_roughness_view = + view_or_white(layered_bindings[6].map(|binding| binding.0).unwrap_or(0)); + let anisotropy_view = + view_or_white(layered_bindings[7].map(|binding| binding.0).unwrap_or(0)); + let iridescence_factor_view = + view_or_white(layered_bindings[8].map(|binding| binding.0).unwrap_or(0)); + let iridescence_thickness_view = + view_or_white(layered_bindings[9].map(|binding| binding.0).unwrap_or(0)); + let sheen_lut_fallback = view_or_white(0); + let sheen_albedo_view = self + .scene_sheen_albedo_lut + .as_ref() + .map(|lut| &lut.view) + .unwrap_or(&sheen_lut_fallback); + let base_normal_view_owned = self + .textures + .get(normal_tex_idx as usize) + .filter(|_| normal_tex_idx != 0) + .map(|texture| texture.create_view(&wgpu::TextureViewDescriptor::default())); + let base_normal_view = base_normal_view_owned + .as_ref() + .unwrap_or(&self.default_normal_view); + let coat_normal_index = layered_bindings[2].map(|binding| binding.0).unwrap_or(0); + let coat_normal_view_owned = self + .textures + .get(coat_normal_index as usize) + .filter(|_| coat_normal_index != 0) + .map(|texture| texture.create_view(&wgpu::TextureViewDescriptor::default())); + let coat_normal_view = coat_normal_view_owned + .as_ref() + .unwrap_or(&self.default_normal_view); + + let layout = &self + .scene_layered_refractive_resources + .as_ref() + .expect("layered refractive resources initialize first") + .material_layout; + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("scene_layered_refractive_material_bg"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&base_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(base_normal_view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&mr_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&emissive_view), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: material_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::TextureView(&occlusion_view), + }, + wgpu::BindGroupEntry { + binding: 10, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: wgpu::BindingResource::TextureView(&transmission_view), + }, + wgpu::BindGroupEntry { + binding: 12, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 13, + resource: wgpu::BindingResource::TextureView(&thickness_view), + }, + wgpu::BindGroupEntry { + binding: 14, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 15, + resource: transmission_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 16, + resource: wgpu::BindingResource::TextureView(&clearcoat_factor_view), + }, + wgpu::BindGroupEntry { + binding: 17, + resource: wgpu::BindingResource::TextureView(&clearcoat_roughness_view), + }, + wgpu::BindGroupEntry { + binding: 18, + resource: wgpu::BindingResource::TextureView(coat_normal_view), + }, + wgpu::BindGroupEntry { + binding: 19, + resource: wgpu::BindingResource::TextureView(&specular_factor_view), + }, + wgpu::BindGroupEntry { + binding: 20, + resource: wgpu::BindingResource::TextureView(&specular_color_view), + }, + wgpu::BindGroupEntry { + binding: 21, + resource: wgpu::BindingResource::TextureView(&sheen_color_view), + }, + wgpu::BindGroupEntry { + binding: 22, + resource: wgpu::BindingResource::TextureView(&sheen_roughness_view), + }, + wgpu::BindGroupEntry { + binding: 23, + resource: wgpu::BindingResource::TextureView(&anisotropy_view), + }, + wgpu::BindGroupEntry { + binding: 24, + resource: wgpu::BindingResource::TextureView(&iridescence_factor_view), + }, + wgpu::BindGroupEntry { + binding: 25, + resource: wgpu::BindingResource::TextureView(&iridescence_thickness_view), + }, + wgpu::BindGroupEntry { + binding: 26, + resource: wgpu::BindingResource::TextureView(sheen_albedo_view), + }, + wgpu::BindGroupEntry { + binding: 27, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 28, + resource: layered_uniform.as_entire_binding(), + }, + ], + }); + Some((transmission_uniform, layered_uniform, bind_group, uses_uv1)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn combined_refractive_variants_parse_without_touching_base_shader() { + for secondary_uv in [false, true] { + for reactive in [false, true] { + for (folded, reflections) in [(false, false), (false, true), (true, false)] { + let source = scene_layered_refractive_shader_source( + SCENE_SHADER, + folded, + reflections, + secondary_uv, + reactive, + ); + wgpu::naga::front::wgsl::parse_str(&source).unwrap_or_else(|error| { + panic!( + "combined layered refraction (secondary_uv={secondary_uv}, \ + reactive={reactive}, folded={folded}, \ + reflections={reflections}) failed: {error}" + ) + }); + assert!(source.contains("@group(2) @binding(15)")); + assert!(source.contains("@group(2) @binding(28)")); + assert!(source.contains("let dielectric_below_coat =")); + assert!(source.contains("let reflected_direction = layered_ibl_reflection(")); + } + } + } + assert!(!SCENE_SHADER.contains("layered_material")); + assert!(!SCENE_SHADER.contains("transmission_material")); + } +} diff --git a/native/shared/src/renderer/layered_pbr_scene.rs b/native/shared/src/renderer/layered_pbr_scene.rs new file mode 100644 index 00000000..a8d1267d --- /dev/null +++ b/native/shared/src/renderer/layered_pbr_scene.rs @@ -0,0 +1,1784 @@ +//! Lazy scene-pipeline specialization for clearcoat, dielectric specular/IOR, +//! sheen, and tangent-space anisotropy. +//! +//! Base-only materials never create these resources and continue to use the +//! established scene shader, group-2 layout, GPU-driven record, and draw path. + +use super::*; + +const LAYERED_PBR_V3_WGSL: &str = include_str!("../../shaders/layered_pbr_v3.wgsl"); +const SHEEN_ALBEDO_LUT_R16F: &[u8] = include_bytes!("../../shaders/sheen_albedo_lut_r16f.bin"); +const SHEEN_ALBEDO_LUT_SIZE: u32 = 128; +pub(super) const SHEEN_ALBEDO_LUT_BYTES: usize = + (SHEEN_ALBEDO_LUT_SIZE * SHEEN_ALBEDO_LUT_SIZE * 2) as usize; + +const CLEARCOAT_FACTOR_TEXTURE: usize = 0; +const CLEARCOAT_ROUGHNESS_TEXTURE: usize = 1; +const CLEARCOAT_NORMAL_TEXTURE: usize = 2; +const SPECULAR_FACTOR_TEXTURE: usize = 3; +const SPECULAR_COLOR_TEXTURE: usize = 4; +const SHEEN_COLOR_TEXTURE: usize = 5; +const SHEEN_ROUGHNESS_TEXTURE: usize = 6; +const ANISOTROPY_TEXTURE: usize = 7; +const IRIDESCENCE_FACTOR_TEXTURE: usize = 8; +const IRIDESCENCE_THICKNESS_TEXTURE: usize = 9; +pub(super) const LAYERED_TEXTURE_COUNT: usize = 10; + +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub(crate) struct SceneLayeredPbrUniforms { + clearcoat_ior: [f32; 4], + specular: [f32; 4], + sheen: [f32; 4], + anisotropy: [f32; 4], + iridescence: [f32; 4], + clearcoat_factor_uv: [f32; 4], + clearcoat_factor_rotation: [f32; 4], + clearcoat_roughness_uv: [f32; 4], + clearcoat_roughness_rotation: [f32; 4], + clearcoat_normal_uv: [f32; 4], + clearcoat_normal_rotation: [f32; 4], + specular_factor_uv: [f32; 4], + specular_factor_rotation: [f32; 4], + specular_color_uv: [f32; 4], + specular_color_rotation: [f32; 4], + sheen_color_uv: [f32; 4], + sheen_color_rotation: [f32; 4], + sheen_roughness_uv: [f32; 4], + sheen_roughness_rotation: [f32; 4], + anisotropy_uv: [f32; 4], + anisotropy_texture_rotation: [f32; 4], + iridescence_factor_uv: [f32; 4], + iridescence_factor_rotation: [f32; 4], + iridescence_thickness_uv: [f32; 4], + iridescence_thickness_rotation: [f32; 4], +} + +impl SceneLayeredPbrUniforms { + pub(super) fn new( + material: crate::models::MaterialLayeredPbr, + texture_usable: [bool; LAYERED_TEXTURE_COUNT], + ) -> Self { + let transform = |binding: Option, + usable: bool| + -> ([f32; 4], [f32; 4]) { + let transform = binding.map(|binding| binding.transform).unwrap_or_default(); + let (sin, cos) = transform.rotation.sin_cos(); + ( + [ + transform.offset[0], + transform.offset[1], + transform.scale[0], + transform.scale[1], + ], + [ + cos, + sin, + if usable { 1.0 } else { 0.0 }, + transform.tex_coord as f32, + ], + ) + }; + let (clearcoat_factor_uv, clearcoat_factor_rotation) = transform( + material.clearcoat_texture, + texture_usable[CLEARCOAT_FACTOR_TEXTURE], + ); + let (clearcoat_roughness_uv, clearcoat_roughness_rotation) = transform( + material.clearcoat_roughness_texture, + texture_usable[CLEARCOAT_ROUGHNESS_TEXTURE], + ); + let (clearcoat_normal_uv, clearcoat_normal_rotation) = transform( + material.clearcoat_normal_texture, + texture_usable[CLEARCOAT_NORMAL_TEXTURE], + ); + let (specular_factor_uv, specular_factor_rotation) = transform( + material.specular_texture, + texture_usable[SPECULAR_FACTOR_TEXTURE], + ); + let (specular_color_uv, specular_color_rotation) = transform( + material.specular_color_texture, + texture_usable[SPECULAR_COLOR_TEXTURE], + ); + let (sheen_color_uv, sheen_color_rotation) = transform( + material.sheen_color_texture, + texture_usable[SHEEN_COLOR_TEXTURE], + ); + let (sheen_roughness_uv, sheen_roughness_rotation) = transform( + material.sheen_roughness_texture, + texture_usable[SHEEN_ROUGHNESS_TEXTURE], + ); + let (anisotropy_uv, anisotropy_texture_rotation) = transform( + material.anisotropy_texture, + texture_usable[ANISOTROPY_TEXTURE], + ); + let (iridescence_factor_uv, iridescence_factor_rotation) = transform( + material.iridescence_texture, + texture_usable[IRIDESCENCE_FACTOR_TEXTURE], + ); + let (iridescence_thickness_uv, iridescence_thickness_rotation) = transform( + material.iridescence_thickness_texture, + texture_usable[IRIDESCENCE_THICKNESS_TEXTURE], + ); + let (anisotropy_sine, anisotropy_cosine) = material.anisotropy_rotation.sin_cos(); + Self { + clearcoat_ior: [ + material.clearcoat_factor.clamp(0.0, 1.0), + material.clearcoat_roughness_factor.clamp(0.0, 1.0), + material.clearcoat_normal_scale, + material.ior, + ], + specular: [ + material.specular_color_factor[0].max(0.0), + material.specular_color_factor[1].max(0.0), + material.specular_color_factor[2].max(0.0), + material.specular_factor.clamp(0.0, 1.0), + ], + sheen: [ + material.sheen_color_factor[0].clamp(0.0, 1.0), + material.sheen_color_factor[1].clamp(0.0, 1.0), + material.sheen_color_factor[2].clamp(0.0, 1.0), + material.sheen_roughness_factor.clamp(0.0, 1.0), + ], + anisotropy: [ + material.anisotropy_strength.clamp(0.0, 1.0), + anisotropy_cosine, + anisotropy_sine, + 0.0, + ], + iridescence: [ + material.iridescence_factor.clamp(0.0, 1.0), + material.iridescence_ior.max(1.0), + material.iridescence_thickness_minimum.max(0.0), + material.iridescence_thickness_maximum.max(0.0), + ], + clearcoat_factor_uv, + clearcoat_factor_rotation, + clearcoat_roughness_uv, + clearcoat_roughness_rotation, + clearcoat_normal_uv, + clearcoat_normal_rotation, + specular_factor_uv, + specular_factor_rotation, + specular_color_uv, + specular_color_rotation, + sheen_color_uv, + sheen_color_rotation, + sheen_roughness_uv, + sheen_roughness_rotation, + anisotropy_uv, + anisotropy_texture_rotation, + iridescence_factor_uv, + iridescence_factor_rotation, + iridescence_thickness_uv, + iridescence_thickness_rotation, + } + } +} + +fn replace_once(source: String, anchor: &str, replacement: &str, label: &str) -> String { + let replaced = source.replacen(anchor, replacement, 1); + assert_ne!( + replaced, source, + "scene shader {label} changed; layered-PBR specialization must be updated" + ); + replaced +} + +pub(super) fn scene_layered_shader_source(base_scene_shader: &str, secondary_uv: bool) -> String { + scene_layered_shader_source_with_bindings(base_scene_shader, secondary_uv, 11) +} + +pub(super) fn scene_layered_shader_source_with_bindings( + base_scene_shader: &str, + secondary_uv: bool, + first_material_binding: u32, +) -> String { + const JOINT_DECLARATION: &str = + "@group(3) @binding(1) var joints_prev: JointMatrices;"; + let secondary_uv_function = if secondary_uv { + r#" +fn layered_secondary_uv(in: VertexOutputScene) -> vec2 { + return in.secondary_uv; +} +"# + } else { + r#" +fn layered_secondary_uv(in: VertexOutputScene) -> vec2 { + return in.uv; +} +"# + }; + let declarations = format!( + r#"{JOINT_DECLARATION} +@group(2) @binding({clearcoat_factor_texture}) var layered_clearcoat_factor_tex: texture_2d; +@group(2) @binding({clearcoat_roughness_texture}) var layered_clearcoat_roughness_tex: texture_2d; +@group(2) @binding({clearcoat_normal_texture}) var layered_clearcoat_normal_tex: texture_2d; +@group(2) @binding({specular_factor_texture}) var layered_specular_factor_tex: texture_2d; +@group(2) @binding({specular_color_texture}) var layered_specular_color_tex: texture_2d; +@group(2) @binding({sheen_color_texture}) var layered_sheen_color_tex: texture_2d; +@group(2) @binding({sheen_roughness_texture}) var layered_sheen_roughness_tex: texture_2d; +@group(2) @binding({anisotropy_texture}) var layered_anisotropy_tex: texture_2d; +@group(2) @binding({iridescence_factor_texture}) var layered_iridescence_factor_tex: texture_2d; +@group(2) @binding({iridescence_thickness_texture}) var layered_iridescence_thickness_tex: texture_2d; +@group(2) @binding({sheen_albedo_texture}) var layered_sheen_albedo_tex: texture_2d; +@group(2) @binding({sampler}) var layered_sampler: sampler; +@group(2) @binding({uniform}) var layered_material: LayeredPbrFactors; + +{LAYERED_PBR_V3_WGSL} +{secondary_uv_function}"#, + clearcoat_factor_texture = first_material_binding, + clearcoat_roughness_texture = first_material_binding + 1, + clearcoat_normal_texture = first_material_binding + 2, + specular_factor_texture = first_material_binding + 3, + specular_color_texture = first_material_binding + 4, + sheen_color_texture = first_material_binding + 5, + sheen_roughness_texture = first_material_binding + 6, + anisotropy_texture = first_material_binding + 7, + iridescence_factor_texture = first_material_binding + 8, + iridescence_thickness_texture = first_material_binding + 9, + sheen_albedo_texture = first_material_binding + 10, + sampler = first_material_binding + 11, + uniform = first_material_binding + 12, + ); + let mut source = replace_once( + base_scene_shader.to_owned(), + JOINT_DECLARATION, + &declarations, + "joint declaration", + ); + + if secondary_uv { + source = replace_once( + source, + " @location(6) tangent: vec4,\n};", + " @location(6) tangent: vec4,\n\ + @location(7) secondary_uv: vec2,\n\ + };", + "vertex input", + ); + source = replace_once( + source, + " @location(6) prev_clip: vec4,", + " @location(6) prev_clip: vec4,\n\ + @location(7) secondary_uv: vec2,", + "vertex output", + ); + source = replace_once( + source, + " o.uv = in.uv;", + " o.uv = in.uv;\n o.secondary_uv = in.secondary_uv;", + "skinned UV output", + ); + source = replace_once( + source, + " out.uv = in.uv;", + " out.uv = in.uv;\n out.secondary_uv = in.secondary_uv;", + "rigid UV output", + ); + } + + source = replace_once( + source, + " o.tangent = vec4(safe_scene_tangent(tan4.xyz), in.tangent.w);", + " let layered_model_handedness = select(\n\ + -1.0,\n\ + 1.0,\n\ + dot(cross(u.model[0].xyz, u.model[1].xyz), u.model[2].xyz) >= 0.0,\n\ + );\n\ + o.tangent = vec4(\n\ + safe_scene_tangent(tan4.xyz),\n\ + in.tangent.w * layered_model_handedness,\n\ + );", + "skinned mirrored-transform tangent handedness", + ); + source = replace_once( + source, + r#" out.tangent = vec4( + safe_scene_tangent((u.model * vec4(in.tangent.xyz, 0.0)).xyz), + in.tangent.w, + );"#, + r#" let layered_model_handedness = select( + -1.0, + 1.0, + dot(cross(u.model[0].xyz, u.model[1].xyz), u.model[2].xyz) >= 0.0, + ); + out.tangent = vec4( + safe_scene_tangent((u.model * vec4(in.tangent.xyz, 0.0)).xyz), + in.tangent.w * layered_model_handedness, + );"#, + "rigid mirrored-transform tangent handedness", + ); + + source = replace_once( + source, + "fn shade_pbr(\n n: vec3,", + "fn shade_layered_base_pbr(\n surface: LayeredSurface,\n n: vec3,", + "direct BRDF entry point", + ); + source = replace_once( + source, + r#" let kd0 = (vec3(1.0) - mix(vec3(0.04), base_color, metallic)) * (1.0 - metallic); + return kd0 * base_color / PI * light_color * intensity * n_dot_l;"#, + r#" let interface_transmission = + layered_dielectric_transmission(surface, n_dot_v) + * layered_dielectric_transmission(surface, n_dot_l); + return base_color * (1.0 - metallic) * interface_transmission + / PI * light_color * intensity * n_dot_l;"#, + "degenerate-half diffuse", + ); + source = replace_once( + source, + r#" let f0 = mix(vec3(0.04), base_color, metallic); + let f = f_schlick(v_dot_h, f0);"#, + r#" let f0 = mix(surface.dielectric_f0, base_color, metallic); + let f = layered_base_fresnel(surface, v_dot_h, base_color, metallic);"#, + "direct Fresnel", + ); + source = replace_once( + source, + r#" let d = d_ggx(n_dot_h, alpha2); + let vis = v_smith_ggx_correlated(n_dot_l, n_dot_v, alpha2);"#, + r#" let d = layered_base_distribution(surface, n, h, n_dot_h, alpha); + let vis = layered_base_visibility( + surface, + n, + v, + l_dir, + n_dot_l, + n_dot_v, + alpha, + );"#, + "anisotropic direct GGX", + ); + source = replace_once( + source, + r#" let kd = (vec3(1.0) - f) * (1.0 - metallic); + let diffuse = kd * base_color / PI;"#, + r#" let interface_transmission = + layered_dielectric_transmission(surface, n_dot_v) + * layered_dielectric_transmission(surface, n_dot_l); + let diffuse = base_color * (1.0 - metallic) * interface_transmission / PI;"#, + "direct diffuse complement", + ); + + source = source.replace( + "shade_pbr(n, v,", + "shade_layered_pbr(layered_surface, n, v,", + ); + assert_eq!( + source + .matches("shade_layered_pbr(layered_surface, n, v,") + .count(), + 3, + "scene direct-light call count changed; layered specialization must be updated" + ); + + source = replace_once( + source, + r#" // --- PBR direct lighting --- + let v = normalize(lighting.camera_pos.xyz - in.world_pos);"#, + r#" // --- PBR direct lighting --- + let v = normalize(lighting.camera_pos.xyz - in.world_pos); + let layered_surface = evaluate_layered_surface(in, n, lod_bias); + let layered_base_attenuation = + layered_clearcoat_ibl_attenuation(layered_surface, v); + let layered_sheen_base_attenuation = layered_sheen_ibl_scale( + layered_surface, + max(dot(n, v), 0.0), + );"#, + "surface evaluation", + ); + source = replace_once( + source, + " var lit = lighting.ambient.rgb * lighting.ambient.a * base_color;", + r#" var lit = lighting.ambient.rgb * lighting.ambient.a * base_color + * layered_base_attenuation * layered_sheen_base_attenuation;"#, + "ambient coat attenuation", + ); + + source = replace_once( + source, + " let f0 = mix(vec3(0.04), base_color, metallic);", + r#" let f0 = layered_ibl_f0( + layered_surface, + max(dot(n, v), 0.0), + base_color, + metallic, + ); + let f90 = mix( + vec3(layered_surface.dielectric_f90), + vec3(1.0), + metallic, + );"#, + "IBL F0/F90", + ); + source = replace_once( + source, + r#" let fc_n = pow(1.0 - n_dot_v_ibl, 5.0); + let f_ibl = f0 + (max(vec3(1.0 - roughness), f0) - f0) * fc_n; + let kd = (vec3(1.0) - f_ibl) * (1.0 - metallic);"#, + r#" let dielectric_f_ibl = layered_base_fresnel_roughness( + layered_surface, + n_dot_v_ibl, + base_color, + 0.0, + roughness, + ); + let kd = vec3(max(1.0 - max( + dielectric_f_ibl.r, + max(dielectric_f_ibl.g, dielectric_f_ibl.b), + ), 0.0)) * (1.0 - metallic);"#, + "IBL diffuse complement", + ); + source = replace_once( + source, + " let r = reflect(-v, n);", + " let r = layered_ibl_reflection(layered_surface, n, v, roughness);", + "anisotropic IBL reflection", + ); + source = replace_once( + source, + " let single_spec = prefiltered_env * (f0 * brdf.x + vec3(brdf.y));", + " let single_spec = prefiltered_env * (f0 * brdf.x + f90 * brdf.y);", + "IBL single scatter F90", + ); + source = replace_once( + source, + " let f_avg = f0 + (vec3(1.0) - f0) * (1.0 / 21.0);", + " let f_avg = f0 + (f90 - f0) * (1.0 / 21.0);", + "IBL average Fresnel", + ); + source = replace_once( + source, + " * (f0 * brdf.x + vec3(brdf.y) + ms_contribution);", + " * (f0 * brdf.x + f90 * brdf.y + ms_contribution);", + "IBL compensated F90", + ); + source = replace_once( + source, + " let roughness_amp = smoothstep(0.05, 0.75, roughness);", + r#" let roughness_amp = smoothstep(0.05, 0.75, roughness); + // Smooth ordinary materials retain the established visibility clamp. + // Thin-film materials need an IBL owner when SSR is disabled; otherwise + // their physically evaluated Fresnel is multiplied by zero. The existing + // SSR complement below still prevents double ownership when SSR runs. + let layered_roughness_amp = mix( + roughness_amp, + 1.0, + layered_surface.iridescence_factor, + );"#, + "iridescence IBL visibility", + ); + source = replace_once( + source, + " * dielectric_scale * spec_occ * roughness_amp * cap2 * (1.0 - ssr_own);", + " * dielectric_scale * spec_occ * layered_roughness_amp * cap2\n\ + * (1.0 - ssr_own);", + "iridescence IBL ownership", + ); + source = replace_once( + source, + " //IBL_STRIP_END", + r#" let ibl_sheen = layered_sheen_ibl( + layered_surface, + n, + v, + max_spec_mip, + occlusion, + ); + let clearcoat_n_dot_v = max( + dot(layered_surface.clearcoat_normal, v), + 0.0, + ); + let clearcoat_reflection = reflect(-v, layered_surface.clearcoat_normal); + let clearcoat_prefiltered = env_sample_lod( + clearcoat_reflection, + layered_surface.clearcoat_roughness * max_spec_mip, + ); + let clearcoat_brdf = textureSample( + brdf_lut_tex, + brdf_lut_samp, + vec2(clearcoat_n_dot_v, layered_surface.clearcoat_roughness), + ).rg; + let clearcoat_f0 = vec3(0.04 * layered_surface.clearcoat_factor); + let clearcoat_f90 = vec3(layered_surface.clearcoat_factor); + let clearcoat_spec_raw = clearcoat_prefiltered + * (clearcoat_f0 * clearcoat_brdf.x + clearcoat_f90 * clearcoat_brdf.y); + let clearcoat_luma = dot( + clearcoat_spec_raw, + vec3(0.2126, 0.7152, 0.0722), + ); + let clearcoat_cap = 1.0 / (1.0 + clearcoat_luma / 0.3); + let clearcoat_spec_occ = clamp( + pow( + clearcoat_n_dot_v + occlusion, + exp2(-16.0 * layered_surface.clearcoat_roughness - 1.0), + ) - 1.0 + occlusion, + 0.0, + 1.0, + ); + // The existing SSR material buffer describes the base lobe. It can own + // coated-metal reflections, but must not suppress dielectric varnish. + let clearcoat_ssr_own = ssr_own * metallic + * (1.0 - smoothstep( + 0.5, + 0.85, + layered_surface.clearcoat_roughness, + )); + let ibl_clearcoat = clearcoat_spec_raw + * clearcoat_cap + * clearcoat_spec_occ + * (1.0 - clearcoat_ssr_own); + //IBL_STRIP_END"#, + "clearcoat IBL", + ); + source = replace_once( + source, + " let hdr_raw = lit + (ibl_diffuse + ibl_spec) * indirect_shadow + emissive;", + r#" let hdr_raw = lit + + (((ibl_diffuse + ibl_spec) * layered_sheen_base_attenuation + ibl_sheen) + * layered_base_attenuation + + ibl_clearcoat) + * indirect_shadow + + emissive * layered_sheen_base_attenuation * layered_base_attenuation;"#, + "final layered composition", + ); + source +} + +const LAYERED_FINAL_COMPOSITION: &str = r#" let hdr_raw = lit + + (((ibl_diffuse + ibl_spec) * layered_sheen_base_attenuation + ibl_sheen) + * layered_base_attenuation + + ibl_clearcoat) + * indirect_shadow + + emissive * layered_sheen_base_attenuation * layered_base_attenuation;"#; + +/// Opt-in, compile-time material diagnostics for capture qualification. +/// +/// The selected expression replaces the final layered composition before +/// wgpu compiles the lazy specialization, so production shaders pay no branch +/// or binding cost. This is deliberately internal: the public renderer debug +/// API owns stable user-facing modes. +fn apply_layered_capture_debug(source: String, mode: Option<&str>) -> String { + let Some(replacement) = mode.and_then(|mode| match mode { + "material" => Some( + " let hdr_raw = vec3(\n\ + roughness,\n\ + metallic,\n\ + layered_surface.iridescence_factor,\n\ + );", + ), + "normal" => Some(" let hdr_raw = n * 0.5 + vec3(0.5);"), + "normal-texel" => Some(" let hdr_raw = nm_sample4.rgb;"), + "normal-tangent-length" => Some( + " let layered_debug_tangent_length = min(tlen2, 1.0);\n\ + let hdr_raw = vec3(layered_debug_tangent_length);", + ), + "geometric-normal" => Some( + " let layered_debug_geometric_normal = normalize(in.normal);\n\ + let hdr_raw = layered_debug_geometric_normal * 0.5 + vec3(0.5);", + ), + "view-cosine" => Some( + " let layered_debug_n_dot_v = max(dot(n, v), 0.0);\n\ + let hdr_raw = vec3(layered_debug_n_dot_v);", + ), + "geometric-view-cosine" => Some( + " let layered_debug_n_dot_v = max(dot(normalize(in.normal), v), 0.0);\n\ + let hdr_raw = vec3(layered_debug_n_dot_v);", + ), + "iridescence-fresnel" => Some( + " let hdr_raw = layered_raw_iridescence_base_fresnel(\n\ + layered_surface,\n\ + max(dot(n, v), 0.0),\n\ + base_color,\n\ + metallic,\n\ + );", + ), + "iridescence-ibl-f0" => Some( + " let hdr_raw = layered_ibl_f0(\n\ + layered_surface,\n\ + max(dot(n, v), 0.0),\n\ + base_color,\n\ + metallic,\n\ + );", + ), + "iridescence-prefiltered-env" => Some(" let hdr_raw = prefiltered_env;"), + "iridescence-single-spec" => Some(" let hdr_raw = single_spec;"), + "iridescence-multiscatter" => Some(" let hdr_raw = ms_contribution;"), + "iridescence-ibl-spec-raw" => Some(" let hdr_raw = ibl_spec_raw;"), + "iridescence-ibl-spec" => Some(" let hdr_raw = ibl_spec;"), + "iridescence-roughness" => Some(" let hdr_raw = vec3(roughness);"), + _ => None, + }) else { + return source; + }; + replace_once( + source, + LAYERED_FINAL_COMPOSITION, + replacement, + "capture debug composition", + ) +} + +pub(crate) struct SceneLayeredPbrResources { + pub(crate) material_layout: wgpu::BindGroupLayout, + opaque: wgpu::RenderPipeline, + opaque_prepassed: wgpu::RenderPipeline, + opaque_uv1: wgpu::RenderPipeline, + opaque_prepassed_uv1: wgpu::RenderPipeline, + transparent: wgpu::RenderPipeline, + transparent_double_sided: wgpu::RenderPipeline, + transparent_uv1: wgpu::RenderPipeline, + transparent_uv1_double_sided: wgpu::RenderPipeline, + reactive: Option, + reactive_double_sided: Option, + reactive_uv1: Option, + reactive_uv1_double_sided: Option, + weighted: Option, + weighted_double_sided: Option, + weighted_uv1: Option, + weighted_uv1_double_sided: Option, +} + +pub(crate) struct SceneSheenAlbedoLut { + _texture: wgpu::Texture, + pub(crate) view: wgpu::TextureView, +} + +impl Renderer { + pub(super) fn ensure_scene_sheen_albedo_lut(&mut self) { + if self.scene_sheen_albedo_lut.is_some() { + return; + } + assert_eq!( + SHEEN_ALBEDO_LUT_R16F.len(), + SHEEN_ALBEDO_LUT_BYTES, + "checked sheen LUT dimensions changed" + ); + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("scene_sheen_albedo_lut"), + size: wgpu::Extent3d { + width: SHEEN_ALBEDO_LUT_SIZE, + height: SHEEN_ALBEDO_LUT_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R16Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + SHEEN_ALBEDO_LUT_R16F, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(SHEEN_ALBEDO_LUT_SIZE * 2), + rows_per_image: Some(SHEEN_ALBEDO_LUT_SIZE), + }, + wgpu::Extent3d { + width: SHEEN_ALBEDO_LUT_SIZE, + height: SHEEN_ALBEDO_LUT_SIZE, + depth_or_array_layers: 1, + }, + ); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + self.scene_sheen_albedo_lut = Some(SceneSheenAlbedoLut { + _texture: texture, + view, + }); + } +} + +impl SceneLayeredPbrResources { + pub(crate) fn opaque_pipeline( + &self, + secondary_uv: bool, + prepassed: bool, + ) -> &wgpu::RenderPipeline { + match (secondary_uv, prepassed) { + (false, false) => &self.opaque, + (false, true) => &self.opaque_prepassed, + (true, false) => &self.opaque_uv1, + (true, true) => &self.opaque_prepassed_uv1, + } + } + + pub(crate) fn transparent_pipeline( + &self, + secondary_uv: bool, + double_sided: bool, + reactive: bool, + weighted: bool, + ) -> &wgpu::RenderPipeline { + match (weighted, reactive, secondary_uv, double_sided) { + (false, false, false, false) => &self.transparent, + (false, false, false, true) => &self.transparent_double_sided, + (false, false, true, false) => &self.transparent_uv1, + (false, false, true, true) => &self.transparent_uv1_double_sided, + (false, true, false, false) => self + .reactive + .as_ref() + .expect("layered reactive resources are initialized"), + (false, true, false, true) => self + .reactive_double_sided + .as_ref() + .expect("layered reactive resources are initialized"), + (false, true, true, false) => self + .reactive_uv1 + .as_ref() + .expect("layered reactive UV1 resources are initialized"), + (false, true, true, true) => self + .reactive_uv1_double_sided + .as_ref() + .expect("layered reactive UV1 resources are initialized"), + (true, _, false, false) => self + .weighted + .as_ref() + .expect("layered weighted resources are initialized"), + (true, _, false, true) => self + .weighted_double_sided + .as_ref() + .expect("layered weighted resources are initialized"), + (true, _, true, false) => self + .weighted_uv1 + .as_ref() + .expect("layered weighted UV1 resources are initialized"), + (true, _, true, true) => self + .weighted_uv1_double_sided + .as_ref() + .expect("layered weighted UV1 resources are initialized"), + } + } +} + +fn create_layered_material_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + let texture = |binding| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }; + let sampler = |binding| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }; + let uniform = |binding, visibility| wgpu::BindGroupLayoutEntry { + binding, + visibility, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }; + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("scene_layered_pbr_material_layout"), + entries: &[ + texture(0), + sampler(1), + texture(2), + sampler(3), + texture(4), + sampler(5), + texture(6), + sampler(7), + uniform(8, wgpu::ShaderStages::VERTEX_FRAGMENT), + texture(9), + sampler(10), + texture(11), + texture(12), + texture(13), + texture(14), + texture(15), + texture(16), + texture(17), + texture(18), + texture(19), + texture(20), + texture(21), + sampler(22), + uniform(23, wgpu::ShaderStages::FRAGMENT), + ], + }) +} + +fn strip_prepassed_discard(source: &str) -> String { + let (begin, end) = ( + source + .find("//PREPASS_STRIP_BEGIN") + .expect("layered scene shader keeps prepass strip start"), + source + .find("//PREPASS_STRIP_END") + .expect("layered scene shader keeps prepass strip end"), + ); + assert!(end > begin, "layered prepass strip markers are ordered"); + let end = end + "//PREPASS_STRIP_END".len(); + format!("{}{}", &source[..begin], &source[end..]) +} + +fn scene_vertex_buffers(secondary_uv: bool) -> Vec> { + let mut buffers = vec![Vertex3D::desc()]; + if secondary_uv { + buffers.push(secondary_uv_desc()); + } + buffers +} + +fn scene_main_targets() -> Vec> { + #[cfg(lean_mrt)] + { + vec![ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + }), + None, + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + None, + ] + } + #[cfg(not(lean_mrt))] + { + vec![ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: MATERIAL_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + ] + } +} + +struct LayeredPipelineModules<'a> { + scalar: &'a wgpu::ShaderModule, + scalar_prepassed: &'a wgpu::ShaderModule, + uv1: &'a wgpu::ShaderModule, + uv1_prepassed: &'a wgpu::ShaderModule, +} + +fn create_layered_opaque_pipeline( + device: &wgpu::Device, + layout: &wgpu::PipelineLayout, + modules: &LayeredPipelineModules<'_>, + secondary_uv: bool, + prepassed: bool, + label: &'static str, +) -> wgpu::RenderPipeline { + let shader = match (secondary_uv, prepassed) { + (false, false) => modules.scalar, + (false, true) => modules.scalar_prepassed, + (true, false) => modules.uv1, + (true, true) => modules.uv1_prepassed, + }; + let buffers = scene_vertex_buffers(secondary_uv); + let targets = scene_main_targets(); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(layout), + vertex: wgpu::VertexState { + module: shader, + entry_point: Some("vs_main_scene"), + buffers: &buffers, + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: shader, + entry_point: Some("fs_main_scene"), + targets: &targets, + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: Some(wgpu::Face::Back), + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(!prepassed), + depth_compare: Some(if prepassed { + wgpu::CompareFunction::Equal + } else { + wgpu::CompareFunction::Less + }), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) +} + +#[allow(clippy::too_many_arguments)] +fn create_layered_transparent_pipeline( + device: &wgpu::Device, + layout: &wgpu::PipelineLayout, + shader: &wgpu::ShaderModule, + entry_point: &'static str, + targets: &[Option], + secondary_uv: bool, + double_sided: bool, + label: &'static str, +) -> wgpu::RenderPipeline { + let buffers = scene_vertex_buffers(secondary_uv); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(layout), + vertex: wgpu::VertexState { + module: shader, + entry_point: Some("vs_main_scene"), + buffers: &buffers, + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: shader, + entry_point: Some(entry_point), + targets, + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: (!double_sided).then_some(wgpu::Face::Back), + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) +} + +impl Renderer { + pub(crate) fn ensure_scene_layered_pbr_resources(&mut self) { + if self.scene_layered_pbr_resources.is_some() { + return; + } + let material_layout = create_layered_material_layout(&self.device); + let pipeline_layout = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("scene_layered_pbr_pipeline_layout"), + bind_group_layouts: &[ + Some(&self.uniform_3d_layout), + Some(&self.lighting_layout), + Some(&material_layout), + Some(&self.joint_layout), + ], + immediate_size: 0, + }); + let source = |secondary_uv| { + let source = specialized_scene_shader_source_from( + scene_layered_shader_source(SCENE_SHADER, secondary_uv).into(), + self.froxel.is_some(), + self.shadow_map.virtual_map.requested(), + ) + .into_owned(); + apply_layered_capture_debug( + source, + std::env::var("BLOOM_LAYERED_DEBUG").ok().as_deref(), + ) + }; + let scalar_source = source(false); + let uv1_source = source(true); + let scalar_prepassed_source = strip_prepassed_discard(&scalar_source); + let uv1_prepassed_source = strip_prepassed_discard(&uv1_source); + let scalar = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_layered_pbr_shader"), + source: wgpu::ShaderSource::Wgsl(scalar_source.into()), + }); + let scalar_prepassed = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_layered_pbr_prepassed_shader"), + source: wgpu::ShaderSource::Wgsl(scalar_prepassed_source.into()), + }); + let uv1 = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_layered_pbr_uv1_shader"), + source: wgpu::ShaderSource::Wgsl(uv1_source.into()), + }); + let uv1_prepassed = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_layered_pbr_uv1_prepassed_shader"), + source: wgpu::ShaderSource::Wgsl(uv1_prepassed_source.into()), + }); + let modules = LayeredPipelineModules { + scalar: &scalar, + scalar_prepassed: &scalar_prepassed, + uv1: &uv1, + uv1_prepassed: &uv1_prepassed, + }; + let opaque = create_layered_opaque_pipeline( + &self.device, + &pipeline_layout, + &modules, + false, + false, + "scene_layered_pbr_pipeline", + ); + let opaque_prepassed = create_layered_opaque_pipeline( + &self.device, + &pipeline_layout, + &modules, + false, + true, + "scene_layered_pbr_prepassed_pipeline", + ); + let opaque_uv1 = create_layered_opaque_pipeline( + &self.device, + &pipeline_layout, + &modules, + true, + false, + "scene_layered_pbr_uv1_pipeline", + ); + let opaque_prepassed_uv1 = create_layered_opaque_pipeline( + &self.device, + &pipeline_layout, + &modules, + true, + true, + "scene_layered_pbr_prepassed_uv1_pipeline", + ); + let transparent_targets = [Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })]; + let transparent = create_layered_transparent_pipeline( + &self.device, + &pipeline_layout, + &scalar, + "fs_transparent_scene", + &transparent_targets, + false, + false, + "scene_layered_pbr_transparent_pipeline", + ); + let transparent_double_sided = create_layered_transparent_pipeline( + &self.device, + &pipeline_layout, + &scalar, + "fs_transparent_scene", + &transparent_targets, + false, + true, + "scene_layered_pbr_transparent_double_sided_pipeline", + ); + let transparent_uv1 = create_layered_transparent_pipeline( + &self.device, + &pipeline_layout, + &uv1, + "fs_transparent_scene", + &transparent_targets, + true, + false, + "scene_layered_pbr_transparent_uv1_pipeline", + ); + let transparent_uv1_double_sided = create_layered_transparent_pipeline( + &self.device, + &pipeline_layout, + &uv1, + "fs_transparent_scene", + &transparent_targets, + true, + true, + "scene_layered_pbr_transparent_uv1_double_sided_pipeline", + ); + self.scene_layered_pbr_resources = Some(SceneLayeredPbrResources { + material_layout, + opaque, + opaque_prepassed, + opaque_uv1, + opaque_prepassed_uv1, + transparent, + transparent_double_sided, + transparent_uv1, + transparent_uv1_double_sided, + reactive: None, + reactive_double_sided: None, + reactive_uv1: None, + reactive_uv1_double_sided: None, + weighted: None, + weighted_double_sided: None, + weighted_uv1: None, + weighted_uv1_double_sided: None, + }); + self.created_pipelines(8); + log::info!( + "bloom materials: lazy layered-PBR v4 scene specialization enabled \ + (base-only scene pipelines remain unchanged)" + ); + } +} + +impl Renderer { + #[allow(clippy::too_many_arguments)] + pub(crate) fn create_scene_layered_pbr_material_bg( + &mut self, + base_color_tex_idx: u32, + normal_tex_idx: u32, + metallic_roughness_tex_idx: u32, + emissive_tex_idx: u32, + occlusion_tex_idx: u32, + material_uniform: &wgpu::Buffer, + material: crate::models::MaterialLayeredPbr, + has_secondary_tex_coords: bool, + ) -> Option<(wgpu::Buffer, wgpu::BindGroup, bool)> { + if !material.is_active() { + return None; + } + + let mut indices = [0u32; LAYERED_TEXTURE_COUNT]; + let mut usable = [false; LAYERED_TEXTURE_COUNT]; + let bindings = [ + material.clearcoat_texture, + material.clearcoat_roughness_texture, + material.clearcoat_normal_texture, + material.specular_texture, + material.specular_color_texture, + material.sheen_color_texture, + material.sheen_roughness_texture, + material.anisotropy_texture, + material.iridescence_texture, + material.iridescence_thickness_texture, + ]; + let contributes = [ + material.has_clearcoat(), + material.has_clearcoat(), + material.has_clearcoat(), + material.has_specular_ior() && material.specular_factor > 0.0, + material.has_specular_ior() && material.specular_factor > 0.0, + material.has_sheen(), + material.has_sheen(), + material.has_anisotropy(), + material.has_iridescence(), + material.has_iridescence(), + ]; + for (slot, binding) in bindings.into_iter().enumerate() { + let Some(binding) = binding.filter(|_| contributes[slot]) else { + continue; + }; + match binding.transform.tex_coord { + 0 => {} + 1 if has_secondary_tex_coords => {} + 1 => { + log::warn!( + "bloom materials: layered-PBR texture requests TEXCOORD_1 but \ + this primitive has no valid secondary UV stream; preserving \ + the authored data and using its scalar factor" + ); + continue; + } + tex_coord => { + log::warn!( + "bloom materials: layered-PBR texture TEXCOORD_{tex_coord} is \ + preserved but only TEXCOORD_0/1 are renderable; using its \ + scalar factor" + ); + continue; + } + } + let Some(index) = binding + .runtime_texture_idx + .filter(|index| *index != 0 && (*index as usize) < self.textures.len()) + else { + log::warn!( + "bloom materials: layered-PBR source texture {} is unavailable \ + at runtime; preserving its source metadata and using the scalar factor", + binding.source_texture_index + ); + continue; + }; + indices[slot] = index; + usable[slot] = true; + } + let uses_uv1 = bindings.into_iter().enumerate().any(|(slot, binding)| { + usable[slot] && binding.is_some_and(|binding| binding.transform.tex_coord == 1) + }); + + self.ensure_scene_layered_pbr_resources(); + if material.has_sheen() { + self.ensure_scene_sheen_albedo_lut(); + } + let uniforms = SceneLayeredPbrUniforms::new(material, usable); + let layered_uniform = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_layered_pbr_uniform"), + contents: bytemuck::bytes_of(&uniforms), + usage: wgpu::BufferUsages::UNIFORM, + }); + let view_or_white = |index: u32| { + self.textures + .get(index as usize) + .unwrap_or(&self.textures[0]) + .create_view(&wgpu::TextureViewDescriptor::default()) + }; + let base_view = view_or_white(base_color_tex_idx); + let mr_view = view_or_white(metallic_roughness_tex_idx); + let emissive_view = view_or_white(emissive_tex_idx); + let occlusion_view = view_or_white(occlusion_tex_idx); + let clearcoat_factor_view = view_or_white(indices[CLEARCOAT_FACTOR_TEXTURE]); + let clearcoat_roughness_view = view_or_white(indices[CLEARCOAT_ROUGHNESS_TEXTURE]); + let specular_factor_view = view_or_white(indices[SPECULAR_FACTOR_TEXTURE]); + let specular_color_view = view_or_white(indices[SPECULAR_COLOR_TEXTURE]); + let sheen_color_view = view_or_white(indices[SHEEN_COLOR_TEXTURE]); + let sheen_roughness_view = view_or_white(indices[SHEEN_ROUGHNESS_TEXTURE]); + let anisotropy_view = view_or_white(indices[ANISOTROPY_TEXTURE]); + let iridescence_factor_view = view_or_white(indices[IRIDESCENCE_FACTOR_TEXTURE]); + let iridescence_thickness_view = view_or_white(indices[IRIDESCENCE_THICKNESS_TEXTURE]); + let sheen_lut_fallback = view_or_white(0); + let sheen_albedo_view = self + .scene_sheen_albedo_lut + .as_ref() + .map(|lut| &lut.view) + .unwrap_or(&sheen_lut_fallback); + let normal_view_owned = self + .textures + .get(normal_tex_idx as usize) + .filter(|_| normal_tex_idx != 0) + .map(|texture| texture.create_view(&wgpu::TextureViewDescriptor::default())); + let normal_view = normal_view_owned + .as_ref() + .unwrap_or(&self.default_normal_view); + let clearcoat_normal_view_owned = self + .textures + .get(indices[CLEARCOAT_NORMAL_TEXTURE] as usize) + .filter(|_| usable[CLEARCOAT_NORMAL_TEXTURE]) + .map(|texture| texture.create_view(&wgpu::TextureViewDescriptor::default())); + let clearcoat_normal_view = clearcoat_normal_view_owned + .as_ref() + .unwrap_or(&self.default_normal_view); + let layout = &self + .scene_layered_pbr_resources + .as_ref() + .expect("layered resources were initialized") + .material_layout; + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("scene_layered_pbr_material_bg"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&base_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(normal_view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&mr_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&emissive_view), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: material_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::TextureView(&occlusion_view), + }, + wgpu::BindGroupEntry { + binding: 10, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: wgpu::BindingResource::TextureView(&clearcoat_factor_view), + }, + wgpu::BindGroupEntry { + binding: 12, + resource: wgpu::BindingResource::TextureView(&clearcoat_roughness_view), + }, + wgpu::BindGroupEntry { + binding: 13, + resource: wgpu::BindingResource::TextureView(clearcoat_normal_view), + }, + wgpu::BindGroupEntry { + binding: 14, + resource: wgpu::BindingResource::TextureView(&specular_factor_view), + }, + wgpu::BindGroupEntry { + binding: 15, + resource: wgpu::BindingResource::TextureView(&specular_color_view), + }, + wgpu::BindGroupEntry { + binding: 16, + resource: wgpu::BindingResource::TextureView(&sheen_color_view), + }, + wgpu::BindGroupEntry { + binding: 17, + resource: wgpu::BindingResource::TextureView(&sheen_roughness_view), + }, + wgpu::BindGroupEntry { + binding: 18, + resource: wgpu::BindingResource::TextureView(&anisotropy_view), + }, + wgpu::BindGroupEntry { + binding: 19, + resource: wgpu::BindingResource::TextureView(&iridescence_factor_view), + }, + wgpu::BindGroupEntry { + binding: 20, + resource: wgpu::BindingResource::TextureView(&iridescence_thickness_view), + }, + wgpu::BindGroupEntry { + binding: 21, + resource: wgpu::BindingResource::TextureView(sheen_albedo_view), + }, + wgpu::BindGroupEntry { + binding: 22, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 23, + resource: layered_uniform.as_entire_binding(), + }, + ], + }); + Some((layered_uniform, bind_group, uses_uv1)) + } +} + +fn layered_pipeline_layout(renderer: &Renderer, label: &'static str) -> wgpu::PipelineLayout { + let material_layout = &renderer + .scene_layered_pbr_resources + .as_ref() + .expect("layered resources are initialized") + .material_layout; + renderer + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(label), + bind_group_layouts: &[ + Some(&renderer.uniform_3d_layout), + Some(&renderer.lighting_layout), + Some(material_layout), + Some(&renderer.joint_layout), + ], + immediate_size: 0, + }) +} + +impl Renderer { + pub(super) fn ensure_scene_layered_pbr_reactive_resources(&mut self) { + let Some(resources) = self.scene_layered_pbr_resources.as_ref() else { + return; + }; + if resources.reactive.is_some() { + return; + } + let layout = layered_pipeline_layout(self, "scene_layered_pbr_reactive_pipeline_layout"); + let source = |secondary_uv| { + let source = specialized_scene_shader_source_from( + scene_layered_shader_source(SCENE_SHADER, secondary_uv).into(), + self.froxel.is_some(), + self.shadow_map.virtual_map.requested(), + ); + temporal_reactive::scene_transparent_reactive_shader_source(&source) + }; + let scalar = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_layered_pbr_reactive_shader"), + source: wgpu::ShaderSource::Wgsl(source(false).into()), + }); + let uv1 = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_layered_pbr_reactive_uv1_shader"), + source: wgpu::ShaderSource::Wgsl(source(true).into()), + }); + let targets = [ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: temporal_reactive::TEMPORAL_REACTIVE_FORMAT, + blend: Some(temporal_reactive::reactive_union_blend()), + write_mask: wgpu::ColorWrites::RED, + }), + ]; + let reactive = create_layered_transparent_pipeline( + &self.device, + &layout, + &scalar, + "fs_transparent_scene_reactive", + &targets, + false, + false, + "scene_layered_pbr_reactive_pipeline", + ); + let reactive_double_sided = create_layered_transparent_pipeline( + &self.device, + &layout, + &scalar, + "fs_transparent_scene_reactive", + &targets, + false, + true, + "scene_layered_pbr_reactive_double_sided_pipeline", + ); + let reactive_uv1 = create_layered_transparent_pipeline( + &self.device, + &layout, + &uv1, + "fs_transparent_scene_reactive", + &targets, + true, + false, + "scene_layered_pbr_reactive_uv1_pipeline", + ); + let reactive_uv1_double_sided = create_layered_transparent_pipeline( + &self.device, + &layout, + &uv1, + "fs_transparent_scene_reactive", + &targets, + true, + true, + "scene_layered_pbr_reactive_uv1_double_sided_pipeline", + ); + let resources = self + .scene_layered_pbr_resources + .as_mut() + .expect("layered resources are initialized"); + resources.reactive = Some(reactive); + resources.reactive_double_sided = Some(reactive_double_sided); + resources.reactive_uv1 = Some(reactive_uv1); + resources.reactive_uv1_double_sided = Some(reactive_uv1_double_sided); + self.created_pipelines(4); + } + + pub(super) fn ensure_scene_layered_pbr_weighted_resources(&mut self) { + let Some(resources) = self.scene_layered_pbr_resources.as_ref() else { + return; + }; + if resources.weighted.is_some() { + return; + } + let layout = layered_pipeline_layout(self, "scene_layered_pbr_weighted_pipeline_layout"); + let source = |secondary_uv| { + let source = specialized_scene_shader_source_from( + scene_layered_shader_source(SCENE_SHADER, secondary_uv).into(), + self.froxel.is_some(), + self.shadow_map.virtual_map.requested(), + ); + scene_weighted_transparency_shader_source(&source) + }; + let scalar = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_layered_pbr_weighted_shader"), + source: wgpu::ShaderSource::Wgsl(source(false).into()), + }); + let uv1 = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_layered_pbr_weighted_uv1_shader"), + source: wgpu::ShaderSource::Wgsl(source(true).into()), + }); + let accumulation_blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + }; + let revealage_blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Zero, + dst_factor: wgpu::BlendFactor::OneMinusSrc, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Zero, + dst_factor: wgpu::BlendFactor::OneMinusSrc, + operation: wgpu::BlendOperation::Add, + }, + }; + let targets = [ + Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba16Float, + blend: Some(accumulation_blend), + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::R16Float, + blend: Some(revealage_blend), + write_mask: wgpu::ColorWrites::RED, + }), + ]; + let weighted = create_layered_transparent_pipeline( + &self.device, + &layout, + &scalar, + "fs_weighted_transparent_scene", + &targets, + false, + false, + "scene_layered_pbr_weighted_pipeline", + ); + let weighted_double_sided = create_layered_transparent_pipeline( + &self.device, + &layout, + &scalar, + "fs_weighted_transparent_scene", + &targets, + false, + true, + "scene_layered_pbr_weighted_double_sided_pipeline", + ); + let weighted_uv1 = create_layered_transparent_pipeline( + &self.device, + &layout, + &uv1, + "fs_weighted_transparent_scene", + &targets, + true, + false, + "scene_layered_pbr_weighted_uv1_pipeline", + ); + let weighted_uv1_double_sided = create_layered_transparent_pipeline( + &self.device, + &layout, + &uv1, + "fs_weighted_transparent_scene", + &targets, + true, + true, + "scene_layered_pbr_weighted_uv1_double_sided_pipeline", + ); + let resources = self + .scene_layered_pbr_resources + .as_mut() + .expect("layered resources are initialized"); + resources.weighted = Some(weighted); + resources.weighted_double_sided = Some(weighted_double_sided); + resources.weighted_uv1 = Some(weighted_uv1); + resources.weighted_uv1_double_sided = Some(weighted_uv1_double_sided); + self.created_pipelines(4); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ordinary_shader_remains_free_of_layered_bindings_and_calls() { + assert!(!SCENE_SHADER.contains("layered_material")); + assert!(!SCENE_SHADER.contains("shade_layered_pbr")); + assert!(!SCENE_SHADER.contains("layered_iridescence")); + assert!(!SCENE_SHADER.contains("@group(2) @binding(23)")); + } + + #[test] + fn scalar_and_secondary_uv_layered_variants_parse() { + for secondary_uv in [false, true] { + let source = scene_layered_shader_source(SCENE_SHADER, secondary_uv); + wgpu::naga::front::wgsl::parse_str(&source).unwrap_or_else(|error| { + panic!("layered scene WGSL (secondary_uv={secondary_uv}) failed: {error}") + }); + assert_eq!(source.matches("fn shade_layered_pbr(").count(), 1); + assert_eq!(source.matches("@group(2) @binding(23)").count(), 1); + assert_eq!(source.matches("@group(2) @binding(19)").count(), 1); + assert_eq!(source.matches("@group(2) @binding(20)").count(), 1); + assert_eq!(source.matches("@group(2) @binding(21)").count(), 1); + assert_eq!(source.matches("var layered_sampler: sampler;").count(), 1); + assert!(source.contains("fn layered_eval_iridescence(")); + assert_eq!(source.matches("let layered_model_handedness =").count(), 2); + assert!(source.contains("in.tangent.w * layered_model_handedness")); + assert_eq!(source.contains("@location(7) secondary_uv"), secondary_uv); + } + } + + #[test] + fn opt_in_capture_debug_modes_parse_without_production_shader_branches() { + let production = scene_layered_shader_source(SCENE_SHADER, false); + assert_eq!( + apply_layered_capture_debug(production.clone(), None), + production + ); + for mode in [ + "material", + "normal", + "normal-texel", + "normal-tangent-length", + "geometric-normal", + "view-cosine", + "geometric-view-cosine", + "iridescence-fresnel", + "iridescence-ibl-f0", + "iridescence-prefiltered-env", + "iridescence-single-spec", + "iridescence-multiscatter", + "iridescence-ibl-spec-raw", + "iridescence-ibl-spec", + "iridescence-roughness", + ] { + let source = apply_layered_capture_debug(production.clone(), Some(mode)); + wgpu::naga::front::wgsl::parse_str(&source) + .unwrap_or_else(|error| panic!("layered debug mode {mode} failed: {error}")); + assert!(!source.contains(LAYERED_FINAL_COMPOSITION)); + } + } + + #[test] + fn iridescence_uniforms_preserve_reversed_range_and_both_uv_transforms() { + let binding = |source_texture_index, tex_coord, rotation| { + Some(crate::models::MaterialTextureBinding { + source_texture_index, + source_image_index: 0, + runtime_texture_idx: Some(source_texture_index + 1), + transform: crate::models::MaterialTextureTransform { + offset: [0.1, 0.2], + rotation, + scale: [0.4, 0.5], + tex_coord, + }, + }) + }; + let material = crate::models::MaterialLayeredPbr { + iridescence_authored: true, + iridescence_factor: 0.82, + iridescence_texture: binding(3, 1, 0.3), + iridescence_ior: 1.42, + iridescence_thickness_minimum: 620.0, + iridescence_thickness_maximum: 180.0, + iridescence_thickness_texture: binding(4, 0, -0.2), + ..Default::default() + }; + let mut usable = [false; LAYERED_TEXTURE_COUNT]; + usable[IRIDESCENCE_FACTOR_TEXTURE] = true; + usable[IRIDESCENCE_THICKNESS_TEXTURE] = true; + let uniforms = SceneLayeredPbrUniforms::new(material, usable); + + assert_eq!(uniforms.iridescence, [0.82, 1.42, 620.0, 180.0]); + assert_eq!(uniforms.iridescence_factor_uv, [0.1, 0.2, 0.4, 0.5]); + assert_eq!(uniforms.iridescence_factor_rotation[2..], [1.0, 1.0]); + assert_eq!(uniforms.iridescence_thickness_rotation[2..], [1.0, 0.0]); + } + + #[test] + fn specialized_opaque_and_transparent_variants_parse() { + for secondary_uv in [false, true] { + for clustered in [false, true] { + for virtual_shadows in [false, true] { + let source = specialized_scene_shader_source_from( + scene_layered_shader_source(SCENE_SHADER, secondary_uv).into(), + clustered, + virtual_shadows, + ); + wgpu::naga::front::wgsl::parse_str(&source).unwrap_or_else(|error| { + panic!( + "layered scene WGSL (secondary_uv={secondary_uv}, \ + clustered={clustered}, virtual_shadows={virtual_shadows}) failed: \ + {error}" + ) + }); + } + } + + let source = specialized_scene_shader_source_from( + scene_layered_shader_source(SCENE_SHADER, secondary_uv).into(), + false, + false, + ); + for (label, source) in [ + ( + "reactive", + temporal_reactive::scene_transparent_reactive_shader_source(&source), + ), + ( + "weighted", + scene_weighted_transparency_shader_source(&source), + ), + ] { + wgpu::naga::front::wgsl::parse_str(&source).unwrap_or_else(|error| { + panic!("layered {label} WGSL (secondary_uv={secondary_uv}) failed: {error}") + }); + } + } + } +} diff --git a/native/shared/src/renderer/layered_pbr_ssr.rs b/native/shared/src/renderer/layered_pbr_ssr.rs new file mode 100644 index 00000000..ffdc1932 --- /dev/null +++ b/native/shared/src/renderer/layered_pbr_ssr.rs @@ -0,0 +1,582 @@ +//! Lazy iridescence-to-SSR transport. +//! +//! The compact legacy G-buffer has room only for metallic/roughness and base +//! albedo. Re-encoding those channels would either corrupt ordinary materials +//! or quantize the thin-film response. Instead, frames that actually contain +//! visible opaque iridescence replay only those meshes into a linear Fresnel +//! target. A matching lazy SSR variant consumes it. Base-only frames retain the +//! original targets, shader, bind group, and pass count. + +use super::*; + +const IRIDESCENCE_SSR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +pub(crate) struct SceneIridescenceSsrResources { + _metadata_texture: wgpu::Texture, + pub(crate) metadata_view: wgpu::TextureView, + scalar_metadata_pipeline: wgpu::RenderPipeline, + uv1_metadata_pipeline: wgpu::RenderPipeline, + pub(crate) ssr_layout: wgpu::BindGroupLayout, + pub(crate) ssr_pipeline: wgpu::RenderPipeline, +} + +impl SceneIridescenceSsrResources { + fn metadata_pipeline(&self, secondary_uv: bool) -> &wgpu::RenderPipeline { + if secondary_uv { + &self.uv1_metadata_pipeline + } else { + &self.scalar_metadata_pipeline + } + } +} + +fn iridescence_metadata_shader_source(base_scene_shader: &str, secondary_uv: bool) -> String { + let source = specialized_scene_shader_source_from( + layered_pbr_scene::scene_layered_shader_source(base_scene_shader, secondary_uv).into(), + false, + false, + ) + .into_owned(); + let tail_begin = source + .find(" let em_tex_sample = textureSample(em_tex, em_samp, in.uv);") + .expect("layered scene shader keeps the post-material tail anchor"); + let tail_end = source[tail_begin..] + .find("\n}\n\n@fragment\nfn fs_main_scene(") + .map(|offset| tail_begin + offset) + .expect("layered scene shader keeps the scene fragment entry point"); + let replacement = r#" // The metadata replay deliberately stops after material + normal + // evaluation. Direct lights, IBL, shadows, velocity, and albedo outputs are + // dead here, keeping the opt-in pass bounded to the data SSR cannot infer. + let v = normalize(lighting.camera_pos.xyz - in.world_pos); + let layered_surface = evaluate_layered_surface(in, n, lod_bias); + let raw_fresnel = layered_raw_iridescence_base_fresnel( + layered_surface, + max(dot(n, v), 0.0), + base_color, + metallic, + ); + let finite_fresnel = select( + vec3(0.0), + clamp(raw_fresnel, vec3(0.0), vec3(1.0)), + raw_fresnel == raw_fresnel, + ); + return SceneOut( + vec4( + finite_fresnel, + clamp(layered_surface.iridescence_factor, 0.0, 1.0), + ), + vec2(0.0), + vec2(0.0), + vec4(0.0), + );"#; + format!( + "{}{}{}", + &source[..tail_begin], + replacement, + &source[tail_end..] + ) +} + +fn iridescence_ssr_shader_source(base_ssr_shader: &str) -> String { + let declaration_anchor = "@group(0) @binding(10) var env_samp: sampler;"; + let declaration = format!( + "{declaration_anchor}\n\ + @group(0) @binding(11) var iridescence_fresnel_tex: texture_2d;" + ); + let source = base_ssr_shader.replacen(declaration_anchor, &declaration, 1); + assert_ne!( + source, base_ssr_shader, + "SSR bindings changed; iridescence transport must be updated" + ); + let fresnel_anchor = r#" let f0 = mix(vec3(0.04), albedo, metallic); + let fresnel = f0 + (vec3(1.0) - f0) * pow(1.0 - n_dot_v, 5.0);"#; + let fresnel_replacement = r#" let f0 = mix(vec3(0.04), albedo, metallic); + let ordinary_fresnel = + f0 + (vec3(1.0) - f0) * pow(1.0 - n_dot_v, 5.0); + // RGB is the unblended thin-film response and A is the sampled glTF + // iridescence factor. Clear pixels have A=0, so non-iridescent surfaces + // remain bit-for-bit on the established Schlick path. + let iridescence_sample = textureSampleLevel( + iridescence_fresnel_tex, + mat_samp, + in.uv, + 0.0, + ); + let iridescence_reflection_roughness = max( + roughness, + clamp(iridescence_sample.a, 0.0, 1.0) / max(u.params2.x, 1.0), + ); + let fresnel = mix( + ordinary_fresnel, + iridescence_sample.rgb, + clamp(iridescence_sample.a, 0.0, 1.0), + );"#; + let replaced = source.replacen(fresnel_anchor, fresnel_replacement, 1); + assert_ne!( + replaced, source, + "SSR Fresnel changed; iridescence transport must be updated" + ); + let fallback = replaced.replace( + "env_fallback(r, roughness)", + "env_fallback(r, iridescence_reflection_roughness)", + ); + assert_eq!( + fallback + .matches("env_fallback(r, iridescence_reflection_roughness)") + .count(), + 2, + "SSR env fallback call count changed; iridescence mip bias must be updated" + ); + fallback +} + +fn create_metadata_pipeline( + device: &wgpu::Device, + layout: &wgpu::PipelineLayout, + shader: &wgpu::ShaderModule, + secondary_uv: bool, + label: &'static str, +) -> wgpu::RenderPipeline { + let mut buffers = vec![Vertex3D::desc()]; + if secondary_uv { + buffers.push(secondary_uv_desc()); + } + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(layout), + vertex: wgpu::VertexState { + module: shader, + entry_point: Some("vs_main_scene"), + buffers: &buffers, + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: shader, + entry_point: Some("fs_transparent_scene"), + targets: &[Some(wgpu::ColorTargetState { + format: IRIDESCENCE_SSR_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: Some(wgpu::Face::Back), + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Equal), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) +} + +fn create_layered_ssr_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }; + let sampler = |binding, kind| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(kind), + count: None, + }; + let filterable = wgpu::TextureSampleType::Float { filterable: true }; + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("iridescence_ssr_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + texture(1, wgpu::TextureSampleType::Depth), + sampler(2, wgpu::SamplerBindingType::NonFiltering), + texture(3, filterable), + sampler(4, wgpu::SamplerBindingType::Filtering), + texture(5, filterable), + sampler(6, wgpu::SamplerBindingType::Filtering), + texture(7, filterable), + sampler(8, wgpu::SamplerBindingType::Filtering), + texture(9, filterable), + sampler(10, wgpu::SamplerBindingType::Filtering), + texture(11, filterable), + ], + }) +} + +fn create_layered_ssr_pipeline( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, +) -> wgpu::RenderPipeline { + let shader_source = iridescence_ssr_shader_source(SSR_SHADER_WGSL); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("iridescence_ssr_shader"), + source: wgpu::ShaderSource::Wgsl(shader_source.into()), + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("iridescence_ssr_pipeline_layout"), + bind_group_layouts: &[Some(layout)], + immediate_size: 0, + }); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("iridescence_ssr_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) +} + +impl Renderer { + fn ensure_scene_iridescence_ssr_resources(&mut self) { + if self.scene_iridescence_ssr_resources.is_some() { + return; + } + self.ensure_scene_layered_pbr_resources(); + let material_layout = &self + .scene_layered_pbr_resources + .as_ref() + .expect("layered scene resources initialized") + .material_layout; + let metadata_layout = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("iridescence_ssr_metadata_pipeline_layout"), + bind_group_layouts: &[ + Some(&self.uniform_3d_layout), + Some(&self.lighting_layout), + Some(material_layout), + Some(&self.joint_layout), + ], + immediate_size: 0, + }); + let scalar_source = iridescence_metadata_shader_source(SCENE_SHADER, false); + let uv1_source = iridescence_metadata_shader_source(SCENE_SHADER, true); + let scalar_shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("iridescence_ssr_metadata_shader"), + source: wgpu::ShaderSource::Wgsl(scalar_source.into()), + }); + let uv1_shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("iridescence_ssr_metadata_uv1_shader"), + source: wgpu::ShaderSource::Wgsl(uv1_source.into()), + }); + let scalar_metadata_pipeline = create_metadata_pipeline( + &self.device, + &metadata_layout, + &scalar_shader, + false, + "iridescence_ssr_metadata_pipeline", + ); + let uv1_metadata_pipeline = create_metadata_pipeline( + &self.device, + &metadata_layout, + &uv1_shader, + true, + "iridescence_ssr_metadata_uv1_pipeline", + ); + let (width, height) = self.render_extent(); + let metadata_texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("iridescence_ssr_metadata"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: IRIDESCENCE_SSR_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let metadata_view = metadata_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let ssr_layout = create_layered_ssr_layout(&self.device); + let ssr_pipeline = create_layered_ssr_pipeline(&self.device, &ssr_layout); + self.scene_iridescence_ssr_resources = Some(SceneIridescenceSsrResources { + _metadata_texture: metadata_texture, + metadata_view, + scalar_metadata_pipeline, + uv1_metadata_pipeline, + ssr_layout, + ssr_pipeline, + }); + self.created_pipelines(3); + self.ssr_layered_bg_cache = None; + log::info!( + "bloom materials: lazy iridescence SSR transport enabled \ + (ordinary SSR path remains unchanged)" + ); + } + + fn cached_iridescence_visible(&self, camera_planes: &[[f32; 4]; 6]) -> bool { + if self.dbg_skip("cached_models") { + return false; + } + self.model_draw_commands.iter().any(|command| { + let Some(Some(meshes)) = self.model_gpu_cache.get(&command.cache_handle) else { + return false; + }; + let Some(mesh) = meshes.get(command.mesh_idx) else { + return false; + }; + if !mesh.layered_pbr.has_iridescence() + || mesh.alpha_mode == MaterialAlphaMode::Blend + || (self.imported_refraction_enabled && mesh.transmission.is_active()) + || mesh.layered_material_bg.is_none() + || (self.gpu_driven.submitting() + && !command.skinned + && matches!(&mesh.geometry, gpu_driven::MeshGeometry::Shared(_))) + { + return false; + } + let (world_min, world_max) = command + .bounds_override + .unwrap_or_else(|| transform_aabb(&command.model, mesh.local_min, mesh.local_max)); + world_min[0] > world_max[0] + || !crate::scene::aabb_outside_frustum(camera_planes, world_min, world_max) + }) + } + + fn append_cached_iridescence_draws<'a>( + &'a self, + out: &mut Vec>, + camera_planes: &[[f32; 4]; 6], + ) { + if self.dbg_skip("cached_models") { + return; + } + for command in &self.model_draw_commands { + let Some(Some(meshes)) = self.model_gpu_cache.get(&command.cache_handle) else { + continue; + }; + let Some(mesh) = meshes.get(command.mesh_idx) else { + continue; + }; + if !mesh.layered_pbr.has_iridescence() + || mesh.alpha_mode == MaterialAlphaMode::Blend + || (self.imported_refraction_enabled && mesh.transmission.is_active()) + || (self.gpu_driven.submitting() + && !command.skinned + && matches!(&mesh.geometry, gpu_driven::MeshGeometry::Shared(_))) + { + continue; + } + let Some(material) = mesh.layered_material_bg.as_ref() else { + continue; + }; + let (world_min, world_max) = command + .bounds_override + .unwrap_or_else(|| transform_aabb(&command.model, mesh.local_min, mesh.local_max)); + if world_min[0] <= world_max[0] + && crate::scene::aabb_outside_frustum(camera_planes, world_min, world_max) + { + continue; + } + let secondary_uv = if mesh.layered_uses_uv1 { + let Some(buffer) = mesh.layered_uv1_buffer.as_ref() else { + continue; + }; + Some(buffer) + } else { + None + }; + let (mesh_draw, vertex_byte_offset, index_byte_offset) = if secondary_uv.is_some() { + self.gpu_driven + .mesh_draw_localized(&mesh.geometry, mesh.index_count) + } else { + ( + self.gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count), + 0, + 0, + ) + }; + out.push(ImportedIridescenceDrawRef { + uniforms: &self.model_uniform_bind_groups[command.uniform_slot], + material, + mesh: mesh_draw, + secondary_uv, + vertex_byte_offset, + index_byte_offset, + }); + } + } + + pub(super) fn record_layered_iridescence_ssr_metadata( + &mut self, + encoder: &mut wgpu::CommandEncoder, + profiler: &mut crate::profiler::Profiler, + scene: &crate::scene::SceneGraph, + ) { + self.iridescence_ssr_active = false; + if !self.ssr_enabled || self.pt_owns_frame() || self.scene_layered_pbr_resources.is_none() { + return; + } + let camera_vp = mat4_multiply( + self.current_proj_matrix_unjittered, + self.current_view_matrix, + ); + let camera_planes = crate::scene::extract_frustum_planes(&camera_vp); + let cached_visible = self.cached_iridescence_visible(&camera_planes); + let scene_visible = !self.dbg_skip("scene_graph") + && scene.has_visible_opaque_iridescence(self.imported_refraction_enabled); + if !cached_visible && !scene_visible { + return; + } + + self.ensure_scene_iridescence_ssr_resources(); + let mut draws = Vec::new(); + if cached_visible { + self.append_cached_iridescence_draws(&mut draws, &camera_planes); + } + if scene_visible { + scene.append_opaque_iridescence_draws(&mut draws); + } + if draws.is_empty() { + return; + } + + profiler.begin("iridescence_ssr_metadata"); + let timestamp_writes = profiler.pass_timestamp_writes("iridescence_ssr_metadata"); + let resources = self + .scene_iridescence_ssr_resources + .as_ref() + .expect("visible iridescence initialized SSR resources"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("iridescence_ssr_metadata_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &resources.metadata_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_bind_group(1, &self.lighting_bind_group, &[]); + pass.set_bind_group(3, &self.joint_bind_group, &[]); + let mut current_uv1 = None; + for draw in draws { + let uses_uv1 = draw.secondary_uv.is_some(); + if current_uv1 != Some(uses_uv1) { + pass.set_pipeline(resources.metadata_pipeline(uses_uv1)); + current_uv1 = Some(uses_uv1); + } + pass.set_bind_group(0, draw.uniforms, &[]); + pass.set_bind_group(2, draw.material, &[]); + pass.set_vertex_buffer(0, draw.mesh.vertex.slice(draw.vertex_byte_offset..)); + if let Some(secondary_uv) = draw.secondary_uv { + pass.set_vertex_buffer(1, secondary_uv.slice(..)); + } + pass.set_index_buffer( + draw.mesh.index.slice(draw.index_byte_offset..), + wgpu::IndexFormat::Uint32, + ); + pass.draw_indexed(draw.mesh.index_range(), draw.mesh.base_vertex, 0..1); + } + drop(pass); + profiler.end("iridescence_ssr_metadata"); + self.iridescence_ssr_active = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(source: &str) { + wgpu::naga::front::wgsl::parse_str(source).unwrap_or_else(|error| { + panic!( + "generated WGSL failed to parse:\n{}\n{error:?}", + error.emit_to_string(source) + ) + }); + } + + #[test] + fn metadata_variants_parse_and_strip_unneeded_lighting() { + for secondary_uv in [false, true] { + let source = iridescence_metadata_shader_source(SCENE_SHADER, secondary_uv); + parse(&source); + assert!(source.contains("layered_raw_iridescence_base_fresnel(")); + assert!(source.contains("iridescence_factor")); + assert!(!source.contains("// --- Split-sum IBL")); + assert!(!source.contains("let em_tex_sample =")); + } + } + + #[test] + fn layered_ssr_variant_adds_one_texture_and_preserves_base_source() { + let source = iridescence_ssr_shader_source(SSR_SHADER_WGSL); + parse(&source); + assert!(source.contains("@binding(11) var iridescence_fresnel_tex")); + assert!(source.contains("let ordinary_fresnel")); + assert!(!SSR_SHADER_WGSL.contains("iridescence_fresnel_tex")); + assert!(!SSR_SHADER_WGSL.contains("@binding(11)")); + } +} diff --git a/native/shared/src/renderer/lighting.rs b/native/shared/src/renderer/lighting.rs index 324e511e..a79f4178 100644 --- a/native/shared/src/renderer/lighting.rs +++ b/native/shared/src/renderer/lighting.rs @@ -18,6 +18,7 @@ use super::{froxel, Renderer}; pub(super) fn create_lighting_layout( device: &wgpu::Device, clustered: bool, + virtual_shadows: bool, ) -> wgpu::BindGroupLayout { let tex_float = wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, @@ -44,7 +45,12 @@ pub(super) fn create_lighting_layout( count: None, }, // 1/2: env (IBL specular) texture + sampler - wgpu::BindGroupLayoutEntry { binding: 1, visibility: frag, ty: tex_float, count: None }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: frag, + ty: tex_float, + count: None, + }, wgpu::BindGroupLayoutEntry { binding: 2, visibility: frag, @@ -52,7 +58,12 @@ pub(super) fn create_lighting_layout( count: None, }, // 3/4: BRDF LUT + sampler - wgpu::BindGroupLayoutEntry { binding: 3, visibility: frag, ty: tex_float, count: None }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: frag, + ty: tex_float, + count: None, + }, wgpu::BindGroupLayoutEntry { binding: 4, visibility: frag, @@ -60,9 +71,24 @@ pub(super) fn create_lighting_layout( count: None, }, // 5-7: shadow cascades, 8: comparison sampler - wgpu::BindGroupLayoutEntry { binding: 5, visibility: frag, ty: tex_depth, count: None }, - wgpu::BindGroupLayoutEntry { binding: 6, visibility: frag, ty: tex_depth, count: None }, - wgpu::BindGroupLayoutEntry { binding: 7, visibility: frag, ty: tex_depth, count: None }, + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: frag, + ty: tex_depth, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: frag, + ty: tex_depth, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 7, + visibility: frag, + ty: tex_depth, + count: None, + }, wgpu::BindGroupLayoutEntry { binding: 8, visibility: frag, @@ -70,11 +96,52 @@ pub(super) fn create_lighting_layout( count: None, }, // 9: env diffuse (IBL irradiance) - wgpu::BindGroupLayoutEntry { binding: 9, visibility: frag, ty: tex_float, count: None }, + wgpu::BindGroupLayoutEntry { + binding: 9, + visibility: frag, + ty: tex_float, + count: None, + }, ]; if clustered { entries.extend(froxel::extra_lighting_layout_entries()); } + if virtual_shadows { + entries.extend([ + wgpu::BindGroupLayoutEntry { + binding: 13, + visibility: frag, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Uint, + view_dimension: wgpu::TextureViewDimension::D2Array, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 14, + visibility: frag, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2Array, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 15, + visibility: frag, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: std::num::NonZeroU64::new( + std::mem::size_of::<[u32; 4]>() as u64 + ), + }, + count: None, + }, + ]); + } device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("lighting_layout"), entries: &entries, @@ -104,20 +171,81 @@ pub(super) fn create_lighting_bind_group( diffuse_view: &wgpu::TextureView, ) -> wgpu::BindGroup { let mut entries = vec![ - wgpu::BindGroupEntry { binding: 0, resource: src.lighting_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(env_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(src.env_sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(src.brdf_lut_view) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(src.brdf_lut_sampler) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&src.shadow_map.depth_views[0]) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&src.shadow_map.depth_views[1]) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&src.shadow_map.depth_views[2]) }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::Sampler(&src.shadow_map.sampler) }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::TextureView(diffuse_view) }, + wgpu::BindGroupEntry { + binding: 0, + resource: src.lighting_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(env_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(src.env_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(src.brdf_lut_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(src.brdf_lut_sampler), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&src.shadow_map.depth_views[0]), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&src.shadow_map.depth_views[1]), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(&src.shadow_map.depth_views[2]), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::Sampler(&src.shadow_map.sampler), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::TextureView(diffuse_view), + }, ]; if let Some(f) = src.froxel { entries.extend(f.extra_lighting_bind_entries()); } + if src.shadow_map.virtual_map.requested() { + entries.extend([ + wgpu::BindGroupEntry { + binding: 13, + resource: wgpu::BindingResource::TextureView( + src.shadow_map + .virtual_map + .page_table_view() + .expect("requested VSM requires a page table"), + ), + }, + wgpu::BindGroupEntry { + binding: 14, + resource: wgpu::BindingResource::TextureView( + src.shadow_map + .virtual_map + .physical_array_view() + .expect("requested VSM requires physical pages"), + ), + }, + wgpu::BindGroupEntry { + binding: 15, + resource: src + .shadow_map + .virtual_map + .sampling_params_buffer() + .expect("requested VSM requires sampling parameters") + .as_entire_binding(), + }, + ]); + } device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some(label), layout, diff --git a/native/shared/src/renderer/lighting_upload.rs b/native/shared/src/renderer/lighting_upload.rs new file mode 100644 index 00000000..24267974 --- /dev/null +++ b/native/shared/src/renderer/lighting_upload.rs @@ -0,0 +1,149 @@ +//! Dirty-range planning for the renderer's large lighting uniform buffer. +//! +//! Lighting setters mutate the CPU snapshot throughout a frame. Uploading the +//! complete ~9 KiB block after every setter made one ordinary frame enqueue the +//! same data many times. This tracker compares the final snapshot against the +//! last submitted bytes and emits at most one aligned range for each logical +//! region: fixed/directional fields, point lights, and view/shadow/frame data. + +use std::ops::Range; + +use super::types::LightingUniforms; + +const POINT_LIGHTS_OFFSET: usize = std::mem::offset_of!(LightingUniforms, point_lights); +const VIEW_DATA_OFFSET: usize = std::mem::offset_of!(LightingUniforms, camera_pos); +const LIGHTING_BYTES: usize = std::mem::size_of::(); +const REGIONS: [Range; 3] = [ + 0..POINT_LIGHTS_OFFSET, + POINT_LIGHTS_OFFSET..VIEW_DATA_OFFSET, + VIEW_DATA_OFFSET..LIGHTING_BYTES, +]; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct LightingUploadStats { + pub(super) write_count: u32, + pub(super) byte_count: u64, +} + +pub(super) struct LightingUploadBatch { + ranges: [Option>; 3], +} + +impl LightingUploadBatch { + pub(super) fn ranges(&self) -> impl Iterator> { + self.ranges.iter().flatten() + } +} + +pub(super) struct LightingUploadTracker { + last_uploaded: LightingUniforms, + frame_stats: LightingUploadStats, +} + +impl LightingUploadTracker { + pub(super) fn new(initial: LightingUniforms) -> Self { + Self { + last_uploaded: initial, + frame_stats: LightingUploadStats::default(), + } + } + + pub(super) fn begin_frame(&mut self) { + self.frame_stats = LightingUploadStats::default(); + } + + pub(super) fn plan(&mut self, current: LightingUniforms) -> LightingUploadBatch { + let current_bytes = bytemuck::bytes_of(¤t); + let previous_bytes = bytemuck::bytes_of(&self.last_uploaded); + let ranges = REGIONS + .clone() + .map(|region| changed_word_range(current_bytes, previous_bytes, region)); + for range in ranges.iter().flatten() { + self.frame_stats.write_count = self.frame_stats.write_count.saturating_add(1); + self.frame_stats.byte_count = self + .frame_stats + .byte_count + .saturating_add((range.end - range.start) as u64); + } + self.last_uploaded = current; + LightingUploadBatch { ranges } + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn frame_stats(&self) -> LightingUploadStats { + self.frame_stats + } +} + +fn changed_word_range( + current: &[u8], + previous: &[u8], + region: Range, +) -> Option> { + debug_assert_eq!(region.start % wgpu::COPY_BUFFER_ALIGNMENT as usize, 0); + debug_assert_eq!(region.end % wgpu::COPY_BUFFER_ALIGNMENT as usize, 0); + let alignment = wgpu::COPY_BUFFER_ALIGNMENT as usize; + let first = (region.start..region.end) + .step_by(alignment) + .find(|&offset| { + current[offset..offset + alignment] != previous[offset..offset + alignment] + })?; + let last = (region.start..region.end) + .step_by(alignment) + .rev() + .find(|&offset| current[offset..offset + alignment] != previous[offset..offset + alignment]) + .expect("the first changed word proves a last changed word exists"); + Some(first..last + alignment) +} + +#[cfg(test)] +mod tests { + use super::{LightingUploadTracker, LIGHTING_BYTES}; + use crate::renderer::types::LightingUniforms; + + #[test] + fn unchanged_snapshot_schedules_no_upload() { + let lighting = LightingUniforms::defaults(); + let mut tracker = LightingUploadTracker::new(lighting); + tracker.begin_frame(); + + assert_eq!(tracker.plan(lighting).ranges().count(), 0); + assert_eq!(tracker.frame_stats().write_count, 0); + assert_eq!(tracker.frame_stats().byte_count, 0); + } + + #[test] + fn changes_are_bounded_to_three_non_overlapping_regions() { + let initial = LightingUniforms::defaults(); + let mut changed = initial; + changed.ambient[0] = 0.25; + changed.point_lights[7].position[2] = 42.0; + changed.camera_pos[0] = 3.0; + + let mut tracker = LightingUploadTracker::new(initial); + tracker.begin_frame(); + let batch = tracker.plan(changed); + let ranges: Vec<_> = batch.ranges().cloned().collect(); + + assert_eq!(ranges.len(), 3); + assert!(ranges.windows(2).all(|pair| pair[0].end <= pair[1].start)); + assert_eq!(tracker.frame_stats().write_count, 3); + assert!(tracker.frame_stats().byte_count < LIGHTING_BYTES as u64); + } + + #[test] + fn repeated_setter_mutations_coalesce_before_planning() { + let initial = LightingUniforms::defaults(); + let mut changed = initial; + changed.point_lights[3].position = [1.0, 2.0, 3.0, 4.0]; + changed.point_lights[3].color = [0.2, 0.4, 0.6, 8.0]; + + let mut tracker = LightingUploadTracker::new(initial); + tracker.begin_frame(); + let batch = tracker.plan(changed); + + assert_eq!(batch.ranges().count(), 1); + assert_eq!(tracker.frame_stats().write_count, 1); + assert_eq!(tracker.frame_stats().byte_count, 32); + } +} diff --git a/native/shared/src/renderer/material_api.rs b/native/shared/src/renderer/material_api.rs new file mode 100644 index 00000000..80fbf753 --- /dev/null +++ b/native/shared/src/renderer/material_api.rs @@ -0,0 +1,618 @@ +//! Public material-system API implemented on [`Renderer`]. +//! +//! Kept separate from the render-loop implementation so material compilation, +//! instance submission, authoring controls, and per-frame ABI synchronization +//! can evolve without growing `renderer/mod.rs`. + +use super::*; + +impl Renderer { + /// Read-only capability, limit, residency, and diagnostic report for the + /// material indirection backend selected from the actual wgpu device. + pub fn material_binding_report_json(&self) -> String { + self.material_system.indirection.report_json() + } + + /// Debug override for qualification. 0=auto, 1=Tier C, 2=Tier B, 3=Tier A. + /// The indirection layer rejects requests above the adapter's detected tier. + pub fn set_material_binding_tier_override(&mut self, code: u32) -> bool { + let accepted = self + .material_system + .indirection + .set_tier_override(&self.device, code); + self.material_system + .indirection + .flush(&self.device, &self.queue); + accepted + } + + /// Stable GPU-facing ID corresponding to a legacy material handle. + pub fn material_id( + &self, + handle: material_system::MaterialHandle, + ) -> material_indirection::MaterialId { + handle + .checked_sub(1) + .and_then(|index| self.material_system.material_ids.get(index as usize)) + .copied() + .unwrap_or(material_indirection::MaterialId::FALLBACK) + } + + /// Stable GPU-facing ID corresponding to a legacy renderer texture index. + pub fn texture_id(&self, texture_index: u32) -> material_indirection::TextureId { + self.global_texture_ids + .get(texture_index as usize) + .copied() + .unwrap_or(material_indirection::TextureId::FALLBACK) + } + + /// Compile a material from user-supplied WGSL source. Returns a + /// handle to use with `submit_material_draw`. The source may + /// `#include "material_abi.wgsl"` and any `common/*.wgsl` header. + pub fn compile_material( + &mut self, + wgsl_source: &str, + ) -> Result { + self.compile_material_with_options( + wgsl_source, + material_pipeline::FragmentProfile::Opaque, + material_pipeline::Bucket::Opaque, + false, + false, + ) + } + + /// Phase 4a — full-control material compile. Games that want a + /// translucent / refractive / additive material (or a non-default + /// bucket) call this directly. Plain `compile_material` is a + /// convenience for Opaque + no scene reads. + /// + /// `wants_instancing` adds a per-instance vertex buffer layout at + /// slot 1 (EN-001). Materials compiled with it must be drawn via + /// `submit_material_draw_instanced` + a buffer from + /// `create_instance_buffer`. + pub fn compile_material_with_options( + &mut self, + wgsl_source: &str, + profile: material_pipeline::FragmentProfile, + bucket: material_pipeline::Bucket, + reads_scene: bool, + wants_instancing: bool, + ) -> Result { + self.material_system.compile( + &self.device, + wgsl_source, + profile, + bucket, + reads_scene, + wants_instancing, + formats::HDR_FORMAT, + formats::MATERIAL_FORMAT, + formats::VELOCITY_FORMAT, + wgpu::TextureFormat::Rgba8Unorm, + formats::DEPTH_FORMAT, + ) + } + + /// EN-001 — compile a material that opts into the standard per-instance + /// vertex layout (Opaque profile + Opaque bucket + wants_instancing). + /// Pair with `create_instance_buffer` + `submit_material_draw_instanced`. + /// The game shader's VertexInput must declare the instance attribute + /// locations (see `material_abi.wgsl` for the layout). + pub fn compile_material_instanced( + &mut self, + wgsl_source: &str, + ) -> Result { + self.compile_material_instanced_bucket(wgsl_source, 0, false) + } + + /// EN-026/027 — instanced compile into a chosen bucket. + /// + /// The original instanced path was hardcoded to Opaque, which is right for + /// grass and wrong for the two things that most want instancing: particles + /// (additive, thousands of quads) and decals (cutout, alpha-tested against + /// the atlas). `bucket`: 0 = opaque, 1 = cutout, 2 = additive, + /// 3 = transparent. + /// + /// `reads_scene` binds the scene colour/depth snapshot group. Soft + /// particles NEED it — a billboard that intersects the ground shows a hard + /// straight seam otherwise, which is the single biggest tell that a "puff" + /// is a flat card — and without this flag the group is absent from the + /// pipeline layout and the shader fails validation at create time. + /// + /// TAA-sensitive additive/transparent materials such as particles can + /// additionally author `@fragment fn fs_reactive(...) -> + /// ReactiveTranslucentOut`. It must produce the same HDR result as + /// `fs_main` plus 0..1 reactive coverage. The extra R8 target and sibling + /// pipeline stay lazy unless a submitted material declares that entry. + pub fn compile_material_instanced_bucket( + &mut self, + wgsl_source: &str, + bucket: u32, + reads_scene: bool, + ) -> Result { + let (profile, bucket) = match bucket { + 1 => ( + material_pipeline::FragmentProfile::Opaque, + material_pipeline::Bucket::Cutout, + ), + 2 => ( + material_pipeline::FragmentProfile::Translucent, + material_pipeline::Bucket::Additive, + ), + 3 => ( + material_pipeline::FragmentProfile::Translucent, + material_pipeline::Bucket::Transparent, + ), + _ => ( + material_pipeline::FragmentProfile::Opaque, + material_pipeline::Bucket::Opaque, + ), + }; + self.material_system.compile( + &self.device, + wgsl_source, + profile, + bucket, + reads_scene, + true, + formats::HDR_FORMAT, + formats::MATERIAL_FORMAT, + formats::VELOCITY_FORMAT, + wgpu::TextureFormat::Rgba8Unorm, + formats::DEPTH_FORMAT, + ) + } + + /// EN-001 — upload a CPU-side per-instance buffer to GPU memory. + /// `raw` is laid out as 9 floats per instance (pos.xyz, rot_y, + /// scale, tint.rgba). Returns a handle for use with + /// `submit_material_draw_instanced`. + pub fn create_instance_buffer(&mut self, raw: &[f32], count: u32) -> u32 { + self.material_system + .create_instance_buffer(&self.device, &self.queue, raw, count) + } + + /// EN-001 — release the GPU memory backing an instance buffer. + /// Safe to call with handle 0 or stale handles (no-op). + pub fn destroy_instance_buffer(&mut self, handle: u32) { + self.material_system.destroy_instance_buffer(handle); + } + + /// EN-017 V2 — append a fullscreen WGSL post-pass to the stack. + /// Compiles the shader, lazily allocates ping-pong LDR + /// intermediates as the stack grows, and pushes onto the stack. + /// Returns the 1-based handle of the newly added pass on success + /// (so callers can treat 0 as "compile failed"), or Err on + /// shader-compile failure; the existing stack is left intact. + /// + /// The fragment shader sees `scene_color_tex` (LDR, post-tonemap) + /// + `scene_depth_tex` at `@group(0)` — see + /// `post_pass::POST_PASS_PRELUDE` for the exact ABI. + /// + /// Stack order matters: the first added pass runs first, the + /// next sees the first's output, and so on. The last pass writes + /// the swapchain. + pub fn add_post_pass( + &mut self, + wgsl_source: &str, + ) -> Result { + let pipeline = post_pass::compile_post_pass(&self.device, wgsl_source, self.output_format)?; + self.created_pipelines(1); + + if self.composite_ldr_rt_a.is_none() { + let (texture, view) = post_pass::create_composite_ldr_rt( + &self.device, + self.surface_config.width, + self.surface_config.height, + self.output_format, + ); + self.composite_ldr_rt_a = Some(texture); + self.composite_ldr_rt_a_view = Some(view); + } + if self.post_passes.len() + 1 >= 2 && self.composite_ldr_rt_b.is_none() { + let (texture, view) = post_pass::create_composite_ldr_rt( + &self.device, + self.surface_config.width, + self.surface_config.height, + self.output_format, + ); + self.composite_ldr_rt_b = Some(texture); + self.composite_ldr_rt_b_view = Some(view); + } + + self.post_passes.push(pipeline); + Ok(self.post_passes.len() as u32) + } + + /// EN-017 V2 — wipe the post-pass stack. The composite output + /// goes directly to the swapchain again (zero post-pass cost). + /// LDR intermediates stay allocated to avoid churn when toggled. + pub fn clear_all_post_passes(&mut self) { + self.post_passes.clear(); + } + + /// EN-017 V1 backward-compat — replace the entire stack with a + /// single post-pass. + pub fn set_post_pass( + &mut self, + wgsl_source: &str, + ) -> Result<(), post_pass::PostPassCompileError> { + self.clear_all_post_passes(); + self.add_post_pass(wgsl_source)?; + Ok(()) + } + + /// EN-017 V1 backward-compat — clear the post-pass stack. + pub fn clear_post_pass(&mut self) { + self.clear_all_post_passes(); + } + + /// Phase 6 — compile a material from a WGSL file on disk and + /// register the path with the hot-reload watcher. + pub fn compile_material_from_file( + &mut self, + path: &std::path::Path, + profile: material_pipeline::FragmentProfile, + bucket: material_pipeline::Bucket, + reads_scene: bool, + ) -> Result { + let canonical = + std::fs::canonicalize(path).map_err(|e| format!("canonicalize {path:?}: {e}"))?; + let source = + std::fs::read_to_string(&canonical).map_err(|e| format!("read {canonical:?}: {e}"))?; + let handle = self + .compile_material_with_options(&source, profile, bucket, reads_scene, false) + .map_err(|e| format!("compile {canonical:?}: {e:?}"))?; + self.material_hot_reload.register( + handle, + hot_reload::FileMaterialDesc { + path: canonical, + profile, + bucket, + reads_scene, + wants_instancing: false, + }, + ); + Ok(handle) + } + + /// Drain pending hot-reload events and rebuild affected pipelines. + /// Failures retain the previous live pipeline. + pub fn poll_material_hot_reload(&mut self) { + let pending = self.material_hot_reload.drain_pending(); + for (handle, desc) in pending { + let source = match std::fs::read_to_string(&desc.path) { + Ok(source) => source, + Err(error) => { + eprintln!("[hot_reload] read {:?} failed: {error}", desc.path); + continue; + } + }; + match self.material_system.compile( + &self.device, + &source, + desc.profile, + desc.bucket, + desc.reads_scene, + desc.wants_instancing, + formats::HDR_FORMAT, + formats::MATERIAL_FORMAT, + formats::VELOCITY_FORMAT, + wgpu::TextureFormat::Rgba8Unorm, + formats::DEPTH_FORMAT, + ) { + Ok(new_handle) => { + let new_idx = (new_handle - 1) as usize; + let old_idx = (handle - 1) as usize; + if let Some(pipeline) = self + .material_system + .pipelines + .get_mut(new_idx) + .and_then(|slot| slot.take()) + { + if let Some(slot) = self.material_system.pipelines.get_mut(old_idx) { + *slot = Some(pipeline); + } + } + eprintln!("[hot_reload] reloaded {:?} (handle {handle})", desc.path); + } + Err(error) => { + eprintln!( + "[hot_reload] compile {:?} failed: {error:?} — keeping previous", + desc.path + ); + } + } + } + } + + /// Submit a material draw against a cached mesh. + pub fn submit_material_draw( + &mut self, + material: material_system::MaterialHandle, + mesh_handle: u64, + mesh_idx: usize, + position: [f32; 3], + scale: f32, + tint: [f32; 4], + ) { + let model = mat4_multiply( + mat4_translate(IDENTITY_MAT4, position), + mat4_scale(IDENTITY_MAT4, [scale, scale, scale]), + ); + let mvp = mat4_multiply(self.current_vp_matrix, model); + self.material_system.submit_draw( + &self.device, + &self.queue, + &self.joint_buffer, + material, + mesh_handle, + mesh_idx, + mvp, + model, + mvp, + tint, + [0, 0, 0, 0], + ); + } + + /// EN-001 — submit an instanced material draw. + pub fn submit_material_draw_instanced( + &mut self, + material: material_system::MaterialHandle, + mesh_handle: u64, + mesh_idx: usize, + instance_buffer: u32, + instance_count: u32, + ) { + let model = IDENTITY_MAT4; + let mvp = self.current_vp_matrix; + self.material_system.submit_draw_instanced( + &self.device, + &self.queue, + &self.joint_buffer, + material, + mesh_handle, + mesh_idx, + instance_buffer, + instance_count, + mvp, + model, + mvp, + [1.0, 1.0, 1.0, 1.0], + [0, 0, 0, 0], + ); + } + + /// Create a planar reflection probe and return its 1-based handle. + pub fn create_planar_reflection( + &mut self, + plane_y: f32, + normal: [f32; 3], + resolution: u32, + ) -> u32 { + let resolution = if resolution == 0 { + (self.surface_config.width / 2).max(16) + } else { + resolution + }; + let probe = planar_reflection::PlanarReflectionProbe::new( + &self.device, + plane_y, + normal, + resolution, + ); + let view_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("planar_probe_per_view"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + self.planar_probes.push(Some(probe)); + self.planar_probe_view_buffers.push(Some(view_buffer)); + self.planar_probe_view_bgs.push(None); + self.planar_probes.len() as u32 + } + + /// Link a material handle to a planar reflection probe. + pub fn set_material_reflection_probe(&mut self, material: u32, probe: u32) { + if material == 0 { + return; + } + if probe == 0 { + let view = self.material_system.default_black_view.clone(); + if let Err(error) = + self.material_system + .set_reflection_probe(&self.device, material, 0, &view) + { + eprintln!("[planar_reflection] unlink failed for material {material}: {error}"); + } + return; + } + let idx = probe as usize - 1; + let probe_view = match self.planar_probes.get(idx).and_then(|item| item.as_ref()) { + Some(probe) => probe.color_view.clone(), + None => { + eprintln!("[planar_reflection] unknown probe handle {probe}"); + return; + } + }; + if let Err(error) = + self.material_system + .set_reflection_probe(&self.device, material, probe, &probe_view) + { + eprintln!("[planar_reflection] set failed for material {material}: {error}"); + } + } + + /// Create a texture array from RGBA8 source layers. + pub fn create_texture_array(&mut self, layers: &[(&[u8], u32, u32)]) -> u32 { + self.material_system + .create_texture_array(&self.device, &self.queue, layers) + } + + /// Create a texture array with explicit format and mip control. + pub fn create_texture_array_ex( + &mut self, + layers: &[(&[u8], u32, u32)], + format: u32, + mip_levels: u32, + ) -> u32 { + self.material_system.create_texture_array_ex( + &self.device, + &self.queue, + layers, + format, + mip_levels, + ) + } + + /// Link an albedo, normal, or metallic-roughness texture array. + pub fn set_material_texture_array(&mut self, material: u32, slot: u32, array: u32) { + let probe_view = match self + .material_system + .material_reflection_probe_handle(material) + { + Some(probe) if probe != 0 => { + let idx = probe as usize - 1; + self.planar_probes + .get(idx) + .and_then(|item| item.as_ref()) + .map(|probe| probe.color_view.clone()) + .unwrap_or_else(|| self.material_system.default_black_view.clone()) + } + _ => self.material_system.default_black_view.clone(), + }; + self.material_system.set_material_texture_array( + &self.device, + material, + slot, + array, + &probe_view, + ); + } + + /// Set the material shading model. + pub fn set_material_shading_model(&mut self, material: u32, model: u32) { + let probe_view = self.resolve_probe_view_for_material(material); + if let Err(error) = self.material_system.set_material_shading_model( + &self.device, + &self.queue, + material, + model, + &probe_view, + ) { + eprintln!("[foliage] set_material_shading_model failed: {error}"); + } + } + + /// Set foliage transmission and wrap-light parameters. + pub fn set_material_foliage( + &mut self, + material: u32, + trans_color: [f32; 3], + trans_amount: f32, + wrap_factor: f32, + ) { + let probe_view = self.resolve_probe_view_for_material(material); + if let Err(error) = self.material_system.set_material_foliage( + &self.device, + &self.queue, + material, + trans_color, + trans_amount, + wrap_factor, + &probe_view, + ) { + eprintln!("[foliage] set_material_foliage failed: {error}"); + } + } + + fn resolve_probe_view_for_material(&self, material: u32) -> wgpu::TextureView { + match self + .material_system + .material_reflection_probe_handle(material) + { + Some(probe) if probe != 0 => { + let idx = probe as usize - 1; + self.planar_probes + .get(idx) + .and_then(|item| item.as_ref()) + .map(|probe| probe.color_view.clone()) + .unwrap_or_else(|| self.material_system.default_black_view.clone()) + } + _ => self.material_system.default_black_view.clone(), + } + } + + /// Set whether a material is rendered into planar-reflection probes. + pub fn set_material_probe_visible(&mut self, material: u32, visible: bool) { + self.material_system.set_probe_visible(material, visible); + } + + /// Synchronize material PerFrame and PerView uniforms. + pub fn material_system_begin_frame(&mut self, time_seconds: f32, delta_time: f32) { + self.lighting_uniforms.wind = [self.wind[0], self.wind[1], self.wind[2], time_seconds]; + self.lighting_uniforms.cloud = self.cloud_params; + self.lighting_uniforms.frame_misc = [delta_time, 0.0, 0.0, 0.0]; + let screen_w = self.surface_config.width as f32; + let screen_h = self.surface_config.height as f32; + let (render_w, render_h) = self.render_extent(); + let per_frame = material_system::PerFrameUniforms { + time: time_seconds, + delta_time, + frame_index: self.taa_frame_index as u32, + _pad0: 0, + screen_resolution: [screen_w, screen_h], + render_resolution: [render_w as f32, render_h as f32], + taa_jitter: [0.0, 0.0], + _pad1: [0.0, 0.0], + wind: self.wind, + cloud: self.cloud_params, + }; + let per_view = material_system::PerViewUniforms { + view: self.current_view_matrix, + proj: self.current_proj_matrix, + view_proj: self.current_vp_matrix, + prev_view_proj: self.velocity_ref_vp, + inv_proj: self.current_inv_proj_matrix, + camera_pos: [ + self.current_camera_pos[0], + self.current_camera_pos[1], + self.current_camera_pos[2], + self.lighting_uniforms.camera_pos[3], + ], + camera_dir: [0.0, 0.0, -1.0, 70.0_f32.to_radians()], + ambient: self.lighting_uniforms.ambient, + fog: [ + self.fog_color[0], + self.fog_color[1], + self.fog_color[2], + self.fog_density, + ], + sun_dir: self.lighting_uniforms.light_dir, + sun_color: self.lighting_uniforms.light_color, + dir_light_count: self.lighting_uniforms.dir_light_count, + dir_lights: std::array::from_fn(|index| material_system::PerViewDirLight { + direction: self.lighting_uniforms.dir_lights[index].direction, + color: self.lighting_uniforms.dir_lights[index].color, + }), + point_light_count: self.lighting_uniforms.point_light_count, + point_lights: std::array::from_fn(|index| material_system::PerViewPointLight { + position: self.lighting_uniforms.point_lights[index].position, + color: self.lighting_uniforms.point_lights[index].color, + }), + shadow_splits: self.lighting_uniforms.shadow_cascade_splits, + shadow_view: self.lighting_uniforms.shadow_view_matrix, + shadow_cascades: self.lighting_uniforms.shadow_cascade_vps, + }; + self.material_system.update_frame_uniforms( + &self.device, + &self.queue, + &per_frame, + &per_view, + ); + } +} diff --git a/native/shared/src/renderer/material_indirection.rs b/native/shared/src/renderer/material_indirection.rs new file mode 100644 index 00000000..21cc795f --- /dev/null +++ b/native/shared/src/renderer/material_indirection.rs @@ -0,0 +1,1813 @@ +//! Capability-tiered material and texture indirection. +//! +//! This module is deliberately independent from the legacy material bind-group +//! path. Tier C can therefore continue to produce the exact same commands and +//! pixels while Tier A/B resource tables are populated for GPU-driven draws. +//! The future visibility-buffer and GPU-culling passes consume the typed IDs +//! exposed here rather than owning backend bind groups. + +use std::collections::BTreeSet; +use std::marker::PhantomData; +use std::num::NonZeroU32; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use super::capabilities::{forced_renderer_tier, RendererCapabilities, RendererCapabilityTier}; +use super::layered_pbr::{ + global_material_lobe_mask, global_material_version, pack_global_material_metadata, + MaterialLobeMask, +}; + +const ID_SLOT_BITS: u32 = 20; +const ID_SLOT_MASK: u32 = (1 << ID_SLOT_BITS) - 1; +const ID_GENERATION_MASK: u32 = (1 << (32 - ID_SLOT_BITS)) - 1; +const DEFAULT_RESOURCE_CAPACITY: usize = 8_192; +const TIER_A_TARGET_TEXTURES: u32 = 4_096; +const TIER_A_TARGET_SAMPLERS: u32 = 64; +const TIER_A_TARGET_BINDING_ARRAY_ELEMENTS: u32 = + TIER_A_TARGET_TEXTURES + TIER_A_TARGET_SAMPLERS + 2; +const INITIAL_MATERIAL_CAPACITY: usize = 64; + +pub const TIER_A_FEATURES: wgpu::Features = wgpu::Features::TEXTURE_BINDING_ARRAY + .union(wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING); + +/// Add Tier A's optional features and bounded working-set limits to a request. +/// +/// Platform bring-up paths call this after choosing their mandatory limits. +/// Unsupported adapters are unchanged and naturally select Tier B/C later. +pub fn request_tier_a_if_supported( + supported: wgpu::Features, + adapter_limits: &wgpu::Limits, + required_features: &mut wgpu::Features, + required_limits: &mut wgpu::Limits, +) { + if !supported.contains(TIER_A_FEATURES) { + return; + } + *required_features |= TIER_A_FEATURES; + required_limits.max_binding_array_elements_per_shader_stage = adapter_limits + .max_binding_array_elements_per_shader_stage + .min(TIER_A_TARGET_BINDING_ARRAY_ELEMENTS); + required_limits.max_binding_array_sampler_elements_per_shader_stage = adapter_limits + .max_binding_array_sampler_elements_per_shader_stage + .min(TIER_A_TARGET_SAMPLERS + 1); +} + +/// Common behavior for the typed 32-bit IDs stored in scene and GPU records. +pub trait StableResourceId: + Copy + Clone + Eq + Ord + std::hash::Hash + std::fmt::Debug + Send + Sync + 'static +{ + const FALLBACK: Self; + fn from_parts(slot: usize, generation: u32) -> Option; + fn raw(self) -> u32; + + fn descriptor_index(self) -> usize { + (self.raw() & ID_SLOT_MASK) as usize + } + + fn generation(self) -> u32 { + self.raw() >> ID_SLOT_BITS + } + + fn is_fallback(self) -> bool { + self.raw() == 0 + } +} + +macro_rules! stable_id { + ($name:ident) => { + #[repr(transparent)] + #[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] + pub struct $name(u32); + + impl $name { + pub const FALLBACK: Self = Self(0); + + pub const fn raw(self) -> u32 { + self.0 + } + + pub const fn descriptor_index(self) -> u32 { + self.0 & ID_SLOT_MASK + } + + pub const fn generation(self) -> u32 { + self.0 >> ID_SLOT_BITS + } + } + + impl StableResourceId for $name { + const FALLBACK: Self = Self::FALLBACK; + + fn from_parts(slot: usize, generation: u32) -> Option { + let one_based = slot.checked_add(1)?; + if one_based > ID_SLOT_MASK as usize || generation > ID_GENERATION_MASK { + return None; + } + Some(Self((generation << ID_SLOT_BITS) | one_based as u32)) + } + + fn raw(self) -> u32 { + self.0 + } + } + }; +} + +stable_id!(MaterialId); +stable_id!(TextureId); +stable_id!(SamplerId); +stable_id!(MeshId); +stable_id!(BufferViewId); + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ResolveStatus { + Resident, + Fallback, + Stale, + Retiring, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ResidencyUpdate { + Resident(I), + Retired(I), + Reclaimed(I), +} + +struct ResidencySlot { + generation: u32, + live: bool, + retire_epoch: Option, + value: Option, +} + +/// Generation-safe resource storage with GPU-completion-delayed reclamation. +/// +/// Retirement makes an ID resolve to the diagnostic fallback immediately, but +/// the owned resource is retained until `collect(completed_epoch)` proves the +/// last referencing submission finished. Only then is the slot reusable and +/// its generation incremented. +pub struct ResidencyTable { + slots: Vec>, + free: Vec, + updates: Vec>, + max_live: usize, + live_count: usize, + _id: PhantomData, +} + +impl ResidencyTable { + pub fn new(max_live: usize) -> Self { + Self { + slots: Vec::new(), + free: Vec::new(), + updates: Vec::new(), + max_live: max_live.min(ID_SLOT_MASK as usize), + live_count: 0, + _id: PhantomData, + } + } + + pub fn insert(&mut self, value: T) -> Result { + if self.live_count >= self.max_live { + return Err("residency table limit reached"); + } + let slot = if let Some(slot) = self.free.pop() { + self.slots[slot].value = Some(value); + self.slots[slot].live = true; + self.slots[slot].retire_epoch = None; + slot + } else { + if self.slots.len() >= self.max_live { + return Err("residency table limit reached"); + } + let slot = self.slots.len(); + self.slots.push(ResidencySlot { + generation: 0, + live: true, + retire_epoch: None, + value: Some(value), + }); + slot + }; + self.live_count += 1; + let id = I::from_parts(slot, self.slots[slot].generation) + .ok_or("resource ID space exhausted")?; + self.updates.push(ResidencyUpdate::Resident(id)); + Ok(id) + } + + pub fn resolve(&self, id: I) -> (Option<&T>, ResolveStatus) { + if id.is_fallback() { + return (None, ResolveStatus::Fallback); + } + let descriptor_index = id.descriptor_index(); + let Some(slot_index) = descriptor_index.checked_sub(1) else { + return (None, ResolveStatus::Fallback); + }; + let Some(slot) = self.slots.get(slot_index) else { + return (None, ResolveStatus::Stale); + }; + if slot.generation != id.generation() { + return (None, ResolveStatus::Stale); + } + if !slot.live { + return (None, ResolveStatus::Retiring); + } + match slot.value.as_ref() { + Some(value) => (Some(value), ResolveStatus::Resident), + None => (None, ResolveStatus::Stale), + } + } + + pub fn resolve_mut(&mut self, id: I) -> (Option<&mut T>, ResolveStatus) { + if id.is_fallback() { + return (None, ResolveStatus::Fallback); + } + let Some(slot_index) = id.descriptor_index().checked_sub(1) else { + return (None, ResolveStatus::Fallback); + }; + let Some(slot) = self.slots.get_mut(slot_index) else { + return (None, ResolveStatus::Stale); + }; + if slot.generation != id.generation() { + return (None, ResolveStatus::Stale); + } + if !slot.live { + return (None, ResolveStatus::Retiring); + } + match slot.value.as_mut() { + Some(value) => (Some(value), ResolveStatus::Resident), + None => (None, ResolveStatus::Stale), + } + } + + pub fn retire(&mut self, id: I, completion_epoch: u64) -> bool { + if id.is_fallback() { + return false; + } + let Some(slot_index) = id.descriptor_index().checked_sub(1) else { + return false; + }; + let Some(slot) = self.slots.get_mut(slot_index) else { + return false; + }; + if slot.generation != id.generation() || !slot.live || slot.value.is_none() { + return false; + } + slot.live = false; + slot.retire_epoch = Some(completion_epoch); + self.live_count = self.live_count.saturating_sub(1); + self.updates.push(ResidencyUpdate::Retired(id)); + true + } + + pub fn collect(&mut self, completed_epoch: u64) -> usize { + let mut reclaimed = 0; + for (slot_index, slot) in self.slots.iter_mut().enumerate() { + if slot.live + || slot.value.is_none() + || slot + .retire_epoch + .is_none_or(|epoch| epoch > completed_epoch) + { + continue; + } + let old_id = I::from_parts(slot_index, slot.generation) + .expect("existing residency slot must have an encodable ID"); + slot.value.take(); + slot.retire_epoch = None; + slot.generation = (slot.generation + 1) & ID_GENERATION_MASK; + self.free.push(slot_index); + self.updates.push(ResidencyUpdate::Reclaimed(old_id)); + reclaimed += 1; + } + reclaimed + } + + pub fn drain_updates(&mut self) -> Vec> { + std::mem::take(&mut self.updates) + } + + pub fn live_count(&self) -> usize { + self.live_count + } + + pub fn max_live(&self) -> usize { + self.max_live + } + + fn slots(&self) -> &[ResidencySlot] { + &self.slots + } +} + +/// Tracks resource retirement against actual queue-completion callbacks. +#[derive(Clone)] +pub struct GpuCompletionTracker { + next_epoch: Arc, + completed_epoch: Arc, +} + +impl Default for GpuCompletionTracker { + fn default() -> Self { + Self { + next_epoch: Arc::new(AtomicU64::new(0)), + completed_epoch: Arc::new(AtomicU64::new(0)), + } + } +} + +impl GpuCompletionTracker { + /// Return an epoch that completes after all work submitted before this call. + pub fn track_submitted_work(&self, queue: &wgpu::Queue) -> u64 { + let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed) + 1; + let completed = Arc::clone(&self.completed_epoch); + queue.on_submitted_work_done(move || { + completed.fetch_max(epoch, Ordering::Release); + }); + epoch + } + + pub fn completed_epoch(&self) -> u64 { + self.completed_epoch.load(Ordering::Acquire) + } + + #[cfg(test)] + fn mark_complete_for_test(&self, epoch: u64) { + self.completed_epoch.fetch_max(epoch, Ordering::Release); + } +} + +/// Runtime-selected backend for global material and texture lookup. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum MaterialBindingTier { + /// Existing per-material bind groups. Compatibility and oracle path. + C = 1, + /// Paged texture arrays/atlases with deterministic page grouping. + B = 2, + /// Descriptor-indexed global texture and sampler tables. + A = 3, +} + +impl MaterialBindingTier { + pub const fn name(self) -> &'static str { + match self { + Self::A => "A", + Self::B => "B", + Self::C => "C", + } + } + + pub fn from_override(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "a" | "tier-a" | "3" => Some(Self::A), + "b" | "tier-b" | "2" => Some(Self::B), + "c" | "tier-c" | "1" => Some(Self::C), + _ => None, + } + } +} + +#[derive(Clone, Debug)] +pub struct MaterialBindingCapabilities { + pub detected_tier: MaterialBindingTier, + pub selected_tier: MaterialBindingTier, + pub override_tier: Option, + pub texture_binding_array: bool, + pub non_uniform_indexing: bool, + pub max_binding_array_elements: u32, + pub max_binding_array_samplers: u32, + pub max_texture_array_layers: u32, + pub max_sampled_textures: u32, + pub max_samplers: u32, + pub max_material_records: u32, + pub tier_a_texture_capacity: u32, + pub tier_a_sampler_capacity: u32, + pub tier_b_page_capacity: u32, + pub diagnostic: Option, +} + +impl MaterialBindingCapabilities { + pub fn detect(features: wgpu::Features, limits: &wgpu::Limits) -> Self { + let detected_tier = detected_material_tier(features, limits); + let material_override = std::env::var("BLOOM_MATERIAL_TIER") + .ok() + .and_then(|value| MaterialBindingTier::from_override(&value)); + let requested_override = + lower_material_override(material_override, forced_renderer_material_tier()); + Self::with_override(features, limits, detected_tier, requested_override) + } + + pub fn detect_with_override( + features: wgpu::Features, + limits: &wgpu::Limits, + override_tier: Option, + ) -> Self { + let detected_tier = detected_material_tier(features, limits); + let requested_override = + lower_material_override(override_tier, forced_renderer_material_tier()); + Self::with_override(features, limits, detected_tier, requested_override) + } + + fn with_override( + features: wgpu::Features, + limits: &wgpu::Limits, + detected_tier: MaterialBindingTier, + requested_override: Option, + ) -> Self { + let (selected_tier, override_tier, diagnostic) = match requested_override { + Some(requested) if requested <= detected_tier => (requested, Some(requested), None), + Some(requested) => ( + detected_tier, + None, + Some(format!( + "requested Tier {} exceeds adapter Tier {}; using Tier {}", + requested.name(), + detected_tier.name(), + detected_tier.name() + )), + ), + None => (detected_tier, None, None), + }; + let record_size = std::mem::size_of::() as u64; + let max_material_records = (limits.max_storage_buffer_binding_size / record_size) + .min(ID_SLOT_MASK as u64) + .max(1) as u32; + let tier_a_texture_capacity = limits + .max_binding_array_elements_per_shader_stage + .saturating_sub(1) + .min(TIER_A_TARGET_TEXTURES); + let tier_a_sampler_capacity = limits + .max_binding_array_sampler_elements_per_shader_stage + .saturating_sub(1) + .min(TIER_A_TARGET_SAMPLERS); + Self { + detected_tier, + selected_tier, + override_tier, + texture_binding_array: features.contains(wgpu::Features::TEXTURE_BINDING_ARRAY), + non_uniform_indexing: features.contains( + wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING, + ), + max_binding_array_elements: limits.max_binding_array_elements_per_shader_stage, + max_binding_array_samplers: limits.max_binding_array_sampler_elements_per_shader_stage, + max_texture_array_layers: limits.max_texture_array_layers, + max_sampled_textures: limits.max_sampled_textures_per_shader_stage, + max_samplers: limits.max_samplers_per_shader_stage, + max_material_records, + tier_a_texture_capacity, + tier_a_sampler_capacity, + tier_b_page_capacity: limits.max_texture_array_layers.clamp(1, 256), + diagnostic, + } + } + + pub fn report_json(&self) -> String { + let mut out = String::from("{\"version\":1,\"detected_tier\":\""); + out.push_str(self.detected_tier.name()); + out.push_str("\",\"selected_tier\":\""); + out.push_str(self.selected_tier.name()); + out.push_str("\",\"override_tier\":"); + match self.override_tier { + Some(tier) => { + out.push('"'); + out.push_str(tier.name()); + out.push('"'); + } + None => out.push_str("null"), + } + out.push_str(",\"features\":{\"texture_binding_array\":"); + out.push_str(if self.texture_binding_array { + "true" + } else { + "false" + }); + out.push_str(",\"non_uniform_indexing\":"); + out.push_str(if self.non_uniform_indexing { + "true" + } else { + "false" + }); + out.push_str("},\"limits\":{\"max_binding_array_elements\":"); + out.push_str(&self.max_binding_array_elements.to_string()); + out.push_str(",\"max_binding_array_samplers\":"); + out.push_str(&self.max_binding_array_samplers.to_string()); + out.push_str(",\"max_texture_array_layers\":"); + out.push_str(&self.max_texture_array_layers.to_string()); + out.push_str(",\"max_sampled_textures\":"); + out.push_str(&self.max_sampled_textures.to_string()); + out.push_str(",\"max_samplers\":"); + out.push_str(&self.max_samplers.to_string()); + out.push_str(",\"max_material_records\":"); + out.push_str(&self.max_material_records.to_string()); + out.push_str("},\"capacities\":{\"tier_a_textures\":"); + out.push_str(&self.tier_a_texture_capacity.to_string()); + out.push_str(",\"tier_a_samplers\":"); + out.push_str(&self.tier_a_sampler_capacity.to_string()); + out.push_str(",\"tier_b_page_layers\":"); + out.push_str(&self.tier_b_page_capacity.to_string()); + out.push_str("},\"diagnostic\":"); + match &self.diagnostic { + Some(message) => json_string(&mut out, message), + None => out.push_str("null"), + } + out.push('}'); + out + } +} + +fn detected_material_tier(features: wgpu::Features, limits: &wgpu::Limits) -> MaterialBindingTier { + match RendererCapabilities::detect_with_override(features, limits, None).detected_tier { + RendererCapabilityTier::Baseline => MaterialBindingTier::C, + RendererCapabilityTier::Modern => MaterialBindingTier::B, + RendererCapabilityTier::HighEnd => MaterialBindingTier::A, + } +} + +fn forced_renderer_material_tier() -> Option { + forced_renderer_tier().map(|tier| match tier { + RendererCapabilityTier::Baseline => MaterialBindingTier::C, + RendererCapabilityTier::Modern => MaterialBindingTier::B, + RendererCapabilityTier::HighEnd => MaterialBindingTier::A, + }) +} + +fn lower_material_override( + material: Option, + renderer: Option, +) -> Option { + match (material, renderer) { + (Some(material), Some(renderer)) => Some(material.min(renderer)), + (material, renderer) => material.or(renderer), + } +} + +/// Storage-buffer record shared by every material tier. +/// +/// Texture/sampler fields contain typed-ID raw values. The shader validates a +/// material generation before using the record and falls back to record zero +/// on stale/non-resident IDs. +#[repr(C, align(16))] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub struct GpuMaterialRecord { + /// x=generation, y=layered-PBR version/mask, z=user-param byte offset, + /// w=user-param size. `header.y` packs version in its high 8 bits and the + /// lobe mask in its low 24 bits. + pub header: [u32; 4], + pub base_color: [f32; 4], + pub metal_rough: [f32; 4], + pub emissive: [f32; 4], + pub shading_model: [f32; 4], + pub foliage_params: [f32; 4], + /// base-color, normal, metallic-roughness, emissive. + pub texture_ids_0: [u32; 4], + /// occlusion, planar reflection, albedo array/page, normal array/page. + pub texture_ids_1: [u32; 4], + /// MR array/page, reserved. + pub texture_ids_2: [u32; 4], + /// base, normal, MR, emissive sampler IDs. + pub sampler_ids_0: [u32; 4], + /// occlusion, reflection, array/page, reserved sampler IDs. + pub sampler_ids_1: [u32; 4], +} + +impl Default for GpuMaterialRecord { + fn default() -> Self { + Self { + header: [ + 0, + pack_global_material_metadata(MaterialLobeMask::NONE), + 0, + 0, + ], + base_color: [1.0, 1.0, 1.0, 1.0], + metal_rough: [0.0, 1.0, 0.0, 0.0], + emissive: [0.0; 4], + shading_model: [0.0, 1.0, 1.0, 1.0], + foliage_params: [0.5, 0.5, 0.0, 0.0], + texture_ids_0: [0; 4], + texture_ids_1: [0; 4], + texture_ids_2: [0; 4], + sampler_ids_0: [0; 4], + sampler_ids_1: [0; 4], + } + } +} + +impl GpuMaterialRecord { + pub(crate) fn layered_pbr_version(&self) -> u32 { + global_material_version(self.header[1]) + } + + pub(crate) fn layered_pbr_lobe_mask(&self) -> MaterialLobeMask { + global_material_lobe_mask(self.header[1]) + } + + fn normalize_layered_pbr_metadata(&mut self) { + let mask = if self.layered_pbr_version() + == crate::renderer::layered_pbr::MATERIAL_RECORD_VERSION + { + self.layered_pbr_lobe_mask() + } else { + // Version zero is the pre-layered record. Its old flags lane was + // unused, so no bit may silently acquire a lobe meaning. + MaterialLobeMask::NONE + }; + self.header[1] = pack_global_material_metadata(mask); + } +} + +struct GpuMaterialTable { + records: ResidencyTable, + fallback: GpuMaterialRecord, + buffer: wgpu::Buffer, + buffer_capacity: usize, + dirty: bool, + buffer_recreated: bool, +} + +impl GpuMaterialTable { + fn new(device: &wgpu::Device, max_records: usize) -> Self { + let capacity = INITIAL_MATERIAL_CAPACITY.min(max_records.max(1)); + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("global_material_records"), + size: (capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + Self { + records: ResidencyTable::new(max_records), + fallback: GpuMaterialRecord::default(), + buffer, + buffer_capacity: capacity, + dirty: true, + buffer_recreated: true, + } + } + + fn allocate( + &mut self, + device: &wgpu::Device, + mut record: GpuMaterialRecord, + ) -> Result { + record.normalize_layered_pbr_metadata(); + let id = self.records.insert(record)?; + self.ensure_capacity(device, id.descriptor_index() as usize + 1); + self.dirty = true; + Ok(id) + } + + fn update(&mut self, id: MaterialId, mut record: GpuMaterialRecord) -> ResolveStatus { + let (slot, status) = self.records.resolve_mut(id); + if let Some(slot) = slot { + record.normalize_layered_pbr_metadata(); + record.header[0] = id.generation(); + *slot = record; + self.dirty = true; + } + status + } + + fn retire(&mut self, id: MaterialId, completion_epoch: u64) -> bool { + let retired = self.records.retire(id, completion_epoch); + self.dirty |= retired; + retired + } + + fn collect(&mut self, completed_epoch: u64) -> usize { + let reclaimed = self.records.collect(completed_epoch); + self.dirty |= reclaimed > 0; + reclaimed + } + + fn ensure_capacity(&mut self, device: &wgpu::Device, required: usize) { + if required <= self.buffer_capacity { + return; + } + let max_capacity = self.records.max_live().saturating_add(1); + let capacity = required.next_power_of_two().min(max_capacity).max(required); + self.buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("global_material_records"), + size: (capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.buffer_capacity = capacity; + self.buffer_recreated = true; + self.dirty = true; + } + + fn flush(&mut self, queue: &wgpu::Queue) -> bool { + if !self.dirty { + return false; + } + let mut upload = vec![self.fallback; self.buffer_capacity]; + for (slot_index, slot) in self.records.slots().iter().enumerate() { + if !slot.live { + continue; + } + let Some(mut record) = slot.value else { + continue; + }; + record.header[0] = slot.generation; + upload[slot_index + 1] = record; + } + queue.write_buffer(&self.buffer, 0, bytemuck::cast_slice(&upload)); + self.dirty = false; + true + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum TextureColorSpace { + Srgb, + Linear, + HdrLinear, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum TextureSemantic { + BaseColor, + Normal, + MetallicRoughness, + Emissive, + Occlusion, + General, +} + +pub struct ResidentTexture { + pub view: wgpu::TextureView, + pub width: u32, + pub height: u32, + pub mip_count: u32, + pub color_space: TextureColorSpace, + pub semantic: TextureSemantic, + /// True when the view format performs the sRGB transfer in hardware. + /// False + `Srgb` asks the shared WGSL helper to decode explicitly. + pub hardware_srgb_decode: bool, + /// Only D2 float views can enter Tier A's `texture_2d` table. D2Array + /// resources still receive stable IDs for Tier B page records, but their + /// descriptor entry remains the safe fallback. + pub global_2d: bool, +} + +pub struct ResidentSampler { + pub sampler: wgpu::Sampler, +} + +#[derive(Clone, Debug, Default)] +pub struct ResidentMesh { + pub vertex_count: u32, + pub index_count: u32, +} + +#[derive(Clone, Debug, Default)] +pub struct ResidentBufferView { + pub byte_offset: u64, + pub byte_size: u64, +} + +#[derive(Clone, Debug)] +pub struct TierBMaterialTextures { + pub material: MaterialId, + pub textures: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TierBPage { + pub textures: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TierBDraw { + pub original_draw_index: usize, + pub material: MaterialId, + pub page: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TierBDispatchPlan { + pub pages: Vec, + pub draws: Vec, + pub page_switches: u32, + pub fallback_materials: Vec, +} + +/// Deterministic first-fit paging and stable grouping for Tier B. +/// +/// A material whose unique texture set exceeds the adapter page limit is sent +/// to the Tier C fallback. All other draws are stable-sorted by page, bounding +/// bind-group switches to the number of populated pages. +pub fn build_tier_b_dispatch_plan( + draws: &[TierBMaterialTextures], + page_capacity: u32, +) -> TierBDispatchPlan { + let capacity = page_capacity.max(1) as usize; + let mut pages: Vec> = Vec::new(); + let mut draw_pages = Vec::with_capacity(draws.len()); + let mut fallback_materials = Vec::new(); + + for (draw_index, draw) in draws.iter().enumerate() { + let needed: BTreeSet = draw + .textures + .iter() + .copied() + .filter(|id| !id.is_fallback()) + .collect(); + if needed.len() > capacity { + fallback_materials.push(draw.material); + draw_pages.push(TierBDraw { + original_draw_index: draw_index, + material: draw.material, + page: None, + }); + continue; + } + let page = pages + .iter() + .position(|resident| resident.union(&needed).count() <= capacity) + .unwrap_or_else(|| { + pages.push(BTreeSet::new()); + pages.len() - 1 + }); + pages[page].extend(needed); + draw_pages.push(TierBDraw { + original_draw_index: draw_index, + material: draw.material, + page: Some(page as u32), + }); + } + + draw_pages.sort_by_key(|draw| (draw.page.unwrap_or(u32::MAX), draw.original_draw_index)); + let page_switches = draw_pages + .iter() + .filter_map(|draw| draw.page) + .fold((None, 0u32), |(last, switches), page| { + if last == Some(page) { + (last, switches) + } else { + (Some(page), switches + 1) + } + }) + .1; + TierBDispatchPlan { + pages: pages + .into_iter() + .map(|textures| TierBPage { + textures: textures.into_iter().collect(), + }) + .collect(), + draws: draw_pages, + page_switches, + fallback_materials, + } +} + +/// All typed resource tables and optional Tier A global binding state. +pub struct MaterialIndirection { + pub capabilities: MaterialBindingCapabilities, + materials: GpuMaterialTable, + pub textures: ResidencyTable, + pub samplers: ResidencyTable, + pub meshes: ResidencyTable, + pub buffer_views: ResidencyTable, + completion: GpuCompletionTracker, + fallback_texture: Option, + fallback_sampler: Option, + resource_generations: wgpu::Buffer, + resource_generation_capacity: usize, + pub global_layout: Option, + pub global_bind_group: Option, + resources_dirty: bool, + stale_fallbacks: u64, + limit_fallbacks: u64, + last_tier_b_pages: u32, + last_tier_b_switches: u32, + last_tier_b_fallbacks: u32, +} + +impl MaterialIndirection { + pub fn new(device: &wgpu::Device) -> Self { + let capabilities = MaterialBindingCapabilities::detect(device.features(), &device.limits()); + let texture_capacity = match capabilities.detected_tier { + MaterialBindingTier::A => capabilities.tier_a_texture_capacity as usize, + _ => DEFAULT_RESOURCE_CAPACITY, + } + .max(1); + let sampler_capacity = match capabilities.detected_tier { + MaterialBindingTier::A => capabilities.tier_a_sampler_capacity as usize, + _ => 256, + } + .max(1); + let resource_generation_capacity = texture_capacity.max(sampler_capacity) + 1; + let resource_generations = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("global_resource_generations"), + size: (resource_generation_capacity * std::mem::size_of::<[u32; 4]>()) as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + Self { + materials: GpuMaterialTable::new(device, capabilities.max_material_records as usize), + textures: ResidencyTable::new(texture_capacity), + samplers: ResidencyTable::new(sampler_capacity), + meshes: ResidencyTable::new(DEFAULT_RESOURCE_CAPACITY), + buffer_views: ResidencyTable::new(DEFAULT_RESOURCE_CAPACITY * 2), + capabilities, + completion: GpuCompletionTracker::default(), + fallback_texture: None, + fallback_sampler: None, + resource_generations, + resource_generation_capacity, + global_layout: None, + global_bind_group: None, + resources_dirty: true, + stale_fallbacks: 0, + limit_fallbacks: 0, + last_tier_b_pages: 0, + last_tier_b_switches: 0, + last_tier_b_fallbacks: 0, + } + } + + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub(crate) fn active_layered_material_count(&self) -> usize { + self.materials + .records + .slots() + .iter() + .filter_map(|slot| if slot.live { slot.value.as_ref() } else { None }) + .filter(|record| !record.layered_pbr_lobe_mask().is_empty()) + .count() + } + + pub fn initialize_fallbacks( + &mut self, + device: &wgpu::Device, + texture: &wgpu::TextureView, + sampler: &wgpu::Sampler, + ) { + self.fallback_texture = Some(texture.clone()); + self.fallback_sampler = Some(sampler.clone()); + self.rebuild_layout(device); + self.resources_dirty = true; + } + + pub fn allocate_material( + &mut self, + device: &wgpu::Device, + record: GpuMaterialRecord, + ) -> MaterialId { + match self.materials.allocate(device, record) { + Ok(id) => id, + Err(error) => { + self.limit_fallbacks += 1; + crate::ffi::log_error(&format!( + "bloom: material indirection allocation failed ({error}); using diagnostic fallback" + )); + MaterialId::FALLBACK + } + } + } + + pub fn update_material(&mut self, id: MaterialId, record: GpuMaterialRecord) -> bool { + let status = self.materials.update(id, record); + if status != ResolveStatus::Resident { + self.stale_fallbacks += 1; + return false; + } + true + } + + pub fn register_texture(&mut self, texture: ResidentTexture) -> TextureId { + match self.textures.insert(texture) { + Ok(id) => { + self.resources_dirty = true; + id + } + Err(error) => { + self.limit_fallbacks += 1; + crate::ffi::log_error(&format!( + "bloom: global texture table allocation failed ({error}); using diagnostic fallback" + )); + TextureId::FALLBACK + } + } + } + + pub fn register_sampler(&mut self, sampler: wgpu::Sampler) -> SamplerId { + match self.samplers.insert(ResidentSampler { sampler }) { + Ok(id) => { + self.resources_dirty = true; + id + } + Err(error) => { + self.limit_fallbacks += 1; + crate::ffi::log_error(&format!( + "bloom: global sampler table allocation failed ({error}); using diagnostic fallback" + )); + SamplerId::FALLBACK + } + } + } + + pub fn register_mesh(&mut self, mesh: ResidentMesh) -> MeshId { + self.meshes.insert(mesh).unwrap_or_else(|_| { + self.limit_fallbacks += 1; + MeshId::FALLBACK + }) + } + + pub fn register_buffer_view(&mut self, view: ResidentBufferView) -> BufferViewId { + self.buffer_views.insert(view).unwrap_or_else(|_| { + self.limit_fallbacks += 1; + BufferViewId::FALLBACK + }) + } + + pub fn retire_texture(&mut self, queue: &wgpu::Queue, id: TextureId) -> bool { + let epoch = self.completion.track_submitted_work(queue); + let retired = self.textures.retire(id, epoch); + self.resources_dirty |= retired; + retired + } + + pub fn retire_sampler(&mut self, queue: &wgpu::Queue, id: SamplerId) -> bool { + let epoch = self.completion.track_submitted_work(queue); + let retired = self.samplers.retire(id, epoch); + self.resources_dirty |= retired; + retired + } + + /// Retire a standalone material record. Scene-graph materials may be + /// shared by thousands of nodes, so their cache owns only this ID rather + /// than the mesh/buffer-view IDs retired by `retire_cached_mesh`. + pub fn retire_material(&mut self, queue: &wgpu::Queue, id: MaterialId) -> bool { + let epoch = self.completion.track_submitted_work(queue); + self.materials.retire(id, epoch) + } + + pub fn retire_materials( + &mut self, + queue: &wgpu::Queue, + ids: impl IntoIterator, + ) -> usize { + let ids: Vec<_> = ids + .into_iter() + .filter(|id| *id != MaterialId::FALLBACK) + .collect(); + if ids.is_empty() { + return 0; + } + let epoch = self.completion.track_submitted_work(queue); + ids.into_iter() + .filter(|id| self.materials.retire(*id, epoch)) + .count() + } + + /// Retire the IDs owned by one cached mesh with a single queue callback. + pub fn retire_cached_mesh( + &mut self, + queue: &wgpu::Queue, + material: MaterialId, + mesh: MeshId, + buffer_views: &[BufferViewId], + ) { + let epoch = self.completion.track_submitted_work(queue); + self.materials.retire(material, epoch); + self.meshes.retire(mesh, epoch); + for &buffer_view in buffer_views { + self.buffer_views.retire(buffer_view, epoch); + } + } + + pub fn flush(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) { + let completed = self.completion.completed_epoch(); + self.materials.collect(completed); + let reclaimed = self.textures.collect(completed) + + self.samplers.collect(completed) + + self.meshes.collect(completed) + + self.buffer_views.collect(completed); + self.resources_dirty |= reclaimed > 0; + let material_buffer_changed = self.materials.buffer_recreated; + self.materials.flush(queue); + if self.resources_dirty { + self.flush_resource_generations(queue); + } + if self.capabilities.selected_tier == MaterialBindingTier::A + && (self.resources_dirty || material_buffer_changed) + { + self.rebuild_global_bind_group(device); + } + self.materials.buffer_recreated = false; + self.resources_dirty = false; + } + + /// Apply a debug override. Codes: 0=auto, 1=C, 2=B, 3=A. + /// Requests above the adapter's detected tier are rejected. + pub fn set_tier_override(&mut self, device: &wgpu::Device, code: u32) -> bool { + let requested = match code { + 0 => None, + 1 => Some(MaterialBindingTier::C), + 2 => Some(MaterialBindingTier::B), + 3 => Some(MaterialBindingTier::A), + _ => return false, + }; + let next = MaterialBindingCapabilities::detect_with_override( + device.features(), + &device.limits(), + requested, + ); + let accepted = requested.is_none() || next.override_tier == requested; + self.capabilities = next; + self.rebuild_layout(device); + self.resources_dirty = true; + accepted + } + + /// Plan and retain Tier B paging telemetry for a GPU-driven dispatch. + pub fn plan_tier_b_dispatch(&mut self, draws: &[TierBMaterialTextures]) -> TierBDispatchPlan { + let plan = build_tier_b_dispatch_plan(draws, self.capabilities.tier_b_page_capacity); + self.last_tier_b_pages = plan.pages.len() as u32; + self.last_tier_b_switches = plan.page_switches; + self.last_tier_b_fallbacks = plan.fallback_materials.len() as u32; + plan + } + + pub fn report_json(&self) -> String { + let mut out = self.capabilities.report_json(); + out.pop(); + out.push_str(",\"residency\":{\"materials\":"); + out.push_str(&self.materials.records.live_count().to_string()); + out.push_str(",\"textures\":"); + out.push_str(&self.textures.live_count().to_string()); + out.push_str(",\"samplers\":"); + out.push_str(&self.samplers.live_count().to_string()); + out.push_str(",\"meshes\":"); + out.push_str(&self.meshes.live_count().to_string()); + out.push_str(",\"buffer_views\":"); + out.push_str(&self.buffer_views.live_count().to_string()); + out.push_str(",\"stale_fallbacks\":"); + out.push_str(&self.stale_fallbacks.to_string()); + out.push_str(",\"limit_fallbacks\":"); + out.push_str(&self.limit_fallbacks.to_string()); + out.push_str("},\"dispatch\":{\"tier_a_per_material_bind_group_switches\":0"); + out.push_str(",\"tier_b_last_page_count\":"); + out.push_str(&self.last_tier_b_pages.to_string()); + out.push_str(",\"tier_b_last_page_switches\":"); + out.push_str(&self.last_tier_b_switches.to_string()); + out.push_str(",\"tier_b_last_fallback_materials\":"); + out.push_str(&self.last_tier_b_fallbacks.to_string()); + out.push_str("}}"); + out + } + + pub fn material_buffer(&self) -> &wgpu::Buffer { + &self.materials.buffer + } + + fn rebuild_layout(&mut self, device: &wgpu::Device) { + if self.capabilities.selected_tier != MaterialBindingTier::A { + self.global_layout = None; + self.global_bind_group = None; + return; + } + let texture_count = self.capabilities.tier_a_texture_capacity.saturating_add(1); + let sampler_count = self.capabilities.tier_a_sampler_capacity.saturating_add(1); + let Some(texture_count) = NonZeroU32::new(texture_count) else { + return; + }; + let Some(sampler_count) = NonZeroU32::new(sampler_count) else { + return; + }; + self.global_layout = Some(device.create_bind_group_layout( + &wgpu::BindGroupLayoutDescriptor { + label: Some("global_material_indirection_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT + | wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT | wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: Some(texture_count), + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT | wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: Some(sampler_count), + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT + | wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }, + )); + self.global_bind_group = None; + } + + fn rebuild_global_bind_group(&mut self, device: &wgpu::Device) { + let (Some(layout), Some(fallback_texture), Some(fallback_sampler)) = ( + self.global_layout.as_ref(), + self.fallback_texture.as_ref(), + self.fallback_sampler.as_ref(), + ) else { + return; + }; + let texture_count = self.capabilities.tier_a_texture_capacity as usize + 1; + let sampler_count = self.capabilities.tier_a_sampler_capacity as usize + 1; + let mut texture_refs = vec![fallback_texture; texture_count]; + for (slot_index, slot) in self.textures.slots().iter().enumerate() { + let descriptor_index = slot_index + 1; + if descriptor_index >= texture_count || !slot.live { + continue; + } + if let Some(texture) = slot.value.as_ref().filter(|texture| texture.global_2d) { + texture_refs[descriptor_index] = &texture.view; + } + } + let mut sampler_refs = vec![fallback_sampler; sampler_count]; + for (slot_index, slot) in self.samplers.slots().iter().enumerate() { + let descriptor_index = slot_index + 1; + if descriptor_index >= sampler_count || !slot.live { + continue; + } + if let Some(sampler) = slot.value.as_ref() { + sampler_refs[descriptor_index] = &sampler.sampler; + } + } + self.global_bind_group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("global_material_indirection_bind_group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.materials.buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureViewArray(&texture_refs), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::SamplerArray(&sampler_refs), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: self.resource_generations.as_entire_binding(), + }, + ], + })); + } + + fn flush_resource_generations(&self, queue: &wgpu::Queue) { + let mut generations = vec![[0u32; 4]; self.resource_generation_capacity]; + for (slot_index, slot) in self.textures.slots().iter().enumerate() { + let descriptor_index = slot_index + 1; + if descriptor_index >= generations.len() { + break; + } + generations[descriptor_index][0] = if slot.live { + slot.generation + } else { + (slot.generation + 1) & ID_GENERATION_MASK + }; + if let Some(texture) = slot.value.as_ref().filter(|_| slot.live) { + generations[descriptor_index][2] = match texture.color_space { + TextureColorSpace::Srgb => 1 | if texture.hardware_srgb_decode { 2 } else { 0 }, + TextureColorSpace::Linear => 0, + TextureColorSpace::HdrLinear => 4, + }; + generations[descriptor_index][3] = match texture.semantic { + TextureSemantic::BaseColor => 1, + TextureSemantic::Normal => 2, + TextureSemantic::MetallicRoughness => 3, + TextureSemantic::Emissive => 4, + TextureSemantic::Occlusion => 5, + TextureSemantic::General => 0, + }; + } + } + for (slot_index, slot) in self.samplers.slots().iter().enumerate() { + let descriptor_index = slot_index + 1; + if descriptor_index >= generations.len() { + break; + } + generations[descriptor_index][1] = if slot.live { + slot.generation + } else { + (slot.generation + 1) & ID_GENERATION_MASK + }; + } + queue.write_buffer( + &self.resource_generations, + 0, + bytemuck::cast_slice(&generations), + ); + } +} + +fn json_string(out: &mut String, value: &str) { + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c < ' ' => { + use std::fmt::Write; + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); +} + +#[cfg(test)] +mod tests { + use super::*; + use wgpu::util::DeviceExt; + + #[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] + struct TestId(u32); + + impl StableResourceId for TestId { + const FALLBACK: Self = Self(0); + + fn from_parts(slot: usize, generation: u32) -> Option { + let one_based = slot.checked_add(1)?; + (one_based <= ID_SLOT_MASK as usize && generation <= ID_GENERATION_MASK) + .then_some(Self((generation << ID_SLOT_BITS) | one_based as u32)) + } + + fn raw(self) -> u32 { + self.0 + } + } + + #[test] + fn typed_ids_are_generation_safe_and_zero_is_fallback() { + let mut table = ResidencyTable::::new(2); + let first = table.insert("first").unwrap(); + assert_eq!(first.raw(), 1); + assert_eq!( + table.resolve(first), + (Some(&"first"), ResolveStatus::Resident) + ); + assert!(table.retire(first, 7)); + assert_eq!(table.resolve(first), (None, ResolveStatus::Retiring)); + assert_eq!(table.collect(6), 0); + assert_eq!(table.insert("blocked"), Ok(TestId(2))); + assert_eq!(table.collect(7), 1); + let reused = table.insert("reused").unwrap(); + assert_eq!(reused.descriptor_index(), first.descriptor_index()); + assert_ne!(reused.generation(), first.generation()); + assert_eq!(table.resolve(first), (None, ResolveStatus::Stale)); + assert_eq!( + table.resolve(reused), + (Some(&"reused"), ResolveStatus::Resident) + ); + assert_eq!( + table.resolve(TestId::FALLBACK), + (None, ResolveStatus::Fallback) + ); + } + + #[test] + fn queue_tracker_never_completes_before_callback() { + let tracker = GpuCompletionTracker::default(); + assert_eq!(tracker.completed_epoch(), 0); + tracker.mark_complete_for_test(3); + assert_eq!(tracker.completed_epoch(), 3); + tracker.mark_complete_for_test(2); + assert_eq!(tracker.completed_epoch(), 3); + } + + #[test] + fn capability_selection_uses_features_and_limits_not_platform() { + let mut limits = wgpu::Limits::downlevel_defaults(); + limits.max_texture_array_layers = 256; + limits.max_sampled_textures_per_shader_stage = 16; + let tier_b = MaterialBindingCapabilities::detect_with_override( + wgpu::Features::empty(), + &limits, + None, + ); + assert_eq!(tier_b.detected_tier, MaterialBindingTier::B); + + limits.max_binding_array_elements_per_shader_stage = 500_000; + limits.max_binding_array_sampler_elements_per_shader_stage = 1_000; + let features = wgpu::Features::TEXTURE_BINDING_ARRAY + | wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING; + let tier_a = MaterialBindingCapabilities::detect_with_override(features, &limits, None); + assert_eq!(tier_a.detected_tier, MaterialBindingTier::A); + assert_eq!(tier_a.tier_a_texture_capacity, TIER_A_TARGET_TEXTURES); + + let forced_c = MaterialBindingCapabilities::detect_with_override( + features, + &limits, + Some(MaterialBindingTier::C), + ); + assert_eq!(forced_c.selected_tier, MaterialBindingTier::C); + + let rejected_a = MaterialBindingCapabilities::detect_with_override( + wgpu::Features::empty(), + &wgpu::Limits::downlevel_defaults(), + Some(MaterialBindingTier::A), + ); + assert_ne!(rejected_a.selected_tier, MaterialBindingTier::A); + assert!(rejected_a.diagnostic.is_some()); + } + + #[test] + fn tier_b_paging_is_deterministic_bounded_and_falls_back_safely() { + let material = |slot| MaterialId::from_parts(slot, 0).unwrap(); + let texture = |slot| TextureId::from_parts(slot, 0).unwrap(); + let draws = vec![ + TierBMaterialTextures { + material: material(0), + textures: vec![texture(0), texture(1)], + }, + TierBMaterialTextures { + material: material(1), + textures: vec![texture(1), texture(2)], + }, + TierBMaterialTextures { + material: material(2), + textures: vec![texture(3), texture(4)], + }, + TierBMaterialTextures { + material: material(3), + textures: vec![texture(5), texture(6), texture(7), texture(8)], + }, + ]; + let a = build_tier_b_dispatch_plan(&draws, 3); + let b = build_tier_b_dispatch_plan(&draws, 3); + assert_eq!(a, b); + assert_eq!(a.pages.len(), 2); + assert!(a.page_switches <= a.pages.len() as u32); + assert_eq!(a.fallback_materials, vec![material(3)]); + assert_eq!(a.draws.last().unwrap().page, None); + } + + #[test] + fn stress_4096_textures_and_10k_draws_remains_bounded() { + let textures: Vec<_> = (0..4_096) + .map(|slot| TextureId::from_parts(slot, 0).unwrap()) + .collect(); + let draws: Vec<_> = (0..10_000) + .map(|draw| { + let base = (draw * 4) % textures.len(); + TierBMaterialTextures { + material: MaterialId::from_parts(draw % 128, 0).unwrap(), + textures: vec![ + textures[base], + textures[(base + 1) % textures.len()], + textures[(base + 2) % textures.len()], + textures[(base + 3) % textures.len()], + ], + } + }) + .collect(); + let plan = build_tier_b_dispatch_plan(&draws, 256); + assert_eq!(plan.draws.len(), 10_000); + assert_eq!(plan.pages.len(), 16); + assert_eq!(plan.page_switches, 16); + assert!(plan.fallback_materials.is_empty()); + assert!(plan.pages.iter().all(|page| page.textures.len() <= 256)); + } + + #[test] + fn gpu_record_layout_is_storage_buffer_safe() { + assert_eq!(std::mem::align_of::(), 16); + assert_eq!(std::mem::size_of::(), 176); + let record = GpuMaterialRecord::default(); + assert_eq!(record.base_color, [1.0; 4]); + assert_eq!( + record.layered_pbr_version(), + crate::renderer::layered_pbr::MATERIAL_RECORD_VERSION + ); + assert!(record.layered_pbr_lobe_mask().is_empty()); + let mut legacy = GpuMaterialRecord::default(); + legacy.header[1] = u32::MAX; + legacy.normalize_layered_pbr_metadata(); + assert_eq!( + legacy.layered_pbr_version(), + crate::renderer::layered_pbr::MATERIAL_RECORD_VERSION + ); + assert!(legacy.layered_pbr_lobe_mask().is_empty()); + } + + #[cfg(not(target_arch = "wasm32"))] + fn try_tier_a_device() -> Option<(wgpu::Device, wgpu::Queue)> { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: None, + })) + .ok()?; + let supported = adapter.features(); + if !supported.contains(TIER_A_FEATURES) { + return None; + } + let adapter_limits = adapter.limits(); + let mut features = wgpu::Features::empty(); + let mut limits = wgpu::Limits::downlevel_defaults(); + request_tier_a_if_supported(supported, &adapter_limits, &mut features, &mut limits); + limits.max_storage_buffer_binding_size = adapter_limits + .max_storage_buffer_binding_size + .min(wgpu::Limits::default().max_storage_buffer_binding_size); + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("material_indirection_tier_a_test"), + required_features: features, + required_limits: limits, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + ..Default::default() + })) + .ok() + } + + #[cfg(not(target_arch = "wasm32"))] + fn render_global_material_pixel( + device: &wgpu::Device, + queue: &wgpu::Queue, + indirection: &MaterialIndirection, + material_id: MaterialId, + ) -> [u8; 4] { + let source = format!( + "{}\n\ + struct TestVertexOut {{ @builtin(position) position: vec4, }};\n\ + @vertex fn vs_main(@builtin(vertex_index) index: u32) -> TestVertexOut {{\n\ + var positions = array, 3>(\n\ + vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));\n\ + var out: TestVertexOut;\n\ + out.position = vec4(positions[index], 0.0, 1.0);\n\ + return out;\n\ + }}\n\ + @fragment fn fs_main() -> @location(0) vec4 {{\n\ + return bloom_sample_base_color(\n\ + bloom_material_record({}u), vec2(0.5, 0.5));\n\ + }}\n", + include_str!("../../shaders/material_indirection.wgsl"), + material_id.raw(), + ); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("material_indirection_tier_a_shader"), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("material_indirection_tier_a_pipeline_layout"), + bind_group_layouts: &[None, None, indirection.global_layout.as_ref()], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("material_indirection_tier_a_pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("material_indirection_tier_a_target"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let target_view = target.create_view(&Default::default()); + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("material_indirection_tier_a_readback"), + size: 256, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("material_indirection_tier_a_encoder"), + }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("material_indirection_tier_a_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &target_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&pipeline); + pass.set_bind_group( + 2, + indirection + .global_bind_group + .as_ref() + .expect("Tier A bind group"), + &[], + ); + pass.draw(0..3, 0..1); + } + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(256), + rows_per_image: Some(1), + }, + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + queue.submit(std::iter::once(encoder.finish())); + let slice = readback.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + let _ = tx.send(result); + }); + let _ = device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); + rx.recv().unwrap().unwrap(); + let mapped = slice.get_mapped_range(); + [mapped[0], mapped[1], mapped[2], mapped[3]] + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn tier_a_binds_4096_textures_decodes_srgb_and_rejects_reused_stale_id() { + let Some((device, queue)) = try_tier_a_device() else { + eprintln!("Tier A adapter unavailable; device-backed descriptor test skipped"); + return; + }; + let fallback_texture = device.create_texture_with_data( + &queue, + &wgpu::TextureDescriptor { + label: Some("material_indirection_fallback"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }, + wgpu::util::TextureDataOrder::LayerMajor, + &[255, 255, 255, 255], + ); + let fallback_view = fallback_texture.create_view(&Default::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor::default()); + let source_texture = device.create_texture_with_data( + &queue, + &wgpu::TextureDescriptor { + label: Some("material_indirection_source"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }, + wgpu::util::TextureDataOrder::LayerMajor, + &[128, 0, 0, 255], + ); + let source_view = source_texture.create_view(&Default::default()); + let mut indirection = MaterialIndirection::new(&device); + assert_eq!( + indirection.capabilities.selected_tier, + MaterialBindingTier::A + ); + indirection.initialize_fallbacks(&device, &fallback_view, &sampler); + let sampler_id = indirection.register_sampler(sampler.clone()); + let mut first_texture_id = TextureId::FALLBACK; + for texture_index in 0..TIER_A_TARGET_TEXTURES { + let id = indirection.register_texture(ResidentTexture { + view: source_view.clone(), + width: 1, + height: 1, + mip_count: 1, + color_space: TextureColorSpace::Srgb, + semantic: TextureSemantic::BaseColor, + hardware_srgb_decode: false, + global_2d: true, + }); + assert!(!id.is_fallback(), "texture {texture_index} fell back"); + if texture_index == 0 { + first_texture_id = id; + } + } + let mut record = GpuMaterialRecord::default(); + record.texture_ids_0[0] = first_texture_id.raw(); + record.sampler_ids_0[0] = sampler_id.raw(); + let material_id = indirection.allocate_material(&device, record); + indirection.flush(&device, &queue); + assert_eq!(indirection.textures.live_count(), 4_096); + let srgb_pixel = render_global_material_pixel(&device, &queue, &indirection, material_id); + assert!( + (53..=57).contains(&srgb_pixel[0]), + "manual sRGB decode drifted: {srgb_pixel:?}" + ); + assert_eq!(&srgb_pixel[1..], &[0, 0, 255]); + + assert!(indirection.retire_texture(&queue, first_texture_id)); + let _ = device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); + indirection.flush(&device, &queue); + let replacement = indirection.register_texture(ResidentTexture { + view: source_view, + width: 1, + height: 1, + mip_count: 1, + color_space: TextureColorSpace::Linear, + semantic: TextureSemantic::BaseColor, + hardware_srgb_decode: false, + global_2d: true, + }); + assert_eq!( + replacement.descriptor_index(), + first_texture_id.descriptor_index() + ); + assert_ne!(replacement.generation(), first_texture_id.generation()); + indirection.flush(&device, &queue); + let stale_pixel = render_global_material_pixel(&device, &queue, &indirection, material_id); + assert_eq!(stale_pixel, [255, 255, 255, 255]); + } +} diff --git a/native/shared/src/renderer/material_instancing.rs b/native/shared/src/renderer/material_instancing.rs index 132686f6..5bf9288a 100644 --- a/native/shared/src/renderer/material_instancing.rs +++ b/native/shared/src/renderer/material_instancing.rs @@ -3,7 +3,7 @@ //! draws per tile. Split from material_system.rs (2000-line file //! policy). -use super::material_system::{MaterialSystem, InstanceBuffer, InstanceTile}; +use super::material_system::{InstanceBuffer, InstanceTile, MaterialSystem}; impl MaterialSystem { /// EN-001 — create a persistent instance buffer from CPU-side @@ -37,10 +37,18 @@ impl MaterialSystem { let mut xz_max = [f32::MIN; 2]; for i in 0..count { let p = &raw[i * 9..i * 9 + 3]; - if p[0] < xz_min[0] { xz_min[0] = p[0]; } - if p[0] > xz_max[0] { xz_max[0] = p[0]; } - if p[2] < xz_min[1] { xz_min[1] = p[2]; } - if p[2] > xz_max[1] { xz_max[1] = p[2]; } + if p[0] < xz_min[0] { + xz_min[0] = p[0]; + } + if p[0] > xz_max[0] { + xz_max[0] = p[0]; + } + if p[2] < xz_min[1] { + xz_min[1] = p[2]; + } + if p[2] > xz_max[1] { + xz_max[1] = p[2]; + } } let grid = ((count as f32 / TILE_TARGET as f32).sqrt().ceil() as usize).max(1); let ext_x = (xz_max[0] - xz_min[0]).max(1e-3); @@ -57,17 +65,25 @@ impl MaterialSystem { while start < count { let cell = cell_of(order[start]); let mut end = start + 1; - while end < count && cell_of(order[end]) == cell { end += 1; } + while end < count && cell_of(order[end]) == cell { + end += 1; + } let mut pmin = [f32::MAX; 3]; let mut pmax = [f32::MIN; 3]; let mut max_scale = 0.0f32; for &i in &order[start..end] { let inst = &raw[i * 9..i * 9 + 9]; for a in 0..3 { - if inst[a] < pmin[a] { pmin[a] = inst[a]; } - if inst[a] > pmax[a] { pmax[a] = inst[a]; } + if inst[a] < pmin[a] { + pmin[a] = inst[a]; + } + if inst[a] > pmax[a] { + pmax[a] = inst[a]; + } + } + if inst[4].abs() > max_scale { + max_scale = inst[4].abs(); } - if inst[4].abs() > max_scale { max_scale = inst[4].abs(); } } tiles.push(InstanceTile { first: start as u32, @@ -83,11 +99,11 @@ impl MaterialSystem { let mut packed: Vec = Vec::with_capacity(count * 12); for &i in order.iter() { let off = i * 9; - packed.extend_from_slice(&raw[off..off + 3]); // pos.xyz - packed.push(raw[off + 3]); // rot_y - packed.push(raw[off + 4]); // scale + packed.extend_from_slice(&raw[off..off + 3]); // pos.xyz + packed.push(raw[off + 3]); // rot_y + packed.push(raw[off + 4]); // scale packed.extend_from_slice(&raw[off + 5..off + 9]); // tint.rgba - packed.extend_from_slice(&[0.0, 0.0, 0.0]); // pad to 48 bytes + packed.extend_from_slice(&[0.0, 0.0, 0.0]); // pad to 48 bytes } let size = (packed.len() * std::mem::size_of::()) as u64; // Empty buffers can't be created (size 0 is invalid in wgpu). @@ -119,11 +135,7 @@ impl MaterialSystem { /// and would have to be re-tiled (a sort) each time. Here the caller /// simply writes the live prefix of the buffer and draws that many /// instances. - pub fn create_dynamic_instance_buffer( - &mut self, - device: &wgpu::Device, - capacity: u32, - ) -> u32 { + pub fn create_dynamic_instance_buffer(&mut self, device: &wgpu::Device, capacity: u32) -> u32 { let size = (capacity.max(1) as u64) * 48; let buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("dynamic_instance_buffer"), @@ -150,11 +162,17 @@ impl MaterialSystem { packed: &[f32], count: u32, ) { - if handle == 0 || count == 0 { return; } + if handle == 0 || count == 0 { + return; + } let idx = handle as usize - 1; - let Some(Some(ib)) = self.instance_buffers.get(idx) else { return }; + let Some(Some(ib)) = self.instance_buffers.get(idx) else { + return; + }; let n = (count as usize).min(packed.len() / 12); - if n == 0 { return; } + if n == 0 { + return; + } queue.write_buffer(&ib.buffer, 0, bytemuck::cast_slice(&packed[..n * 12])); } @@ -162,7 +180,9 @@ impl MaterialSystem { /// `None` so previously-issued handles never alias a future /// allocation. No-op for `handle == 0` or out-of-range handles. pub fn destroy_instance_buffer(&mut self, handle: u32) { - if handle == 0 { return; } + if handle == 0 { + return; + } let idx = handle as usize - 1; if idx < self.instance_buffers.len() { self.instance_buffers[idx] = None; diff --git a/native/shared/src/renderer/material_pipeline.rs b/native/shared/src/renderer/material_pipeline.rs index bc1c4384..5aeab38e 100644 --- a/native/shared/src/renderer/material_pipeline.rs +++ b/native/shared/src/renderer/material_pipeline.rs @@ -11,7 +11,7 @@ // from user WGSL against the ABI. Draw dispatch, per-draw uniform // writes, and FFI glue all land in follow-up phases. -use super::shader_include::{BakedSource, IncludeError, process}; +use super::shader_include::{process, BakedSource, IncludeError}; // ===================================================================== // Bind-group layouts — one struct, five layouts, matching RFC §1 @@ -21,20 +21,20 @@ use super::shader_include::{BakedSource, IncludeError, process}; /// Owned by Renderer once per process (not per pipeline). Cheap to clone /// references since `wgpu::BindGroupLayout` is Arc'd internally. pub struct MaterialAbiLayouts { - pub per_frame: wgpu::BindGroupLayout, - pub per_view: wgpu::BindGroupLayout, - pub per_material: wgpu::BindGroupLayout, - pub per_draw: wgpu::BindGroupLayout, - pub scene_inputs: wgpu::BindGroupLayout, + pub per_frame: wgpu::BindGroupLayout, + pub per_view: wgpu::BindGroupLayout, + pub per_material: wgpu::BindGroupLayout, + pub per_draw: wgpu::BindGroupLayout, + pub scene_inputs: wgpu::BindGroupLayout, } impl MaterialAbiLayouts { pub fn create(device: &wgpu::Device) -> Self { Self { - per_frame: create_per_frame_layout(device), - per_view: create_per_view_layout(device), + per_frame: create_per_frame_layout(device), + per_view: create_per_view_layout(device), per_material: create_per_material_layout(device), - per_draw: create_per_draw_layout(device), + per_draw: create_per_draw_layout(device), scene_inputs: create_scene_inputs_layout(device), } } @@ -59,10 +59,10 @@ impl MaterialAbiLayouts { /// (`build_per_frame_bg_wasm` in material_system.rs). /// /// Native targets keep the five-group layout bit-identically. -#[cfg(target_arch = "wasm32")] +#[cfg(fold_scene_inputs)] pub const WASM_SCENE_INPUTS_BASE: u32 = 1; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(fold_scene_inputs))] fn create_per_frame_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("abi_per_frame"), @@ -85,20 +85,32 @@ fn create_per_frame_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { /// Every bind group created against this layout must supply all /// eight entries — see `build_per_frame_bg_wasm` in /// material_system.rs, the single creation site. -#[cfg(target_arch = "wasm32")] +#[cfg(fold_scene_inputs)] fn create_per_frame_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { const B: u32 = WASM_SCENE_INPUTS_BASE; device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("abi_per_frame"), entries: &[ entry_ubo(0, wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT), - entry_tex_f(B, wgpu::ShaderStages::FRAGMENT), - entry_samp(B + 1, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::Filtering), + entry_tex_f(B, wgpu::ShaderStages::FRAGMENT), + entry_samp( + B + 1, + wgpu::ShaderStages::FRAGMENT, + wgpu::SamplerBindingType::Filtering, + ), entry_tex_depth(B + 2, wgpu::ShaderStages::FRAGMENT), - entry_samp(B + 3, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::NonFiltering), + entry_samp( + B + 3, + wgpu::ShaderStages::FRAGMENT, + wgpu::SamplerBindingType::NonFiltering, + ), entry_tex_f_nonfilt(B + 4, wgpu::ShaderStages::FRAGMENT), - entry_samp(B + 5, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::NonFiltering), - entry_tex_f(B + 6, wgpu::ShaderStages::FRAGMENT), + entry_samp( + B + 5, + wgpu::ShaderStages::FRAGMENT, + wgpu::SamplerBindingType::NonFiltering, + ), + entry_tex_f(B + 6, wgpu::ShaderStages::FRAGMENT), ], }) } @@ -107,20 +119,58 @@ fn create_per_view_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { // Mirror of the ABI header: UBO at 0, env colour + sampler at 1+2, // env diffuse at 3, BRDF LUT + sampler at 4+5, three cascades at // 6..8, comparison sampler at 9. + let frag = wgpu::ShaderStages::FRAGMENT; + let mut entries = vec![ + entry_ubo(0, wgpu::ShaderStages::VERTEX | frag), + entry_tex_f(1, frag), + entry_samp(2, frag, wgpu::SamplerBindingType::Filtering), + entry_tex_f(3, frag), + entry_tex_f(4, frag), + entry_samp(5, frag, wgpu::SamplerBindingType::Filtering), + entry_tex_depth(6, frag), + entry_tex_depth(7, frag), + entry_tex_depth(8, frag), + entry_samp(9, frag, wgpu::SamplerBindingType::Comparison), + ]; + if crate::virtual_shadows::virtual_shadows_requested() { + entries.extend([ + wgpu::BindGroupLayoutEntry { + binding: 10, + visibility: frag, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Uint, + view_dimension: wgpu::TextureViewDimension::D2Array, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 11, + visibility: frag, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2Array, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 12, + visibility: frag, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: std::num::NonZeroU64::new( + std::mem::size_of::<[u32; 4]>() as u64 + ), + }, + count: None, + }, + ]); + } device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("abi_per_view"), - entries: &[ - entry_ubo(0, wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT), - entry_tex_f(1, wgpu::ShaderStages::FRAGMENT), - entry_samp(2, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::Filtering), - entry_tex_f(3, wgpu::ShaderStages::FRAGMENT), - entry_tex_f(4, wgpu::ShaderStages::FRAGMENT), - entry_samp(5, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::Filtering), - entry_tex_depth(6, wgpu::ShaderStages::FRAGMENT), - entry_tex_depth(7, wgpu::ShaderStages::FRAGMENT), - entry_tex_depth(8, wgpu::ShaderStages::FRAGMENT), - entry_samp(9, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::Comparison), - ], + entries: &entries, }) } @@ -139,26 +189,60 @@ fn create_per_material_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("abi_per_material"), entries: &[ - entry_tex_f(0, wgpu::ShaderStages::FRAGMENT), - entry_samp(1, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::Filtering), - entry_tex_f(2, wgpu::ShaderStages::FRAGMENT), - entry_samp(3, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::Filtering), - entry_tex_f(4, wgpu::ShaderStages::FRAGMENT), - entry_samp(5, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::Filtering), - entry_tex_f(6, wgpu::ShaderStages::FRAGMENT), - entry_samp(7, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::Filtering), - entry_tex_f(8, wgpu::ShaderStages::FRAGMENT), - entry_samp(9, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::Filtering), - entry_ubo(10, wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT), - entry_ubo(11, wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT), + entry_tex_f(0, wgpu::ShaderStages::FRAGMENT), + entry_samp( + 1, + wgpu::ShaderStages::FRAGMENT, + wgpu::SamplerBindingType::Filtering, + ), + entry_tex_f(2, wgpu::ShaderStages::FRAGMENT), + entry_samp( + 3, + wgpu::ShaderStages::FRAGMENT, + wgpu::SamplerBindingType::Filtering, + ), + entry_tex_f(4, wgpu::ShaderStages::FRAGMENT), + entry_samp( + 5, + wgpu::ShaderStages::FRAGMENT, + wgpu::SamplerBindingType::Filtering, + ), + entry_tex_f(6, wgpu::ShaderStages::FRAGMENT), + entry_samp( + 7, + wgpu::ShaderStages::FRAGMENT, + wgpu::SamplerBindingType::Filtering, + ), + entry_tex_f(8, wgpu::ShaderStages::FRAGMENT), + entry_samp( + 9, + wgpu::ShaderStages::FRAGMENT, + wgpu::SamplerBindingType::Filtering, + ), + entry_ubo( + 10, + wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ), + entry_ubo( + 11, + wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ), // EN-011 — planar reflection RT (texture + sampler). entry_tex_f(12, wgpu::ShaderStages::FRAGMENT), - entry_samp(13, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::Filtering), + entry_samp( + 13, + wgpu::ShaderStages::FRAGMENT, + wgpu::SamplerBindingType::Filtering, + ), // EN-014 — texture-array slots for splat-mapped terrain. entry_tex_f_array(14, wgpu::ShaderStages::FRAGMENT), entry_tex_f_array(15, wgpu::ShaderStages::FRAGMENT), entry_tex_f_array(16, wgpu::ShaderStages::FRAGMENT), - entry_samp(17, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::Filtering), + entry_samp( + 17, + wgpu::ShaderStages::FRAGMENT, + wgpu::SamplerBindingType::Filtering, + ), ], }) } @@ -168,7 +252,7 @@ fn create_per_draw_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { label: Some("abi_per_draw"), entries: &[ entry_ubo(0, wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT), - entry_ubo(1, wgpu::ShaderStages::VERTEX), // JointMatrices (1024 × mat4) + entry_ubo(1, wgpu::ShaderStages::VERTEX), // JointMatrices (1024 × mat4) ], }) } @@ -177,24 +261,37 @@ fn create_scene_inputs_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("abi_scene_inputs"), entries: &[ - entry_tex_f(0, wgpu::ShaderStages::FRAGMENT), - entry_samp(1, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::Filtering), + entry_tex_f(0, wgpu::ShaderStages::FRAGMENT), + entry_samp( + 1, + wgpu::ShaderStages::FRAGMENT, + wgpu::SamplerBindingType::Filtering, + ), entry_tex_depth(2, wgpu::ShaderStages::FRAGMENT), - entry_samp(3, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::NonFiltering), + entry_samp( + 3, + wgpu::ShaderStages::FRAGMENT, + wgpu::SamplerBindingType::NonFiltering, + ), // Phase 7 — impulse_tex is R32Float which is non-filterable // in wgpu 29 without a feature flag, so the binding is // declared NonFiltering. Materials sample via textureLoad // (no filtering; 0.5 m / texel is already coarse). entry_tex_f_nonfilt(4, wgpu::ShaderStages::FRAGMENT), - entry_samp(5, wgpu::ShaderStages::FRAGMENT, wgpu::SamplerBindingType::NonFiltering), - entry_tex_f(6, wgpu::ShaderStages::FRAGMENT), + entry_samp( + 5, + wgpu::ShaderStages::FRAGMENT, + wgpu::SamplerBindingType::NonFiltering, + ), + entry_tex_f(6, wgpu::ShaderStages::FRAGMENT), ], }) } fn entry_tex_f_nonfilt(binding: u32, vis: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry { wgpu::BindGroupLayoutEntry { - binding, visibility: vis, + binding, + visibility: vis, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D2, @@ -207,7 +304,8 @@ fn entry_tex_f_nonfilt(binding: u32, vis: wgpu::ShaderStages) -> wgpu::BindGroup // Small helpers for binding entry construction. fn entry_ubo(binding: u32, vis: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry { wgpu::BindGroupLayoutEntry { - binding, visibility: vis, + binding, + visibility: vis, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, @@ -218,7 +316,8 @@ fn entry_ubo(binding: u32, vis: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntr } fn entry_tex_f(binding: u32, vis: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry { wgpu::BindGroupLayoutEntry { - binding, visibility: vis, + binding, + visibility: vis, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, @@ -233,7 +332,8 @@ fn entry_tex_f(binding: u32, vis: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEn /// per-layer. fn entry_tex_f_array(binding: u32, vis: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry { wgpu::BindGroupLayoutEntry { - binding, visibility: vis, + binding, + visibility: vis, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2Array, @@ -244,7 +344,8 @@ fn entry_tex_f_array(binding: u32, vis: wgpu::ShaderStages) -> wgpu::BindGroupLa } fn entry_tex_depth(binding: u32, vis: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry { wgpu::BindGroupLayoutEntry { - binding, visibility: vis, + binding, + visibility: vis, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Depth, view_dimension: wgpu::TextureViewDimension::D2, @@ -253,10 +354,14 @@ fn entry_tex_depth(binding: u32, vis: wgpu::ShaderStages) -> wgpu::BindGroupLayo count: None, } } -fn entry_samp(binding: u32, vis: wgpu::ShaderStages, ty: wgpu::SamplerBindingType) - -> wgpu::BindGroupLayoutEntry { +fn entry_samp( + binding: u32, + vis: wgpu::ShaderStages, + ty: wgpu::SamplerBindingType, +) -> wgpu::BindGroupLayoutEntry { wgpu::BindGroupLayoutEntry { - binding, visibility: vis, + binding, + visibility: vis, ty: wgpu::BindingType::Sampler(ty), count: None, } @@ -315,7 +420,10 @@ impl Bucket { /// (single HDR attachment, alpha/additive blending) rather than /// the main HDR pass. pub fn is_translucent(self) -> bool { - matches!(self, Bucket::Transparent | Bucket::Refractive | Bucket::Additive) + matches!( + self, + Bucket::Transparent | Bucket::Refractive | Bucket::Additive + ) } /// True if this bucket requires a SceneColor snapshot before it /// runs. Only Refractive does today; future buckets (e.g. @@ -329,12 +437,47 @@ impl Bucket { // Material pipeline — the compiled artefact // ===================================================================== +struct OwnedVertexBufferLayout { + array_stride: wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode, + attributes: Vec, +} + +/// CPU-only recipe retained until a custom translucent material first shares +/// a TAA-reactive sorted pass with imported BLEND geometry. The sibling keeps +/// the material's exact shader/blend/depth contract while declaring the +/// reactive attachment with an empty write mask. Keeping source instead of an +/// eagerly compiled GPU pipeline avoids startup work for the ordinary and +/// unmixed paths; the recipe is dropped immediately after specialization. +struct TranslucentReactiveRecipe { + source: TranslucentReactiveSource, + pipeline_layout: wgpu::PipelineLayout, + vertex_buffers: Vec, + hdr_format: wgpu::TextureFormat, + depth_format: wgpu::TextureFormat, + bucket: Bucket, + label: String, + writes_reactive: bool, +} + +enum TranslucentReactiveSource { + /// General `compile_material` callers may supply an arbitrary include + /// overlay; retain its already validated expansion as the safe fallback. + Expanded(String), + /// The public custom-material API has one synthetic user source plus the + /// baked library. Retaining only the authored source avoids keeping a full + /// expanded shader per never-mixed translucent material. + User(String), +} + /// A pipeline ready to receive draws. Owns only the `RenderPipeline`; /// the layouts are borrowed from the shared `MaterialAbiLayouts`. pub struct MaterialPipeline { pub pipeline: wgpu::RenderPipeline, - pub profile: FragmentProfile, - pub bucket: Bucket, + pub(crate) reactive_pipeline: Option, + reactive_recipe: Option, + pub profile: FragmentProfile, + pub bucket: Bucket, pub reads_scene: bool, /// EN-001 — true when the pipeline was compiled with the /// per-instance vertex layout (slot 1, step_mode = Instance). The @@ -342,6 +485,9 @@ pub struct MaterialPipeline { /// what bind groups get bound, only which `set_vertex_buffer(1, …)` /// path runs for a given draw command. pub wants_instancing: bool, + /// The authored source exposes `fs_reactive`, so submitted translucent + /// draws can activate and write the lazy temporal-reactive attachment. + pub(crate) writes_reactive: bool, /// Label carried through for debug output. pub label: String, /// EN-011 V2 — sibling pipeline with front-face culling for use in @@ -359,24 +505,138 @@ pub struct MaterialPipeline { pub reflection_pipeline: Option, } +impl MaterialPipeline { + /// Lazily create the attachment-compatible sibling used only when this + /// custom material is globally interleaved with imported reactive BLEND. + /// + /// Ordinary custom shaders use `fs_main` with an empty location-1 write + /// mask. Opt-in responsive shaders use their authored `fs_reactive` entry + /// and union its coverage into the R8 attachment. + pub(crate) fn ensure_reactive_pipeline(&mut self, device: &wgpu::Device) -> bool { + if self.reactive_pipeline.is_some() { + return false; + } + let Some(recipe) = self.reactive_recipe.take() else { + return false; + }; + let source = match recipe.source { + TranslucentReactiveSource::Expanded(source) => source, + TranslucentReactiveSource::User(source) => { + expand_material_source("__user_material.wgsl", &[("__user_material.wgsl", &source)]) + .expect("custom material source was validated by its ordinary pipeline") + } + }; + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(&recipe.label), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + let vertex_buffers = recipe + .vertex_buffers + .iter() + .map(|layout| wgpu::VertexBufferLayout { + array_stride: layout.array_stride, + step_mode: layout.step_mode, + attributes: &layout.attributes, + }) + .collect::>(); + let additive_blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent::OVER, + }; + let color_blend = if recipe.bucket == Bucket::Additive { + additive_blend + } else { + wgpu::BlendState::ALPHA_BLENDING + }; + let reactive_target = if recipe.writes_reactive { + wgpu::ColorTargetState { + format: wgpu::TextureFormat::R8Unorm, + blend: Some(super::temporal_reactive::reactive_union_blend()), + write_mask: wgpu::ColorWrites::RED, + } + } else { + wgpu::ColorTargetState { + format: wgpu::TextureFormat::R8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::empty(), + } + }; + let targets = [ + Some(wgpu::ColorTargetState { + format: recipe.hdr_format, + blend: Some(color_blend), + write_mask: wgpu::ColorWrites::ALL, + }), + Some(reactive_target), + ]; + self.reactive_pipeline = Some(device.create_render_pipeline( + &wgpu::RenderPipelineDescriptor { + label: Some(&format!("{}_reactive_compatible", recipe.label)), + layout: Some(&recipe.pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &vertex_buffers, + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some(if recipe.writes_reactive { + "fs_reactive" + } else { + "fs_main" + }), + targets: &targets, + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: recipe.depth_format, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }, + )); + true + } +} + /// Options passed to compile a material. Matches the material-descriptor /// shape in RFC §3.1, minus the textures / parameters (those are game /// data, set per draw). pub struct MaterialCompileDesc<'a> { - pub label: &'a str, - pub entry_path: &'a str, // e.g. "materials/water.wgsl" + pub label: &'a str, + pub entry_path: &'a str, // e.g. "materials/water.wgsl" /// Additional (path, source) entries layered over the baked library. /// Game-supplied shaders live here. pub extra_sources: &'a [(&'a str, &'a str)], - pub profile: FragmentProfile, - pub bucket: Bucket, - pub reads_scene: bool, - pub hdr_format: wgpu::TextureFormat, + /// Compact source that can be re-expanded if a TAA-reactive mixed sorted + /// frame later needs an attachment-compatible sibling. `None` retains the + /// validated expanded source for general custom include overlays. + pub lazy_reactive_source: Option<&'a str>, + pub profile: FragmentProfile, + pub bucket: Bucket, + pub reads_scene: bool, + pub hdr_format: wgpu::TextureFormat, pub material_format: wgpu::TextureFormat, pub velocity_format: wgpu::TextureFormat, - pub albedo_format: wgpu::TextureFormat, - pub depth_format: wgpu::TextureFormat, - pub vertex_buffers: &'a [wgpu::VertexBufferLayout<'a>], + pub albedo_format: wgpu::TextureFormat, + pub depth_format: wgpu::TextureFormat, + pub vertex_buffers: &'a [wgpu::VertexBufferLayout<'a>], /// EN-001 — when true, `compile_material` appends /// `InstanceData3D::desc()` to `vertex_buffers` so the pipeline /// expects a second VB at slot 1 with step_mode = Instance. @@ -390,7 +650,142 @@ pub enum MaterialCompileError { Wgpu(String), } impl From for MaterialCompileError { - fn from(e: IncludeError) -> Self { MaterialCompileError::Include(e) } + fn from(e: IncludeError) -> Self { + MaterialCompileError::Include(e) + } +} + +fn expand_material_source( + entry_path: &str, + extra_sources: &[(&str, &str)], +) -> Result { + let mut entries: Vec<(&str, &str)> = BAKED_ENTRIES_SNAPSHOT.to_vec(); + entries.extend(extra_sources.iter().copied()); + let source = BakedSource { entries: &entries }; + let expanded = process(&source, entry_path)?; + let expanded = if crate::virtual_shadows::virtual_shadows_requested() { + crate::virtual_shadows::directional_material_shader(expanded) + } else { + expanded + }; + // Browser WebGPU and folded mobile tiers keep SceneInputs in group 0. + #[cfg(fold_scene_inputs)] + let expanded = rewrite_scene_inputs_for_wasm(expanded); + Ok(expanded) +} + +fn wgsl_tokens(source: &str) -> Vec<&str> { + let bytes = source.as_bytes(); + let mut tokens = Vec::new(); + let mut index = 0; + while index < bytes.len() { + if bytes[index].is_ascii_whitespace() { + index += 1; + } else if bytes[index..].starts_with(b"//") { + index += bytes[index..] + .iter() + .position(|byte| *byte == b'\n') + .unwrap_or(bytes.len() - index); + } else if bytes[index..].starts_with(b"/*") { + let mut depth = 1usize; + index += 2; + while index < bytes.len() && depth > 0 { + if bytes[index..].starts_with(b"/*") { + depth += 1; + index += 2; + } else if bytes[index..].starts_with(b"*/") { + depth -= 1; + index += 2; + } else { + index += 1; + } + } + } else if bytes[index].is_ascii_alphabetic() || bytes[index] == b'_' { + let start = index; + index += 1; + while index < bytes.len() + && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') + { + index += 1; + } + tokens.push(&source[start..index]); + } else if bytes[index..].starts_with(b"->") { + tokens.push(&source[index..index + 2]); + index += 2; + } else if !bytes[index].is_ascii() { + index += source[index..] + .chars() + .next() + .expect("index is inside source") + .len_utf8(); + } else { + tokens.push(&source[index..index + 1]); + index += 1; + } + } + tokens +} + +fn declares_reactive_fragment(source: &str) -> Result { + if !source.contains("fs_reactive") { + return Ok(false); + } + let tokens = wgsl_tokens(source); + for function in tokens + .windows(2) + .enumerate() + .filter_map(|(index, pair)| (pair == ["fn", "fs_reactive"]).then_some(index)) + { + let declaration_start = tokens[..function] + .iter() + .rposition(|token| matches!(*token, "}" | ";")) + .map_or(0, |index| index + 1); + let is_fragment = tokens[declaration_start..function] + .windows(2) + .any(|pair| pair == ["@", "fragment"]); + if !is_fragment { + continue; + } + let Some(open) = tokens[function + 2..] + .iter() + .position(|token| *token == "(") + .map(|index| function + 2 + index) + else { + return Err(MaterialCompileError::Naga( + "fs_reactive has no parameter list".to_owned(), + )); + }; + let mut depth = 0usize; + let close = tokens[open..] + .iter() + .enumerate() + .find_map(|(offset, token)| { + match *token { + "(" => depth += 1, + ")" => { + depth -= 1; + if depth == 0 { + return Some(open + offset); + } + } + _ => {} + } + None + }); + let valid_result = close.is_some_and(|close| { + tokens.get(close + 1) == Some(&"->") + && tokens.get(close + 2) == Some(&"ReactiveTranslucentOut") + }); + if !valid_result { + return Err(MaterialCompileError::Naga( + "fs_reactive must return ReactiveTranslucentOut with @location(0) HDR and \ + @location(1) f32 reactive coverage" + .to_owned(), + )); + } + return Ok(true); + } + Ok(false) } /// Compile a material pipeline. This is the happy-path you call at @@ -402,30 +797,15 @@ pub fn compile_material( ) -> Result { // 1. Resolve #include chain against the baked library + // game-supplied overlay. - let baked_entries = BAKED_ENTRIES_SNAPSHOT; // from library snapshot below - let mut entries: Vec<(&str, &str)> = baked_entries.to_vec(); - for &(p, s) in desc.extra_sources { - entries.push((p, s)); - } - let source = BakedSource { entries: &entries }; - let expanded = process(&source, desc.entry_path)?; - - // EN-063 — WebGPU in the browser caps maxBindGroups at 4, so no - // shader module may declare group 4 on wasm32. Rewrite the seven - // engine-owned SceneInputs declarations from material_abi.wgsl - // into the per_frame group (group 0) at WASM_SCENE_INPUTS_BASE..+6. - // String replacement is safe here: the declarations exist only in - // engine-owned material_abi.wgsl with exactly this formatting. - #[cfg(target_arch = "wasm32")] - let expanded = rewrite_scene_inputs_for_wasm(expanded); + let expanded = expand_material_source(desc.entry_path, desc.extra_sources)?; // 2. Create shader module. wgpu's WGSL parser surfaces errors as - // panics through the default handler; we catch them by - // pushing the scope and popping on failure. + // panics through the default handler; we catch them by pushing + // the scope and popping on failure. let _ = device.push_error_scope(wgpu::ErrorFilter::Validation); let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some(desc.label), - source: wgpu::ShaderSource::Wgsl(expanded.into()), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(expanded.as_str())), }); // Note: we don't poll the error scope here because wgpu 29 returns // validation errors synchronously via the device's uncaptured-error @@ -442,7 +822,7 @@ pub fn compile_material( // EN-063 — on wasm32 the scene inputs live inside per_frame // (group 0, bindings WASM_SCENE_INPUTS_BASE..+6), so the pipeline // layout stays at 4 groups: the browser caps maxBindGroups at 4. - if desc.reads_scene && cfg!(not(target_arch = "wasm32")) { + if desc.reads_scene && cfg!(not(fold_scene_inputs)) { bg_layouts.push(Some(&layouts.scene_inputs)); } let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { @@ -452,6 +832,32 @@ pub fn compile_material( }); // 4. Colour targets based on profile. + // SH-055 — `lean_mrt` (Android): drop the `material` and `albedo` targets + // to `None`. Both are unread on Android (material only feeds SSR/PT, + // both off/unavailable there; albedo only feeds SSGI-modulation and + // SSAO-alpha-weighting in scene-compose, also both off), and dropping + // them from main_hdr_pass's simultaneous render-target set is what + // actually addresses the Adreno GMEM-overflow cost (see build.rs). The + // material's WGSL is unchanged — `out.material`/`out.albedo` writes are + // silently discarded with no backing attachment (wgpu-core requires the + // pipeline target and the render-pass attachment to agree index-for-index + // on `None`; scene_pass.rs's main_hdr_pass color_attachments mirrors this). + #[cfg(lean_mrt)] + let opaque_targets = [ + Some(wgpu::ColorTargetState { + format: desc.hdr_format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + None, + Some(wgpu::ColorTargetState { + format: desc.velocity_format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + None, + ]; + #[cfg(not(lean_mrt))] let opaque_targets = [ Some(wgpu::ColorTargetState { format: desc.hdr_format, @@ -481,13 +887,13 @@ pub fn compile_material( color: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::One, dst_factor: wgpu::BlendFactor::One, - operation: wgpu::BlendOperation::Add, + operation: wgpu::BlendOperation::Add, }, alpha: wgpu::BlendComponent::OVER, }; let translucent_blend = match desc.bucket { Bucket::Additive => additive_blend, - _ => wgpu::BlendState::ALPHA_BLENDING, + _ => wgpu::BlendState::ALPHA_BLENDING, }; let translucent_targets = [Some(wgpu::ColorTargetState { format: desc.hdr_format, @@ -495,7 +901,7 @@ pub fn compile_material( write_mask: wgpu::ColorWrites::ALL, })]; let targets: &[Option] = match desc.profile { - FragmentProfile::Opaque => &opaque_targets, + FragmentProfile::Opaque => &opaque_targets, FragmentProfile::Translucent => &translucent_targets, }; @@ -516,23 +922,39 @@ pub fn compile_material( // InstanceData3D layout so the pipeline expects a second VB at // slot 1 (step_mode = Instance). The owned Vec only lives long // enough to be referenced by the RenderPipelineDescriptor. + let writes_reactive = matches!(desc.profile, FragmentProfile::Translucent) + && declares_reactive_fragment(&expanded)?; let vertex_buffers_owned: Vec>; let vertex_buffers: &[wgpu::VertexBufferLayout<'_>] = if desc.wants_instancing { - vertex_buffers_owned = desc.vertex_buffers + vertex_buffers_owned = desc + .vertex_buffers .iter() .cloned() - .chain(std::iter::once(crate::renderer::types::InstanceData3D::desc())) + .chain(std::iter::once( + crate::renderer::types::InstanceData3D::desc(), + )) .collect(); &vertex_buffers_owned } else { desc.vertex_buffers }; + let reactive_vertex_buffers = matches!(desc.profile, FragmentProfile::Translucent).then(|| { + vertex_buffers + .iter() + .map(|layout| OwnedVertexBufferLayout { + array_stride: layout.array_stride, + step_mode: layout.step_mode, + attributes: layout.attributes.to_vec(), + }) + .collect::>() + }); // Translucent materials (water, glass, particles) are commonly // viewed from both sides, so they render double-sided. Cutout // materials (foliage cards, chain-link fences) likewise need to // be visible from both faces. Plain Opaque materials cull backfaces. let main_cull = if matches!(desc.profile, FragmentProfile::Translucent) - || matches!(desc.bucket, Bucket::Cutout) { + || matches!(desc.bucket, Bucket::Cutout) + { None } else { Some(wgpu::Face::Back) @@ -585,18 +1007,37 @@ pub fn compile_material( // than lazily at `set_reflection_probe` time so we never need to // stash the WGSL source for a later recompile. let reflection_pipeline = match main_cull { - Some(wgpu::Face::Back) => Some(make_pipeline(Some(wgpu::Face::Front), "_reflection")), - Some(wgpu::Face::Front) => Some(make_pipeline(Some(wgpu::Face::Back), "_reflection")), - None => None, + Some(wgpu::Face::Back) => Some(make_pipeline(Some(wgpu::Face::Front), "_reflection")), + Some(wgpu::Face::Front) => Some(make_pipeline(Some(wgpu::Face::Back), "_reflection")), + None => None, }; + let reactive_recipe = reactive_vertex_buffers.map(|vertex_buffers| { + let source = match desc.lazy_reactive_source { + Some(source) => TranslucentReactiveSource::User(source.to_string()), + None => TranslucentReactiveSource::Expanded(expanded), + }; + TranslucentReactiveRecipe { + source, + pipeline_layout, + vertex_buffers, + hdr_format: desc.hdr_format, + depth_format: desc.depth_format, + bucket: desc.bucket, + label: desc.label.to_string(), + writes_reactive, + } + }); Ok(MaterialPipeline { pipeline, - profile: desc.profile, - bucket: desc.bucket, - reads_scene: desc.reads_scene, + reactive_pipeline: None, + reactive_recipe, + profile: desc.profile, + bucket: desc.bucket, + reads_scene: desc.reads_scene, wants_instancing: desc.wants_instancing, - label: desc.label.to_string(), + writes_reactive, + label: desc.label.to_string(), reflection_pipeline, }) } @@ -611,14 +1052,14 @@ pub fn compile_material( /// group 4 at all. Materials that don't use the scene inputs simply /// leave the group-0 bindings statically unused, which wgpu ignores /// at pipeline-layout validation exactly as it did for group 4. -#[cfg(target_arch = "wasm32")] +#[cfg(fold_scene_inputs)] fn rewrite_scene_inputs_for_wasm(expanded: String) -> String { let had_group4 = expanded.contains("@group(4)"); let mut out = expanded; let mut replaced: u32 = 0; for n in 0..7u32 { let from = format!("@group(4) @binding({n})"); - let to = format!("@group(0) @binding({})", WASM_SCENE_INPUTS_BASE + n); + let to = format!("@group(0) @binding({})", WASM_SCENE_INPUTS_BASE + n); replaced += out.matches(from.as_str()).count() as u32; out = out.replace(from.as_str(), to.as_str()); } @@ -647,14 +1088,38 @@ fn rewrite_scene_inputs_for_wasm(expanded: String) -> String { // library contents here; kept in sync by a test. const BAKED_ENTRIES_SNAPSHOT: &[(&str, &str)] = &[ - ("material_abi.wgsl", include_str!("../../../shared/shaders/material_abi.wgsl")), - ("common/pbr.wgsl", include_str!("../../../shared/shaders/common/pbr.wgsl")), - ("common/shadows.wgsl", include_str!("../../../shared/shaders/common/shadows.wgsl")), - ("common/fog.wgsl", include_str!("../../../shared/shaders/common/fog.wgsl")), - ("common/tonemap.wgsl", include_str!("../../../shared/shaders/common/tonemap.wgsl")), - ("common/sky.wgsl", include_str!("../../../shared/shaders/common/sky.wgsl")), - ("common/clouds.wgsl", include_str!("../../../shared/shaders/common/clouds.wgsl")), - ("materials/test_minimal.wgsl", include_str!("../../../shared/shaders/materials/test_minimal.wgsl")), + ( + "material_abi.wgsl", + include_str!("../../../shared/shaders/material_abi.wgsl"), + ), + ( + "common/pbr.wgsl", + include_str!("../../../shared/shaders/common/pbr.wgsl"), + ), + ( + "common/shadows.wgsl", + include_str!("../../../shared/shaders/common/shadows.wgsl"), + ), + ( + "common/fog.wgsl", + include_str!("../../../shared/shaders/common/fog.wgsl"), + ), + ( + "common/tonemap.wgsl", + include_str!("../../../shared/shaders/common/tonemap.wgsl"), + ), + ( + "common/sky.wgsl", + include_str!("../../../shared/shaders/common/sky.wgsl"), + ), + ( + "common/clouds.wgsl", + include_str!("../../../shared/shaders/common/clouds.wgsl"), + ), + ( + "materials/test_minimal.wgsl", + include_str!("../../../shared/shaders/materials/test_minimal.wgsl"), + ), ]; #[cfg(test)] @@ -670,7 +1135,8 @@ mod tests { use super::super::shader_library; let lib = shader_library::library(); for (path, body) in BAKED_ENTRIES_SNAPSHOT { - let from_lib = lib.fetch(path) + let from_lib = lib + .fetch(path) .unwrap_or_else(|| panic!("snapshot includes '{}' not in library", path)); assert_eq!(*body, from_lib, "mismatch for {}", path); } @@ -688,13 +1154,73 @@ mod tests { /// exercised when `compile_material` runs at application startup. #[test] fn test_minimal_parses_through_naga() { - let source = BakedSource { entries: BAKED_ENTRIES_SNAPSHOT }; + let source = BakedSource { + entries: BAKED_ENTRIES_SNAPSHOT, + }; let expanded = process(&source, "materials/test_minimal.wgsl") .expect("preprocessor resolves test_minimal.wgsl"); let result = wgpu::naga::front::wgsl::parse_str(&expanded); if let Err(ref e) = result { eprintln!("naga parse error:\n{}", e.emit_to_string(&expanded)); } - assert!(result.is_ok(), "test_minimal.wgsl should parse via naga after include expansion"); + assert!( + result.is_ok(), + "test_minimal.wgsl should parse via naga after include expansion" + ); + } + + #[test] + fn reactive_fragment_detection_requires_the_named_fragment_entry() { + let ordinary = " + @fragment + fn fs_main() -> @location(0) vec4 { + return vec4(1.0); + } + "; + assert!(!declares_reactive_fragment(ordinary).unwrap()); + + let helper_only = " + // @fragment fn fs_reactive() -> ReactiveTranslucentOut {} + fn fs_reactive() -> f32 { + return 1.0; + } + @fragment + fn fs_main() -> @location(0) vec4 { + return vec4(1.0); + } + "; + assert!(!declares_reactive_fragment(helper_only).unwrap()); + + let responsive = " + struct ReactiveTranslucentOut { + @location(0) hdr: vec4, + @location(1) reactive: f32, + }; + @fragment + fn fs_main() -> @location(0) vec4 { + return vec4(1.0); + } + @fragment + fn fs_reactive() -> ReactiveTranslucentOut { + return ReactiveTranslucentOut(vec4(1.0), 1.0); + } + "; + assert!(declares_reactive_fragment(responsive).unwrap()); + + let malformed = " + struct Out { + @location(0) hdr: vec4, + @location(1) reactive: vec4, + }; + @fragment + fn fs_reactive() -> Out { + return Out(vec4(1.0), vec4(1.0)); + } + "; + assert!(matches!( + declares_reactive_fragment(malformed), + Err(MaterialCompileError::Naga(message)) + if message.contains("must return ReactiveTranslucentOut") + )); } } diff --git a/native/shared/src/renderer/material_system.rs b/native/shared/src/renderer/material_system.rs index fb6604de..e781a512 100644 --- a/native/shared/src/renderer/material_system.rs +++ b/native/shared/src/renderer/material_system.rs @@ -10,12 +10,18 @@ use wgpu::util::DeviceExt; +use super::layered_pbr::{ + bound_material_lobe_mask_lane, bound_material_version_lane, MaterialLobeMask, +}; +use super::material_indirection::{GpuMaterialRecord, MaterialId, MaterialIndirection, TextureId}; use super::material_pipeline::{ - MaterialAbiLayouts, MaterialPipeline, MaterialCompileDesc, FragmentProfile, - Bucket, compile_material, MaterialCompileError, + compile_material, Bucket, FragmentProfile, MaterialAbiLayouts, MaterialCompileDesc, + MaterialCompileError, MaterialPipeline, }; use super::types::Vertex3D; +mod texture_arrays; + // ===================================================================== // Uniform structs — repr(C), bytemuck-Pod, mirror the WGSL in // material_abi.wgsl. Kept local to this module so changes to ABI @@ -25,94 +31,100 @@ use super::types::Vertex3D; #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct PerFrameUniforms { - pub time: f32, - pub delta_time: f32, - pub frame_index: u32, - pub _pad0: u32, + pub time: f32, + pub delta_time: f32, + pub frame_index: u32, + pub _pad0: u32, pub screen_resolution: [f32; 2], pub render_resolution: [f32; 2], - pub taa_jitter: [f32; 2], - pub _pad1: [f32; 2], + pub taa_jitter: [f32; 2], + pub _pad1: [f32; 2], /// Global wind: x=dir_x, y=dir_z, z=amplitude, w=frequency. - pub wind: [f32; 4], + pub wind: [f32; 4], /// Cloud deck: x = shadow strength, y = deck height (m), z = feature scale, /// w = drift speed (m/s). Materials feed this to `cloud_shadow_at` from /// common/clouds.wgsl — the same deck the sky pass draws. - pub cloud: [f32; 4], + pub cloud: [f32; 4], } #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct PerViewDirLight { - pub direction: [f32; 4], // xyz + intensity - pub color: [f32; 4], + pub direction: [f32; 4], // xyz + intensity + pub color: [f32; 4], } #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct PerViewPointLight { pub position: [f32; 4], - pub color: [f32; 4], + pub color: [f32; 4], } #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct PerViewUniforms { - pub view: [[f32; 4]; 4], - pub proj: [[f32; 4]; 4], - pub view_proj: [[f32; 4]; 4], + pub view: [[f32; 4]; 4], + pub proj: [[f32; 4]; 4], + pub view_proj: [[f32; 4]; 4], pub prev_view_proj: [[f32; 4]; 4], - pub inv_proj: [[f32; 4]; 4], - pub camera_pos: [f32; 4], - pub camera_dir: [f32; 4], - pub ambient: [f32; 4], - pub fog: [f32; 4], - pub sun_dir: [f32; 4], - pub sun_color: [f32; 4], - pub dir_light_count: [f32; 4], - pub dir_lights: [PerViewDirLight; 8], + pub inv_proj: [[f32; 4]; 4], + pub camera_pos: [f32; 4], + pub camera_dir: [f32; 4], + pub ambient: [f32; 4], + pub fog: [f32; 4], + pub sun_dir: [f32; 4], + pub sun_color: [f32; 4], + pub dir_light_count: [f32; 4], + pub dir_lights: [PerViewDirLight; 8], pub point_light_count: [f32; 4], - pub point_lights: [PerViewPointLight; 256], - pub shadow_splits: [f32; 4], - pub shadow_view: [[f32; 4]; 4], + pub point_lights: [PerViewPointLight; 256], + pub shadow_splits: [f32; 4], + pub shadow_view: [[f32; 4]; 4], pub shadow_cascades: [[[f32; 4]; 4]; 3], } #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct MaterialFactorsUniforms { - pub metal_rough: [f32; 4], - pub emissive: [f32; 4], - pub base_color: [f32; 4], + pub metal_rough: [f32; 4], + pub emissive: [f32; 4], + pub base_color: [f32; 4], /// EN-012 — shading-model selector + foliage transmission tint. /// x = shading_model enum (0 = default lit, 1 = foliage, /// 2 = subsurface — V2 stub), /// yzw = transmission_color (rgb tint for back-lit foliage; ignored /// when shading_model == 0). - pub shading_model: [f32; 4], + pub shading_model: [f32; 4], /// EN-012 — foliage shading parameters. Only consumed when /// `shading_model.x == 1.0`. /// x = transmission_amount (0..1), /// y = wrap_factor (0..1), - /// zw = reserved. + /// z = layered-PBR record version bits, w = lobe-mask bits. Stored with + /// `f32::from_bits` so the 80-byte legacy/custom UBO stays unchanged. pub foliage_params: [f32; 4], } impl Default for MaterialFactorsUniforms { fn default() -> Self { Self { - metal_rough: [0.0, 1.0, 0.0, 0.0], // non-metal, rough, no MR tex, no cutoff - emissive: [0.0, 0.0, 0.0, 0.0], - base_color: [1.0, 1.0, 1.0, 1.0], + metal_rough: [0.0, 1.0, 0.0, 0.0], // non-metal, rough, no MR tex, no cutoff + emissive: [0.0, 0.0, 0.0, 0.0], + base_color: [1.0, 1.0, 1.0, 1.0], // EN-012 — default lit, white transmission tint. Materials // that never call `setMaterialShadingModel` get standard PBR // (shading_model.x == 0.0). - shading_model: [0.0, 1.0, 1.0, 1.0], + shading_model: [0.0, 1.0, 1.0, 1.0], // EN-012 — moderate defaults so a freshly-flagged foliage // material (shading_model = 1) looks reasonable before any // tuning. Wrap=0.5 + transmission=0.5 gives soft back-face // shading + a noticeable halo against the sun. - foliage_params: [0.5, 0.5, 0.0, 0.0], + foliage_params: [ + 0.5, + 0.5, + bound_material_version_lane(), + bound_material_lobe_mask_lane(MaterialLobeMask::NONE), + ], } } } @@ -120,11 +132,11 @@ impl Default for MaterialFactorsUniforms { #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct PerDrawUniforms { - pub mvp: [[f32; 4]; 4], - pub model: [[f32; 4]; 4], - pub prev_mvp: [[f32; 4]; 4], + pub mvp: [[f32; 4]; 4], + pub model: [[f32; 4]; 4], + pub prev_mvp: [[f32; 4]; 4], pub model_tint: [f32; 4], - pub skin_info: [u32; 4], + pub skin_info: [u32; 4], } // ===================================================================== @@ -134,22 +146,22 @@ pub struct PerDrawUniforms { pub type MaterialHandle = u32; pub struct MaterialDrawCommand { - pub material: MaterialHandle, - pub mesh_handle: u64, // matches model_gpu_cache keys - pub mesh_idx: usize, // sub-mesh index within that cached model - pub draw_slot: usize, // which slot in per_draw_buffers to bind + pub material: MaterialHandle, + pub mesh_handle: u64, // matches model_gpu_cache keys + pub mesh_idx: usize, // sub-mesh index within that cached model + pub draw_slot: usize, // which slot in per_draw_buffers to bind /// Clip-space w of the object pivot at submit time (= view-space /// depth for a standard projection). Drives the back-to-front sort /// of the translucent bucket; opaque draws ignore it. - pub view_depth: f32, + pub view_depth: f32, /// EN-001 — when set, the engine binds vertex slot 1 to this /// instance buffer and emits draw_indexed with `0..count` instances. /// `None` means a single-instance draw (the legacy path). - pub instance: Option, + pub instance: Option, /// CPU-side copy of the model matrix (the GPU one lives in the /// per-draw UBO slot). The shadow pass needs it to re-render these /// draws into the sun cascades without reading buffers back. - pub model: [[f32; 4]; 4], + pub model: [[f32; 4]; 4], } /// Reference to an instance buffer for an instanced draw command. @@ -160,7 +172,7 @@ pub struct MaterialDrawCommand { #[derive(Copy, Clone, Debug)] pub struct InstanceDrawInfo { pub buffer_handle: u32, - pub count: u32, + pub count: u32, } /// EN-001 — owned wgpu::Buffer + element count for an instance buffer. @@ -168,7 +180,7 @@ pub struct InstanceDrawInfo { /// by 1-based handle (0 = invalid). pub struct InstanceBuffer { pub buffer: wgpu::Buffer, - pub count: u32, + pub count: u32, /// Spatial tiles over the instances (built at creation by /// reordering instances into an XZ grid). Empty = untiled (small /// buffers). Opaque/cutout instanced draws use these to @@ -198,8 +210,8 @@ pub struct InstanceTile { /// view over all `layer_count` layers (so `textureSample(arr, samp, /// uv, layer_idx)` resolves layers 0..layer_count-1). pub struct TextureArray { - pub texture: wgpu::Texture, - pub view: wgpu::TextureView, + pub texture: wgpu::Texture, + pub view: wgpu::TextureView, pub layer_count: u32, } @@ -211,7 +223,7 @@ pub const MAX_TEXTURE_ARRAY_LAYERS: u32 = 16; /// EN-014 V2 — texture-array format codes, as exposed to the TS API /// via `TEX_ARRAY_FORMAT_*`. Anything unrecognised falls back to sRGB /// since albedo is the most common splat layer. -pub const TEX_ARRAY_FORMAT_SRGB: u32 = 0; +pub const TEX_ARRAY_FORMAT_SRGB: u32 = 0; pub const TEX_ARRAY_FORMAT_LINEAR: u32 = 1; /// Map a TS-side format code to a wgpu format. sRGB suits albedo; linear @@ -219,9 +231,9 @@ pub const TEX_ARRAY_FORMAT_LINEAR: u32 = 1; /// the encoded normals or rough/metal channels). pub fn map_texture_array_format(code: u32) -> wgpu::TextureFormat { match code { - TEX_ARRAY_FORMAT_SRGB => wgpu::TextureFormat::Rgba8UnormSrgb, + TEX_ARRAY_FORMAT_SRGB => wgpu::TextureFormat::Rgba8UnormSrgb, TEX_ARRAY_FORMAT_LINEAR => wgpu::TextureFormat::Rgba8Unorm, - _ => wgpu::TextureFormat::Rgba8UnormSrgb, + _ => wgpu::TextureFormat::Rgba8UnormSrgb, } } @@ -231,9 +243,15 @@ pub fn map_texture_array_format(code: u32) -> wgpu::TextureFormat { pub struct MaterialSystem { pub layouts: MaterialAbiLayouts, + pub(super) pipeline_creation_count: u64, // Compiled pipelines, indexed by MaterialHandle (1-based; 0 = invalid). pub pipelines: Vec>, + /// Stable material IDs consumed by GPU-driven passes. Kept parallel to + /// `pipelines`; legacy handles remain the Tier C compatibility API. + pub material_ids: Vec, + /// Capability-selected global/paged resource tables. + pub indirection: MaterialIndirection, // Per-material "render into planar reflection probes" flag (1-based // like `pipelines`, default true). Authoring control for content @@ -244,12 +262,12 @@ pub struct MaterialSystem { // Per-frame UBO + bind group (rewritten at the start of every frame). pub per_frame_buffer: wgpu::Buffer, - pub per_frame_bg: wgpu::BindGroup, + pub per_frame_bg: wgpu::BindGroup, // Per-view UBO — one for now (single camera). Phase 2 may add more // for split-screen / shadow cascades. pub per_view_buffer: wgpu::Buffer, - pub per_view_bg: wgpu::BindGroup, + pub per_view_bg: wgpu::BindGroup, // Default per-material bind group: all white 1×1 textures, default // factors, zero-initialised user-params. Materials that don't @@ -257,23 +275,23 @@ pub struct MaterialSystem { pub default_per_material_bg: wgpu::BindGroup, /// Kept alive so the BG it backs doesn't dangle. _default_material_factors_buffer: wgpu::Buffer, - _default_user_params_buffer: wgpu::Buffer, - _default_white_tex: wgpu::Texture, - _default_white_view: wgpu::TextureView, - _default_sampler: wgpu::Sampler, + _default_user_params_buffer: wgpu::Buffer, + _default_white_tex: wgpu::Texture, + _default_white_view: wgpu::TextureView, + _default_sampler: wgpu::Sampler, /// EN-011 — 1×1 black texture bound at @group(2) @binding(12) for /// materials that don't have a planar reflection probe linked. /// Lets shaders unconditionally `textureSample(planar_reflection_tex, /// …)` without branching on probe presence. - _default_black_tex: wgpu::Texture, - pub default_black_view: wgpu::TextureView, + _default_black_tex: wgpu::Texture, + pub default_black_view: wgpu::TextureView, /// EN-014 — 1×1×1 transparent-black texture-array stub bound to /// bindings 14/15/16 (@group(2)) for materials that don't declare /// their own array. Has to be a real D2Array view (not a 2D /// texture cast) so the layout's `view_dimension: D2Array` /// matches at bind time. - _default_array_tex: wgpu::Texture, - pub default_array_view: wgpu::TextureView, + _default_array_tex: wgpu::Texture, + pub default_array_view: wgpu::TextureView, /// Phase 5 — per-material `user_params` UBOs. Indexed by /// `MaterialHandle - 1`; `None` means the material uses the default @@ -295,7 +313,7 @@ pub struct MaterialSystem { // Each entry is `(PerDraw UBO, bind group binding it + the global // joint buffer at binding 1)`. pub per_draw_buffers: Vec, - pub per_draw_bgs: Vec, + pub per_draw_bgs: Vec, // Phase 4b — group 4 (SceneInputs) bind group. Rebuilt per-frame // when any Refractive material is submitted and a scene-colour @@ -309,21 +327,21 @@ pub struct MaterialSystem { _scene_depth_sampler: wgpu::Sampler, /// 1×1 default texture for impulse / motion-vectors slots when /// no Phase 7 impulse system is wired up yet. - _scene_stub_tex: wgpu::Texture, - _scene_stub_view: wgpu::TextureView, + _scene_stub_tex: wgpu::Texture, + _scene_stub_view: wgpu::TextureView, /// 1×1 stub depth texture — bound to scene_depth_tex in Phase 4b /// because the live depth buffer can't be simultaneously sampled /// and used as a depth-stencil attachment. Phase 4c will add a /// copy-to-sample depth snapshot for shoreline-fade materials. - _scene_stub_depth: wgpu::Texture, + _scene_stub_depth: wgpu::Texture, _scene_stub_depth_view: wgpu::TextureView, // Frame state — commands split by bucket so the graph can // schedule them into the right pass. Phase 4a keeps them in // parallel lists; Phase 4b dispatches the translucent lists in // their own sub-pass. - pub commands: Vec, // Bucket::Opaque + Bucket::Cutout - pub translucent_commands: Vec, // Transparent + Refractive + Additive + pub commands: Vec, // Bucket::Opaque + Bucket::Cutout + pub translucent_commands: Vec, // Transparent + Refractive + Additive next_draw_slot: usize, /// EN-022 — per-slot model matrices from the PREVIOUS frame, keyed @@ -335,7 +353,7 @@ pub struct MaterialSystem { /// with the CURRENT mvp, making world velocity identically zero /// (round-2 audit F8). prev_models: Vec<[[f32; 4]; 4]>, - cur_models: Vec<[[f32; 4]; 4]>, + cur_models: Vec<[[f32; 4]; 4]>, /// Previous frame's view-projection (set in reset_draw_slot). prev_vp: [[f32; 4]; 4], @@ -352,6 +370,8 @@ pub struct MaterialSystem { /// Created via `create_texture_array`, linked to a material's /// per-material BG via `set_material_texture_array`. pub texture_arrays: Vec>, + /// Stable Tier B IDs parallel to `texture_arrays`. + pub texture_array_ids: Vec, /// EN-014 — per-material → array-handle link, one slot for each /// of the 3 array bindings (0 = albedo, 1 = normal, 2 = MR). @@ -368,7 +388,7 @@ pub struct MaterialSystem { /// `material_factors_data` lets us partial-update one field at a /// time without losing the others. pub material_factors_buffers: Vec>, - pub material_factors_data: Vec, + pub material_factors_data: Vec, } impl MaterialSystem { @@ -392,12 +412,13 @@ impl MaterialSystem { // EN-063 — on wasm32 the per_frame group also carries the seven // folded SceneInputs bindings, whose stub resources are only // created further down; the wasm32 bind group is built there. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(fold_scene_inputs))] let per_frame_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("material_per_frame_bg"), layout: &layouts.per_frame, entries: &[wgpu::BindGroupEntry { - binding: 0, resource: per_frame_buffer.as_entire_binding(), + binding: 0, + resource: per_frame_buffer.as_entire_binding(), }], }); @@ -406,7 +427,11 @@ impl MaterialSystem { queue, &wgpu::TextureDescriptor { label: Some("material_default_white"), - size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -428,7 +453,11 @@ impl MaterialSystem { queue, &wgpu::TextureDescriptor { label: Some("material_default_black_reflection"), - size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -453,7 +482,11 @@ impl MaterialSystem { queue, &wgpu::TextureDescriptor { label: Some("material_default_array_stub"), - size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -503,7 +536,11 @@ impl MaterialSystem { // Use a 1×1 depth as a stub. let stub_depth = device.create_texture(&wgpu::TextureDescriptor { label: Some("material_stub_depth"), - size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -512,37 +549,117 @@ impl MaterialSystem { view_formats: &[], }); let stub_depth_view = stub_depth.create_view(&Default::default()); + let stub_depth_array_view = stub_depth.create_view(&wgpu::TextureViewDescriptor { + label: Some("material_stub_depth_array"), + dimension: Some(wgpu::TextureViewDimension::D2Array), + ..Default::default() + }); + let vsm_stub = crate::virtual_shadows::virtual_shadows_requested().then(|| { + let page_table = device.create_texture(&wgpu::TextureDescriptor { + label: Some("material_stub_vsm_page_table"), + size: wgpu::Extent3d { + width: crate::virtual_shadows::VSM_VIRTUAL_PAGES_PER_AXIS as u32, + height: crate::virtual_shadows::VSM_VIRTUAL_PAGES_PER_AXIS as u32, + depth_or_array_layers: crate::virtual_shadows::VSM_CLIP_LEVELS as u32, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R32Uint, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let page_table_view = page_table.create_view(&wgpu::TextureViewDescriptor { + label: Some("material_stub_vsm_page_table_view"), + dimension: Some(wgpu::TextureViewDimension::D2Array), + ..Default::default() + }); + let params = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("material_stub_vsm_params"), + size: std::mem::size_of::<[u32; 4]>() as u64, + usage: wgpu::BufferUsages::UNIFORM, + mapped_at_creation: false, + }); + (page_table, page_table_view, params) + }); + let mut per_view_entries = vec![ + wgpu::BindGroupEntry { + binding: 0, + resource: per_view_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(white_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(white_samp), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(white_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(white_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler(white_samp), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&stub_depth_view), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(&stub_depth_view), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView(&stub_depth_view), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::Sampler(&cmp_sampler), + }, + ]; + if let Some((_, page_table_view, params)) = vsm_stub.as_ref() { + per_view_entries.extend([ + wgpu::BindGroupEntry { + binding: 10, + resource: wgpu::BindingResource::TextureView(page_table_view), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: wgpu::BindingResource::TextureView(&stub_depth_array_view), + }, + wgpu::BindGroupEntry { + binding: 12, + resource: params.as_entire_binding(), + }, + ]); + } let per_view_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("material_per_view_bg"), layout: &layouts.per_view, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: per_view_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(white_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(white_samp) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(white_view) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(white_view) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(white_samp) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&stub_depth_view) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&stub_depth_view) }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&stub_depth_view) }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&cmp_sampler) }, - ], + entries: &per_view_entries, }); // The stub_depth texture and cmp_sampler outlive the bind group via // wgpu internal Arc; we don't need to hold them in the struct. std::mem::forget(stub_depth); std::mem::forget(stub_depth_view); + std::mem::forget(stub_depth_array_view); std::mem::forget(cmp_sampler); // Default MaterialFactors UBO. let default_mf = MaterialFactorsUniforms::default(); - let default_material_factors_buffer = device.create_buffer_init( - &wgpu::util::BufferInitDescriptor { + let default_material_factors_buffer = + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("material_default_factors"), contents: bytemuck::bytes_of(&default_mf), usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - }, - ); + }); // Default user-params UBO — 256 bytes of zeros (ABI §1.4). let default_user_params_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("material_default_user_params"), @@ -558,31 +675,85 @@ impl MaterialSystem { label: Some("material_default_per_material_bg"), layout: &layouts.per_material, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&default_white_view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&default_sampler) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&default_white_view) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&default_sampler) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&default_white_view) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(&default_sampler) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&default_white_view) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::Sampler(&default_sampler) }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&default_white_view) }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&default_sampler) }, - wgpu::BindGroupEntry { binding: 10, resource: default_material_factors_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 11, resource: default_user_params_buffer.as_entire_binding() }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&default_white_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&default_sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&default_white_view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&default_sampler), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&default_white_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler(&default_sampler), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&default_white_view), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::Sampler(&default_sampler), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView(&default_white_view), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::Sampler(&default_sampler), + }, + wgpu::BindGroupEntry { + binding: 10, + resource: default_material_factors_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: default_user_params_buffer.as_entire_binding(), + }, // EN-011 — default black 1×1 reflection texture + // shared linear sampler. Replaced per-material when a // game calls `set_material_reflection_probe`. - wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::TextureView(&default_black_view) }, - wgpu::BindGroupEntry { binding: 13, resource: wgpu::BindingResource::Sampler(&default_sampler) }, + wgpu::BindGroupEntry { + binding: 12, + resource: wgpu::BindingResource::TextureView(&default_black_view), + }, + wgpu::BindGroupEntry { + binding: 13, + resource: wgpu::BindingResource::Sampler(&default_sampler), + }, // EN-014 — default 1×1×1 stub array bound to all 3 // texture-array slots, with the shared default // sampler at binding 17. Replaced per-slot when a // game calls `set_material_texture_array`. - wgpu::BindGroupEntry { binding: 14, resource: wgpu::BindingResource::TextureView(&default_array_view) }, - wgpu::BindGroupEntry { binding: 15, resource: wgpu::BindingResource::TextureView(&default_array_view) }, - wgpu::BindGroupEntry { binding: 16, resource: wgpu::BindingResource::TextureView(&default_array_view) }, - wgpu::BindGroupEntry { binding: 17, resource: wgpu::BindingResource::Sampler(&default_sampler) }, + wgpu::BindGroupEntry { + binding: 14, + resource: wgpu::BindingResource::TextureView(&default_array_view), + }, + wgpu::BindGroupEntry { + binding: 15, + resource: wgpu::BindingResource::TextureView(&default_array_view), + }, + wgpu::BindGroupEntry { + binding: 16, + resource: wgpu::BindingResource::TextureView(&default_array_view), + }, + wgpu::BindGroupEntry { + binding: 17, + resource: wgpu::BindingResource::Sampler(&default_sampler), + }, ], }); @@ -613,7 +784,11 @@ impl MaterialSystem { queue, &wgpu::TextureDescriptor { label: Some("scene_inputs_stub"), - size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -629,7 +804,11 @@ impl MaterialSystem { // Stub depth texture — Depth32Float 1×1, cleared to 1.0 (far). let scene_stub_depth = device.create_texture(&wgpu::TextureDescriptor { label: Some("scene_depth_stub"), - size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -643,23 +822,30 @@ impl MaterialSystem { // folded SceneInputs slots, initially bound to the same stub // resources `update_scene_inputs` falls back to. Rebuilt with // the real snapshot views each frame by `update_scene_inputs`. - #[cfg(target_arch = "wasm32")] + #[cfg(fold_scene_inputs)] let per_frame_bg = super::material_system_wasm::build_per_frame_bg_wasm( device, &layouts.per_frame, &per_frame_buffer, - &scene_stub_view, // scene_color_tex stub + &scene_stub_view, // scene_color_tex stub &scene_color_sampler, - &scene_stub_depth_view, // scene_depth_tex stub + &scene_stub_depth_view, // scene_depth_tex stub &scene_depth_sampler, - &scene_stub_view, // impulse_tex stub - &scene_depth_sampler, // impulse_samp — NonFiltering, matches layout - &scene_stub_view, // motion_vectors stub + &scene_stub_view, // impulse_tex stub + &scene_depth_sampler, // impulse_samp — NonFiltering, matches layout + &scene_stub_view, // motion_vectors stub ); + let mut indirection = MaterialIndirection::new(device); + indirection.initialize_fallbacks(device, &default_white_view, &default_sampler); + indirection.flush(device, queue); + Self { layouts, + pipeline_creation_count: 0, pipelines: Vec::new(), + material_ids: Vec::new(), + indirection, probe_visible: Vec::new(), per_frame_buffer, per_frame_bg, @@ -695,12 +881,13 @@ impl MaterialSystem { prev_vp: super::IDENTITY_MAT4, instance_buffers: Vec::new(), texture_arrays: Vec::new(), + texture_array_ids: Vec::new(), material_texture_arrays: Vec::new(), // EN-012 — per-material MaterialFactors UBOs are lazy. // Until a material calls `set_shading_model` / // `set_foliage`, it shares the default factors buffer. material_factors_buffers: Vec::new(), - material_factors_data: Vec::new(), + material_factors_data: Vec::new(), } } @@ -729,6 +916,7 @@ impl MaterialSystem { label: "user_material", entry_path, extra_sources: &[(entry_path, wgsl_source)], + lazy_reactive_source: Some(wgsl_source), profile, bucket, reads_scene, @@ -741,7 +929,13 @@ impl MaterialSystem { wants_instancing, }; let pipeline = compile_material(device, &self.layouts, &desc)?; + let created = 1 + u64::from(pipeline.reflection_pipeline.is_some()); + self.pipeline_creation_count = self.pipeline_creation_count.saturating_add(created); self.pipelines.push(Some(pipeline)); + let material_id = self + .indirection + .allocate_material(device, GpuMaterialRecord::default()); + self.material_ids.push(material_id); self.probe_visible.push(true); Ok(self.pipelines.len() as MaterialHandle) } @@ -757,8 +951,13 @@ impl MaterialSystem { } pub fn material_probe_visible(&self, material: MaterialHandle) -> bool { - if material < 1 { return true; } - self.probe_visible.get(material as usize - 1).copied().unwrap_or(true) + if material < 1 { + return true; + } + self.probe_visible + .get(material as usize - 1) + .copied() + .unwrap_or(true) } // --- Frame lifecycle ---------------------------------------------- @@ -770,12 +969,14 @@ impl MaterialSystem { /// end_frame, so draws submitted during the frame survive). pub fn update_frame_uniforms( &mut self, + device: &wgpu::Device, queue: &wgpu::Queue, per_frame: &PerFrameUniforms, - per_view: &PerViewUniforms, + per_view: &PerViewUniforms, ) { queue.write_buffer(&self.per_frame_buffer, 0, bytemuck::bytes_of(per_frame)); - queue.write_buffer(&self.per_view_buffer, 0, bytemuck::bytes_of(per_view)); + queue.write_buffer(&self.per_view_buffer, 0, bytemuck::bytes_of(per_view)); + self.indirection.flush(device, queue); } /// Shadow-flicker fix — re-upload just PerView's trailing shadow @@ -802,11 +1003,15 @@ impl MaterialSystem { shadow_view: [[f32; 4]; 4], shadow_cascades: [[[f32; 4]; 4]; 3], } - let tail = ShadowTail { shadow_splits, shadow_view, shadow_cascades }; + let tail = ShadowTail { + shadow_splits, + shadow_view, + shadow_cascades, + }; // The shadow fields are the last three in PerViewUniforms; Pod // guarantees no padding, so the tail offset is exact. - let offset = (std::mem::size_of::() - - std::mem::size_of::()) as u64; + let offset = + (std::mem::size_of::() - std::mem::size_of::()) as u64; queue.write_buffer(&self.per_view_buffer, offset, bytemuck::bytes_of(&tail)); } @@ -824,24 +1029,20 @@ impl MaterialSystem { self.prev_vp = prev_vp; } - /// EN-022 fix — `begin_mode_3d` overrides the velocity reference - /// with the previous frame's UNJITTERED VP re-jittered with the - /// CURRENT frame's offsets, so prev_mvp cancels the TAA jitter - /// exactly. (reset_draw_slot runs at begin_frame, before the - /// current frame's jitter is known.) + /// EN-022: apply current jitter to the previous unjittered VP so + /// motion vectors exclude jitter (called after `reset_draw_slot`). pub fn set_velocity_reference_vp(&mut self, vp: [[f32; 4]; 4]) { self.prev_vp = vp; } - /// Phase 5 — set/replace `user_params` for a specific material. The - /// next dispatch of this handle binds a per-material BindGroup with - /// the given bytes uploaded to `@group(2) @binding(11)`. Materials - /// that never receive a `set_user_params` call keep using the - /// default zero-initialised UBO. - /// - /// `params.len()` must be ≤ 256 bytes (ABI §1.4 cap). The buffer - /// is allocated lazily on first call per handle and reused on - /// subsequent updates. Pass an empty slice to revert to the default. + /// A camera cut has no meaningful object-motion predecessor. Fresh draws + /// fall back to their current model, producing zero cut-frame velocity. + pub(super) fn reset_motion_history(&mut self) { + self.prev_models.clear(); + } + + /// Set/replace ≤256 B of per-material user parameters. Empty input + /// restores the shared zero UBO; storage is otherwise reused. pub fn set_user_params( &mut self, device: &wgpu::Device, @@ -849,7 +1050,9 @@ impl MaterialSystem { handle: MaterialHandle, params: &[u8], ) -> Result<(), &'static str> { - if handle == 0 { return Err("invalid material handle"); } + if handle == 0 { + return Err("invalid material handle"); + } let idx = (handle - 1) as usize; if idx >= self.pipelines.len() || self.pipelines[idx].is_none() { return Err("material handle not registered"); @@ -888,7 +1091,8 @@ impl MaterialSystem { // material that previously called `set_shading_model` / // `set_foliage` keeps its custom factors at binding 10 // when user_params is later set on it. - let factors_buf: &wgpu::Buffer = self.material_factors_buffers + let factors_buf: &wgpu::Buffer = self + .material_factors_buffers .get(idx) .and_then(|b| b.as_ref()) .unwrap_or(&self._default_material_factors_buffer); @@ -903,24 +1107,78 @@ impl MaterialSystem { label: Some("material_per_material_bg_user"), layout: &self.layouts.per_material, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 10, resource: factors_buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 11, resource: buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::TextureView(&self.default_black_view) }, - wgpu::BindGroupEntry { binding: 13, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 14, resource: wgpu::BindingResource::TextureView(albedo_view) }, - wgpu::BindGroupEntry { binding: 15, resource: wgpu::BindingResource::TextureView(normal_view) }, - wgpu::BindGroupEntry { binding: 16, resource: wgpu::BindingResource::TextureView(mr_view) }, - wgpu::BindGroupEntry { binding: 17, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 10, + resource: factors_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 12, + resource: wgpu::BindingResource::TextureView(&self.default_black_view), + }, + wgpu::BindGroupEntry { + binding: 13, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 14, + resource: wgpu::BindingResource::TextureView(albedo_view), + }, + wgpu::BindGroupEntry { + binding: 15, + resource: wgpu::BindingResource::TextureView(normal_view), + }, + wgpu::BindGroupEntry { + binding: 16, + resource: wgpu::BindingResource::TextureView(mr_view), + }, + wgpu::BindGroupEntry { + binding: 17, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, ], }); self.material_params_buffers[idx] = Some(buf); @@ -936,9 +1194,10 @@ impl MaterialSystem { } /// Per-material BG when set, otherwise the shared default. - fn per_material_bg_for(&self, handle: MaterialHandle) -> &wgpu::BindGroup { + pub(crate) fn per_material_bg_for(&self, handle: MaterialHandle) -> &wgpu::BindGroup { let idx = (handle as usize).wrapping_sub(1); - self.material_per_material_bgs.get(idx) + self.material_per_material_bgs + .get(idx) .and_then(|b| b.as_ref()) .unwrap_or(&self.default_per_material_bg) } @@ -968,11 +1227,13 @@ impl MaterialSystem { pub fn set_reflection_probe( &mut self, device: &wgpu::Device, - handle: MaterialHandle, + handle: MaterialHandle, probe_handle: u32, - probe_view: &wgpu::TextureView, + probe_view: &wgpu::TextureView, ) -> Result<(), &'static str> { - if handle == 0 { return Err("invalid material handle"); } + if handle == 0 { + return Err("invalid material handle"); + } let idx = (handle - 1) as usize; if idx >= self.pipelines.len() || self.pipelines[idx].is_none() { return Err("material handle not registered"); @@ -981,7 +1242,7 @@ impl MaterialSystem { // Grow parallel vectors so the index is in bounds. We grow // BOTH params + reflection registries together — they share // an index domain (MaterialHandle - 1). - while self.material_params_buffers.len() <= idx { + while self.material_params_buffers.len() <= idx { self.material_params_buffers.push(None); self.material_per_material_bgs.push(None); } @@ -992,7 +1253,8 @@ impl MaterialSystem { // Resolve binding 11 — per-material UBO if one's been // allocated, else the shared zero-init default. - let user_params_buf: &wgpu::Buffer = self.material_params_buffers + let user_params_buf: &wgpu::Buffer = self + .material_params_buffers .get(idx) .and_then(|b| b.as_ref()) .unwrap_or(&self._default_user_params_buffer); @@ -1002,7 +1264,8 @@ impl MaterialSystem { // `set_foliage`. Otherwise the reflection-probe rebind would // silently revert a foliage-flagged material back to the // default factors at binding 10. - let factors_buf: &wgpu::Buffer = self.material_factors_buffers + let factors_buf: &wgpu::Buffer = self + .material_factors_buffers .get(idx) .and_then(|b| b.as_ref()) .unwrap_or(&self._default_material_factors_buffer); @@ -1016,26 +1279,80 @@ impl MaterialSystem { label: Some("material_per_material_bg_reflection"), layout: &self.layouts.per_material, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 10, resource: factors_buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 11, resource: user_params_buf.as_entire_binding() }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 10, + resource: factors_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: user_params_buf.as_entire_binding(), + }, // EN-011 — probe's color view + a filtering sampler. - wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::TextureView(probe_view) }, - wgpu::BindGroupEntry { binding: 13, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, + wgpu::BindGroupEntry { + binding: 12, + resource: wgpu::BindingResource::TextureView(probe_view), + }, + wgpu::BindGroupEntry { + binding: 13, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, // EN-014 — resolved texture arrays + shared sampler. - wgpu::BindGroupEntry { binding: 14, resource: wgpu::BindingResource::TextureView(albedo_view) }, - wgpu::BindGroupEntry { binding: 15, resource: wgpu::BindingResource::TextureView(normal_view) }, - wgpu::BindGroupEntry { binding: 16, resource: wgpu::BindingResource::TextureView(mr_view) }, - wgpu::BindGroupEntry { binding: 17, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, + wgpu::BindGroupEntry { + binding: 14, + resource: wgpu::BindingResource::TextureView(albedo_view), + }, + wgpu::BindGroupEntry { + binding: 15, + resource: wgpu::BindingResource::TextureView(normal_view), + }, + wgpu::BindGroupEntry { + binding: 16, + resource: wgpu::BindingResource::TextureView(mr_view), + }, + wgpu::BindGroupEntry { + binding: 17, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, ], }); self.material_per_material_bgs[idx] = Some(bg); @@ -1088,23 +1405,24 @@ impl MaterialSystem { /// `flush_material_factors`. Grows `material_factors_buffers` / /// `material_factors_data` so `idx` is in bounds. fn ensure_material_factors( - &mut self, device: &wgpu::Device, idx: usize, + &mut self, + device: &wgpu::Device, + idx: usize, ) -> &mut MaterialFactorsUniforms { while self.material_factors_buffers.len() <= idx { self.material_factors_buffers.push(None); - self.material_factors_data.push(MaterialFactorsUniforms::default()); + self.material_factors_data + .push(MaterialFactorsUniforms::default()); } if self.material_factors_buffers[idx].is_none() { // Allocate a fresh per-material factors UBO seeded with the // current CPU-side data (defaults on first call). let init = self.material_factors_data[idx]; - let buf = device.create_buffer_init( - &wgpu::util::BufferInitDescriptor { - label: Some("material_factors_per_material"), - contents: bytemuck::bytes_of(&init), - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - }, - ); + let buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("material_factors_per_material"), + contents: bytemuck::bytes_of(&init), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); self.material_factors_buffers[idx] = Some(buf); } &mut self.material_factors_data[idx] @@ -1113,11 +1431,46 @@ impl MaterialSystem { /// EN-012 — write the current CPU-side `MaterialFactorsUniforms` /// for `idx` to the per-material UBO. Caller must have previously /// called `ensure_material_factors` so the buffer exists. - fn flush_material_factors(&self, queue: &wgpu::Queue, idx: usize) { + fn flush_material_factors(&mut self, queue: &wgpu::Queue, idx: usize) { if let Some(Some(buf)) = self.material_factors_buffers.get(idx) { let data = &self.material_factors_data[idx]; queue.write_buffer(buf, 0, bytemuck::bytes_of(data)); } + self.sync_gpu_material_record(idx); + } + + fn sync_gpu_material_record(&mut self, idx: usize) { + let Some(&material_id) = self.material_ids.get(idx) else { + return; + }; + let factors = self + .material_factors_data + .get(idx) + .copied() + .unwrap_or_default(); + let mut record = GpuMaterialRecord { + base_color: factors.base_color, + metal_rough: factors.metal_rough, + emissive: factors.emissive, + shading_model: factors.shading_model, + foliage_params: factors.foliage_params, + ..GpuMaterialRecord::default() + }; + if let Some(array_links) = self.material_texture_arrays.get(idx) { + let resolve = |link: Option| { + link.and_then(|handle| { + self.texture_array_ids + .get(handle.saturating_sub(1) as usize) + .copied() + }) + .unwrap_or(TextureId::FALLBACK) + .raw() + }; + record.texture_ids_1[2] = resolve(array_links[0]); + record.texture_ids_1[3] = resolve(array_links[1]); + record.texture_ids_2[0] = resolve(array_links[2]); + } + self.indirection.update_material(material_id, record); } /// EN-012 — rebuild the per-material BG for `idx` after a @@ -1130,8 +1483,8 @@ impl MaterialSystem { /// either the probe's RT view or the default 1×1 black view). fn rebuild_per_material_bg( &mut self, - device: &wgpu::Device, - idx: usize, + device: &wgpu::Device, + idx: usize, probe_view: &wgpu::TextureView, ) { // Grow parallel BG vector so `idx` is in bounds. @@ -1141,7 +1494,8 @@ impl MaterialSystem { } let factors_buf: &wgpu::Buffer = self.resolve_factors_buffer(idx); - let user_params_buf: &wgpu::Buffer = self.material_params_buffers + let user_params_buf: &wgpu::Buffer = self + .material_params_buffers .get(idx) .and_then(|b| b.as_ref()) .unwrap_or(&self._default_user_params_buffer); @@ -1151,24 +1505,78 @@ impl MaterialSystem { label: Some("material_per_material_bg_factors"), layout: &self.layouts.per_material, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 10, resource: factors_buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 11, resource: user_params_buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::TextureView(probe_view) }, - wgpu::BindGroupEntry { binding: 13, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 14, resource: wgpu::BindingResource::TextureView(albedo_view) }, - wgpu::BindGroupEntry { binding: 15, resource: wgpu::BindingResource::TextureView(normal_view) }, - wgpu::BindGroupEntry { binding: 16, resource: wgpu::BindingResource::TextureView(mr_view) }, - wgpu::BindGroupEntry { binding: 17, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 10, + resource: factors_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: user_params_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 12, + resource: wgpu::BindingResource::TextureView(probe_view), + }, + wgpu::BindGroupEntry { + binding: 13, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 14, + resource: wgpu::BindingResource::TextureView(albedo_view), + }, + wgpu::BindGroupEntry { + binding: 15, + resource: wgpu::BindingResource::TextureView(normal_view), + }, + wgpu::BindGroupEntry { + binding: 16, + resource: wgpu::BindingResource::TextureView(mr_view), + }, + wgpu::BindGroupEntry { + binding: 17, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, ], }); self.material_per_material_bgs[idx] = Some(bg); @@ -1183,12 +1591,14 @@ impl MaterialSystem { pub fn set_material_shading_model( &mut self, device: &wgpu::Device, - queue: &wgpu::Queue, - material: MaterialHandle, - model: u32, + queue: &wgpu::Queue, + material: MaterialHandle, + model: u32, probe_view: &wgpu::TextureView, ) -> Result<(), &'static str> { - if material == 0 { return Err("invalid material handle"); } + if material == 0 { + return Err("invalid material handle"); + } let idx = (material - 1) as usize; if idx >= self.pipelines.len() || self.pipelines[idx].is_none() { return Err("material handle not registered"); @@ -1210,14 +1620,16 @@ impl MaterialSystem { pub fn set_material_foliage( &mut self, device: &wgpu::Device, - queue: &wgpu::Queue, - material: MaterialHandle, + queue: &wgpu::Queue, + material: MaterialHandle, trans_color: [f32; 3], trans_amount: f32, - wrap_factor: f32, - probe_view: &wgpu::TextureView, + wrap_factor: f32, + probe_view: &wgpu::TextureView, ) -> Result<(), &'static str> { - if material == 0 { return Err("invalid material handle"); } + if material == 0 { + return Err("invalid material handle"); + } let idx = (material - 1) as usize; if idx >= self.pipelines.len() || self.pipelines[idx].is_none() { return Err("material handle not registered"); @@ -1235,179 +1647,6 @@ impl MaterialSystem { Ok(()) } - /// EN-014 — create a 2D texture array from a slice of layer data. - /// Each `(bytes, w, h)` describes one layer's RGBA8 source. All - /// layers must share `w × h` (wgpu requires a uniform extent for - /// D2Array). V1 panics on mismatch — V2 may resize. Layer count - /// is capped at `MAX_TEXTURE_ARRAY_LAYERS`; extra layers are - /// dropped. Returns a 1-based handle (0 on failure: empty layers - /// or zero extent). - /// - /// Defaults: `format = 0` (Rgba8UnormSrgb, suitable for albedo), - /// `mip_levels = 1` (no mips). For data textures (normal / MR) - /// or auto-mip generation, see `create_texture_array_ex`. - pub fn create_texture_array( - &mut self, - device: &wgpu::Device, - queue: &wgpu::Queue, - layers: &[(&[u8], u32, u32)], - ) -> u32 { - // V1 default: sRGB albedo, no mips. V2 callers use the _ex - // variant directly. - self.create_texture_array_ex(device, queue, layers, 0, 1) - } - - /// EN-014 V2 — create a 2D texture array with explicit pixel format - /// and mip-level control. Layer extent / count rules match V1. - /// - /// `format`: - /// 0 → `Rgba8UnormSrgb` (albedo / colour textures; default) - /// 1 → `Rgba8Unorm` (normal / MR / data textures — linear) - /// _ → falls back to `Rgba8UnormSrgb` - /// - /// `mip_levels`: - /// 1 → no mips (matches V1 behaviour) - /// 0 → auto-generate `floor(log2(max(w,h))) + 1` mips, filled - /// by point-downsample (`copy_texture_to_texture` halving - /// the previous mip). Cheap, correct sized, but aliased - /// — a render-pass box filter is the V2.5 follow-up. - /// N > 1 → not yet supported (game-supplied per-mip bytes); V2 - /// treats this as auto-generate. - pub fn create_texture_array_ex( - &mut self, - device: &wgpu::Device, - queue: &wgpu::Queue, - layers: &[(&[u8], u32, u32)], - format: u32, - mip_levels: u32, - ) -> u32 { - let layer_count = (layers.len() as u32).min(MAX_TEXTURE_ARRAY_LAYERS); - if layer_count == 0 { return 0; } - let (_first_bytes, w, h) = layers[0]; - if w == 0 || h == 0 { return 0; } - // Uniform extent check — V1 hard-fail surfaces obvious bugs at - // creation rather than during silent GPU truncation later. - for (i, (_, lw, lh)) in layers.iter().enumerate().take(layer_count as usize) { - if *lw != w || *lh != h { - eprintln!( - "[texture_array] layer {} extent {}×{} does not match layer 0 ({}×{}); aborting create", - i, lw, lh, w, h, - ); - return 0; - } - } - let wgpu_format = map_texture_array_format(format); - // Resolve mip count. mip_levels = 1 → single mip. mip_levels = 0 - // → engine-generated max. Anything else (game-supplied per-mip) - // is V2.5; for now treat N > 1 as auto so games opting into mips - // don't silently regress to no-mips. - let max_mips = (w.max(h) as f32).log2().floor() as u32 + 1; - let auto_generate = mip_levels == 0 || mip_levels > 1; - let mip_level_count = if mip_levels == 1 { 1 } else { max_mips.max(1) }; - // Auto-gen needs COPY_SRC on the texture so we can ping-pong each - // mip into the next via copy_texture_to_texture. - let mut usage = wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::COPY_DST; - if auto_generate && mip_level_count > 1 { - usage |= wgpu::TextureUsages::COPY_SRC; - } - let bytes_per_layer = (w as usize) * (h as usize) * 4; - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("material_texture_array"), - size: wgpu::Extent3d { - width: w, - height: h, - depth_or_array_layers: layer_count, - }, - mip_level_count, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu_format, - usage, - view_formats: &[], - }); - for (i, (bytes, _, _)) in layers.iter().enumerate().take(layer_count as usize) { - // Defensive — a short layer slice would panic in - // write_texture; skip and emit a diagnostic instead. - if bytes.len() < bytes_per_layer { - eprintln!( - "[texture_array] layer {} short: {} B < {} B (skipping)", - i, bytes.len(), bytes_per_layer, - ); - continue; - } - queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &texture, - mip_level: 0, - origin: wgpu::Origin3d { x: 0, y: 0, z: i as u32 }, - aspect: wgpu::TextureAspect::All, - }, - &bytes[..bytes_per_layer], - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(w * 4), - rows_per_image: Some(h), - }, - wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, - ); - } - // EN-014 V2 — auto-generate the mip chain via point-sample copies. - // For each mip > 0, copy a half-size region of mip-1 into mip, - // for every layer. wgpu's copy_texture_to_texture covers a single - // mip level + array layer per call. This is point-filtered (not - // box-filtered): correct sizes, sampleable at distance, but - // aliased. V2.5 follow-up upgrades this to a render-pass box - // filter (one fullscreen draw per (mip, layer)). - if auto_generate && mip_level_count > 1 { - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("material_texture_array_mipgen"), - }); - for mip in 1..mip_level_count { - let src_w = (w >> (mip - 1)).max(1); - let src_h = (h >> (mip - 1)).max(1); - let dst_w = (w >> mip).max(1); - let dst_h = (h >> mip).max(1); - // Copy region is dst-sized so we read the top-left - // 2x2 reduction implicitly. Truly we'd want a filter, - // but copy is the cheapest "mips exist" path. - let copy_w = dst_w.min(src_w); - let copy_h = dst_h.min(src_h); - for layer in 0..layer_count { - encoder.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: &texture, - mip_level: mip - 1, - origin: wgpu::Origin3d { x: 0, y: 0, z: layer }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyTextureInfo { - texture: &texture, - mip_level: mip, - origin: wgpu::Origin3d { x: 0, y: 0, z: layer }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::Extent3d { - width: copy_w, - height: copy_h, - depth_or_array_layers: 1, - }, - ); - } - } - queue.submit(std::iter::once(encoder.finish())); - } - let view = texture.create_view(&wgpu::TextureViewDescriptor { - label: Some("material_texture_array_view"), - dimension: Some(wgpu::TextureViewDimension::D2Array), - ..Default::default() - }); - self.texture_arrays.push(Some(TextureArray { - texture, view, layer_count, - })); - self.texture_arrays.len() as u32 - } - /// EN-014 — link a texture array to a material's per-material /// bind-group at one of three slots (0 = albedo, 1 = normal, /// 2 = MR). Pass `array = 0` to revert the slot to the default @@ -1424,13 +1663,15 @@ impl MaterialSystem { /// unknown handles are no-ops with a diagnostic. pub fn set_material_texture_array( &mut self, - device: &wgpu::Device, - material: MaterialHandle, - slot: u32, - array: u32, + device: &wgpu::Device, + material: MaterialHandle, + slot: u32, + array: u32, probe_view: &wgpu::TextureView, ) { - if material == 0 { return; } + if material == 0 { + return; + } let idx = (material - 1) as usize; if idx >= self.pipelines.len() || self.pipelines[idx].is_none() { eprintln!("[texture_array] unknown material handle {material}"); @@ -1442,9 +1683,7 @@ impl MaterialSystem { } if array != 0 { let h = array as usize; - if h == 0 || h > self.texture_arrays.len() - || self.texture_arrays[h - 1].is_none() - { + if h == 0 || h > self.texture_arrays.len() || self.texture_arrays[h - 1].is_none() { eprintln!("[texture_array] unknown array handle {array}"); return; } @@ -1458,6 +1697,7 @@ impl MaterialSystem { } let link = if array == 0 { None } else { Some(array) }; self.material_texture_arrays[idx][slot as usize] = link; + self.sync_gpu_material_record(idx); // Rebuild the per-material BG. Resolve user_params from // existing state so we don't clobber EN-005 links. @@ -1465,13 +1705,15 @@ impl MaterialSystem { self.material_params_buffers.push(None); self.material_per_material_bgs.push(None); } - let user_params_buf: &wgpu::Buffer = self.material_params_buffers + let user_params_buf: &wgpu::Buffer = self + .material_params_buffers .get(idx) .and_then(|b| b.as_ref()) .unwrap_or(&self._default_user_params_buffer); // EN-012 — preserve any per-material MaterialFactors UBO // across an EN-014 array rebind. - let factors_buf: &wgpu::Buffer = self.material_factors_buffers + let factors_buf: &wgpu::Buffer = self + .material_factors_buffers .get(idx) .and_then(|b| b.as_ref()) .unwrap_or(&self._default_material_factors_buffer); @@ -1482,24 +1724,78 @@ impl MaterialSystem { label: Some("material_per_material_bg_array"), layout: &self.layouts.per_material, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self._default_white_view) }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 10, resource: factors_buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 11, resource: user_params_buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::TextureView(probe_view) }, - wgpu::BindGroupEntry { binding: 13, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, - wgpu::BindGroupEntry { binding: 14, resource: wgpu::BindingResource::TextureView(albedo_view) }, - wgpu::BindGroupEntry { binding: 15, resource: wgpu::BindingResource::TextureView(normal_view) }, - wgpu::BindGroupEntry { binding: 16, resource: wgpu::BindingResource::TextureView(mr_view) }, - wgpu::BindGroupEntry { binding: 17, resource: wgpu::BindingResource::Sampler(&self._default_sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView(&self._default_white_view), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 10, + resource: factors_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: user_params_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 12, + resource: wgpu::BindingResource::TextureView(probe_view), + }, + wgpu::BindGroupEntry { + binding: 13, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, + wgpu::BindGroupEntry { + binding: 14, + resource: wgpu::BindingResource::TextureView(albedo_view), + }, + wgpu::BindGroupEntry { + binding: 15, + resource: wgpu::BindingResource::TextureView(normal_view), + }, + wgpu::BindGroupEntry { + binding: 16, + resource: wgpu::BindingResource::TextureView(mr_view), + }, + wgpu::BindGroupEntry { + binding: 17, + resource: wgpu::BindingResource::Sampler(&self._default_sampler), + }, ], }); self.material_per_material_bgs[idx] = Some(bg); @@ -1511,7 +1807,9 @@ impl MaterialSystem { /// `set_material_texture_array`. Returns `None` for unset / out- /// of-range / unlinked materials. pub fn material_reflection_probe_handle(&self, material: MaterialHandle) -> Option { - if material == 0 { return None; } + if material == 0 { + return None; + } let idx = (material as usize).checked_sub(1)?; self.material_reflection_probe.get(idx).copied().flatten() } @@ -1542,7 +1840,9 @@ impl MaterialSystem { skin_info: [u32; 4], ) { let idx = material as usize; - if material == 0 || idx > self.pipelines.len() { return; } + if material == 0 || idx > self.pipelines.len() { + return; + } let bucket = match self.pipelines[idx - 1].as_ref() { Some(p) => p.bucket, None => return, @@ -1563,11 +1863,24 @@ impl MaterialSystem { } self.cur_models[slot] = model; - let per_draw = PerDrawUniforms { mvp, model, prev_mvp, model_tint: tint, skin_info }; - queue.write_buffer(&self.per_draw_buffers[slot], 0, bytemuck::bytes_of(&per_draw)); + let per_draw = PerDrawUniforms { + mvp, + model, + prev_mvp, + model_tint: tint, + skin_info, + }; + queue.write_buffer( + &self.per_draw_buffers[slot], + 0, + bytemuck::bytes_of(&per_draw), + ); let cmd = MaterialDrawCommand { - material, mesh_handle, mesh_idx, draw_slot: slot, + material, + mesh_handle, + mesh_idx, + draw_slot: slot, view_depth: mvp[3][3], instance: None, model, @@ -1608,7 +1921,9 @@ impl MaterialSystem { skin_info: [u32; 4], ) { let idx = material as usize; - if material == 0 || idx > self.pipelines.len() { return; } + if material == 0 || idx > self.pipelines.len() { + return; + } let bucket = match self.pipelines[idx - 1].as_ref() { Some(p) => p.bucket, None => return, @@ -1630,18 +1945,31 @@ impl MaterialSystem { } self.cur_models[slot] = model; - let per_draw = PerDrawUniforms { mvp, model, prev_mvp, model_tint: tint, skin_info }; - queue.write_buffer(&self.per_draw_buffers[slot], 0, bytemuck::bytes_of(&per_draw)); + let per_draw = PerDrawUniforms { + mvp, + model, + prev_mvp, + model_tint: tint, + skin_info, + }; + queue.write_buffer( + &self.per_draw_buffers[slot], + 0, + bytemuck::bytes_of(&per_draw), + ); let cmd = MaterialDrawCommand { - material, mesh_handle, mesh_idx, draw_slot: slot, + material, + mesh_handle, + mesh_idx, + draw_slot: slot, // Instanced draws sort as a group by their fallback-transform // pivot — per-instance ordering inside one buffer is the // standard engine limitation. view_depth: mvp[3][3], instance: Some(InstanceDrawInfo { buffer_handle: instance_buffer, - count: instance_count, + count: instance_count, }), model, }; @@ -1652,7 +1980,6 @@ impl MaterialSystem { } } - fn ensure_draw_slot( &mut self, device: &wgpu::Device, @@ -1670,8 +1997,14 @@ impl MaterialSystem { label: Some("material_per_draw_bg"), layout: &self.layouts.per_draw, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: joint_buffer.as_entire_binding() }, + wgpu::BindGroupEntry { + binding: 0, + resource: buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: joint_buffer.as_entire_binding(), + }, ], }); self.per_draw_buffers.push(buf); @@ -1682,16 +2015,16 @@ impl MaterialSystem { /// Dispatch all queued material draws. Caller owns the render pass; /// this method binds the pipelines + groups + meshes and issues /// indexed draws. `mesh_fetch` is a closure that returns - /// `(vertex_buffer, index_buffer, index_count)` for a given + /// a shared/dedicated [`MeshDrawRef`](super::MeshDrawRef) for a given /// (mesh_handle, mesh_idx) — lets the renderer hand over its /// `model_gpu_cache` without this module taking a dependency on it. - pub fn dispatch<'pass, F>( + pub(crate) fn dispatch<'pass, F>( &'pass self, pass: &mut wgpu::RenderPass<'pass>, planes: Option<&[[f32; 4]; 6]>, mesh_fetch: F, - ) - where F: FnMut(u64, usize) -> Option<(&'pass wgpu::Buffer, &'pass wgpu::Buffer, u32, [f32; 3], [f32; 3])> + ) where + F: FnMut(u64, usize) -> Option<(super::MeshDrawRef<'pass>, [f32; 3], [f32; 3])>, { self.dispatch_with_view(pass, &self.per_view_bg, |_| true, false, planes, mesh_fetch); } @@ -1713,26 +2046,29 @@ impl MaterialSystem { /// Falls back to the main pipeline if no reflection variant /// exists (translucent / cutout materials, where the original /// pipeline already cull-mode = None and no flip is needed). - pub fn dispatch_with_view<'pass, F, A>( + pub(crate) fn dispatch_with_view<'pass, F, A>( &'pass self, pass: &mut wgpu::RenderPass<'pass>, per_view_bg: &'pass wgpu::BindGroup, - mut accept: A, + mut accept: A, use_reflection_pipeline: bool, // View frustum for instance-tile culling (`mesh_fetch` supplies // the mesh's local AABB). None = no per-tile culling. planes: Option<&[[f32; 4]; 6]>, mut mesh_fetch: F, - ) - where - F: FnMut(u64, usize) -> Option<(&'pass wgpu::Buffer, &'pass wgpu::Buffer, u32, [f32; 3], [f32; 3])>, + ) where + F: FnMut(u64, usize) -> Option<(super::MeshDrawRef<'pass>, [f32; 3], [f32; 3])>, A: FnMut(MaterialHandle) -> bool, { - if self.commands.is_empty() { return; } + if self.commands.is_empty() { + return; + } let mut last_material: MaterialHandle = 0; for cmd in &self.commands { - if !accept(cmd.material) { continue; } + if !accept(cmd.material) { + continue; + } if cmd.material != last_material { let mat = match self.pipelines.get(cmd.material as usize - 1) { Some(Some(m)) => m, @@ -1762,12 +2098,14 @@ impl MaterialSystem { pass.set_bind_group(2, self.per_material_bg_for(cmd.material), &[]); last_material = cmd.material; } - if let Some((vb, ib, icount, lmin, lmax)) = mesh_fetch(cmd.mesh_handle, cmd.mesh_idx) { + if let Some((mesh, lmin, lmax)) = mesh_fetch(cmd.mesh_handle, cmd.mesh_idx) { pass.set_bind_group(3, &self.per_draw_bgs[cmd.draw_slot], &[]); - pass.set_vertex_buffer(0, vb.slice(..)); - pass.set_index_buffer(ib.slice(..), wgpu::IndexFormat::Uint32); + pass.set_vertex_buffer(0, mesh.vertex.slice(..)); + pass.set_index_buffer(mesh.index.slice(..), wgpu::IndexFormat::Uint32); let instance_range = self.bind_instance_buffer(pass, &cmd.instance); - if instance_range.end <= instance_range.start { continue; } + if instance_range.end <= instance_range.start { + continue; + } // Instance-tile culling: `commands` holds only the // opaque + cutout buckets (translucent draws dispatch // elsewhere), so emitting the visible tile ranges is @@ -1775,7 +2113,8 @@ impl MaterialSystem { // AABBs are inflated by max_scale × the mesh's local // half-diagonal (rotation-safe). Adjacent visible tiles // merge into one draw. - let tiles = cmd.instance + let tiles = cmd + .instance .as_ref() .filter(|_| planes.is_some() && lmin[0] <= lmax[0]) .and_then(|inst| { @@ -1787,9 +2126,11 @@ impl MaterialSystem { .filter(|t| !t.is_empty()); match (tiles, planes) { (Some(tiles), Some(planes)) => { - let half_diag = 0.5 * ((lmax[0] - lmin[0]).powi(2) - + (lmax[1] - lmin[1]).powi(2) - + (lmax[2] - lmin[2]).powi(2)).sqrt(); + let half_diag = 0.5 + * ((lmax[0] - lmin[0]).powi(2) + + (lmax[1] - lmin[1]).powi(2) + + (lmax[2] - lmin[2]).powi(2)) + .sqrt(); let mut run: Option<(u32, u32)> = None; for tile in tiles.iter() { let r = tile.max_scale * half_diag; @@ -1797,25 +2138,27 @@ impl MaterialSystem { let bmax = [tile.pmax[0] + r, tile.pmax[1] + r, tile.pmax[2] + r]; if crate::scene::aabb_outside_frustum(planes, bmin, bmax) { if let Some((s, e)) = run.take() { - pass.draw_indexed(0..icount, 0, s..e); + pass.draw_indexed(mesh.index_range(), mesh.base_vertex, s..e); } continue; } run = match run { - Some((s, e)) if e == tile.first => Some((s, tile.first + tile.count)), + Some((s, e)) if e == tile.first => { + Some((s, tile.first + tile.count)) + } Some((s, e)) => { - pass.draw_indexed(0..icount, 0, s..e); + pass.draw_indexed(mesh.index_range(), mesh.base_vertex, s..e); Some((tile.first, tile.first + tile.count)) } None => Some((tile.first, tile.first + tile.count)), }; } if let Some((s, e)) = run { - pass.draw_indexed(0..icount, 0, s..e); + pass.draw_indexed(mesh.index_range(), mesh.base_vertex, s..e); } } _ => { - pass.draw_indexed(0..icount, 0, instance_range); + pass.draw_indexed(mesh.index_range(), mesh.base_vertex, instance_range); } } } @@ -1828,7 +2171,7 @@ impl MaterialSystem { /// with a missing/destroyed buffer slot we return an empty range /// so the caller skips the draw rather than crashing on a stale /// handle. - fn bind_instance_buffer<'pass>( + pub(crate) fn bind_instance_buffer<'pass>( &'pass self, pass: &mut wgpu::RenderPass<'pass>, info: &Option, @@ -1836,7 +2179,9 @@ impl MaterialSystem { match info { None => 0..1, Some(inst) => { - if inst.buffer_handle == 0 { return 0..1; } + if inst.buffer_handle == 0 { + return 0..1; + } let slot_idx = inst.buffer_handle as usize - 1; match self.instance_buffers.get(slot_idx).and_then(|s| s.as_ref()) { Some(ib_slot) => { @@ -1870,7 +2215,7 @@ impl MaterialSystem { // filtering color sampler so the layout matches either way. let (imp_view, imp_samp): (&wgpu::TextureView, &wgpu::Sampler) = match impulse_view { Some((v, s)) => (v, s), - None => (&self._scene_stub_view, &self._scene_depth_sampler), + None => (&self._scene_stub_view, &self._scene_depth_sampler), }; // Phase 4c — group 4 binding 2 receives a COPY_DST snapshot of // the opaque depth buffer, rather than the live depth-stencil @@ -1883,13 +2228,34 @@ impl MaterialSystem { label: Some("scene_inputs_bg"), layout: &self.layouts.scene_inputs, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(scene_color_view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self._scene_color_sampler) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(depth_view) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self._scene_depth_sampler) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(imp_view) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(imp_samp) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self._scene_stub_view) }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(scene_color_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self._scene_color_sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(depth_view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&self._scene_depth_sampler), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(imp_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler(imp_samp), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&self._scene_stub_view), + }, ], }); self.scene_inputs_bg = Some(bg); @@ -1900,7 +2266,7 @@ impl MaterialSystem { // resources bound above, at WASM_SCENE_INPUTS_BASE..+6. The // opaque pass re-binds this group too, which is harmless — // opaque materials never statically use the scene-input slots. - #[cfg(target_arch = "wasm32")] + #[cfg(fold_scene_inputs)] { self.per_frame_bg = super::material_system_wasm::build_per_frame_bg_wasm( device, @@ -1912,7 +2278,7 @@ impl MaterialSystem { &self._scene_depth_sampler, imp_view, imp_samp, - &self._scene_stub_view, // motion_vectors stub + &self._scene_stub_view, // motion_vectors stub ); } } @@ -1935,50 +2301,23 @@ impl MaterialSystem { /// materials additionally receive the SceneInputs bind group at /// group 4 — `update_scene_inputs` must have been called this /// frame for that to be non-None. - pub fn dispatch_translucent<'pass, F>( + pub(crate) fn dispatch_translucent<'pass, F>( &'pass self, pass: &mut wgpu::RenderPass<'pass>, mut mesh_fetch: F, - ) - where F: FnMut(u64, usize) -> Option<(&'pass wgpu::Buffer, &'pass wgpu::Buffer, u32)> + ) where + F: FnMut(u64, usize) -> Option>, { - if self.translucent_commands.is_empty() { return; } + if self.translucent_commands.is_empty() { + return; + } let mut last_material: MaterialHandle = 0; - let mut last_reads_scene: bool = false; for cmd in &self.translucent_commands { - if cmd.material != last_material { - let mat = match self.pipelines.get(cmd.material as usize - 1) { - Some(Some(m)) => m, - _ => continue, - }; - pass.set_pipeline(&mat.pipeline); - pass.set_bind_group(0, &self.per_frame_bg, &[]); - pass.set_bind_group(1, &self.per_view_bg, &[]); - pass.set_bind_group(2, self.per_material_bg_for(cmd.material), &[]); - // EN-063 — on wasm32 there is no group 4: the scene - // inputs are folded into per_frame (group 0), already - // bound above with the frame's snapshot views. - if mat.reads_scene && cfg!(not(target_arch = "wasm32")) { - if let Some(bg) = self.scene_inputs_bg.as_ref() { - pass.set_bind_group(4, bg, &[]); - } - } - last_material = cmd.material; - last_reads_scene = mat.reads_scene; - } - // Re-bind group 4 if the material switches its reads_scene - // between subsequent draws — rarely happens with a - // stable bucket but keeps the state machine honest. - let _ = last_reads_scene; - - if let Some((vb, ib, icount)) = mesh_fetch(cmd.mesh_handle, cmd.mesh_idx) { - pass.set_bind_group(3, &self.per_draw_bgs[cmd.draw_slot], &[]); - pass.set_vertex_buffer(0, vb.slice(..)); - pass.set_index_buffer(ib.slice(..), wgpu::IndexFormat::Uint32); - let instance_range = self.bind_instance_buffer(pass, &cmd.instance); - if instance_range.end > instance_range.start { - pass.draw_indexed(0..icount, 0, instance_range); + if let Some(mesh) = mesh_fetch(cmd.mesh_handle, cmd.mesh_idx) { + let bind_material_state = cmd.material != last_material; + if self.dispatch_translucent_command(pass, cmd, mesh, false, bind_material_state) { + last_material = cmd.material; } } } diff --git a/native/shared/src/renderer/material_system/texture_arrays.rs b/native/shared/src/renderer/material_system/texture_arrays.rs new file mode 100644 index 00000000..132f685c --- /dev/null +++ b/native/shared/src/renderer/material_system/texture_arrays.rs @@ -0,0 +1,181 @@ +//! Texture-array creation lives outside `material_system.rs` so the core +//! material lifecycle and dispatch code remain reviewable. + +use super::{map_texture_array_format, MaterialSystem, TextureArray, MAX_TEXTURE_ARRAY_LAYERS}; +use crate::renderer::material_indirection::{ResidentTexture, TextureColorSpace, TextureSemantic}; + +impl MaterialSystem { + /// Create a 2D texture array from RGBA8 layers using the default sRGB + /// format and a single mip. Returns a 1-based handle, or zero on error. + pub fn create_texture_array( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + layers: &[(&[u8], u32, u32)], + ) -> u32 { + self.create_texture_array_ex(device, queue, layers, 0, 1) + } + + /// Create a 2D texture array with explicit color-space and mip control. + /// + /// `format` is 0 for sRGB color and 1 for linear data. `mip_levels` is + /// 1 for a single mip; 0 or values above 1 generate the full chain. + pub fn create_texture_array_ex( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + layers: &[(&[u8], u32, u32)], + format: u32, + mip_levels: u32, + ) -> u32 { + let layer_count = (layers.len() as u32).min(MAX_TEXTURE_ARRAY_LAYERS); + if layer_count == 0 { + return 0; + } + let (_first_bytes, width, height) = layers[0]; + if width == 0 || height == 0 { + return 0; + } + for (index, (_, layer_width, layer_height)) in + layers.iter().enumerate().take(layer_count as usize) + { + if *layer_width != width || *layer_height != height { + eprintln!( + "[texture_array] layer {} extent {}×{} does not match layer 0 ({}×{}); aborting create", + index, layer_width, layer_height, width, height, + ); + return 0; + } + } + + let wgpu_format = map_texture_array_format(format); + let max_mips = (width.max(height) as f32).log2().floor() as u32 + 1; + let auto_generate = mip_levels == 0 || mip_levels > 1; + let mip_level_count = if mip_levels == 1 { 1 } else { max_mips.max(1) }; + let mut usage = wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST; + if auto_generate && mip_level_count > 1 { + usage |= wgpu::TextureUsages::COPY_SRC; + } + + let bytes_per_layer = (width as usize) * (height as usize) * 4; + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("material_texture_array"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: layer_count, + }, + mip_level_count, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu_format, + usage, + view_formats: &[], + }); + for (index, (bytes, _, _)) in layers.iter().enumerate().take(layer_count as usize) { + if bytes.len() < bytes_per_layer { + eprintln!( + "[texture_array] layer {} short: {} B < {} B (skipping)", + index, + bytes.len(), + bytes_per_layer, + ); + continue; + } + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: 0, + y: 0, + z: index as u32, + }, + aspect: wgpu::TextureAspect::All, + }, + &bytes[..bytes_per_layer], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(width * 4), + rows_per_image: Some(height), + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + } + + // Keep the established point-copy mip behavior. The indirection + // record still exposes the real mip count to every capability tier. + if auto_generate && mip_level_count > 1 { + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("material_texture_array_mipgen"), + }); + for mip in 1..mip_level_count { + let src_width = (width >> (mip - 1)).max(1); + let src_height = (height >> (mip - 1)).max(1); + let copy_width = (width >> mip).max(1).min(src_width); + let copy_height = (height >> mip).max(1).min(src_height); + for layer in 0..layer_count { + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: mip - 1, + origin: wgpu::Origin3d { + x: 0, + y: 0, + z: layer, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: mip, + origin: wgpu::Origin3d { + x: 0, + y: 0, + z: layer, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width: copy_width, + height: copy_height, + depth_or_array_layers: 1, + }, + ); + } + } + queue.submit(std::iter::once(encoder.finish())); + } + + let view = texture.create_view(&wgpu::TextureViewDescriptor { + label: Some("material_texture_array_view"), + dimension: Some(wgpu::TextureViewDimension::D2Array), + ..Default::default() + }); + let texture_id = self.indirection.register_texture(ResidentTexture { + view: view.clone(), + width, + height, + mip_count: mip_level_count, + color_space: if wgpu_format.is_srgb() { + TextureColorSpace::Srgb + } else { + TextureColorSpace::Linear + }, + semantic: TextureSemantic::General, + hardware_srgb_decode: wgpu_format.is_srgb(), + global_2d: false, + }); + self.texture_arrays.push(Some(TextureArray { + texture, + view, + layer_count, + })); + self.texture_array_ids.push(texture_id); + self.texture_arrays.len() as u32 + } +} diff --git a/native/shared/src/renderer/material_system_tests.rs b/native/shared/src/renderer/material_system_tests.rs index eda49f13..220ec516 100644 --- a/native/shared/src/renderer/material_system_tests.rs +++ b/native/shared/src/renderer/material_system_tests.rs @@ -11,6 +11,30 @@ mod tests { use crate::renderer::formats; use crate::renderer::types::Vertex3D; + #[test] + fn base_material_factors_reuse_reserved_lanes_without_growing() { + assert_eq!(std::mem::size_of::(), 80); + let mut factors = MaterialFactorsUniforms::default(); + assert_eq!( + factors.layered_pbr_version(), + crate::renderer::layered_pbr::MATERIAL_RECORD_VERSION + ); + assert!(factors.layered_pbr_lobe_mask().is_empty()); + let metadata_bits = [ + factors.foliage_params[2].to_bits(), + factors.foliage_params[3].to_bits(), + ]; + factors.foliage_params[0] = 0.9; + factors.foliage_params[1] = 0.1; + assert_eq!( + [ + factors.foliage_params[2].to_bits(), + factors.foliage_params[3].to_bits(), + ], + metadata_bits, + ); + } + /// Headless wgpu device. See sibling helpers in `transient.rs` / /// `impulse_field.rs` — same fallback adapter pattern. Returns None /// (test skips gracefully) when no GPU is available. @@ -23,7 +47,8 @@ mod tests { power_preference: wgpu::PowerPreference::LowPower, compatible_surface: None, force_fallback_adapter: true, - })).ok()?; + })) + .ok()?; // The material ABI uses 5 bind groups (PerFrame, PerView, // PerMaterial, PerDraw, SceneInputs). downlevel_defaults caps // max_bind_groups at 4, which is fine on Metal (it silently @@ -34,14 +59,13 @@ mod tests { let mut required_limits = wgpu::Limits::downlevel_defaults(); required_limits.max_bind_groups = 5; required_limits.max_uniform_buffer_binding_size = 64 << 10; - let (device, queue) = pollster::block_on(adapter.request_device( - &wgpu::DeviceDescriptor { - label: Some("material-test-device"), - required_features: wgpu::Features::empty(), - required_limits, - ..Default::default() - }, - )).ok()?; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("material-test-device"), + required_features: wgpu::Features::empty(), + required_limits, + ..Default::default() + })) + .ok()?; Some((device, queue)) } @@ -72,6 +96,40 @@ fn fs_main(_in: VsOut) -> TranslucentOut { out.hdr = vec4(1.0, 0.0, 0.0, 0.5); return out; } +"#; + + const REACTIVE_TRANSLUCENT_WGSL: &str = r#" +#include "material_abi.wgsl" + +struct VsOut { + @builtin(position) clip_position: vec4, +}; + +@vertex +fn vs_main(in: VertexInput) -> VsOut { + var out: VsOut; + out.clip_position = draw.mvp * vec4(in.position, 1.0); + return out; +} + +fn shade() -> vec4 { + return vec4(1.0, 0.0, 0.0, 0.5); +} + +@fragment +fn fs_main(_in: VsOut) -> TranslucentOut { + var out: TranslucentOut; + out.hdr = shade(); + return out; +} + +@fragment +fn fs_reactive(_in: VsOut) -> ReactiveTranslucentOut { + var out: ReactiveTranslucentOut; + out.hdr = shade(); + out.reactive = 0.5; + return out; +} "#; /// Create a tiny joint buffer so MaterialSystem::new is happy. The @@ -91,11 +149,14 @@ fn fs_main(_in: VsOut) -> TranslucentOut { /// at (-1,-1), (3,-1), (-1,3). The pipeline's MVP starts as /// identity (we override it below) so the triangle covers the /// whole viewport. - fn make_fullscreen_tri(device: &wgpu::Device, queue: &wgpu::Queue) -> (wgpu::Buffer, wgpu::Buffer, u32) { + fn make_fullscreen_tri( + device: &wgpu::Device, + queue: &wgpu::Queue, + ) -> (wgpu::Buffer, wgpu::Buffer, u32) { let mut verts: [Vertex3D; 3] = [Vertex3D::default(); 3]; verts[0].position = [-1.0, -1.0, 0.5]; - verts[1].position = [ 3.0, -1.0, 0.5]; - verts[2].position = [-1.0, 3.0, 0.5]; + verts[1].position = [3.0, -1.0, 0.5]; + verts[2].position = [-1.0, 3.0, 0.5]; // The MaterialPipeline's depth-stencil uses Less; the load-op // for a translucent pass clears to 1.0 (far) by default in // production but in this test we use a depth attachment with @@ -141,37 +202,47 @@ fn fs_main(_in: VsOut) -> TranslucentOut { /// Skipped on adapters where `try_create_device` returns None. #[test] fn dispatch_translucent_alpha_blends_into_hdr() { - let Some((device, queue)) = try_create_device() else { return; }; + let Some((device, queue)) = try_create_device() else { + return; + }; let joint_buf = make_joint_buffer(&device); let mut sys = MaterialSystem::new(&device, &queue, &joint_buf); // Compile a refractive (translucent) material. Use the engine's // production format constants so the pipeline matches what // Renderer::new would have produced. - let handle = sys.compile( - &device, - TRANSLUCENT_WGSL, - FragmentProfile::Translucent, - Bucket::Transparent, - false, // reads_scene - false, // wants_instancing - wgpu::TextureFormat::Rgba16Float, // hdr_format - wgpu::TextureFormat::Rg8Unorm, // material_format (unused in translucent) - wgpu::TextureFormat::Rg16Float, // velocity_format (unused) - wgpu::TextureFormat::Rgba8Unorm, // albedo_format (unused) - formats::DEPTH_FORMAT, - ).expect("translucent material compiles"); + let handle = sys + .compile( + &device, + TRANSLUCENT_WGSL, + FragmentProfile::Translucent, + Bucket::Transparent, + false, // reads_scene + false, // wants_instancing + wgpu::TextureFormat::Rgba16Float, // hdr_format + wgpu::TextureFormat::Rg8Unorm, // material_format (unused in translucent) + wgpu::TextureFormat::Rg16Float, // velocity_format (unused) + wgpu::TextureFormat::Rgba8Unorm, // albedo_format (unused) + formats::DEPTH_FORMAT, + ) + .expect("translucent material compiles"); assert!(handle != 0, "compile returns a 1-based handle"); // Frame uniforms — zeros are fine for a constant-colour shader. let pf = PerFrameUniforms { - time: 0.0, delta_time: 0.0, frame_index: 0, _pad0: 0, - screen_resolution: [64.0, 64.0], render_resolution: [64.0, 64.0], - taa_jitter: [0.0; 2], _pad1: [0.0; 2], wind: [0.0; 4], + time: 0.0, + delta_time: 0.0, + frame_index: 0, + _pad0: 0, + screen_resolution: [64.0, 64.0], + render_resolution: [64.0, 64.0], + taa_jitter: [0.0; 2], + _pad1: [0.0; 2], + wind: [0.0; 4], cloud: [0.0; 4], }; let pv = bytemuck::Zeroable::zeroed(); - sys.update_frame_uniforms(&queue, &pf, &pv); + sys.update_frame_uniforms(&device, &queue, &pf, &pv); sys.reset_draw_slot(crate::renderer::IDENTITY_MAT4); // MVP = identity so the fullscreen tri stays in NDC. @@ -184,19 +255,26 @@ fn fs_main(_in: VsOut) -> TranslucentOut { let (vb, ib, icount) = make_fullscreen_tri(&device, &queue); sys.submit_draw( - &device, &queue, &joint_buf, - handle, /* mesh_handle */ 1, /* mesh_idx */ 0, - identity, identity, identity, - [1.0; 4], [0; 4], + &device, &queue, &joint_buf, handle, /* mesh_handle */ 1, /* mesh_idx */ 0, + identity, identity, identity, [1.0; 4], [0; 4], + ); + assert_eq!( + sys.translucent_commands.len(), + 1, + "draw queued in translucent bucket" ); - assert_eq!(sys.translucent_commands.len(), 1, "draw queued in translucent bucket"); // Build the HDR + depth render targets for the dispatch. let (rt_w, rt_h) = (64u32, 64u32); let hdr_rt = device.create_texture(&wgpu::TextureDescriptor { label: Some("test_hdr_rt"), - size: wgpu::Extent3d { width: rt_w, height: rt_h, depth_or_array_layers: 1 }, - mip_level_count: 1, sample_count: 1, + size: wgpu::Extent3d { + width: rt_w, + height: rt_h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba16Float, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, @@ -205,8 +283,13 @@ fn fs_main(_in: VsOut) -> TranslucentOut { let hdr_view = hdr_rt.create_view(&Default::default()); let depth_rt = device.create_texture(&wgpu::TextureDescriptor { label: Some("test_depth_rt"), - size: wgpu::Extent3d { width: rt_w, height: rt_h, depth_or_array_layers: 1 }, - mip_level_count: 1, sample_count: 1, + size: wgpu::Extent3d { + width: rt_w, + height: rt_h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, dimension: wgpu::TextureDimension::D2, format: formats::DEPTH_FORMAT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT, @@ -216,7 +299,12 @@ fn fs_main(_in: VsOut) -> TranslucentOut { // Pre-clear HDR to opaque cyan so we can detect alpha-blended red: // (1, 0, 0, 0.5) over (0, 1, 1, 1) → (0.5, 0.5, 0.5, 1.0). - let bg_color = wgpu::Color { r: 0.0, g: 1.0, b: 1.0, a: 1.0 }; + let bg_color = wgpu::Color { + r: 0.0, + g: 1.0, + b: 1.0, + a: 1.0, + }; let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("test_translucent_encoder"), }); @@ -272,7 +360,17 @@ fn fs_main(_in: VsOut) -> TranslucentOut { multiview_mask: None, }); sys.dispatch_translucent(&mut pass, |mh, _idx| { - if mh == 1 { Some((&vb, &ib, icount)) } else { None } + if mh == 1 { + Some(crate::renderer::MeshDrawRef { + vertex: &vb, + index: &ib, + first_index: 0, + index_count: icount, + base_vertex: 0, + }) + } else { + None + } }); } @@ -301,14 +399,23 @@ fn fs_main(_in: VsOut) -> TranslucentOut { rows_per_image: Some(rt_h), }, }, - wgpu::Extent3d { width: rt_w, height: rt_h, depth_or_array_layers: 1 }, + wgpu::Extent3d { + width: rt_w, + height: rt_h, + depth_or_array_layers: 1, + }, ); queue.submit(std::iter::once(encoder.finish())); let slice = staging.slice(..); let (tx, rx) = std::sync::mpsc::channel(); - slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); - let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); rx.recv().expect("map sender").expect("map failed"); let data = slice.get_mapped_range(); @@ -319,7 +426,7 @@ fn fs_main(_in: VsOut) -> TranslucentOut { let row_start = (cy * bpr) as usize; let texel_start = row_start + (cx as usize) * 8; let halfs: [u16; 4] = [ - u16::from_le_bytes([data[texel_start], data[texel_start + 1]]), + u16::from_le_bytes([data[texel_start], data[texel_start + 1]]), u16::from_le_bytes([data[texel_start + 2], data[texel_start + 3]]), u16::from_le_bytes([data[texel_start + 4], data[texel_start + 5]]), u16::from_le_bytes([data[texel_start + 6], data[texel_start + 7]]), @@ -340,8 +447,252 @@ fn fs_main(_in: VsOut) -> TranslucentOut { // Allow 1/256 tolerance for half-precision round-trip. let eps = 0.02; assert!((r - 0.5).abs() < eps, "red channel = {} (expected ~0.5)", r); - assert!((g - 0.5).abs() < eps, "green channel = {} (expected ~0.5)", g); - assert!((b - 0.5).abs() < eps, "blue channel = {} (expected ~0.5)", b); + assert!( + (g - 0.5).abs() < eps, + "green channel = {} (expected ~0.5)", + g + ); + assert!( + (b - 0.5).abs() < eps, + "blue channel = {} (expected ~0.5)", + b + ); + } + + #[test] + fn reactive_custom_siblings_are_lazy_and_only_opt_in_writes_coverage() { + let Some((device, queue)) = try_create_device() else { + return; + }; + let joint_buf = make_joint_buffer(&device); + let mut sys = MaterialSystem::new(&device, &queue, &joint_buf); + let handle = sys + .compile( + &device, + TRANSLUCENT_WGSL, + FragmentProfile::Translucent, + Bucket::Transparent, + false, + false, + wgpu::TextureFormat::Rgba16Float, + wgpu::TextureFormat::Rg8Unorm, + wgpu::TextureFormat::Rg16Float, + wgpu::TextureFormat::Rgba8Unorm, + formats::DEPTH_FORMAT, + ) + .expect("translucent material compiles"); + let reactive_handle = sys + .compile( + &device, + REACTIVE_TRANSLUCENT_WGSL, + FragmentProfile::Translucent, + Bucket::Transparent, + false, + false, + wgpu::TextureFormat::Rgba16Float, + wgpu::TextureFormat::Rg8Unorm, + wgpu::TextureFormat::Rg16Float, + wgpu::TextureFormat::Rgba8Unorm, + formats::DEPTH_FORMAT, + ) + .expect("reactive translucent material compiles"); + assert_eq!( + sys.reactive_translucent_pipeline_count(), + 0, + "ordinary material compilation must not eagerly create the sibling" + ); + assert_eq!(sys.pipeline_creation_count, 2); + + let pf = PerFrameUniforms { + time: 0.0, + delta_time: 0.0, + frame_index: 0, + _pad0: 0, + screen_resolution: [8.0, 8.0], + render_resolution: [8.0, 8.0], + taa_jitter: [0.0; 2], + _pad1: [0.0; 2], + wind: [0.0; 4], + cloud: [0.0; 4], + }; + let pv = bytemuck::Zeroable::zeroed(); + sys.update_frame_uniforms(&device, &queue, &pf, &pv); + sys.reset_draw_slot(crate::renderer::IDENTITY_MAT4); + let identity = crate::renderer::IDENTITY_MAT4; + let (vertex, index, index_count) = make_fullscreen_tri(&device, &queue); + sys.submit_draw( + &device, &queue, &joint_buf, handle, 1, 0, identity, identity, identity, [1.0; 4], + [0; 4], + ); + sys.submit_draw( + &device, + &queue, + &joint_buf, + reactive_handle, + 1, + 0, + identity, + identity, + identity, + [1.0; 4], + [0; 4], + ); + assert!(sys.has_temporal_reactive_commands()); + + let validation_scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + sys.ensure_translucent_reactive_pipelines(&device); + assert_eq!(sys.reactive_translucent_pipeline_count(), 2); + assert_eq!(sys.pipeline_creation_count, 4); + sys.ensure_translucent_reactive_pipelines(&device); + assert_eq!( + sys.pipeline_creation_count, 4, + "cached reactive material pipeline must not count twice" + ); + + let extent = wgpu::Extent3d { + width: 8, + height: 8, + depth_or_array_layers: 1, + }; + let hdr = device.create_texture(&wgpu::TextureDescriptor { + label: Some("reactive_custom_hdr"), + size: extent, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let coverage = device.create_texture(&wgpu::TextureDescriptor { + label: Some("reactive_custom_coverage"), + size: extent, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let depth = device.create_texture(&wgpu::TextureDescriptor { + label: Some("reactive_custom_depth"), + size: extent, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: formats::DEPTH_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let hdr_view = hdr.create_view(&Default::default()); + let coverage_view = coverage.create_view(&Default::default()); + let depth_view = depth.create_view(&Default::default()); + let mut encoder = device.create_command_encoder(&Default::default()); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("reactive_custom_two_attachment_pass"), + color_attachments: &[ + Some(wgpu::RenderPassColorAttachment { + view: &hdr_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: &coverage_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 0.25, + g: 0.0, + b: 0.0, + a: 0.0, + }), + store: wgpu::StoreOp::Store, + }, + }), + ], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + for command in &sys.translucent_commands { + assert!(sys.dispatch_translucent_command( + &mut pass, + command, + crate::renderer::MeshDrawRef { + vertex: &vertex, + index: &index, + first_index: 0, + index_count, + base_vertex: 0, + }, + true, + true, + )); + } + } + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("reactive_custom_coverage_readback"), + size: 256 * 8, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &coverage, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(256), + rows_per_image: Some(8), + }, + }, + extent, + ); + queue.submit(std::iter::once(encoder.finish())); + let validation = pollster::block_on(validation_scope.pop()); + assert!( + validation.is_none(), + "attachment-compatible sibling failed validation: {validation:?}" + ); + + let slice = readback.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + let _ = tx.send(result); + }); + let _ = device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); + rx.recv().expect("map sender").expect("map failed"); + let bytes = slice.get_mapped_range(); + let coverage_byte = bytes[4 * 256 + 4]; + drop(bytes); + readback.unmap(); + assert!( + (158..=160).contains(&coverage_byte), + "ordinary coverage must stay untouched and opt-in 0.5 coverage must union over 0.25: \ + {coverage_byte}" + ); } /// IEEE-754 binary16 → binary32. We don't pull in the `half` crate @@ -349,7 +700,7 @@ fn fs_main(_in: VsOut) -> TranslucentOut { /// the values this test produces (no NaN / Inf / subnormal cases). fn f16_to_f32(bits: u16) -> f32 { let sign = (bits >> 15) & 0x1; - let exp = (bits >> 10) & 0x1f; + let exp = (bits >> 10) & 0x1f; let frac = bits & 0x3ff; if exp == 0 { if frac == 0 { @@ -360,10 +711,14 @@ fn fs_main(_in: VsOut) -> TranslucentOut { return if sign == 1 { -f } else { f }; } if exp == 0x1f { - return f32::NAN; // Inf or NaN — unexpected in this test. + return f32::NAN; // Inf or NaN — unexpected in this test. } let f = (1.0 + (frac as f32) / 1024.0) * (2.0f32).powi(exp as i32 - 15); - if sign == 1 { -f } else { f } + if sign == 1 { + -f + } else { + f + } } /// Samples layer 0 of the albedo texture array and writes it straight out. @@ -411,28 +766,31 @@ fn fs_main(_in: VsOut) -> TranslucentOut { /// not green. #[test] fn set_user_params_preserves_a_linked_texture_array() { - let Some((device, queue)) = try_create_device() else { return; }; + let Some((device, queue)) = try_create_device() else { + return; + }; let joint_buf = make_joint_buffer(&device); let mut sys = MaterialSystem::new(&device, &queue, &joint_buf); - let handle = sys.compile( - &device, - ARRAY_SAMPLING_WGSL, - FragmentProfile::Translucent, - Bucket::Transparent, - false, - false, - wgpu::TextureFormat::Rgba16Float, - wgpu::TextureFormat::Rg8Unorm, - wgpu::TextureFormat::Rg16Float, - wgpu::TextureFormat::Rgba8Unorm, - formats::DEPTH_FORMAT, - ).expect("array-sampling material compiles"); + let handle = sys + .compile( + &device, + ARRAY_SAMPLING_WGSL, + FragmentProfile::Translucent, + Bucket::Transparent, + false, + false, + wgpu::TextureFormat::Rgba16Float, + wgpu::TextureFormat::Rg8Unorm, + wgpu::TextureFormat::Rg16Float, + wgpu::TextureFormat::Rgba8Unorm, + formats::DEPTH_FORMAT, + ) + .expect("array-sampling material compiles"); // One 2×2 layer of pure green (linear Rgba8 → format code 1). let px: [u8; 2 * 2 * 4] = [ - 0, 255, 0, 255, 0, 255, 0, 255, - 0, 255, 0, 255, 0, 255, 0, 255, + 0, 255, 0, 255, 0, 255, 0, 255, 0, 255, 0, 255, 0, 255, 0, 255, ]; let arr = sys.create_texture_array_ex(&device, &queue, &[(&px[..], 2, 2)], 1, 1); assert!(arr != 0, "texture array allocates"); @@ -442,31 +800,43 @@ fn fs_main(_in: VsOut) -> TranslucentOut { // mutably and would otherwise conflict with holding a & into it.) let probe_view = sys.default_black_view.clone(); sys.set_material_texture_array(&device, handle, 0, arr, &probe_view); - sys.set_user_params(&device, &queue, handle, &[0u8; 16]).expect("params set"); + sys.set_user_params(&device, &queue, handle, &[0u8; 16]) + .expect("params set"); // Render it. let pf = PerFrameUniforms { - time: 0.0, delta_time: 0.0, frame_index: 0, _pad0: 0, - screen_resolution: [64.0, 64.0], render_resolution: [64.0, 64.0], - taa_jitter: [0.0; 2], _pad1: [0.0; 2], wind: [0.0; 4], + time: 0.0, + delta_time: 0.0, + frame_index: 0, + _pad0: 0, + screen_resolution: [64.0, 64.0], + render_resolution: [64.0, 64.0], + taa_jitter: [0.0; 2], + _pad1: [0.0; 2], + wind: [0.0; 4], cloud: [0.0; 4], }; let pv = bytemuck::Zeroable::zeroed(); - sys.update_frame_uniforms(&queue, &pf, &pv); + sys.update_frame_uniforms(&device, &queue, &pf, &pv); sys.reset_draw_slot(crate::renderer::IDENTITY_MAT4); let identity = crate::renderer::IDENTITY_MAT4; let (vb, ib, icount) = make_fullscreen_tri(&device, &queue); sys.submit_draw( - &device, &queue, &joint_buf, - handle, 1, 0, identity, identity, identity, [1.0; 4], [0; 4], + &device, &queue, &joint_buf, handle, 1, 0, identity, identity, identity, [1.0; 4], + [0; 4], ); let (rt_w, rt_h) = (64u32, 64u32); let hdr_rt = device.create_texture(&wgpu::TextureDescriptor { label: Some("test_arr_hdr"), - size: wgpu::Extent3d { width: rt_w, height: rt_h, depth_or_array_layers: 1 }, - mip_level_count: 1, sample_count: 1, + size: wgpu::Extent3d { + width: rt_w, + height: rt_h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba16Float, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, @@ -475,8 +845,13 @@ fn fs_main(_in: VsOut) -> TranslucentOut { let hdr_view = hdr_rt.create_view(&Default::default()); let depth_rt = device.create_texture(&wgpu::TextureDescriptor { label: Some("test_arr_depth"), - size: wgpu::Extent3d { width: rt_w, height: rt_h, depth_or_array_layers: 1 }, - mip_level_count: 1, sample_count: 1, + size: wgpu::Extent3d { + width: rt_w, + height: rt_h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, dimension: wgpu::TextureDimension::D2, format: formats::DEPTH_FORMAT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT, @@ -496,7 +871,12 @@ fn fs_main(_in: VsOut) -> TranslucentOut { // Clear to RED: if the draw never lands, or the stub // (white/black) is sampled, the assert below fails loudly // instead of accidentally passing. - load: wgpu::LoadOp::Clear(wgpu::Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }), + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }), store: wgpu::StoreOp::Store, }, })], @@ -513,7 +893,17 @@ fn fs_main(_in: VsOut) -> TranslucentOut { multiview_mask: None, }); sys.dispatch_translucent(&mut pass, |mh, _idx| { - if mh == 1 { Some((&vb, &ib, icount)) } else { None } + if mh == 1 { + Some(crate::renderer::MeshDrawRef { + vertex: &vb, + index: &ib, + first_index: 0, + index_count: icount, + base_vertex: 0, + }) + } else { + None + } }); } @@ -526,28 +916,41 @@ fn fs_main(_in: VsOut) -> TranslucentOut { }); encoder.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { - texture: &hdr_rt, mip_level: 0, - origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, + texture: &hdr_rt, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { buffer: &staging, layout: wgpu::TexelCopyBufferLayout { - offset: 0, bytes_per_row: Some(bpr), rows_per_image: Some(rt_h), + offset: 0, + bytes_per_row: Some(bpr), + rows_per_image: Some(rt_h), }, }, - wgpu::Extent3d { width: rt_w, height: rt_h, depth_or_array_layers: 1 }, + wgpu::Extent3d { + width: rt_w, + height: rt_h, + depth_or_array_layers: 1, + }, ); queue.submit(std::iter::once(encoder.finish())); let slice = staging.slice(..); let (tx, rx) = std::sync::mpsc::channel(); - slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); - let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); rx.recv().expect("map sender").expect("map failed"); let data = slice.get_mapped_range(); let texel = ((rt_h / 2) * bpr) as usize + ((rt_w / 2) as usize) * 8; - let r = f16_to_f32(u16::from_le_bytes([data[texel], data[texel + 1]])); + let r = f16_to_f32(u16::from_le_bytes([data[texel], data[texel + 1]])); let g = f16_to_f32(u16::from_le_bytes([data[texel + 2], data[texel + 3]])); drop(data); staging.unmap(); @@ -597,5 +1000,4 @@ mod translucent_sort_tests { let order: Vec = ms_cmds.iter().map(|c| c.material).collect(); assert_eq!(order, vec![2, 3, 4, 1, 5]); } - } diff --git a/native/shared/src/renderer/material_system_wasm.rs b/native/shared/src/renderer/material_system_wasm.rs index 464379cc..b31527a8 100644 --- a/native/shared/src/renderer/material_system_wasm.rs +++ b/native/shared/src/renderer/material_system_wasm.rs @@ -1,7 +1,7 @@ //! wasm32-only material bind-group helpers, split out of //! `material_system.rs` to keep that file under the 2000-line policy (the //! same reason `material_system_tests.rs` is a `#[path]` child). The whole -//! module is gated `#[cfg(target_arch = "wasm32")]` at its declaration in +//! module is gated `#[cfg(fold_scene_inputs)]` at its declaration in //! `renderer/mod.rs`, so nothing here compiles on native. /// EN-063 — wasm32-only per_frame bind group builder: the PerFrame UBO @@ -28,14 +28,38 @@ pub(crate) fn build_per_frame_bg_wasm( label: Some("material_per_frame_bg"), layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: per_frame_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: B, resource: wgpu::BindingResource::TextureView(scene_color_view) }, - wgpu::BindGroupEntry { binding: B + 1, resource: wgpu::BindingResource::Sampler(scene_color_samp) }, - wgpu::BindGroupEntry { binding: B + 2, resource: wgpu::BindingResource::TextureView(scene_depth_view) }, - wgpu::BindGroupEntry { binding: B + 3, resource: wgpu::BindingResource::Sampler(scene_depth_samp) }, - wgpu::BindGroupEntry { binding: B + 4, resource: wgpu::BindingResource::TextureView(impulse_view) }, - wgpu::BindGroupEntry { binding: B + 5, resource: wgpu::BindingResource::Sampler(impulse_samp) }, - wgpu::BindGroupEntry { binding: B + 6, resource: wgpu::BindingResource::TextureView(motion_vectors_view) }, + wgpu::BindGroupEntry { + binding: 0, + resource: per_frame_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: B, + resource: wgpu::BindingResource::TextureView(scene_color_view), + }, + wgpu::BindGroupEntry { + binding: B + 1, + resource: wgpu::BindingResource::Sampler(scene_color_samp), + }, + wgpu::BindGroupEntry { + binding: B + 2, + resource: wgpu::BindingResource::TextureView(scene_depth_view), + }, + wgpu::BindGroupEntry { + binding: B + 3, + resource: wgpu::BindingResource::Sampler(scene_depth_samp), + }, + wgpu::BindGroupEntry { + binding: B + 4, + resource: wgpu::BindingResource::TextureView(impulse_view), + }, + wgpu::BindGroupEntry { + binding: B + 5, + resource: wgpu::BindingResource::Sampler(impulse_samp), + }, + wgpu::BindGroupEntry { + binding: B + 6, + resource: wgpu::BindingResource::TextureView(motion_vectors_view), + }, ], }) } diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index 640a5955..a78c7380 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -1,95 +1,143 @@ -use wgpu::util::DeviceExt; +use crate::models::MaterialAlphaMode; use std::collections::HashMap; +use wgpu::util::DeviceExt; -mod shaders; -mod texture_store; +/// Tangent-space +Z with zero authored normal variance. +/// +/// Alpha is consumed by the scene shader as the LEADR/Toksvig variance +/// estimate. Using 255 here forces every material without a normal map to +/// roughness 1.0 even though the RGB direction itself is flat. +const DEFAULT_FLAT_NORMAL_RGBA: [u8; 4] = [128, 128, 255, 0]; + +#[cfg(test)] +#[test] +fn default_flat_normal_carries_no_filtered_variance() { + assert_eq!(DEFAULT_FLAT_NORMAL_RGBA[..3], [128, 128, 255]); + assert_eq!(DEFAULT_FLAT_NORMAL_RGBA[3], 0); +} + +mod alpha_coverage; +mod capability_api; mod draw2d; +mod env_prefilter; +mod final_pass; +mod frame_graph_runtime; +mod frame_resource_stats; +mod froxel; +mod gi_bake; +pub mod gpu_driven; mod hiz; -mod occlusion; -mod ssr_pass; -mod ssgi_pass; -mod pt_pass; -mod shadow_pass; +mod immediate_motion; +mod layered_pbr; +mod layered_pbr_pt; +mod layered_pbr_refraction; +pub(crate) mod layered_pbr_scene; +pub(crate) mod layered_pbr_ssr; +mod lighting; +mod lighting_upload; +mod material_api; +pub mod material_indirection; +mod material_instancing; mod model_draw; +mod occlusion; +mod opaque_material_pass; mod planar_pass; -mod material_instancing; mod postfx_chain; +mod pt_geometry; +mod pt_pass; +#[cfg(not(target_arch = "wasm32"))] +mod pt_temporal_diagnostics; +#[cfg(not(target_arch = "wasm32"))] +mod quality_capture; +mod quality_preset; +mod refractive_reflections; mod scene_pass; -mod gi_bake; -mod froxel; -mod lighting; +mod shaders; +mod shadow_pass; +mod sorted_transparency; +mod ssgi_pass; +#[cfg(not(target_arch = "wasm32"))] +mod ssgi_temporal_diagnostics; +mod ssr_pass; +#[cfg(not(target_arch = "wasm32"))] +mod ssr_temporal_diagnostics; +#[cfg(not(target_arch = "wasm32"))] +mod temporal_diagnostics; +mod temporal_history; +mod temporal_reactive; +mod texture_store; +mod transmitted_shadows; +mod transparent_gi; +mod weighted_transparency; pub use occlusion::OcclusionCuller; use shaders::*; +use weighted_transparency::TransparencyCompositionPreference; -pub mod shader_include; -pub mod shader_library; pub mod material_pipeline; pub mod material_system; -// wasm32-only material bind-group helpers, split out to keep material_system.rs -// under the 2000-line policy (EN-063). -#[cfg(target_arch = "wasm32")] -mod material_system_wasm; -pub mod planar_reflection; +pub mod shader_include; +pub mod shader_library; +// Folded-layout material bind-group helpers (group-4 SceneInputs folded into +// group 0), split out to keep material_system.rs under the 2000-line policy +// (EN-063). Used wherever maxBindGroups is capped at 4 — wasm/WebGPU and +// Android (see the `fold_scene_inputs` cfg in build.rs). Pure wgpu, no wasm +// APIs, so it compiles on Android too despite the historical `_wasm` name. pub mod graph; -pub mod transient; -pub mod impulse_field; pub mod hot_reload; +pub mod impulse_field; +#[cfg(fold_scene_inputs)] +mod material_system_wasm; +pub mod planar_reflection; pub mod post_pass; +pub mod transient; mod util; -pub use util::{ - IDENTITY_MAT4, - mat4_perspective, mat4_ortho, mat4_look_at, - mat4_multiply, mat4_mul_vec4, - mat4_translate, mat4_scale, mat4_invert, -}; #[cfg(not(target_arch = "wasm32"))] // file-writing screenshot path use util::encode_png_simple; +pub use util::{ + mat4_invert, mat4_look_at, mat4_mul_vec4, mat4_multiply, mat4_ortho, mat4_perspective, + mat4_scale, mat4_translate, IDENTITY_MAT4, +}; mod brdf_lut; use brdf_lut::build_brdf_lut; mod atmosphere_lut; +pub mod capabilities; +pub mod device_negotiation; use atmosphere_lut::{ - build_multi_scattering_lut, build_transmittance_lut, AERIAL_D, AERIAL_H, - AERIAL_MAX_DIST_KM, AERIAL_W, MULTI_SCATTERING_SIZE, SKY_VIEW_H, SKY_VIEW_W, - TRANSMITTANCE_H, TRANSMITTANCE_W, + build_multi_scattering_lut, build_transmittance_lut, AERIAL_D, AERIAL_H, AERIAL_MAX_DIST_KM, + AERIAL_W, MULTI_SCATTERING_SIZE, SKY_VIEW_H, SKY_VIEW_W, TRANSMITTANCE_H, TRANSMITTANCE_W, }; mod formats; +#[cfg(not(target_arch = "wasm32"))] +use formats::PROBE_OCT_SIZE; use formats::{ - DEPTH_FORMAT, HDR_FORMAT, SSAO_FORMAT, MATERIAL_FORMAT, - HIZ_FORMAT, VELOCITY_FORMAT, BLOOM_MIP_COUNT, HIZ_MIP_COUNT, - create_depth_texture, create_hdr_rt, create_material_rt, - create_albedo_rt, create_velocity_rt, create_ssr_rt, - create_ssr_history_textures, - create_ssgi_rt, create_probe_trace_tex, create_probe_history_textures, - probe_grid_dims, PROBE_TILE_SIZE, - create_mesh_card_atlas, create_mesh_card_emissive_atlas, - create_mesh_card_radiance_atlas, - CARD_ATLAS_SIZE, CARD_SLOT_SIZE, CARD_SLOTS_PER_ROW, CARD_MAX_SLOTS, - CARD_AXES_PER_MESH, MESH_SDF_RES, - create_scene_sdf_clipmap, create_scene_sdf_clipmap_staging, - SCENE_SDF_CLIPMAP_RES, - SCENE_SDF_CLIPMAP_EXTENT, SCENE_SDF_CLIPMAP_REBAKE_THRESHOLD, - SCENE_SDF_CLIPMAP_BIN_CELLS, SCENE_SDF_CLIPMAP_LAYERS_PER_FRAME, - create_wsrc_atlas, WSRC_GRID_RES, WSRC_CASCADE_COUNT, - WSRC_CASCADE_EXTENTS, WSRC_REBAKE_THRESHOLD, - create_taa_textures, - create_ssao_rt, create_ssao_blur_rt, create_ssao_history_textures, create_sss_rt, - create_exposure_textures, create_composed_rt, create_dof_rt, - create_linear_depth_hiz_chain, create_bloom_chain, halton, + create_albedo_rt, create_bloom_chain, create_composed_rt, create_depth_texture, create_dof_rt, + create_exposure_textures, create_hdr_rt, create_linear_depth_hiz_chain, create_material_rt, + create_mesh_card_atlas, create_mesh_card_emissive_atlas, create_mesh_card_radiance_atlas, + create_probe_history_textures, create_probe_trace_tex, create_scene_sdf_clipmap, + create_scene_sdf_clipmap_staging, create_ssao_blur_rt, create_ssao_history_textures, + create_ssao_rt, create_ssgi_rt, create_ssr_history_textures, create_ssr_rt, create_sss_rt, + create_taa_textures, create_velocity_rt, create_wsrc_atlas, halton, probe_grid_dims, + BLOOM_MIP_COUNT, CARD_ATLAS_SIZE, CARD_AXES_PER_MESH, CARD_MAX_SLOTS, CARD_SLOTS_PER_ROW, + CARD_SLOT_SIZE, DEPTH_FORMAT, HDR_FORMAT, HIZ_FORMAT, HIZ_MIP_COUNT, MATERIAL_FORMAT, + MESH_SDF_RES, PROBE_TILE_SIZE, SCENE_SDF_CLIPMAP_BIN_CELLS, SCENE_SDF_CLIPMAP_EXTENT, + SCENE_SDF_CLIPMAP_LAYERS_PER_FRAME, SCENE_SDF_CLIPMAP_REBAKE_THRESHOLD, SCENE_SDF_CLIPMAP_RES, + SSAO_FORMAT, VELOCITY_FORMAT, WSRC_CASCADE_COUNT, WSRC_CASCADE_EXTENTS, WSRC_GRID_RES, + WSRC_REBAKE_THRESHOLD, }; mod types; -pub use types::{Vertex2D, Vertex3D, SceneMaterialUniforms, RenderMode}; +pub(crate) use types::Uniforms3D; +// Pass/compute uniform parameter structs extracted for EN-052. +use types::*; use types::{ - MAX_UNIFORM_SLOTS, MAX_DIR_LIGHTS, MAX_POINT_LIGHTS, - Uniforms2D, Uniforms3D, DirLight, PointLight, LightingUniforms, - DrawCall2D, DrawCall3D, FNV_OFFSET, fnv1a_bytes, + fnv1a_bytes, secondary_uv_desc, DirLight, DrawCall2D, DrawCall3D, LightingUniforms, PointLight, + SceneTransmissionUniforms, Uniforms2D, FNV_OFFSET, MAX_DIR_LIGHTS, MAX_POINT_LIGHTS, + MAX_UNIFORM_SLOTS, }; -use types::*; // pass/compute uniform param structs (EN-052 split) - - +pub use types::{RenderMode, SceneMaterialUniforms, Vertex2D, Vertex3D}; // ============================================================ // Shaders @@ -122,14 +170,12 @@ use types::*; // pass/compute uniform param structs (EN-052 split) // clamp if fireflies appear (high-luminance pixels with few samples). // At 64 samples per mip we haven't seen them in the test HDRs. - // ============================================================ // Cached model GPU data // ============================================================ struct GpuMesh { - vb: wgpu::Buffer, - ib: wgpu::Buffer, + geometry: gpu_driven::MeshGeometry, index_count: u32, /// Object-space AABB of the mesh, captured at cache time. The shadow / /// main / probe passes transform it by the draw's model matrix to get a @@ -137,6 +183,26 @@ struct GpuMesh { /// "no bounds" (empty mesh) → never culled. local_min: [f32; 3], local_max: [f32; 3], + alpha_mode: MaterialAlphaMode, + double_sided: bool, + /// Preserved physical contract and optional dedicated bind group. Ordinary + /// materials never allocate or bind these resources. + transmission: crate::models::MaterialTransmission, + refractive_material_bg: Option, + _refractive_uniform: Option, + _refractive_layered_uniform: Option, + refractive_layered: bool, + /// Compact second vertex stream allocated only when a usable physical + /// texture samples glTF TEXCOORD_1. + refractive_uv1_buffer: Option, + refractive_uses_uv1: bool, + /// Lazy clearcoat/specular/IOR compatibility material. Base-only meshes + /// retain the ordinary bind group and allocate none of these resources. + layered_pbr: crate::models::MaterialLayeredPbr, + layered_material_bg: Option, + _layered_uniform: Option, + layered_uv1_buffer: Option, + layered_uses_uv1: bool, /// Pre-built scene material bind group (base color + normal + /// metallic-roughness + emissive + material factors). Cached at /// model-upload time so draw_model_cached doesn't build one per @@ -160,10 +226,90 @@ struct GpuMesh { /// meshes (they enter the TLAS as scene nodes or not at all). cpu_vertices: Option>, cpu_indices: Option>, + cpu_secondary_uvs: Option>, /// PT-6 — hit-shading inputs for the dynamic TLAS instance. base_color_idx: u32, metallic_factor: f32, roughness_factor: f32, + /// Stable resources consumed by GPU-driven passes (#27/#28). + material_id: material_indirection::MaterialId, + mesh_id: material_indirection::MeshId, + vertex_buffer_view_id: material_indirection::BufferViewId, + index_buffer_view_id: material_indirection::BufferViewId, +} + +/// Buffer binding plus indexed-draw window used by every compatibility pass. +/// Static cached meshes point into the shared geometry arena; skinned meshes +/// retain dedicated buffers. Keeping offsets in this value lets all existing +/// shadow/reflection/custom-material paths consume either backing without +/// duplicating static geometry. +#[derive(Copy, Clone)] +pub(super) struct MeshDrawRef<'a> { + pub(super) vertex: &'a wgpu::Buffer, + pub(super) index: &'a wgpu::Buffer, + pub(super) first_index: u32, + pub(super) index_count: u32, + pub(super) base_vertex: i32, +} + +impl MeshDrawRef<'_> { + pub(super) fn index_range(&self) -> std::ops::Range { + self.first_index..self.first_index + self.index_count + } +} + +/// Unified retained/cached imported-refraction draw descriptor. It lets the +/// forward pass apply one deterministic depth/stable-ID ordering contract +/// across both submission APIs. +pub(crate) struct ImportedRefractiveDrawRef<'a> { + pub(crate) view_depth: f32, + pub(crate) stable_id: usize, + pub(crate) double_sided: bool, + pub(crate) layered: bool, + pub(crate) uniforms: &'a wgpu::BindGroup, + pub(crate) material: &'a wgpu::BindGroup, + pub(crate) mesh: MeshDrawRef<'a>, + /// Optional glTF TEXCOORD_1 stream. Its presence selects the lazy + /// two-stream pipeline variant. + pub(crate) secondary_uv: Option<&'a wgpu::Buffer>, + /// Cached static meshes live in a shared arena. Refractive UV1 streams are + /// mesh-local, so the primary/index bindings are localized to matching + /// slices and indexed with base vertex zero. + pub(crate) vertex_byte_offset: u64, + pub(crate) index_byte_offset: u64, +} + +/// Unified retained/cached imported BLEND draw descriptor. Sorted alpha and +/// weighted OIT consume the same visible set, material bindings, and stable IDs. +pub(crate) struct ImportedTransparentDrawRef<'a> { + pub(crate) view_depth: f32, + pub(crate) stable_id: usize, + pub(crate) double_sided: bool, + /// Selects the lazy clearcoat/specular/IOR pipeline family. Base-only + /// draws leave this false and retain their existing pipeline/layout. + pub(crate) layered: bool, + pub(crate) uniforms: &'a wgpu::BindGroup, + pub(crate) material: &'a wgpu::BindGroup, + pub(crate) mesh: MeshDrawRef<'a>, + /// Optional glTF TEXCOORD_1 stream for the layered material. + pub(crate) secondary_uv: Option<&'a wgpu::Buffer>, + /// Shared cached geometry is localized whenever a mesh-local UV1 stream + /// is bound, matching the imported-refraction draw contract. + pub(crate) vertex_byte_offset: u64, + pub(crate) index_byte_offset: u64, +} + +/// Visible opaque iridescence draw replayed into the lazy SSR Fresnel target. +/// +/// This deliberately reuses the scene material bind group and resident +/// geometry. Base-only frames allocate neither this list nor its render target. +pub(crate) struct ImportedIridescenceDrawRef<'a> { + pub(crate) uniforms: &'a wgpu::BindGroup, + pub(crate) material: &'a wgpu::BindGroup, + pub(crate) mesh: MeshDrawRef<'a>, + pub(crate) secondary_uv: Option<&'a wgpu::Buffer>, + pub(crate) vertex_byte_offset: u64, + pub(crate) index_byte_offset: u64, } /// PT-6 — a dynamic instance's megabuffer window for this frame: @@ -204,6 +350,9 @@ struct CachedModelDraw { /// Object→world model matrix for this draw, kept CPU-side so the /// shadow pass can render the model depth-only from the light. model: [[f32; 4]; 4], + /// Per-draw tint/coverage, mirrored from Uniforms3D so lazy secondary + /// passes (notably transmitted shadows) preserve the exact authored draw. + tint: [f32; 4], /// Skinned cached draw: the VS skins from the shared joint buffer /// (uniform misc.y = 1.0). The shadow pass renders it through the /// skinning pipeline as a dynamic caster; planar reflections skip it. @@ -244,8 +393,12 @@ pub(crate) fn transform_aabb( ]; let wc = mat4_mul_vec4(model, &corner); for a in 0..3 { - if wc[a] < wmin[a] { wmin[a] = wc[a]; } - if wc[a] > wmax[a] { wmax[a] = wc[a]; } + if wc[a] < wmin[a] { + wmin[a] = wc[a]; + } + if wc[a] > wmax[a] { + wmax[a] = wc[a]; + } } } (wmin, wmax) @@ -262,24 +415,31 @@ pub(crate) fn transform_aabb( /// Alpha stays linear by definition and is NOT decoded. pub(crate) fn srgb_u8_to_linear(c: f64) -> f32 { let c = (c / 255.0).clamp(0.0, 1.0); - (if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }) as f32 + (if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + }) as f32 } pub struct Renderer { pub device: wgpu::Device, + device_negotiation_report: Option, pub queue: wgpu::Queue, - /// None in headless mode (golden tests, server rendering): frames - /// render into `headless_target` instead of a swapchain. + /// None in headless mode; frames render into `headless_target`. pub surface: Option>, headless_target: Option, + /// Surface acquisition naturally limits how far the CPU can submit + /// ahead of the GPU. A surface-less renderer has no such back-pressure, + /// so keep a small explicit fence queue or transient per-frame resources + /// can grow without bound during an uncapped batch render. + headless_in_flight: std::collections::VecDeque, pub surface_config: wgpu::SurfaceConfiguration, /// EN-063 — the format frames are rendered in: equals /// `surface_config.format` on native (already sRGB), or its sRGB view - /// variant on hosts whose surfaces are non-sRGB (WebGPU canvases). Every - /// pipeline that targets the swapchain, and the per-frame surface view, - /// use THIS; `surface_config.format` is only for `surface.configure`. + /// variant on hosts whose surfaces are non-sRGB (WebGPU canvases). + /// Pipelines use this; `surface_config.format` is only for configuration. pub output_format: wgpu::TextureFormat, - // Logical (points / CSS px) size — what user code addresses via // `screenWidth`/HUD coords. Physical render target size is stored // in `surface_config` and is `logical * scale_factor`. On non-HiDPI @@ -287,7 +447,6 @@ pub struct Renderer { pub logical_width: u32, pub logical_height: u32, - // Pipelines pipeline_2d: wgpu::RenderPipeline, pipeline_3d: wgpu::RenderPipeline, custom_pipelines: Vec, @@ -304,6 +463,10 @@ pub struct Renderer { // Lighting uniforms lighting_uniforms: LightingUniforms, + lighting_upload_tracker: lighting_upload::LightingUploadTracker, + frame_resource_stats: frame_resource_stats::FrameResourceStats, + steady_state_frame_resource_stats: frame_resource_stats::FrameResourceStats, + pipeline_creation_count: u64, lighting_buffer: wgpu::Buffer, lighting_bind_group: wgpu::BindGroup, @@ -311,6 +474,7 @@ pub struct Renderer { joint_buffer: wgpu::Buffer, /// PT-7 — previous frame's palette (same slot offsets). joint_prev_buffer: wgpu::Buffer, + joint_layout: wgpu::BindGroupLayout, joint_bind_group: wgpu::BindGroup, // False until the material system's per-view bind group has been @@ -328,8 +492,14 @@ pub struct Renderer { texture_bind_groups: Vec, textures: Vec, texture_sizes: Vec<(u32, u32)>, + /// Stable IDs parallel to `textures`; index zero is the white fallback. + global_texture_ids: Vec, + /// Lifetime count of registered MASK-specific coverage mip variants. + mask_coverage_texture_count: u32, pub sampler: wgpu::Sampler, pub nearest_sampler: wgpu::Sampler, + pub global_linear_sampler_id: material_indirection::SamplerId, + pub global_nearest_sampler_id: material_indirection::SamplerId, // Depth buffer depth_texture: wgpu::Texture, @@ -360,6 +530,8 @@ pub struct Renderer { pub scene_compose_pipeline: wgpu::RenderPipeline, pub scene_compose_layout: wgpu::BindGroupLayout, pub scene_compose_uniform_buffer: wgpu::Buffer, + scene_compose_bind_group_cache: + [Option; postfx_chain::SsrCompositeSource::COUNT], /// Composite-tonemap pipeline + bind group layout. Single full- /// screen draw that samples hdr_rt and writes ACES-tonemapped /// linear-rgb (sRGB hardware encode handles the transfer fn). @@ -368,6 +540,7 @@ pub struct Renderer { pub composite_pipeline: wgpu::RenderPipeline, pub composite_layout: wgpu::BindGroupLayout, pub composite_sampler: wgpu::Sampler, + composite_bind_group_cache: [Option; postfx_chain::CompositeSource::COUNT * 2], /// 0 = ACES (default, matches bloom-reference), 1 = AgX. pub tonemap_kind: u32, /// Auto-exposure on/off. Default off so validation against @@ -400,9 +573,14 @@ pub struct Renderer { pub exposure_textures: [wgpu::Texture; 2], pub exposure_views: [wgpu::TextureView; 2], pub exposure_current_idx: usize, + /// True after auto-exposure wrote a value in the current enable epoch. + pub exposure_history_valid: bool, + /// Per-frame producer bit; only written exposure may advance ping-pong. + exposure_history_written: bool, pub exposure_pipeline: wgpu::RenderPipeline, pub exposure_layout: wgpu::BindGroupLayout, pub exposure_uniform_buffer: wgpu::Buffer, + exposure_bind_group_cache: [Option; postfx_chain::CompositeSource::COUNT * 2], /// Bloom mip-chain texture. Single texture with BLOOM_MIP_COUNT /// mips starting at surface/2 size — each mip is half the /// previous. Downsample chain (with HDR threshold on first tap) @@ -417,6 +595,17 @@ pub struct Renderer { pub bloom_pipeline_downsample: wgpu::RenderPipeline, pub bloom_pipeline_upsample: wgpu::RenderPipeline, pub bloom_layout: wgpu::BindGroupLayout, + /// One stable uniform buffer and bind group per bloom pass. Keeping + /// these persistent avoids allocating Metal buffers/argument tables on + /// every frame, which is both slower and can fragment the driver's + /// resource suballocator during long uncapped runs. + bloom_downsample_param_buffers: Vec, + bloom_downsample_bind_groups: Vec, + bloom_upsample_param_buffers: Vec, + bloom_upsample_bind_groups: Vec, + /// Threshold currently resident in downsample buffer zero. Texel sizes + /// are immutable between resizes; only exposure changes require a write. + bloom_threshold_written: f32, /// Composite-shader uniform — bloom intensity etc. Written each /// frame from the renderer's `bloom_intensity` field. pub composite_uniform_buffer: wgpu::Buffer, @@ -504,6 +693,7 @@ pub struct Renderer { /// clear. Resets to 0 on resize and when SSAO toggles back on. pub ssao_history_frame: u32, ssr_bg_cache: Option, + ssr_layered_bg_cache: Option, /// TAA history ping-pong. Two HDR-format textures the same size /// as the surface — each frame writes to one, reads the other as /// history. `taa_current_idx` flips after every frame. @@ -513,6 +703,15 @@ pub struct Renderer { pub taa_pipeline: wgpu::RenderPipeline, pub taa_layout: wgpu::BindGroupLayout, pub taa_uniform_buffer: wgpu::Buffer, + taa_bind_group_cache: [Option; 2], + #[cfg(not(target_arch = "wasm32"))] + temporal_diagnostics: Option, + /// Lazy TAA variant that consumes imported-transparency coverage. + /// Opaque and TAA-disabled frames never compile or bind it. + taa_reactive_pipeline: Option, + taa_reactive_layout: Option, + taa_reactive_bind_group_cache: [Option; 2], + taa_reactive_bind_group_cache_keys: [Option<(u64, u64)>; 2], /// Frame counter used to pick a different Halton offset every /// frame for sub-pixel camera jitter — accumulating over the /// jitter sequence is what gives TAA its anti-aliasing. @@ -521,7 +720,16 @@ pub struct Renderer { /// 1 = TAA on (default). When off the renderer behaves exactly /// as the pre-TAA pipeline did. pub taa_enabled: bool, - /// Render-resolution multiplier in [0.5, 1.0]. The G-buffer, + /// True only after TAA wrote a current history in the active radiance + /// domain. Separate from the jitter counter so ownership changes can + /// replace history without restarting the sample sequence. + pub taa_history_valid: bool, + /// Per-frame producer bit; prevents ping-pong advancement when the TAA + /// graph node is intentionally skipped. + taa_history_written: bool, + /// Whether the history's scene-color source was path traced. + taa_history_pt_owned: bool, + /// Render-resolution multiplier in [0.15, 1.0]. The G-buffer, /// HDR, and composed RTs are sized to `surface * render_scale`; /// TAA (or the upscale pass) brings the output back up to the /// full surface for composite. At 0.5 = quarter-pixel shading @@ -543,11 +751,6 @@ pub struct Renderer { /// the scale can be changed at runtime without the platform telling us again. native_width: u32, native_height: u32, - /// Set once `set_render_scale` is called explicitly. While false, - /// `set_taa_enabled` keeps the legacy coupling (TAA on = 0.5, - /// TAA off = 1.0). Once the user opts into explicit control, the - /// scale they picked sticks across subsequent TAA toggles. - pub render_scale_explicit: bool, /// Previous frame's view-projection matrix — TAA reads this to /// reproject the history texture into current-frame UV space, /// removing ghosting under camera motion. Updated at the end @@ -568,6 +771,10 @@ pub struct Renderer { /// prev_mvp compositions must use so jitter cancels in the /// shader's (curr_ndc - prev_ndc). pub(crate) velocity_ref_vp: [[f32; 4]; 4], + /// An explicit cut pins the next camera as its own previous frame. + temporal_camera_cut_pending: bool, + /// True for the cut frame so retained scene motion can be zeroed. + temporal_camera_cut_active: bool, /// Fog color (rgb) — blended into scene where fog factor > 0. pub fog_color: [f32; 3], /// EN-005 Phase 4 — `true` once the user has called @@ -596,41 +803,39 @@ pub struct Renderer { /// sky's auto-derived transmittance tint so manual artistic /// overrides stick. sun_shaft_color_user_override: bool, - /// EN-005 Phase 3 — CPU copy of the transmittance LUT, kept - /// alongside the GPU texture so renderer-side code can sample - /// it for things like sun-shaft tint without a GPU readback. + /// EN-005 Phase 3 — CPU copy of the transmittance LUT for renderer-side + /// sampling such as sun-shaft tint, without a GPU readback. /// Sized identically to the GPU texture (`TRANSMITTANCE_W × _H`). transmittance_lut_cpu: Vec, - /// SSR (screen-space reflections) pass output — half-res HDR - /// holding the reflected color for each fragment. Composited - /// into the final image by the TAA pass. + /// Half-res HDR SSR output, composited into the final image by TAA. pub ssr_rt_texture: wgpu::Texture, pub ssr_rt_view: wgpu::TextureView, pub ssr_pipeline: wgpu::RenderPipeline, pub ssr_layout: wgpu::BindGroupLayout, pub ssr_uniform_buffer: wgpu::Buffer, - /// SSR strength multiplier (0 = off, 1 = full). Default 0.5 - /// is conservative — too much SSR makes diffuse surfaces look - /// like wet floors. Applies on top of the prefiltered IBL. + /// SSR strength (0 = off, 1 = full). The conservative 0.5 default avoids + /// making diffuse surfaces look wet and applies on top of prefiltered IBL. pub ssr_strength: f32, pub ssr_enabled: bool, - /// SSR temporal denoiser: ping-pong history textures (same format/size - /// as ssr_rt). One GGX-importance-sampled ray per pixel per frame - /// converges over 4–8 frames of accumulation via velocity reprojection - /// + neighborhood clamp. Compose reads ssr_history[cur] instead of - /// ssr_rt when ssr_enabled. + /// SSR temporal ping-pong, matching ssr_rt. One GGX ray per pixel converges + /// over 4–8 velocity-reprojected, neighborhood-clamped frames; compose reads + /// the current history when SSR is enabled. pub ssr_history_textures: [wgpu::Texture; 2], pub ssr_history_views: [wgpu::TextureView; 2], pub ssr_history_idx: usize, + /// False after resize, feature/mode transitions, or parameter changes. + /// The next temporal pass then replaces history instead of blending it. + pub ssr_history_valid: bool, pub ssr_temporal_pipeline: wgpu::RenderPipeline, pub ssr_temporal_layout: wgpu::BindGroupLayout, pub ssr_temporal_uniform_buffer: wgpu::Buffer, + ssr_temporal_bind_group_cache: [Option; 2], + #[cfg(not(target_arch = "wasm32"))] + ssr_temporal_diagnostics: Option, - /// SSGI (screen-space global illumination) pass output — half-res - /// HDR holding the indirect diffuse bounce light for each fragment. - /// Ticket 007a: written by the probe-resolve pass, composited by - /// the TAA pass. No other code path touches `ssgi_rt_view`. + /// Half-res HDR SSGI indirect diffuse output. Ticket 007a: written by probe + /// resolve and composited by TAA; no other code path touches the view. pub ssgi_rt_texture: wgpu::Texture, pub ssgi_rt_view: wgpu::TextureView, /// SSGI intensity multiplier (0 = off, 0.5 = default, 1+ = strong). @@ -639,6 +844,12 @@ pub struct Renderer { pub ssgi_radius: f32, /// SSGI master switch. pub ssgi_enabled: bool, + /// Lazy one-layer physical-transmission continuation for the HW ray-query + /// and SW SDF GI paths. The ordinary shader/pipeline stays selected when + /// false, so opaque scenes execute the historical first-hit kernel. + pub transparent_gi_active: bool, + pub transparent_gi_instance_count: u32, + transparent_gi_force_probe_refresh: bool, /// Path-tracing mode (docs/pt/pt-roadmap.md): 0 = off (Lumen as /// usual), 1 = progressive (accumulate while the camera is still), /// 2 = realtime (temporal + spatial denoise). Stores the *requested* @@ -657,24 +868,24 @@ pub struct Renderer { pub ssgi_backend_logged: Option<&'static str>, // --- Ticket 007a: Lumen-style screen-probe SSGI --- - /// Current probe grid dimensions. Recomputed on resize. pub probe_grid_w: u32, pub probe_grid_h: u32, - /// Per-probe header buffer (`ProbeHeader`, 32 B each). `STORAGE | - /// COPY_DST`. Written by probe-place, read by trace + resolve. + /// Per-probe headers (48 B, storage/copy-dst), placed then read by trace/resolve. pub probe_header_buffer: wgpu::Buffer, - /// Per-frame trace output — the compute trace pass writes - /// `textureStore` into this. The temporal pass reads it as the - /// "current" input. + /// Per-frame compute trace output; temporal reads it as current input. pub probe_trace_tex: wgpu::Texture, pub probe_trace_view: wgpu::TextureView, - /// Ping-pong 3D history textures (Rgba16Float, gw × gh × 64). - /// Temporal reads `[prev_idx]` + trace, writes to `[write_idx]`. - /// Resolve samples `[write_idx]`. + /// Rgba16Float 3D history: temporal reads prior/trace and writes directions; + /// resolve reads cosine-convolved radiance cached in each probe header. pub probe_history_textures: [wgpu::Texture; 2], pub probe_history_views: [wgpu::TextureView; 2], pub probe_history_idx: usize, + /// True after temporal writes; the index becomes next write after present. + /// Independent from TAA because SSGI may be disabled or replaced by PT. + pub probe_history_valid: bool, + #[cfg(not(target_arch = "wasm32"))] + ssgi_temporal_diagnostics: Option, /// Ticket 007b — true when the adapter granted /// `Features::EXPERIMENTAL_RAY_QUERY` at device creation. Flips the @@ -700,6 +911,11 @@ pub struct Renderer { /// indexed by the TLAS instance's `custom_data`. Rebuilt alongside /// the TLAS instance list. pub tlas_instance_data_buffer: Option, + /// Number of initialized records from the preceding rebuild. The GPU + /// buffer is capacity-sized and SDF shaders use `arrayLength`, so records + /// beyond a shrunken live set must be zeroed instead of retaining stale + /// glass/card metadata. + tlas_instance_data_count: u32, pub probe_place_pipeline: wgpu::ComputePipeline, pub probe_place_layout: wgpu::BindGroupLayout, @@ -716,86 +932,64 @@ pub struct Renderer { /// layout doesn't carry). `Some` only when `hw_rt_enabled`; `None` /// otherwise so the shader module isn't even compiled. pub probe_trace_hw_pipeline: Option, + /// Lazily compiled specialization. It is absent until an SSGI frame + /// actually contains imported physical transmission. + pub probe_trace_hw_transparent_pipeline: Option, pub probe_trace_hw_layout: Option, /// V3 — [Option; 2] so the HW trace can bind the prev-frame /// probe history on a per-frame ping-pong index, matching the /// SW cache shape. probe_trace_hw_bg_cache: [Option; 2], - // --- Path tracing (PT-1, docs/pt/pt-roadmap.md) --- - /// Megakernel pipeline; None when the adapter lacks ray query - /// (same gate as the HW probe trace — there is no SW fallback). + /// Base megakernel; None when the adapter lacks ray query. pub pt_pipeline: Option, pub pt_layout: Option, - /// Rebuilt lazily; nulled on resize and on instance-data rebuild. - /// Two variants for the accumulation ping-pong: bg[i] reads - /// accum_buffers[i] (binding 8) and writes accum_buffers[1-i] - /// (binding 13). + /// Lazy group-2 specialization. Base-only PT never creates or binds it. + pt_layered: layered_pbr_pt::PtLayeredRuntimeState, + /// Accumulation ping-pong bind groups, invalidated with their resources. pt_bg: [Option; 2], pt_uniform_buffer: wgpu::Buffer, - /// rgba32float-equivalent accumulation (vec4 per pixel) as storage - /// buffers — rgba32float storage *textures* lack read_write in core - /// WGSL. Ping-pong pair (PT-3 reprojection reads other pixels from - /// the previous frame). Lazily (re)created at render extent. + /// Vec4 storage-buffer accumulation ping-pong, recreated at trace extent. pt_accum_buffers: [Option; 2], - /// PT-3b SVGF — luminance moments + geometry side-channel, ping-pong - /// with the accum pair: (mu1, mu2, history length, raw depth) per - /// trace texel. The kernel validates reprojection taps and derives - /// the temporal variance from it; the à-trous passes read it for - /// depth edge-stopping and sky markers. + /// SVGF (mu1, mu2, history length, raw depth) ping-pong. pt_moments_buffers: [Option; 2], - /// Index of the buffer holding the PREVIOUS frame's result (the - /// read side of this frame's dispatch). Flipped after dispatch. pt_accum_idx: usize, - /// Samples accumulated at the current view; 0 = history invalid. pt_accum_count: u32, - /// VP matrix of the last accumulated frame; movement beyond epsilon - /// resets accumulation (mode 1) — mode 2 relies on EMA instead. + /// Last accumulated VP; motion resets progressive accumulation. pt_prev_vp: [[f32; 4]; 4], /// TLAS version last traced; a rebuild invalidates accumulation. pt_last_tlas_version: u64, - /// BLOOM_PT_DEBUG view selector, forwarded to the kernel via cfg.w. pt_debug: f32, - /// PT's own rolling frame counter (kernel RNG seed). Deliberately - /// NOT taa_frame_index: that freezes when TAA is off, which froze - /// the sample sequence and stopped accumulation from converging. pt_frame_index: u32, + /// Global sample scramble; zero preserves checked-in golden sequences. + pt_rng_seed: u32, + #[cfg(not(target_arch = "wasm32"))] + pt_temporal_diagnostics: Option, /// BLOOM_PT_RESTIR=1 — PT-4 experimental ReSTIR DI (kernel ext.w). pt_restir: bool, /// PT-4 — ReSTIR reservoir ping-pong, sized with the accum pair. pt_resv_buffers: [Option; 2], - /// PT-6 — skinned meshes drawn this frame (dynamic TLAS instances). - /// Pushed by draw_model_cached_skinned, consumed by - /// rebuild_instance_data, cleared with the per-frame draw lists. + /// Skinned draws awaiting dynamic TLAS instance construction. pt_dynamic_draws: Vec, - /// PT-6 — per-slot megabuffer windows for this frame's dynamic - /// instances. Feeds the compute pre-skin dispatches and the - /// per-frame BLAS builds. + /// Per-slot geometry windows for compute pre-skin and BLAS build. pt_dyn_windows: Vec, - /// PT-6 — dynamic BLAS pool, one slot per dynamic instance. - /// Recreated when a slot's (vertex_count, index_count) changes. + /// Dynamic BLAS pool, recreated when a slot's geometry size changes. pt_dyn_blas: Vec<(wgpu::Blas, u32, u32)>, - /// PT-6 — compute pre-skin pipeline (posed world-space vertices - /// written into the PT megabuffer window before the BLAS build). + /// Writes posed world-space vertices before dynamic BLAS builds. pt_skin_pipeline: Option, pt_skin_layout: Option, /// Per-slot uniform buffers for the pre-skin params (grow-only). pt_skin_params: Vec, - /// PT-2 — concatenated Vertex3D words (f32) / indices (u32) for - /// interpolated hit shading. Grow-only, rebuilt alongside the - /// instance-data buffer. + /// Grow-only concatenated geometry for interpolated hit shading. pt_geo_vertex_buffer: Option, pt_geo_index_buffer: Option, - /// PT-2 — adapter grants binding-array features; when false the - /// kernel compiles without the texture array and hit shading stays - /// on card albedo. + /// Whether bounce hit shading can bind the texture array. pt_texture_arrays_enabled: bool, /// PT-2 — the texture binding array lives in its own group (wgpu /// forbids binding arrays next to uniform buffers). pt_tex_layout: Option, pt_tex_bg: Option, - /// Texture-store length baked into the current pt_tex_bg; growth - /// forces a rebuild so new textures become visible to PT. + /// Texture-store length baked into the current bind group. pt_bg_texture_count: usize, /// Debug-16 numeric readback (see pt_pass.rs). pt_readback_buffer: Option, @@ -828,6 +1022,8 @@ pub struct Renderer { /// Active whenever the scene clipmap has been baked; chosen at /// dispatch time over the Hi-Z SW fallback when available. pub probe_trace_sdf_pipeline: wgpu::ComputePipeline, + /// SW counterpart of `probe_trace_hw_transparent_pipeline`. + pub probe_trace_sdf_transparent_pipeline: Option, pub probe_trace_sdf_layout: wgpu::BindGroupLayout, /// V3 — same [Option; 2] shape as the HW + SW caches. probe_trace_sdf_bg_cache: [Option; 2], @@ -894,6 +1090,10 @@ pub struct Renderer { /// snapped camera position at last bake). Read every frame by the /// trace uniform; updated at bake time. pub scene_sdf_clipmap_origin: [f32; 3], + /// Scene/material representation captured by the live clipmap. A scene + /// version or transparent-GI mode mismatch starts an amortized rebake. + scene_sdf_clipmap_scene_version: u64, + scene_sdf_clipmap_transparent_gi: bool, /// Fullscreen-lag fix — dedicated binned+sliced clipmap bake /// pipeline (the per-mesh `sdf_bake_pipeline` stays brute-force; /// its 32³ per-mesh volumes are already rate-limited). @@ -924,6 +1124,8 @@ pub struct Renderer { /// + card atlas so probe-octel rays can sample pre-lit Mesh /// Cards radiance at hit points. pub wsrc_bake_hw_pipeline: Option, + /// Lazy one-layer transmission specialization of the HW WSRC bake. + pub wsrc_bake_hw_transparent_pipeline: Option, pub wsrc_bake_hw_layout: Option, wsrc_bake_hw_bg_cache: Option, /// V13 — per-cascade state. Each cascade (near/mid/far) tracks @@ -962,6 +1164,7 @@ pub struct Renderer { pub dof_pipeline: wgpu::RenderPipeline, pub dof_layout: wgpu::BindGroupLayout, pub dof_uniform_buffer: wgpu::Buffer, + dof_bind_group_cache: [Option; postfx_chain::PostFxSource::COUNT], /// DoF master switch. Default false — no perf cost when off. pub dof_enabled: bool, /// Focus distance in world units from the camera. Objects at @@ -990,6 +1193,7 @@ pub struct Renderer { pub motion_blur_pipeline: wgpu::RenderPipeline, pub motion_blur_layout: wgpu::BindGroupLayout, pub motion_blur_uniform_buffer: wgpu::Buffer, + motion_blur_bind_group_cache: [Option; postfx_chain::PostFxSource::COUNT], /// Motion blur master switch. Default false — no perf cost when off. pub motion_blur_enabled: bool, /// Velocity multiplier. Higher = more blur for the same motion. @@ -1008,6 +1212,7 @@ pub struct Renderer { pub sss_pipeline: wgpu::RenderPipeline, pub sss_layout: wgpu::BindGroupLayout, pub sss_uniform_buffer: wgpu::Buffer, + sss_bind_group_cache: [Option; postfx_chain::PostFxSource::COUNT], /// SSS master switch. Default false — zero perf cost when off. pub sss_enabled: bool, /// SSS scatter strength: 0 = no blur (even when enabled), 1 = full @@ -1026,6 +1231,7 @@ pub struct Renderer { pub upscale_pipeline: wgpu::RenderPipeline, pub upscale_layout: wgpu::BindGroupLayout, pub upscale_uniform_buffer: wgpu::Buffer, + upscale_bind_group_cache: Option, /// 0 = bilinear (cheap), 1 = Catmull-Rom 5-tap (sharper edges). /// Default 1. pub upscale_mode: u32, @@ -1040,6 +1246,7 @@ pub struct Renderer { pub cas_pipeline: wgpu::RenderPipeline, pub cas_layout: wgpu::BindGroupLayout, pub cas_uniform_buffer: wgpu::Buffer, + cas_bind_group_cache: [Option; postfx_chain::PostFxSource::COUNT], /// 0 = off (default), 0.3 subtle, 0.6 punchy, 1.0 max. pub cas_strength: f32, @@ -1052,6 +1259,7 @@ pub struct Renderer { pub vertices_3d: Vec, pub indices_3d: Vec, draw_calls_3d: Vec, + immediate_motion: immediate_motion::History, current_texture_3d: u32, // Persistent GPU buffers (reused across frames, grown as needed) @@ -1067,11 +1275,29 @@ pub struct Renderer { // Cached model GPU buffers (static AND skinned — skinned VBs keep // raw bind-pose joint indices; the scene VS skins them per draw) model_gpu_cache: HashMap>>, + /// Shared static geometry, GPU culling, and indirect submission. + gpu_driven: gpu_driven::GpuDrivenRenderer, /// Handles whose cached meshes carry skin weights, recorded at cache /// time so `bloom_draw_model` can pick the skinned cached path /// without rescanning vertices every frame. model_skinned: std::collections::HashSet, + /// Cached-model handles containing at least one glTF BLEND primitive. + /// Populated once at upload so draw submission can open the translucent + /// path without rescanning every mesh. + model_blended: std::collections::HashSet, + /// Cached-model handles containing at least one layered-PBR BLEND + /// primitive. Keeps lazy transparent pipeline creation O(1) per frame. + model_layered_blended: std::collections::HashSet, + /// Cached-model handles containing at least one active imported + /// transmission primitive. + model_refractive: std::collections::HashSet, model_draw_commands: Vec, + cached_model_motion: model_draw::CachedModelMotionHistory, + /// Per-frame O(1) translucent-pass gate for cached-model draws. + has_blend_model_draws: bool, + has_layered_blend_model_draws: bool, + has_refractive_model_draws: bool, + has_refractive_scene_nodes: bool, /// Pooled per-draw uniforms for cached-model draws: ONE buffer with /// per-slot bind groups at 256 B offsets. Draw submission packs each /// slot's Uniforms3D into `model_uniform_scratch` CPU-side; end-frame @@ -1143,10 +1369,17 @@ pub struct Renderer { /// real (they were exactly zero before — no history existed). pub pending_skin_groups_prev: Vec>, pub frame_joint_data_prev: Vec<[[f32; 4]; 4]>, + /// Submission-slot history for the unkeyed editor/test API. A skipped + /// frame empties the previous slot stream, so reappearance seeds current. + skin_unkeyed_previous: Vec>, + skin_unkeyed_current: Vec>, + skin_unkeyed_previous_count: usize, + skin_unkeyed_slot: usize, + skin_motion_epoch: u64, /// Last staged palette per animation key (FFI anim handle). A few /// dozen entries at most — anim handles are per model slot, not /// per spawn — so no eviction is needed. - skin_prev_palettes: std::collections::HashMap>, + skin_prev_palettes: std::collections::HashMap)>, pub model_skin_scale: f32, // Shadow mapping @@ -1160,6 +1393,8 @@ pub struct Renderer { /// Used by `bloom_take_screenshot()` so TS code (and CI / diff /// tooling) can grab a frame without going through geisterhand. pub pending_screenshot_path: Option, + /// Opt-in qualification-only directory for named graph outputs. + pub pending_quality_capture_dir: Option, // Q1: Render-to-texture override. When set, end_frame renders to this // texture view instead of the surface. Set by begin_texture_mode, @@ -1252,11 +1487,57 @@ pub struct Renderer { // mapping). Distinct from pipeline_3d so immediate-mode draws // don't have to carry tangent vertex data or normal-map bindings. pub scene_pipeline: wgpu::RenderPipeline, + scene_transparent_pipeline: wgpu::RenderPipeline, + scene_transparent_double_sided_pipeline: wgpu::RenderPipeline, + scene_transparent_reactive_pipeline: Option, + scene_transparent_reactive_double_sided_pipeline: Option, + scene_weighted_transparent_pipeline: Option, + scene_weighted_transparent_double_sided_pipeline: Option, + weighted_transparency_resolve_pipeline: Option, + weighted_transparency_reactive_resolve_pipeline: Option, + weighted_transparency_resolve_layout: Option, + weighted_transparency_resolve_bind_group: Option, + weighted_transparency_resolve_bind_group_key: Option<(u64, u64)>, + transparency_composition_preference: TransparencyCompositionPreference, + weighted_transparency_active: bool, + temporal_reactive_active: bool, + /// Lazy physical-transmission shadow resources and this frame's exact + /// route gate. Opaque-only scenes keep both at None/false. + transmitted_shadow_resources: Option, + transmitted_shadows_active: bool, + scene_refractive_pipeline: Option, + scene_refractive_double_sided_pipeline: Option, + scene_refractive_uv1_pipeline: Option, + scene_refractive_uv1_double_sided_pipeline: Option, + scene_refractive_reactive_pipeline: Option, + scene_refractive_reactive_double_sided_pipeline: Option, + scene_refractive_reactive_uv1_pipeline: Option, + scene_refractive_reactive_uv1_double_sided_pipeline: Option, + /// Native physical-transmission group 4 specialization. Present only when + /// the bounded screen-space reflection tier is startup-enabled and a + /// contributing material has forced lazy pipeline creation. + #[cfg(not(fold_scene_inputs))] + scene_refractive_inputs_layout: Option, + #[cfg(not(fold_scene_inputs))] + scene_refractive_inputs_bg: Option, + #[cfg(not(fold_scene_inputs))] + scene_refractive_reflection_params_buffer: Option, + refractive_reflections_active: bool, /// EN-044 — depth-only prepass over the same cached-model draws. pub scene_depth_pipeline: wgpu::RenderPipeline, /// EN-044 — cached-model pipeline for the prepassed path (no depth write, Equal). pub scene_pipeline_prepassed: wgpu::RenderPipeline, pub scene_material_layout: wgpu::BindGroupLayout, + scene_sheen_albedo_lut: Option, + scene_layered_pbr_resources: Option, + scene_iridescence_ssr_resources: Option, + iridescence_ssr_active: bool, + scene_layered_refractive_resources: + Option, + scene_refractive_material_layout: Option, + /// Startup-selected imported-transmission route. False restores the exact + /// legacy import fallback for A/B diagnosis. + imported_refraction_enabled: bool, /// Froxel light clustering (task #23). `Some` when the device has /// fragment-stage storage buffers (everything but WebGL2); the /// scene shader is then compiled with the clustered point-light @@ -1309,16 +1590,19 @@ pub struct Renderer { _default_normal_texture: wgpu::Texture, pub default_normal_view: wgpu::TextureView, - /// Phase 1c — the new shader-ABI material draw path. Opt-in via - /// `compile_material` + `submit_material_draw`; existing draws are - /// untouched. + /// Opt-in shader-ABI material draw path. pub material_system: material_system::MaterialSystem, - /// Phase 3 — short-lived texture pool. Feeds scene-colour - /// snapshots (Phase 4b), depth-as-sampled linearisations (Phase 4b), - /// and future graph-managed intermediates. + /// Short-lived graph-managed textures and buffers. pub transient_pool: transient::TransientPool, + /// Immutable frame topologies keyed only by pass/resource configuration. + frame_plan_cache: graph::PlanCache, + /// Most recently selected plan, retained for diagnostics/capture tools. + last_frame_plan: Option>, + /// Plan ids already emitted through `BLOOM_GRAPH_DUMP_DIR`. + dumped_frame_plans: std::collections::HashSet, + /// Phase 7 — persistent world-space impulse field. Games submit /// splats via `bloom_splat_impulse`; the renderer dispatches a /// compute pass per frame to decay + accumulate, then the front @@ -1339,16 +1623,16 @@ pub struct Renderer { /// Rebuilding one per probe per frame was measurable churn — the /// inputs only change on env (re)load or probe creation, both of /// which clear the cache slot. - pub planar_probe_view_bgs: Vec>, + pub planar_probe_view_bgs: Vec>, /// EN-011 — lazily-built resources for rendering cached models (trees, /// house) into the planar probe with a mirrored VP. Single-target HDR /// pipeline + a dynamic per-draw model uniform + a sun/ambient uniform. /// `None` until the first `dispatch_planar_reflections` with a probe. pub reflect_scene_pipeline: Option, - pub reflect_model_buf: Option, - pub reflect_model_bg: Option, - pub reflect_light_buf: Option, - pub reflect_light_bg: Option, + pub reflect_model_buf: Option, + pub reflect_model_bg: Option, + pub reflect_light_buf: Option, + pub reflect_light_bg: Option, /// Phase 6 — hot-reload registry for file-backed materials. Each /// frame we drain pending file-change events and recompile any @@ -1391,6 +1675,113 @@ pub fn create_mesh_sdf_texture_public( formats::create_mesh_sdf_texture(device, label) } +fn create_scene_refractive_material_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + let texture = |binding| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }; + let sampler = |binding| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }; + let uniform = |binding, visibility| wgpu::BindGroupLayoutEntry { + binding, + visibility, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }; + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("scene_refractive_material_layout"), + entries: &[ + texture(0), + sampler(1), + texture(2), + sampler(3), + texture(4), + sampler(5), + texture(6), + sampler(7), + uniform(8, wgpu::ShaderStages::VERTEX_FRAGMENT), + texture(9), + sampler(10), + texture(11), + sampler(12), + texture(13), + sampler(14), + uniform(15, wgpu::ShaderStages::FRAGMENT), + ], + }) +} + +fn specialized_scene_shader_source( + clustered_lights_available: bool, + virtual_shadows_requested: bool, +) -> std::borrow::Cow<'static, str> { + specialized_scene_shader_source_from( + SCENE_SHADER.into(), + clustered_lights_available, + virtual_shadows_requested, + ) +} + +fn specialized_scene_shader_source_from( + base: std::borrow::Cow<'static, str>, + clustered_lights_available: bool, + virtual_shadows_requested: bool, +) -> std::borrow::Cow<'static, str> { + let bloom_skip = std::env::var("BLOOM_SKIP").unwrap_or_default(); + let skip_froxel_shader = bloom_skip + .split(',') + .any(|token| token.trim() == "froxel_shader"); + let force_clustered_on = bloom_skip + .split(',') + .any(|token| token.trim() == "clustered_on"); + let use_plain_lights = (cfg!(mobile_lights) || skip_froxel_shader) && !force_clustered_on; + let source: std::borrow::Cow<'static, str> = if clustered_lights_available && !use_plain_lights + { + froxel::clustered_scene_shader(&base).into() + } else { + base + }; + let source: std::borrow::Cow<'static, str> = if virtual_shadows_requested { + crate::virtual_shadows::directional_scene_shader(&source).into() + } else { + source + }; + if bloom_skip.split(',').any(|token| token.trim() == "ibl") { + let source = source.into_owned(); + match ( + source.find("//IBL_STRIP_BEGIN"), + source.find("//IBL_STRIP_END"), + ) { + (Some(begin), Some(end)) if end > begin => { + let end = end + "//IBL_STRIP_END".len(); + format!( + "{}\n let ibl_diffuse = base_color * (1.0 - metallic) * occlusion\n * lighting.camera_pos.w * 0.35;\n let ibl_spec = vec3(0.0);\n let ibl_clearcoat = vec3(0.0);\n{}", + &source[..begin], + &source[end..] + ) + .into() + } + _ => source.into(), + } + } else { + source + } +} + impl Renderer { pub fn new( device: wgpu::Device, @@ -1400,24 +1791,25 @@ impl Renderer { logical_width: u32, logical_height: u32, ) -> Self { - Self::new_impl(device, queue, Some(surface), surface_config, logical_width, logical_height) + Self::new_impl( + device, + queue, + Some(surface), + surface_config, + logical_width, + logical_height, + ) } /// Headless renderer: no swapchain; frames render into an offscreen /// texture readable via the screenshot path. Used by the golden-image /// tests and available for server-side rendering. - pub fn new_headless( - device: wgpu::Device, - queue: wgpu::Queue, - width: u32, - height: u32, - ) -> Self { + pub fn new_headless(device: wgpu::Device, queue: wgpu::Queue, width: u32, height: u32) -> Self { let surface_config = wgpu::SurfaceConfiguration { // COPY_SRC: bloom_take_screenshot reads the swapchain back; // without it the readback copy is a swallowed validation // error and screenshots silently produce nothing. - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::COPY_SRC, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, format: wgpu::TextureFormat::Bgra8UnormSrgb, width, height, @@ -1464,18 +1856,17 @@ impl Renderer { // query, set by whichever platform crate constructed this // device. Renderer internals branch on this flag when picking // the probe-trace pipeline. - let hw_rt_enabled = device - .features() - .contains(wgpu::Features::EXPERIMENTAL_RAY_QUERY); + let hw_rt_enabled = capabilities::hardware_ray_query_enabled(device.features()); // PT-2 — textured hit shading wants a bindless-ish texture // array. Both feature bits AND the element budget (a separate // limit that defaults to 0 and must be granted at device // creation), or the kernel compiles with the card-only stub. - let pt_texture_arrays_enabled = device.features().contains( - wgpu::Features::TEXTURE_BINDING_ARRAY - | wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING, - ) && device.limits().max_binding_array_elements_per_shader_stage - >= PT_MAX_TEXTURES as u32; + let pt_texture_arrays_enabled = + device.features().contains( + wgpu::Features::TEXTURE_BINDING_ARRAY + | wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING, + ) && device.limits().max_binding_array_elements_per_shader_stage + >= PT_MAX_TEXTURES as u32; // --- Shaders --- let shader_2d = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -1502,27 +1893,28 @@ impl Renderer { }], }); - let texture_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("texture_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, + let texture_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("texture_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); let uniform_3d_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("uniform_3d_layout"), @@ -1571,7 +1963,13 @@ impl Renderer { // --- 3D uniform buffer --- let uniform_buffer_3d = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("uniform_3d"), - contents: bytemuck::bytes_of(&Uniforms3D { mvp: IDENTITY_MAT4, model: IDENTITY_MAT4, prev_mvp: IDENTITY_MAT4, model_tint: [1.0, 1.0, 1.0, 1.0], misc: [0.0; 4] }), + contents: bytemuck::bytes_of(&Uniforms3D { + mvp: IDENTITY_MAT4, + model: IDENTITY_MAT4, + prev_mvp: IDENTITY_MAT4, + model_tint: [1.0, 1.0, 1.0, 1.0], + misc: [0.0; 4], + }), usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, }); let uniform_bind_group_3d = device.create_bind_group(&wgpu::BindGroupDescriptor { @@ -1595,10 +1993,14 @@ impl Renderer { // Froxel clustering first — its presence decides whether the // lighting layout grows bindings 10-12 and which point-light // loop the scene shader is compiled with. - let froxel = froxel::FroxelPass::supported(&device) - .then(|| froxel::FroxelPass::new(&device)); + let froxel = + froxel::FroxelPass::supported(&device).then(|| froxel::FroxelPass::new(&device)); - let lighting_layout = lighting::create_lighting_layout(&device, froxel.is_some()); + let lighting_layout = lighting::create_lighting_layout( + &device, + froxel.is_some(), + crate::virtual_shadows::virtual_shadows_requested(), + ); let lighting_uniforms = LightingUniforms::defaults(); let lighting_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("lighting_buffer"), @@ -1665,7 +2067,11 @@ impl Renderer { ]; let scene_env_default_texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("scene_env_default_texture"), - size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -1686,9 +2092,14 @@ impl Renderer { bytes_per_row: Some(8), rows_per_image: Some(1), }, - wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, ); - let scene_env_default_view = scene_env_default_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let scene_env_default_view = + scene_env_default_texture.create_view(&wgpu::TextureViewDescriptor::default()); // --- BRDF LUT (split-sum integration) --- // 256x256 Rg16Float texture. f(NdotV, roughness) → (scale, bias) @@ -1906,7 +2317,11 @@ impl Renderer { &queue, &wgpu::TextureDescriptor { label: Some("white_texture"), - size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -1922,8 +2337,14 @@ impl Renderer { label: Some("white_texture_bg"), layout: &texture_bind_group_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&white_view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&white_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, ], }); @@ -1935,43 +2356,40 @@ impl Renderer { texture_sizes.push((1, 1)); // --- Depth texture --- - let (depth_texture, depth_view) = create_depth_texture(&device, surface_config.width, surface_config.height); - let (hdr_rt_texture, hdr_rt_view) = create_hdr_rt(&device, surface_config.width, surface_config.height); - let (material_rt_texture, material_rt_view) = create_material_rt(&device, surface_config.width, surface_config.height); - let (albedo_rt_texture, albedo_rt_view) = create_albedo_rt(&device, surface_config.width, surface_config.height); - let (composed_rt_texture, composed_rt_view) = create_composed_rt(&device, surface_config.width, surface_config.height); + let (depth_texture, depth_view) = + create_depth_texture(&device, surface_config.width, surface_config.height); + let (hdr_rt_texture, hdr_rt_view) = + create_hdr_rt(&device, surface_config.width, surface_config.height); + let (material_rt_texture, material_rt_view) = + create_material_rt(&device, surface_config.width, surface_config.height); + let (albedo_rt_texture, albedo_rt_view) = + create_albedo_rt(&device, surface_config.width, surface_config.height); + let (composed_rt_texture, composed_rt_view) = + create_composed_rt(&device, surface_config.width, surface_config.height); let (bloom_chain_textures, bloom_mip_views, bloom_full_view) = create_bloom_chain( &device, surface_config.width, surface_config.height, BLOOM_MIP_COUNT, ); - let (ssao_rt_texture, ssao_rt_view) = create_ssao_rt( - &device, surface_config.width, surface_config.height, - ); - let (ssao_history_textures, ssao_history_views) = create_ssao_history_textures( - &device, surface_config.width, surface_config.height, - ); - let (taa_textures, taa_views) = create_taa_textures( - &device, surface_config.width, surface_config.height, - ); - let (ssr_rt_texture, ssr_rt_view) = create_ssr_rt( - &device, surface_config.width, surface_config.height, - ); - let (ssr_history_textures, ssr_history_views) = create_ssr_history_textures( - &device, surface_config.width, surface_config.height, - ); - let (ssgi_rt_texture, ssgi_rt_view) = create_ssgi_rt( - &device, surface_config.width, surface_config.height, - ); + let (ssao_rt_texture, ssao_rt_view) = + create_ssao_rt(&device, surface_config.width, surface_config.height); + let (ssao_history_textures, ssao_history_views) = + create_ssao_history_textures(&device, surface_config.width, surface_config.height); + let (taa_textures, taa_views) = + create_taa_textures(&device, surface_config.width, surface_config.height); + let (ssr_rt_texture, ssr_rt_view) = + create_ssr_rt(&device, surface_config.width, surface_config.height); + let (ssr_history_textures, ssr_history_views) = + create_ssr_history_textures(&device, surface_config.width, surface_config.height); + let (ssgi_rt_texture, ssgi_rt_view) = + create_ssgi_rt(&device, surface_config.width, surface_config.height); let (probe_grid_w, probe_grid_h) = probe_grid_dims(surface_config.width, surface_config.height); - let (probe_trace_tex, probe_trace_view) = create_probe_trace_tex( - &device, surface_config.width, surface_config.height, - ); - let (probe_history_textures, probe_history_views) = create_probe_history_textures( - &device, surface_config.width, surface_config.height, - ); + let (probe_trace_tex, probe_trace_view) = + create_probe_trace_tex(&device, surface_config.width, surface_config.height); + let (probe_history_textures, probe_history_views) = + create_probe_history_textures(&device, surface_config.width, surface_config.height); let probe_header_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("probe_header_buffer"), size: (probe_grid_w * probe_grid_h) as u64 @@ -1979,27 +2397,23 @@ impl Renderer { usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - let (dof_rt_texture, dof_rt_view) = create_dof_rt( - &device, surface_config.width, surface_config.height, - ); - let (velocity_rt_texture, velocity_rt_view) = create_velocity_rt( - &device, surface_config.width, surface_config.height, - ); + let (dof_rt_texture, dof_rt_view) = + create_dof_rt(&device, surface_config.width, surface_config.height); + let (velocity_rt_texture, velocity_rt_view) = + create_velocity_rt(&device, surface_config.width, surface_config.height); // Motion blur RT reuses the same HDR format as DoF. - let (motion_blur_rt_texture, motion_blur_rt_view) = create_dof_rt( - &device, surface_config.width, surface_config.height, - ); + let (motion_blur_rt_texture, motion_blur_rt_view) = + create_dof_rt(&device, surface_config.width, surface_config.height); // SSS RT — full-res HDR, same format as DoF/motion-blur. - let (sss_rt_texture, sss_rt_view) = create_sss_rt( - &device, surface_config.width, surface_config.height, - ); + let (sss_rt_texture, sss_rt_view) = + create_sss_rt(&device, surface_config.width, surface_config.height); let (exposure_textures, exposure_views) = create_exposure_textures(&device); // --- Persistent GPU buffers (reused across frames) --- let vb_3d_cap = 1024 * 1024; // 1MB ~= 10,900 Vertex3D - let ib_3d_cap = 512 * 1024; // 512KB - let vb_2d_cap = 256 * 1024; // 256KB - let ib_2d_cap = 128 * 1024; // 128KB + let ib_3d_cap = 512 * 1024; // 512KB + let vb_2d_cap = 256 * 1024; // 256KB + let ib_2d_cap = 128 * 1024; // 128KB let persistent_vb_3d = device.create_buffer(&wgpu::BufferDescriptor { label: Some("persistent_vb_3d"), @@ -2116,10 +2530,10 @@ impl Renderer { let offset = i * 64; // Identity matrix in column-major: [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1] let one = 1.0f32.to_le_bytes(); - identity_data[offset..offset+4].copy_from_slice(&one); // [0][0] - identity_data[offset+20..offset+24].copy_from_slice(&one); // [1][1] - identity_data[offset+40..offset+44].copy_from_slice(&one); // [2][2] - identity_data[offset+60..offset+64].copy_from_slice(&one); // [3][3] + identity_data[offset..offset + 4].copy_from_slice(&one); // [0][0] + identity_data[offset + 20..offset + 24].copy_from_slice(&one); // [1][1] + identity_data[offset + 40..offset + 44].copy_from_slice(&one); // [2][2] + identity_data[offset + 60..offset + 64].copy_from_slice(&one); // [3][3] } queue.write_buffer(&joint_buffer, 0, &identity_data); queue.write_buffer(&joint_prev_buffer, 0, &identity_data); @@ -2128,7 +2542,12 @@ impl Renderer { // --- 3D Pipeline --- let pipeline_layout_3d = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pipeline_layout_3d"), - bind_group_layouts: &[Some(&uniform_3d_layout), Some(&lighting_layout), Some(&texture_bind_group_layout), Some(&joint_layout)], + bind_group_layouts: &[ + Some(&uniform_3d_layout), + Some(&lighting_layout), + Some(&texture_bind_group_layout), + Some(&joint_layout), + ], immediate_size: 0, }); @@ -2144,6 +2563,28 @@ impl Renderer { fragment: Some(wgpu::FragmentState { module: &shader_3d, entry_point: Some("fs_main_3d"), + // SH-055 — `lean_mrt` (Android): material/albedo -> None, mirroring + // material_pipeline.rs's opaque_targets. Every pipeline drawn + // inside main_hdr_pass must agree index-for-index on which + // targets are Some/None (wgpu-core's RenderPassContext + // compatibility check), so this pipeline, scene_pipeline, and + // scene_pipeline_prepassed all mirror the same layout. + #[cfg(lean_mrt)] + targets: &[ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + }), + None, + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + None, + ], + #[cfg(not(lean_mrt))] targets: &[ Some(wgpu::ColorTargetState { format: HDR_FORMAT, @@ -2200,9 +2641,14 @@ impl Renderer { mapped_at_creation: false, }); let model_uniform_bind_groups: Vec = (0..model_uniform_count) - .map(|slot| Self::model_uniform_bg_for_slot( - &device, &uniform_3d_layout, &model_uniform_pool, slot, - )) + .map(|slot| { + Self::model_uniform_bg_for_slot( + &device, + &uniform_3d_layout, + &model_uniform_pool, + slot, + ) + }) .collect(); // (shadow_map already created above before lighting bind group.) @@ -2231,37 +2677,38 @@ impl Renderer { mipmap_filter: wgpu::MipmapFilterMode::Nearest, ..Default::default() }); - let sky_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("sky_bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, + let sky_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("sky_bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); let sky_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("sky_pl"), bind_group_layouts: &[Some(&sky_bind_group_layout)], @@ -2279,6 +2726,23 @@ impl Renderer { fragment: Some(wgpu::FragmentState { module: &sky_shader, entry_point: Some("sky_fs"), + // SH-055 — `lean_mrt` (Android): see pipeline_3d's comment above. + #[cfg(lean_mrt)] + targets: &[ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + None, + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + None, + ], + #[cfg(not(lean_mrt))] targets: &[ Some(wgpu::ColorTargetState { format: HDR_FORMAT, @@ -2365,70 +2829,73 @@ impl Renderer { label: Some("sky_view_compute_shader"), source: wgpu::ShaderSource::Wgsl(SKY_VIEW_LUT_SHADER_WGSL.into()), }); - let sky_view_compute_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("sky_view_compute_bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, + let sky_view_compute_bgl = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("sky_view_compute_bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 3, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 4, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::StorageTexture { - access: wgpu::StorageTextureAccess::WriteOnly, - format: wgpu::TextureFormat::Rgba16Float, - view_dimension: wgpu::TextureViewDimension::D2, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, - count: None, - }, - ], - }); - let sky_view_compute_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("sky_view_compute_pl"), - bind_group_layouts: &[Some(&sky_view_compute_bgl)], - immediate_size: 0, - }); - let sky_view_compute_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("sky_view_compute_pipeline"), - layout: Some(&sky_view_compute_pl_layout), - module: &sky_view_compute_shader, - entry_point: Some("cs_main"), - compilation_options: Default::default(), - cache: None, - }); + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: wgpu::TextureFormat::Rgba16Float, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + ], + }); + let sky_view_compute_pl_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sky_view_compute_pl"), + bind_group_layouts: &[Some(&sky_view_compute_bgl)], + immediate_size: 0, + }); + let sky_view_compute_pipeline = + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("sky_view_compute_pipeline"), + layout: Some(&sky_view_compute_pl_layout), + module: &sky_view_compute_shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }); let sky_view_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("sky_view_uniform_buffer"), size: std::mem::size_of::() as u64, @@ -2439,11 +2906,26 @@ impl Renderer { label: Some("sky_view_compute_bg"), layout: &sky_view_compute_bgl, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: sky_view_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&atmosphere_transmittance_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&atmosphere_multi_scattering_view) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&atmosphere_lut_sampler) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&sky_view_lut_view) }, + wgpu::BindGroupEntry { + binding: 0, + resource: sky_view_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&atmosphere_transmittance_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&atmosphere_multi_scattering_view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&atmosphere_lut_sampler), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&sky_view_lut_view), + }, ], }); @@ -2458,134 +2940,169 @@ impl Renderer { usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - let procedural_sky_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("procedural_sky_bgl"), + let procedural_sky_bgl = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("procedural_sky_bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + ], + }); + let procedural_sky_pl_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("procedural_sky_pl"), + bind_group_layouts: &[Some(&procedural_sky_bgl)], + immediate_size: 0, + }); + let procedural_sky_pipeline = + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("procedural_sky_pipeline"), + layout: Some(&procedural_sky_pl_layout), + vertex: wgpu::VertexState { + module: &procedural_sky_shader, + entry_point: Some("sky_vs"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &procedural_sky_shader, + entry_point: Some("sky_fs"), + // SH-055 — `lean_mrt` (Android): see pipeline_3d's comment above. + #[cfg(lean_mrt)] + targets: &[ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + None, + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + None, + ], + #[cfg(not(lean_mrt))] + targets: &[ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: MATERIAL_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + ], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + // EN-044 — the sky must NOT write depth. It is drawn first, with + // `Always`, so it used to stamp depth = 1.0 across the whole screen. + // That was harmless while the buffer had just been cleared to 1.0 + // anyway — and instantly destructive once a depth PREPASS started + // writing real geometry depth before it. The sky wiped the lot, the + // main pass's Equal test then failed everywhere, and the entire + // forest and player vanished. It never needed the write: where no + // geometry is drawn the depth is already 1.0. + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + let procedural_sky_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("procedural_sky_bg"), + layout: &procedural_sky_bgl, entries: &[ - wgpu::BindGroupLayoutEntry { + wgpu::BindGroupEntry { binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, + resource: sky_uniform_buffer.as_entire_binding(), }, - wgpu::BindGroupLayoutEntry { + wgpu::BindGroupEntry { binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, + resource: wgpu::BindingResource::TextureView(&sky_view_lut_view), }, - wgpu::BindGroupLayoutEntry { + wgpu::BindGroupEntry { binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, + resource: wgpu::BindingResource::Sampler(&atmosphere_lut_sampler), }, - wgpu::BindGroupLayoutEntry { + wgpu::BindGroupEntry { binding: 3, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, + resource: procedural_sun_uniform_buffer.as_entire_binding(), }, - wgpu::BindGroupLayoutEntry { + wgpu::BindGroupEntry { binding: 4, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, + resource: wgpu::BindingResource::TextureView(&atmosphere_transmittance_view), }, ], }); - let procedural_sky_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("procedural_sky_pl"), - bind_group_layouts: &[Some(&procedural_sky_bgl)], - immediate_size: 0, - }); - let procedural_sky_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("procedural_sky_pipeline"), - layout: Some(&procedural_sky_pl_layout), - vertex: wgpu::VertexState { - module: &procedural_sky_shader, - entry_point: Some("sky_vs"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &procedural_sky_shader, - entry_point: Some("sky_fs"), - targets: &[ - Some(wgpu::ColorTargetState { - format: HDR_FORMAT, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - }), - Some(wgpu::ColorTargetState { - format: MATERIAL_FORMAT, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - }), - Some(wgpu::ColorTargetState { - format: VELOCITY_FORMAT, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - }), - Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - }), - ], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - ..Default::default() - }, - depth_stencil: Some(wgpu::DepthStencilState { - format: DEPTH_FORMAT, - // EN-044 — the sky must NOT write depth. It is drawn first, with - // `Always`, so it used to stamp depth = 1.0 across the whole screen. - // That was harmless while the buffer had just been cleared to 1.0 - // anyway — and instantly destructive once a depth PREPASS started - // writing real geometry depth before it. The sky wiped the lot, the - // main pass's Equal test then failed everywhere, and the entire - // forest and player vanished. It never needed the write: where no - // geometry is drawn the depth is already 1.0. - depth_write_enabled: Some(false), - depth_compare: Some(wgpu::CompareFunction::Always), - stencil: wgpu::StencilState::default(), - bias: wgpu::DepthBiasState::default(), - }), - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); - let procedural_sky_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("procedural_sky_bg"), - layout: &procedural_sky_bgl, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: sky_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&sky_view_lut_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&atmosphere_lut_sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: procedural_sun_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&atmosphere_transmittance_view) }, - ], - }); // ============================================================ // EN-005 Phase 3 — procedural IBL. Allocate the destination @@ -2654,79 +3171,91 @@ impl Renderer { usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - let procedural_ibl_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("procedural_ibl_bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, + let procedural_ibl_bgl = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("procedural_ibl_bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, - count: None, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let procedural_ibl_pl_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("procedural_ibl_pl"), + bind_group_layouts: &[Some(&procedural_ibl_bgl)], + immediate_size: 0, + }); + let procedural_ibl_pipeline = + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("procedural_ibl_pipeline"), + layout: Some(&procedural_ibl_pl_layout), + vertex: wgpu::VertexState { + module: &procedural_ibl_shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, + fragment: Some(wgpu::FragmentState { + module: &procedural_ibl_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba16Float, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + ..Default::default() }, - ], - }); - let procedural_ibl_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("procedural_ibl_pl"), - bind_group_layouts: &[Some(&procedural_ibl_bgl)], - immediate_size: 0, - }); - let procedural_ibl_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("procedural_ibl_pipeline"), - layout: Some(&procedural_ibl_pl_layout), - vertex: wgpu::VertexState { - module: &procedural_ibl_shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &procedural_ibl_shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba16Float, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - ..Default::default() - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); let procedural_ibl_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("procedural_ibl_bg"), layout: &procedural_ibl_bgl, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: procedural_ibl_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&sky_view_lut_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&atmosphere_lut_sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: procedural_ibl_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&sky_view_lut_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&atmosphere_lut_sampler), + }, ], }); @@ -2742,15 +3271,50 @@ impl Renderer { // Clustered devices get the froxel point-light loop spliced in // place of the plain reference loop (same shading math — the // many_point_lights golden enforces equivalence). - let scene_shader_source: std::borrow::Cow<'static, str> = if froxel.is_some() { - froxel::clustered_scene_shader(SCENE_SHADER).into() - } else { - SCENE_SHADER.into() + // SH-055 — pick the plain UBO point-light loop over the froxel + // clustered path when the `mobile_lights` cfg is on (Android; see + // build.rs for the measured rationale) OR when `BLOOM_SKIP=froxel_shader` + // forces it for A/B on any device. `BLOOM_SKIP=clustered_on` overrides + // the mobile default back to the froxel path to reproduce the slow + // baseline. The clustered bind-group layout keeps its extra entries + // either way — a layout may carry bindings the shader doesn't use. + // SH-055 diag — "ibl" in BLOOM_SKIP swaps the split-sum IBL chain + // (equirect env samples, BRDF LUT, multi-scatter math) for a flat + // ambient, to measure its per-fragment cost. Markers in core.rs. + let scene_shader_source = + specialized_scene_shader_source(froxel.is_some(), shadow_map.virtual_map.requested()); + // SH-055 — a second module for the PREPASSED pipeline with the cutout + // `discard` stripped (between the //PREPASS_STRIP markers in core.rs). + // The prepassed pipeline runs depth_write=false + Equal against the + // prepass's exact depths, so the discard is semantically redundant + // there — but its mere presence in the shader disables Adreno + // LRZ/early-Z for the draw, which defeated EN-044's whole point on + // mobile: every overlapping canopy card shaded the full lighting + // shader (~250 ms/frame measured on a Pixel 4a at render scale 0.5). + let scene_shader_prepassed_source: String = { + let src: &str = &scene_shader_source; + match ( + src.find("//PREPASS_STRIP_BEGIN"), + src.find("//PREPASS_STRIP_END"), + ) { + (Some(b), Some(e)) if e > b => { + let end = e + "//PREPASS_STRIP_END".len(); + format!("{}{}", &src[..b], &src[end..]) + } + _ => src.to_string(), + } }; + // #28 derives the indirect scene shader from this exact post-froxel / + // post-diagnostic source so both paths execute identical lighting. + let gpu_scene_shader_source = scene_shader_source.to_string(); let scene_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("scene_shader"), source: wgpu::ShaderSource::Wgsl(scene_shader_source), }); + let scene_shader_prepassed = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_shader_prepassed"), + source: wgpu::ShaderSource::Wgsl(scene_shader_prepassed_source.into()), + }); // Scene material layout: // 0: base_color texture 4: metallic_roughness texture // 1: base_color sampler 5: metallic_roughness sampler @@ -2774,29 +3338,35 @@ impl Renderer { ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }; - let scene_material_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("scene_material_layout"), - entries: &[ - tex_entry(0), samp_entry(1), - tex_entry(2), samp_entry(3), - tex_entry(4), samp_entry(5), - tex_entry(6), samp_entry(7), - wgpu::BindGroupLayoutEntry { - binding: 8, - // VERTEX_FRAGMENT: the vertex stage reads metal_rough.w - // (alpha-cutoff) to gate foliage wind sway; fragment reads - // the full MaterialFactors. - visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, + let scene_material_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("scene_material_layout"), + entries: &[ + tex_entry(0), + samp_entry(1), + tex_entry(2), + samp_entry(3), + tex_entry(4), + samp_entry(5), + tex_entry(6), + samp_entry(7), + wgpu::BindGroupLayoutEntry { + binding: 8, + // VERTEX_FRAGMENT: the vertex stage reads metal_rough.w + // (alpha-cutoff) to gate foliage wind sway; fragment reads + // the full MaterialFactors. + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, - count: None, - }, - tex_entry(9), samp_entry(10), - ], - }); + tex_entry(9), + samp_entry(10), + ], + }); // Env IBL binding is folded into the lighting bind group // above (bindings 1 and 2 of group 1). That keeps the scene // pipeline under the default max_bind_groups = 4 limit, so we @@ -2887,39 +3457,40 @@ impl Renderer { // cosine-weighted convolution in the fragment stage. Reused // bind group layout (so we don't need to rebuild bind groups // when switching pipelines mid-encoder). - let prefilter_diffuse_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("prefilter_diffuse_pipeline"), - layout: Some(&prefilter_pl_layout), - vertex: wgpu::VertexState { - module: &prefilter_shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &prefilter_shader, - entry_point: Some("fs_diffuse"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba16Float, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); + let prefilter_diffuse_pipeline = + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("prefilter_diffuse_pipeline"), + layout: Some(&prefilter_pl_layout), + vertex: wgpu::VertexState { + module: &prefilter_shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &prefilter_shader, + entry_point: Some("fs_diffuse"), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba16Float, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); // EN-005 Phase 3 — bind group for the GGX prefilter when // re-baking procedural IBL. Sources from the mip-0 view of @@ -2927,15 +3498,27 @@ impl Renderer { // Same shape as the temporary panorama-path bind group built // inside `load_env_from_hdr`, but persistent so we don't // re-allocate on every sun move. - let procedural_prefilter_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("procedural_prefilter_src_bg"), - layout: &prefilter_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: prefilter_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&procedural_sky_equirect_mip0_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&env_sampler) }, - ], - }); + let procedural_prefilter_bind_group = + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("procedural_prefilter_src_bg"), + layout: &prefilter_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: prefilter_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView( + &procedural_sky_equirect_mip0_view, + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&env_sampler), + }, + ], + }); // ============================================================ // EN-005 V2 — aerial-perspective 3D LUT. Compute pipeline + @@ -2983,87 +3566,111 @@ impl Renderer { usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - let aerial_perspective_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("aerial_perspective_bgl"), + let aerial_perspective_bgl = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("aerial_perspective_bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: wgpu::TextureFormat::Rgba16Float, + view_dimension: wgpu::TextureViewDimension::D3, + }, + count: None, + }, + ], + }); + let aerial_perspective_pl_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("aerial_perspective_pl"), + bind_group_layouts: &[Some(&aerial_perspective_bgl)], + immediate_size: 0, + }); + let aerial_perspective_pipeline = + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("aerial_perspective_pipeline"), + layout: Some(&aerial_perspective_pl_layout), + module: &aerial_perspective_shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }); + let aerial_perspective_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("aerial_perspective_bg"), + layout: &aerial_perspective_bgl, entries: &[ - wgpu::BindGroupLayoutEntry { + wgpu::BindGroupEntry { binding: 0, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, + resource: aerial_perspective_uniform_buffer.as_entire_binding(), }, - wgpu::BindGroupLayoutEntry { + wgpu::BindGroupEntry { binding: 1, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, + resource: wgpu::BindingResource::TextureView(&atmosphere_transmittance_view), }, - wgpu::BindGroupLayoutEntry { + wgpu::BindGroupEntry { binding: 2, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, + resource: wgpu::BindingResource::TextureView(&atmosphere_multi_scattering_view), }, - wgpu::BindGroupLayoutEntry { + wgpu::BindGroupEntry { binding: 3, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, + resource: wgpu::BindingResource::Sampler(&atmosphere_lut_sampler), }, - wgpu::BindGroupLayoutEntry { + wgpu::BindGroupEntry { binding: 4, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::StorageTexture { - access: wgpu::StorageTextureAccess::WriteOnly, - format: wgpu::TextureFormat::Rgba16Float, - view_dimension: wgpu::TextureViewDimension::D3, - }, - count: None, + resource: wgpu::BindingResource::TextureView(&aerial_perspective_view), }, ], }); - let aerial_perspective_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("aerial_perspective_pl"), - bind_group_layouts: &[Some(&aerial_perspective_bgl)], - immediate_size: 0, - }); - let aerial_perspective_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("aerial_perspective_pipeline"), - layout: Some(&aerial_perspective_pl_layout), - module: &aerial_perspective_shader, - entry_point: Some("cs_main"), - compilation_options: Default::default(), - cache: None, - }); - let aerial_perspective_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("aerial_perspective_bg"), - layout: &aerial_perspective_bgl, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: aerial_perspective_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&atmosphere_transmittance_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&atmosphere_multi_scattering_view) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&atmosphere_lut_sampler) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&aerial_perspective_view) }, - ], - }); - let scene_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("scene_pipeline_layout"), - bind_group_layouts: &[Some(&uniform_3d_layout), Some(&lighting_layout), Some(&scene_material_layout), Some(&joint_layout)], - immediate_size: 0, - }); + let scene_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("scene_pipeline_layout"), + bind_group_layouts: &[ + Some(&uniform_3d_layout), + Some(&lighting_layout), + Some(&scene_material_layout), + Some(&joint_layout), + ], + immediate_size: 0, + }); let scene_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("scene_pipeline"), layout: Some(&scene_pipeline_layout), @@ -3076,6 +3683,23 @@ impl Renderer { fragment: Some(wgpu::FragmentState { module: &scene_shader, entry_point: Some("fs_main_scene"), + // SH-055 — `lean_mrt` (Android): see pipeline_3d's comment above. + #[cfg(lean_mrt)] + targets: &[ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + }), + None, + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + None, + ], + #[cfg(not(lean_mrt))] targets: &[ Some(wgpu::ColorTargetState { format: HDR_FORMAT, @@ -3123,6 +3747,53 @@ impl Renderer { cache: None, }); + let create_scene_transparent_pipeline = + |label: &'static str, cull_mode: Option| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&scene_pipeline_layout), + vertex: wgpu::VertexState { + module: &scene_shader, + entry_point: Some("vs_main_scene"), + buffers: &[Vertex3D::desc()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &scene_shader, + entry_point: Some("fs_transparent_scene"), + targets: &[Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) + }; + let scene_transparent_pipeline = + create_scene_transparent_pipeline("scene_transparent_pipeline", Some(wgpu::Face::Back)); + let scene_transparent_double_sided_pipeline = + create_scene_transparent_pipeline("scene_transparent_double_sided_pipeline", None); + // EN-044 — depth prepass pipeline. Identical layout and vertex stage to // scene_pipeline (the wind must displace the same way, or the depths would // not match); no colour targets, and a fragment stage that only honours the @@ -3180,76 +3851,104 @@ impl Renderer { // // scene_pipeline (Less, write) stays for the retained scene-graph nodes, // which are not in the prepass. - let scene_pipeline_prepassed = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("scene_pipeline_prepassed"), - layout: Some(&scene_pipeline_layout), - vertex: wgpu::VertexState { - module: &scene_shader, - entry_point: Some("vs_main_scene"), - buffers: &[Vertex3D::desc()], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &scene_shader, - entry_point: Some("fs_main_scene"), - targets: &[ - Some(wgpu::ColorTargetState { - format: HDR_FORMAT, - blend: Some(wgpu::BlendState::ALPHA_BLENDING), - write_mask: wgpu::ColorWrites::ALL, - }), - Some(wgpu::ColorTargetState { - format: MATERIAL_FORMAT, - // Replace blend so the material slot reflects - // the topmost-fragment material, not blended. - blend: None, - write_mask: wgpu::ColorWrites::ALL, - }), - Some(wgpu::ColorTargetState { - format: VELOCITY_FORMAT, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - }), - Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - }), - ], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: Some(wgpu::DepthStencilState { - format: DEPTH_FORMAT, - depth_write_enabled: Some(false), - depth_compare: Some(wgpu::CompareFunction::Equal), - stencil: wgpu::StencilState::default(), - bias: wgpu::DepthBiasState::default(), - }), - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); + let scene_pipeline_prepassed = + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("scene_pipeline_prepassed"), + layout: Some(&scene_pipeline_layout), + vertex: wgpu::VertexState { + // Original module for the vertex stage — EN-044's prepass/main + // depth bit-exactness must not depend on the fragment strip. + module: &scene_shader, + entry_point: Some("vs_main_scene"), + buffers: &[Vertex3D::desc()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + // SH-055 — discard-free variant so Adreno keeps LRZ/early-Z + // (see scene_shader_prepassed above). Depth is Equal-tested + // against the prepass, so cutout shape is preserved exactly. + module: &scene_shader_prepassed, + entry_point: Some("fs_main_scene"), + // SH-055 — `lean_mrt` (Android): see pipeline_3d's comment above. + #[cfg(lean_mrt)] + targets: &[ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + }), + None, + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + None, + ], + #[cfg(not(lean_mrt))] + targets: &[ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: MATERIAL_FORMAT, + // Replace blend so the material slot reflects + // the topmost-fragment material, not blended. + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + ], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Equal), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); // Default flat-normal 1×1 texture for meshes that have tangents - // but no normal map. Encodes (0, 0, 1) in tangent space: - // RGB = (0.5, 0.5, 1.0) * 255 = (128, 128, 255) + // but no normal map. RGB encodes tangent-space (0, 0, 1), while + // alpha is zero because the LEADR/Toksvig path interprets it as + // directional variance: + // RGBA = (0.5, 0.5, 1.0, 0.0) * 255 = (128, 128, 255, 0) // After the shader's `sampled * 2 - 1` decode, this gives the // unperturbed geometric normal. - let default_normal_data = [128u8, 128, 255, 255]; let default_normal_tex = device.create_texture_with_data( &queue, &wgpu::TextureDescriptor { label: Some("default_normal_texture"), - size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -3258,9 +3957,10 @@ impl Renderer { view_formats: &[], }, wgpu::util::TextureDataOrder::LayerMajor, - &default_normal_data, + &DEFAULT_FLAT_NORMAL_RGBA, ); - let default_normal_view = default_normal_tex.create_view(&wgpu::TextureViewDescriptor::default()); + let default_normal_view = + default_normal_tex.create_view(&wgpu::TextureViewDescriptor::default()); // Keep the texture owned via a dedicated field — NOT pushed // into `textures`, because that would offset the indices // returned by `register_texture` (callers store those as @@ -3435,46 +4135,48 @@ impl Renderer { bind_group_layouts: &[Some(&bloom_layout)], immediate_size: 0, }); - // NOTE: bloom params are per-pass create_buffer_init uniforms now - // (postfx_chain.rs) — a single shared buffer written once per pass - // aliased to the last write at submit. - - let make_bloom_pipeline = |entry: &str, blend: Option| -> wgpu::RenderPipeline { - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("bloom_pipeline"), - layout: Some(&bloom_pl_layout), - vertex: wgpu::VertexState { - module: &bloom_shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &bloom_shader, - entry_point: Some(entry), - targets: &[Some(wgpu::ColorTargetState { - format: HDR_FORMAT, - blend, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }) - }; - let bloom_pipeline_threshold_downsample = make_bloom_pipeline("fs_threshold_downsample", None); + // Bloom params use one persistent uniform buffer per pass + // (postfx_chain.rs). A single shared buffer written once per pass + // would alias to the last write at submit. + + let make_bloom_pipeline = + |entry: &str, blend: Option| -> wgpu::RenderPipeline { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("bloom_pipeline"), + layout: Some(&bloom_pl_layout), + vertex: wgpu::VertexState { + module: &bloom_shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &bloom_shader, + entry_point: Some(entry), + targets: &[Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) + }; + let bloom_pipeline_threshold_downsample = + make_bloom_pipeline("fs_threshold_downsample", None); let bloom_pipeline_downsample = make_bloom_pipeline("fs_downsample", None); // Upsample blends additively into the destination mip so each // pass progressively builds up the final bloom. @@ -3493,52 +4195,63 @@ impl Renderer { label: Some("hiz_linearize_shader"), source: wgpu::ShaderSource::Wgsl(HIZ_LINEARIZE_SHADER_WGSL.into()), }); - let hiz_linearize_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("hiz_linearize_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Depth, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::StorageTexture { - access: wgpu::StorageTextureAccess::WriteOnly, - format: HIZ_FORMAT, - view_dimension: wgpu::TextureViewDimension::D2, - }, count: None, - }, - ], - }); - let hiz_linearize_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("hiz_linearize_pl_layout"), - bind_group_layouts: &[Some(&hiz_linearize_layout)], - immediate_size: 0, - }); - let hiz_linearize_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("hiz_linearize_pipeline"), - layout: Some(&hiz_linearize_pl_layout), - module: &hiz_linearize_shader, - entry_point: Some("cs_main"), - compilation_options: Default::default(), - cache: None, - }); + let hiz_linearize_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("hiz_linearize_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: HIZ_FORMAT, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + ], + }); + let hiz_linearize_pl_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("hiz_linearize_pl_layout"), + bind_group_layouts: &[Some(&hiz_linearize_layout)], + immediate_size: 0, + }); + let hiz_linearize_pipeline = + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("hiz_linearize_pipeline"), + layout: Some(&hiz_linearize_pl_layout), + module: &hiz_linearize_shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }); let hiz_linearize_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("hiz_linearize_uniform_buffer"), size: std::mem::size_of::() as u64, @@ -3549,54 +4262,66 @@ impl Renderer { label: Some("hiz_downsample_shader"), source: wgpu::ShaderSource::Wgsl(HIZ_DOWNSAMPLE_SHADER_WGSL.into()), }); - let hiz_downsample_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("hiz_downsample_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::StorageTexture { - access: wgpu::StorageTextureAccess::WriteOnly, - format: HIZ_FORMAT, - view_dimension: wgpu::TextureViewDimension::D2, - }, count: None, - }, - ], - }); - let hiz_downsample_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("hiz_downsample_pl_layout"), - bind_group_layouts: &[Some(&hiz_downsample_layout)], - immediate_size: 0, - }); - let hiz_downsample_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("hiz_downsample_pipeline"), - layout: Some(&hiz_downsample_pl_layout), - module: &hiz_downsample_shader, - entry_point: Some("cs_main"), - compilation_options: Default::default(), - cache: None, - }); + let hiz_downsample_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("hiz_downsample_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: HIZ_FORMAT, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + ], + }); + let hiz_downsample_pl_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("hiz_downsample_pl_layout"), + bind_group_layouts: &[Some(&hiz_downsample_layout)], + immediate_size: 0, + }); + let hiz_downsample_pipeline = + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("hiz_downsample_pipeline"), + layout: Some(&hiz_downsample_pl_layout), + module: &hiz_downsample_shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }); let hiz_downsample_uniform_buffers: Vec = (0..HIZ_MIP_COUNT - 1) - .map(|_| device.create_buffer(&wgpu::BufferDescriptor { - label: Some("hiz_downsample_uniform_buffer"), - size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - })) + .map(|_| { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("hiz_downsample_uniform_buffer"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) + }) .collect(); let hiz_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("hiz_sampler"), @@ -3609,9 +4334,8 @@ impl Renderer { ..Default::default() }); let occlusion = OcclusionCuller::new(&device); - let (hiz_textures, hiz_views) = create_linear_depth_hiz_chain( - &device, surface_config.width, surface_config.height, - ); + let (hiz_textures, hiz_views) = + create_linear_depth_hiz_chain(&device, surface_config.width, surface_config.height); // --- SSAO (compute GTAO) pipeline --- let ssao_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -3622,97 +4346,120 @@ impl Renderer { label: Some("ssao_layout"), entries: &[ wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::COMPUTE, + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, format: SSAO_FORMAT, view_dimension: wgpu::TextureViewDimension::D2, - }, count: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::COMPUTE, + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), count: None, }, wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::COMPUTE, + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 4, visibility: wgpu::ShaderStages::COMPUTE, + binding: 4, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 5, visibility: wgpu::ShaderStages::COMPUTE, + binding: 5, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 6, visibility: wgpu::ShaderStages::COMPUTE, + binding: 6, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 7, visibility: wgpu::ShaderStages::COMPUTE, + binding: 7, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + }, + count: None, }, // velocity_tex wgpu::BindGroupLayoutEntry { - binding: 8, visibility: wgpu::ShaderStages::COMPUTE, + binding: 8, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + }, + count: None, }, // history_in (ping-pong read) wgpu::BindGroupLayoutEntry { - binding: 9, visibility: wgpu::ShaderStages::COMPUTE, + binding: 9, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + }, + count: None, }, // filt_samp (history bilinear reprojection) wgpu::BindGroupLayoutEntry { - binding: 10, visibility: wgpu::ShaderStages::COMPUTE, + binding: 10, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, // history_out (ping-pong write) wgpu::BindGroupLayoutEntry { - binding: 11, visibility: wgpu::ShaderStages::COMPUTE, + binding: 11, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, format: SSAO_FORMAT, view_dimension: wgpu::TextureViewDimension::D2, - }, count: None, + }, + count: None, }, ], }); @@ -3844,79 +4591,16 @@ impl Renderer { usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - let (ssao_blur_rt_texture, ssao_blur_rt_view) = create_ssao_blur_rt( - &device, surface_config.width, surface_config.height, - ); + let (ssao_blur_rt_texture, ssao_blur_rt_view) = + create_ssao_blur_rt(&device, surface_config.width, surface_config.height); // --- TAA pipeline --- let taa_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("taa_shader"), source: wgpu::ShaderSource::Wgsl(TAA_SHADER_WGSL.into()), }); - let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("taa_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - // composed_rt (tex + sampler) - wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - // history (tex + sampler) - wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 4, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - // depth (tex + sampler) - wgpu::BindGroupLayoutEntry { - binding: 5, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Depth, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 6, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), - count: None, - }, - // velocity (tex + sampler) - wgpu::BindGroupLayoutEntry { - binding: 7, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 8, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); + let taa_layout = + temporal_reactive::create_taa_bind_group_layout(&device, "taa_layout", false); let taa_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("taa_pl_layout"), bind_group_layouts: &[Some(&taa_layout)], @@ -3926,26 +4610,34 @@ impl Renderer { label: Some("taa_pipeline"), layout: Some(&taa_pl_layout), vertex: wgpu::VertexState { - module: &taa_shader, entry_point: Some("vs_main"), - buffers: &[], compilation_options: Default::default(), + module: &taa_shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), }, fragment: Some(wgpu::FragmentState { - module: &taa_shader, entry_point: Some("fs_main"), + module: &taa_shader, + entry_point: Some("fs_main"), targets: &[Some(wgpu::ColorTargetState { - format: HDR_FORMAT, blend: None, + format: HDR_FORMAT, + blend: None, write_mask: wgpu::ColorWrites::ALL, })], compilation_options: Default::default(), }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, front_face: wgpu::FrontFace::Ccw, - cull_mode: None, polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, conservative: false, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, }, depth_stencil: None, multisample: wgpu::MultisampleState::default(), - multiview_mask: None, cache: None, + multiview_mask: None, + cache: None, }); let taa_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("taa_uniform_buffer"), @@ -3965,21 +4657,28 @@ impl Renderer { label: Some("upscale_layout"), entries: &[ wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, @@ -3994,26 +4693,34 @@ impl Renderer { label: Some("upscale_pipeline"), layout: Some(&upscale_pl_layout), vertex: wgpu::VertexState { - module: &upscale_shader, entry_point: Some("vs_main"), - buffers: &[], compilation_options: Default::default(), + module: &upscale_shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), }, fragment: Some(wgpu::FragmentState { - module: &upscale_shader, entry_point: Some("fs_main"), + module: &upscale_shader, + entry_point: Some("fs_main"), targets: &[Some(wgpu::ColorTargetState { - format: HDR_FORMAT, blend: None, + format: HDR_FORMAT, + blend: None, write_mask: wgpu::ColorWrites::ALL, })], compilation_options: Default::default(), }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, front_face: wgpu::FrontFace::Ccw, - cull_mode: None, polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, conservative: false, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, }, depth_stencil: None, multisample: wgpu::MultisampleState::default(), - multiview_mask: None, cache: None, + multiview_mask: None, + cache: None, }); let upscale_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("upscale_uniform_buffer"), @@ -4021,9 +4728,8 @@ impl Renderer { usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - let (upscale_rt_texture, upscale_rt_view) = create_dof_rt( - &device, surface_config.width, surface_config.height, - ); + let (upscale_rt_texture, upscale_rt_view) = + create_dof_rt(&device, surface_config.width, surface_config.height); // --- RCAS sharpen pipeline. Same 3-binding layout as upscale // (uniform + input tex + sampler), same HDR output format — @@ -4037,21 +4743,28 @@ impl Renderer { label: Some("cas_layout"), entries: &[ wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, @@ -4066,26 +4779,34 @@ impl Renderer { label: Some("cas_pipeline"), layout: Some(&cas_pl_layout), vertex: wgpu::VertexState { - module: &cas_shader, entry_point: Some("vs_main"), - buffers: &[], compilation_options: Default::default(), + module: &cas_shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), }, fragment: Some(wgpu::FragmentState { - module: &cas_shader, entry_point: Some("fs_main"), + module: &cas_shader, + entry_point: Some("fs_main"), targets: &[Some(wgpu::ColorTargetState { - format: HDR_FORMAT, blend: None, + format: HDR_FORMAT, + blend: None, write_mask: wgpu::ColorWrites::ALL, })], compilation_options: Default::default(), }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, front_face: wgpu::FrontFace::Ccw, - cull_mode: None, polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, conservative: false, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, }, depth_stencil: None, multisample: wgpu::MultisampleState::default(), - multiview_mask: None, cache: None, + multiview_mask: None, + cache: None, }); let cas_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("cas_uniform_buffer"), @@ -4093,9 +4814,8 @@ impl Renderer { usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - let (cas_rt_texture, cas_rt_view) = create_dof_rt( - &device, surface_config.width, surface_config.height, - ); + let (cas_rt_texture, cas_rt_view) = + create_dof_rt(&device, surface_config.width, surface_config.height); // --- SSR pipeline --- let ssr_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -4106,70 +4826,93 @@ impl Renderer { label: Some("ssr_layout"), entries: &[ wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Depth, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), count: None, }, wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 4, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, wgpu::BindGroupLayoutEntry { - binding: 5, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 5, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 6, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 6, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, wgpu::BindGroupLayoutEntry { - binding: 7, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 7, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 8, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 8, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, // EN-021 — env panorama for the miss fallback. wgpu::BindGroupLayoutEntry { - binding: 9, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 9, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 10, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 10, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, @@ -4184,26 +4927,34 @@ impl Renderer { label: Some("ssr_pipeline"), layout: Some(&ssr_pl_layout), vertex: wgpu::VertexState { - module: &ssr_shader, entry_point: Some("vs_main"), - buffers: &[], compilation_options: Default::default(), + module: &ssr_shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), }, fragment: Some(wgpu::FragmentState { - module: &ssr_shader, entry_point: Some("fs_main"), + module: &ssr_shader, + entry_point: Some("fs_main"), targets: &[Some(wgpu::ColorTargetState { - format: HDR_FORMAT, blend: None, + format: HDR_FORMAT, + blend: None, write_mask: wgpu::ColorWrites::ALL, })], compilation_options: Default::default(), }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, front_face: wgpu::FrontFace::Ccw, - cull_mode: None, polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, conservative: false, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, }, depth_stencil: None, multisample: wgpu::MultisampleState::default(), - multiview_mask: None, cache: None, + multiview_mask: None, + cache: None, }); let ssr_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("ssr_uniform_buffer"), @@ -4217,87 +4968,113 @@ impl Renderer { label: Some("ssr_temporal_shader"), source: wgpu::ShaderSource::Wgsl(SSR_TEMPORAL_SHADER_WGSL.into()), }); - let ssr_temporal_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("ssr_temporal_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - // binding 1: current noisy SSR - wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - // binding 3: history SSR (previous frame accumulated) - wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 4, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - // binding 5: velocity buffer (motion vectors) - wgpu::BindGroupLayoutEntry { - binding: 5, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + let ssr_temporal_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("ssr_temporal_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // binding 1: current noisy SSR + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + // binding 3: history SSR (previous frame accumulated) + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + // binding 5: velocity buffer (motion vectors) + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let ssr_temporal_pl_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("ssr_temporal_pl_layout"), + bind_group_layouts: &[Some(&ssr_temporal_layout)], + immediate_size: 0, + }); + let ssr_temporal_pipeline = + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("ssr_temporal_pipeline"), + layout: Some(&ssr_temporal_pl_layout), + vertex: wgpu::VertexState { + module: &ssr_temporal_shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), }, - wgpu::BindGroupLayoutEntry { - binding: 6, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, + fragment: Some(wgpu::FragmentState { + module: &ssr_temporal_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, }, - ], - }); - let ssr_temporal_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("ssr_temporal_pl_layout"), - bind_group_layouts: &[Some(&ssr_temporal_layout)], - immediate_size: 0, - }); - let ssr_temporal_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("ssr_temporal_pipeline"), - layout: Some(&ssr_temporal_pl_layout), - vertex: wgpu::VertexState { - module: &ssr_temporal_shader, entry_point: Some("vs_main"), - buffers: &[], compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &ssr_temporal_shader, entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: HDR_FORMAT, blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, front_face: wgpu::FrontFace::Ccw, - cull_mode: None, polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, cache: None, - }); + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); let ssr_temporal_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("ssr_temporal_uniform_buffer"), size: std::mem::size_of::() as u64, @@ -4317,165 +5094,215 @@ impl Renderer { }) }; - let probe_place_shader = probe_shader("probe_place_shader", SSGI_PROBE_PLACE_WGSL); - let probe_trace_shader = probe_shader("probe_trace_shader", SSGI_PROBE_TRACE_SW_WGSL); - let probe_temporal_shader = probe_shader("probe_temporal_shader", SSGI_PROBE_TEMPORAL_WGSL); - let probe_resolve_shader = probe_shader("probe_resolve_shader", SSGI_PROBE_RESOLVE_WGSL); - - // --- Probe placement --- - let probe_place_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("probe_place_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: false }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - ], - }); - let probe_place_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("probe_place_pl_layout"), - bind_group_layouts: &[Some(&probe_place_layout)], - immediate_size: 0, - }); - let probe_place_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("probe_place_pipeline"), - layout: Some(&probe_place_pl_layout), - module: &probe_place_shader, entry_point: Some("cs_main"), - compilation_options: Default::default(), cache: None, - }); - let probe_place_uniform = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("probe_place_uniform"), - size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - - // --- Probe trace (SW Hi-Z) --- - let probe_trace_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("probe_trace_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - // Hi-Z pyramid (5 mips, separate views) - wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 4, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 5, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 6, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 7, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 8, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 9, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 10, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::StorageTexture { - access: wgpu::StorageTextureAccess::WriteOnly, - format: HDR_FORMAT, - view_dimension: wgpu::TextureViewDimension::D3, - }, count: None, - }, - // V3 — prev-frame probe history (textureLoad, no - // sampler). Non-filterable so the layout matches - // the storage-binding constraint on adapters that - // don't advertise FLOAT32_FILTERABLE. - wgpu::BindGroupLayoutEntry { - binding: 11, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D3, - multisampled: false, - }, count: None, - }, - ], - }); - let probe_trace_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("probe_trace_pl_layout"), - bind_group_layouts: &[Some(&probe_trace_layout)], - immediate_size: 0, - }); - let probe_trace_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("probe_trace_pipeline"), - layout: Some(&probe_trace_pl_layout), - module: &probe_trace_shader, entry_point: Some("cs_main"), - compilation_options: Default::default(), cache: None, + let probe_place_shader = probe_shader("probe_place_shader", SSGI_PROBE_PLACE_WGSL); + let probe_trace_shader = probe_shader("probe_trace_shader", SSGI_PROBE_TRACE_SW_WGSL); + let probe_temporal_shader = probe_shader("probe_temporal_shader", SSGI_PROBE_TEMPORAL_WGSL); + let probe_resolve_shader = probe_shader("probe_resolve_shader", SSGI_PROBE_RESOLVE_WGSL); + + // --- Probe placement --- + let probe_place_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("probe_place_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + let probe_place_pl_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("probe_place_pl_layout"), + bind_group_layouts: &[Some(&probe_place_layout)], + immediate_size: 0, + }); + let probe_place_pipeline = + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("probe_place_pipeline"), + layout: Some(&probe_place_pl_layout), + module: &probe_place_shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }); + let probe_place_uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("probe_place_uniform"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, }); + + // --- Probe trace (SW Hi-Z) --- + let probe_trace_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("probe_trace_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // Hi-Z pyramid (5 mips, separate views) + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 7, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 8, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 9, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 10, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: HDR_FORMAT, + view_dimension: wgpu::TextureViewDimension::D3, + }, + count: None, + }, + // V3 — prev-frame probe history (textureLoad, no + // sampler). Non-filterable so the layout matches + // the storage-binding constraint on adapters that + // don't advertise FLOAT32_FILTERABLE. + wgpu::BindGroupLayoutEntry { + binding: 11, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D3, + multisampled: false, + }, + count: None, + }, + ], + }); + let probe_trace_pl_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("probe_trace_pl_layout"), + bind_group_layouts: &[Some(&probe_trace_layout)], + immediate_size: 0, + }); + let probe_trace_pipeline = + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("probe_trace_pipeline"), + layout: Some(&probe_trace_pl_layout), + module: &probe_trace_shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }); let probe_trace_uniform = device.create_buffer(&wgpu::BufferDescriptor { label: Some("probe_trace_uniform"), size: std::mem::size_of::() as u64, @@ -4493,8 +5320,10 @@ impl Renderer { // shared helpers rather than using the generic `probe_shader` // builder (which prepends helpers first). let hw_source = format!( - "enable wgpu_ray_query;\n{}{}", - PROBE_HELPERS_WGSL, SSGI_PROBE_TRACE_HW_WGSL, + "enable wgpu_ray_query;\n{}{}{}", + ray_query_backend_variant(&device), + PROBE_HELPERS_WGSL, + SSGI_PROBE_TRACE_HW_WGSL, ); let hw_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("probe_trace_hw_shader"), @@ -4504,78 +5333,98 @@ impl Renderer { label: Some("probe_trace_hw_layout"), entries: &[ wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::COMPUTE, + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::COMPUTE, + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::AccelerationStructure { vertex_return: false, }, count: None, }, wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::COMPUTE, + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 4, visibility: wgpu::ShaderStages::COMPUTE, + binding: 4, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, format: HDR_FORMAT, view_dimension: wgpu::TextureViewDimension::D3, - }, count: None, + }, + count: None, }, // Ticket 013 — mesh-card albedo atlas + sampler. wgpu::BindGroupLayoutEntry { - binding: 5, visibility: wgpu::ShaderStages::COMPUTE, + binding: 5, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 6, visibility: wgpu::ShaderStages::COMPUTE, + binding: 6, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, // Ticket 014 V7/V10 — WSRC atlas for the HW miss // path. V10 filtering texture + linear sampler. wgpu::BindGroupLayoutEntry { - binding: 7, visibility: wgpu::ShaderStages::COMPUTE, + binding: 7, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D3, multisampled: false, - }, count: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 8, visibility: wgpu::ShaderStages::COMPUTE, + binding: 8, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, // V3 — prev-frame probe history (textureLoad). wgpu::BindGroupLayoutEntry { - binding: 9, visibility: wgpu::ShaderStages::COMPUTE, + binding: 9, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D3, multisampled: false, - }, count: None, + }, + count: None, }, ], }); @@ -4587,8 +5436,10 @@ impl Renderer { let hw_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: Some("probe_trace_hw_pipeline"), layout: Some(&hw_pl_layout), - module: &hw_shader, entry_point: Some("cs_main"), - compilation_options: Default::default(), cache: None, + module: &hw_shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, }); (Some(hw_pipeline), Some(hw_layout)) } else { @@ -4617,181 +5468,248 @@ impl Renderer { "const PT_HAS_TEXTURES: bool = false;\n\ fn pt_tex_sample(idx: u32, uv: vec2) -> vec3 { return vec3(1.0); }\n" }; - let pt_source = format!("enable wgpu_ray_query;\n{}\n{}", PT_KERNEL_WGSL, tex_variant); + let query_diagnostics = std::env::var("BLOOM_GOLDEN_DIAGNOSTICS") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) + || std::env::var("BLOOM_PT_DEBUG") + .ok() + .and_then(|v| v.parse::().ok()) + .is_some_and(|view| (6..=19).contains(&view)); + let pt_kernel_source = pt_kernel_variant(query_diagnostics); + let fault = std::env::var("BLOOM_PT_TEST_FAULT").ok(); + let fault_variant = pt_fault_constants(fault.as_deref()); + let pt_source = format!( + "enable wgpu_ray_query;\n{}\n{}\n{}\n{}", + ray_query_backend_variant(&device), + fault_variant, + pt_kernel_source, + tex_variant, + ); let pt_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pt_kernel_shader"), source: wgpu::ShaderSource::Wgsl(pt_source.into()), }); - let mut pt_layout_entries = vec![ - wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::AccelerationStructure { - vertex_return: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + let pt_layout_entries = vec![ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, }, - wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Depth, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::AccelerationStructure { + vertex_return: false, }, - wgpu::BindGroupLayoutEntry { - binding: 4, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, }, - wgpu::BindGroupLayoutEntry { - binding: 5, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, }, - wgpu::BindGroupLayoutEntry { - binding: 6, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, }, - wgpu::BindGroupLayoutEntry { - binding: 7, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, }, - wgpu::BindGroupLayoutEntry { - binding: 8, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: false }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, }, - wgpu::BindGroupLayoutEntry { - binding: 9, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::StorageTexture { - access: wgpu::StorageTextureAccess::WriteOnly, - format: HDR_FORMAT, - view_dimension: wgpu::TextureViewDimension::D2, - }, count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 7, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 8, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, }, - // PT-2 — geometry megabuffers (vertex words + indices). - wgpu::BindGroupLayoutEntry { - binding: 10, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 9, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: HDR_FORMAT, + view_dimension: wgpu::TextureViewDimension::D2, }, - wgpu::BindGroupLayoutEntry { - binding: 11, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + count: None, + }, + // PT-2 — geometry megabuffers (vertex words + indices). + wgpu::BindGroupLayoutEntry { + binding: 10, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, }, - // PT-3 — accumulation write target (binding 8 holds - // the previous frame's buffer; ping-pong). - wgpu::BindGroupLayoutEntry { - binding: 13, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: false }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 11, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, }, - // Hybrid sun — the raster shadow cascades + their - // comparison sampler. - wgpu::BindGroupLayoutEntry { - binding: 14, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Depth, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, count: None, + count: None, + }, + // PT-3 — accumulation write target (binding 8 holds + // the previous frame's buffer; ping-pong). + wgpu::BindGroupLayoutEntry { + binding: 13, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, + min_binding_size: None, }, - wgpu::BindGroupLayoutEntry { - binding: 15, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Depth, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, count: None, + count: None, + }, + // Hybrid sun — the raster shadow cascades + their + // comparison sampler. + wgpu::BindGroupLayoutEntry { + binding: 14, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, }, - wgpu::BindGroupLayoutEntry { - binding: 16, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Depth, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 15, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, }, - wgpu::BindGroupLayoutEntry { - binding: 17, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison), - count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 16, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, }, - // PT-3b SVGF — moments ping-pong (18 = previous - // frame read side, 19 = this frame's output). - wgpu::BindGroupLayoutEntry { - binding: 18, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: false }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 17, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison), + count: None, + }, + // PT-3b SVGF — moments ping-pong (18 = previous + // frame read side, 19 = this frame's output). + wgpu::BindGroupLayoutEntry { + binding: 18, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, }, - wgpu::BindGroupLayoutEntry { - binding: 19, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: false }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 19, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, + min_binding_size: None, }, - // PT-4 — ReSTIR reservoir ping-pong (20/21). - wgpu::BindGroupLayoutEntry { - binding: 20, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: false }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + count: None, + }, + // PT-4 — ReSTIR reservoir ping-pong (20/21). + wgpu::BindGroupLayoutEntry { + binding: 20, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, }, - wgpu::BindGroupLayoutEntry { - binding: 21, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: false }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 21, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, + min_binding_size: None, }, - // PT-7 — raster velocity MRT for object-motion - // reprojection (skinned characters). - wgpu::BindGroupLayoutEntry { - binding: 22, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, count: None, + count: None, + }, + // PT-7 — raster velocity MRT for object-motion + // reprojection (skinned characters). + wgpu::BindGroupLayoutEntry { + binding: 22, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, }, + count: None, + }, ]; let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pt_layout"), @@ -4802,18 +5720,21 @@ impl Renderer { // size; the bind group pads unused slots with the white // texture (slot 0), so no PARTIALLY_BOUND requirement. let tex_layout = if pt_texture_arrays_enabled { - Some(device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("pt_tex_layout"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: Some(std::num::NonZeroU32::new(PT_MAX_TEXTURES as u32).unwrap()), - }], - })) + Some( + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("pt_tex_layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: Some(std::num::NonZeroU32::new(PT_MAX_TEXTURES as u32).unwrap()), + }], + }), + ) } else { None }; @@ -4829,8 +5750,10 @@ impl Renderer { let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: Some("pt_pipeline"), layout: Some(&pl), - module: &pt_shader, entry_point: Some("cs_main"), - compilation_options: Default::default(), cache: None, + module: &pt_shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, }); (Some(pipeline), Some(layout), tex_layout) } else { @@ -4839,97 +5762,119 @@ impl Renderer { // PT-3 — à-trous denoiser pipelines for the realtime mode // (plain compute; gated with the kernel since it is useless // without it). - let (pt_atrous_mid_pipeline, pt_atrous_final_pipeline, pt_atrous_layout) = - if hw_rt_enabled { - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("pt_atrous_shader"), - source: wgpu::ShaderSource::Wgsl(PT_ATROUS_WGSL.into()), - }); - let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("pt_atrous_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + let (pt_atrous_mid_pipeline, pt_atrous_final_pipeline, pt_atrous_layout) = if hw_rt_enabled + { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("pt_atrous_shader"), + source: wgpu::ShaderSource::Wgsl(PT_ATROUS_WGSL.into()), + }); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("pt_atrous_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, }, - wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, }, - wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: false }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, + min_binding_size: None, }, - wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::StorageTexture { - access: wgpu::StorageTextureAccess::WriteOnly, - format: HDR_FORMAT, - view_dimension: wgpu::TextureViewDimension::D2, - }, count: None, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: HDR_FORMAT, + view_dimension: wgpu::TextureViewDimension::D2, }, - // PT-3 half-res: full-res depth guides the - // upsample in cs_final. - wgpu::BindGroupLayoutEntry { - binding: 4, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Depth, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, count: None, + count: None, + }, + // PT-3 half-res: full-res depth guides the + // upsample in cs_final. + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, }, - // Full-res G-buffer albedo for SVGF - // re-modulation in cs_final. - wgpu::BindGroupLayoutEntry { - binding: 5, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, count: None, + count: None, + }, + // Full-res G-buffer albedo for SVGF + // re-modulation in cs_final. + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, }, - // SVGF geometry side-channel (the kernel's - // moments buffer): depth edge-stopping, history - // length and sky markers. - wgpu::BindGroupLayoutEntry { - binding: 6, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + count: None, + }, + // SVGF geometry side-channel (the kernel's + // moments buffer): depth edge-stopping, history + // length and sky markers. + wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, }, - ], - }); - let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("pt_atrous_pl"), - bind_group_layouts: &[Some(&layout)], - immediate_size: 0, - }); - let mid = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("pt_atrous_mid"), - layout: Some(&pl), - module: &shader, entry_point: Some("cs_mid"), - compilation_options: Default::default(), cache: None, - }); - let fin = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("pt_atrous_final"), - layout: Some(&pl), - module: &shader, entry_point: Some("cs_final"), - compilation_options: Default::default(), cache: None, - }); - (Some(mid), Some(fin), Some(layout)) - } else { - (None, None, None) - }; + count: None, + }, + ], + }); + let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("pt_atrous_pl"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let mid = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("pt_atrous_mid"), + layout: Some(&pl), + module: &shader, + entry_point: Some("cs_mid"), + compilation_options: Default::default(), + cache: None, + }); + let fin = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("pt_atrous_final"), + layout: Some(&pl), + module: &shader, + entry_point: Some("cs_final"), + compilation_options: Default::default(), + cache: None, + }); + (Some(mid), Some(fin), Some(layout)) + } else { + (None, None, None) + }; // Six stages: five à-trous iterations + the final upsample. let pt_atrous_params_bufs = std::array::from_fn::<_, 6, _>(|i| { device.create_buffer(&wgpu::BufferDescriptor { @@ -4973,240 +5918,303 @@ impl Renderer { // Always built. At dispatch time we pick SDF over Hi-Z when // `scene_sdf_clipmap_built` is true; HW (when available) // still wins over both. - let sdf_trace_shader = probe_shader( - "probe_trace_sdf_shader", - SSGI_PROBE_TRACE_SDF_WGSL, - ); - let probe_trace_sdf_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("probe_trace_sdf_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D3, - multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 4, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::StorageTexture { - access: wgpu::StorageTextureAccess::WriteOnly, - format: HDR_FORMAT, - view_dimension: wgpu::TextureViewDimension::D3, - }, count: None, - }, - // V4 — instance_data + card radiance atlas + sampler - // for the broad-phase textured hit lookup. - wgpu::BindGroupLayoutEntry { - binding: 5, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 6, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 7, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - // Ticket 014 V6/V10 — WSRC atlas for the SDF miss - // path. V10 upgrades to a filtering Rgba16Float - // texture + linear sampler so the sampler does octel - // bilinear + Z trilinear natively inside each - // probe's 10×10 padded slab. - wgpu::BindGroupLayoutEntry { - binding: 8, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D3, - multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 9, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - // V3 — prev-frame probe history (textureLoad). - wgpu::BindGroupLayoutEntry { - binding: 10, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D3, - multisampled: false, - }, count: None, - }, - ], - }); - let probe_trace_sdf_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("probe_trace_sdf_pl_layout"), - bind_group_layouts: &[Some(&probe_trace_sdf_layout)], - immediate_size: 0, - }); - let probe_trace_sdf_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("probe_trace_sdf_pipeline"), - layout: Some(&probe_trace_sdf_pl_layout), - module: &sdf_trace_shader, - entry_point: Some("cs_main"), - compilation_options: Default::default(), - cache: None, - }); + let sdf_trace_shader = probe_shader("probe_trace_sdf_shader", SSGI_PROBE_TRACE_SDF_WGSL); + let probe_trace_sdf_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("probe_trace_sdf_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D3, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: HDR_FORMAT, + view_dimension: wgpu::TextureViewDimension::D3, + }, + count: None, + }, + // V4 — instance_data + card radiance atlas + sampler + // for the broad-phase textured hit lookup. + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 7, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + // Ticket 014 V6/V10 — WSRC atlas for the SDF miss + // path. V10 upgrades to a filtering Rgba16Float + // texture + linear sampler so the sampler does octel + // bilinear + Z trilinear natively inside each + // probe's 10×10 padded slab. + wgpu::BindGroupLayoutEntry { + binding: 8, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D3, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 9, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + // V3 — prev-frame probe history (textureLoad). + wgpu::BindGroupLayoutEntry { + binding: 10, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D3, + multisampled: false, + }, + count: None, + }, + ], + }); + let probe_trace_sdf_pl_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("probe_trace_sdf_pl_layout"), + bind_group_layouts: &[Some(&probe_trace_sdf_layout)], + immediate_size: 0, + }); + let probe_trace_sdf_pipeline = + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("probe_trace_sdf_pipeline"), + layout: Some(&probe_trace_sdf_pl_layout), + module: &sdf_trace_shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }); // --- Probe temporal --- - let probe_temporal_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("probe_temporal_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D3, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D3, multisampled: false, - }, count: None, + let probe_temporal_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("probe_temporal_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D3, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D3, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: HDR_FORMAT, + view_dimension: wgpu::TextureViewDimension::D3, + }, + count: None, + }, + PROBE_HEADER_RW_LAYOUT_ENTRY, + ], + }); + let probe_temporal_pl_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("probe_temporal_pl_layout"), + bind_group_layouts: &[Some(&probe_temporal_layout)], + immediate_size: 0, + }); + let probe_temporal_pipeline = + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("probe_temporal_pipeline"), + layout: Some(&probe_temporal_pl_layout), + module: &probe_temporal_shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }); + let probe_temporal_uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("probe_temporal_uniform"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + // --- Probe resolve (fragment → half-res ssgi_rt) --- + let probe_resolve_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("probe_resolve_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D3, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }, + ], + }); + let probe_resolve_pl_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("probe_resolve_pl_layout"), + bind_group_layouts: &[Some(&probe_resolve_layout)], + immediate_size: 0, + }); + let probe_resolve_pipeline = + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("probe_resolve_pipeline"), + layout: Some(&probe_resolve_pl_layout), + vertex: wgpu::VertexState { + module: &probe_resolve_shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), }, - wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::StorageTexture { - access: wgpu::StorageTextureAccess::WriteOnly, + fragment: Some(wgpu::FragmentState { + module: &probe_resolve_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { format: HDR_FORMAT, - view_dimension: wgpu::TextureViewDimension::D3, - }, count: None, - }, - ], - }); - let probe_temporal_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("probe_temporal_pl_layout"), - bind_group_layouts: &[Some(&probe_temporal_layout)], - immediate_size: 0, - }); - let probe_temporal_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("probe_temporal_pipeline"), - layout: Some(&probe_temporal_pl_layout), - module: &probe_temporal_shader, entry_point: Some("cs_main"), - compilation_options: Default::default(), cache: None, - }); - let probe_temporal_uniform = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("probe_temporal_uniform"), - size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - - // --- Probe resolve (fragment → half-res ssgi_rt) --- - let probe_resolve_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("probe_resolve_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D3, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 4, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 5, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), - count: None, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, }, - ], - }); - let probe_resolve_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("probe_resolve_pl_layout"), - bind_group_layouts: &[Some(&probe_resolve_layout)], - immediate_size: 0, - }); - let probe_resolve_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("probe_resolve_pipeline"), - layout: Some(&probe_resolve_pl_layout), - vertex: wgpu::VertexState { - module: &probe_resolve_shader, entry_point: Some("vs_main"), - buffers: &[], compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &probe_resolve_shader, entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: HDR_FORMAT, blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, front_face: wgpu::FrontFace::Ccw, - cull_mode: None, polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, cache: None, - }); + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); let probe_resolve_uniform = device.create_buffer(&wgpu::BufferDescriptor { label: Some("probe_resolve_uniform"), size: std::mem::size_of::() as u64, @@ -5240,10 +6248,10 @@ impl Renderer { label: Some("card_capture_shader"), source: wgpu::ShaderSource::Wgsl(CARD_CAPTURE_WGSL.into()), }); - let card_capture_uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("card_capture_uniform_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { + let card_capture_uniform_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("card_capture_uniform_layout"), + entries: &[wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, ty: wgpu::BindingType::Buffer { @@ -5252,90 +6260,92 @@ impl Renderer { min_binding_size: None, }, count: None, - }, - ], - }); - let card_capture_texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("card_capture_texture_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, + }], + }); + let card_capture_texture_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("card_capture_texture_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - // Ticket 013 V3 — emissive texture binding. - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + // Ticket 013 V3 — emissive texture binding. + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, - count: None, - }, - ], - }); - let card_capture_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("card_capture_pl_layout"), - bind_group_layouts: &[ - Some(&card_capture_uniform_layout), - Some(&card_capture_texture_layout), - ], - immediate_size: 0, - }); - let card_capture_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("card_capture_pipeline"), - layout: Some(&card_capture_pl_layout), - vertex: wgpu::VertexState { - module: &card_capture_shader, - entry_point: Some("vs_main"), - buffers: &[Vertex3D::desc()], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &card_capture_shader, - entry_point: Some("fs_main"), - // Location 0 → albedo atlas, Location 1 → emissive atlas. - targets: &[ - Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8UnormSrgb, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - }), - Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8UnormSrgb, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - }), ], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); + }); + let card_capture_pl_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("card_capture_pl_layout"), + bind_group_layouts: &[ + Some(&card_capture_uniform_layout), + Some(&card_capture_texture_layout), + ], + immediate_size: 0, + }); + let card_capture_pipeline = + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("card_capture_pipeline"), + layout: Some(&card_capture_pl_layout), + vertex: wgpu::VertexState { + module: &card_capture_shader, + entry_point: Some("vs_main"), + buffers: &[Vertex3D::desc()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &card_capture_shader, + entry_point: Some("fs_main"), + // Location 0 → albedo atlas, Location 1 → emissive atlas. + targets: &[ + Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8UnormSrgb, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8UnormSrgb, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + ], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); let card_capture_uniform = device.create_buffer(&wgpu::BufferDescriptor { label: Some("card_capture_uniform"), size: 128, // ortho_vp (64) + base_color (16) + pad to 128 for alignment headroom @@ -5347,7 +6357,11 @@ impl Renderer { // but wgpu still requires a bound texture in the pipeline layout. let card_capture_fallback_tex = device.create_texture(&wgpu::TextureDescriptor { label: Some("card_capture_fallback"), - size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -5363,10 +6377,19 @@ impl Renderer { aspect: wgpu::TextureAspect::All, }, &[255, 255, 255, 255], - wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(4), rows_per_image: Some(1) }, - wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4), + rows_per_image: Some(1), + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, ); - let card_capture_fallback_view = card_capture_fallback_tex.create_view(&wgpu::TextureViewDescriptor::default()); + let card_capture_fallback_view = + card_capture_fallback_tex.create_view(&wgpu::TextureViewDescriptor::default()); // --- Ticket 013 V2: card-lighting compute --- let card_light_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -5377,71 +6400,96 @@ impl Renderer { label: Some("card_light_layout"), entries: &[ wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::COMPUTE, + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::COMPUTE, + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::COMPUTE, + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 4, visibility: wgpu::ShaderStages::COMPUTE, + binding: 4, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, format: HDR_FORMAT, view_dimension: wgpu::TextureViewDimension::D2, - }, count: None, + }, + count: None, }, // V3 — emissive atlas. wgpu::BindGroupLayoutEntry { - binding: 5, visibility: wgpu::ShaderStages::COMPUTE, + binding: 5, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, // V3 — three shadow cascade depth textures + comparison sampler. wgpu::BindGroupLayoutEntry { - binding: 6, visibility: wgpu::ShaderStages::COMPUTE, + binding: 6, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Depth, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 7, visibility: wgpu::ShaderStages::COMPUTE, + binding: 7, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Depth, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 8, visibility: wgpu::ShaderStages::COMPUTE, + binding: 8, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Depth, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 9, visibility: wgpu::ShaderStages::COMPUTE, + binding: 9, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison), count: None, }, @@ -5452,14 +6500,15 @@ impl Renderer { bind_group_layouts: &[Some(&card_light_layout)], immediate_size: 0, }); - let card_light_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("card_light_pipeline"), - layout: Some(&card_light_pl_layout), - module: &card_light_shader, - entry_point: Some("cs_main"), - compilation_options: Default::default(), - cache: None, - }); + let card_light_pipeline = + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("card_light_pipeline"), + layout: Some(&card_light_pl_layout), + module: &card_light_shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }); let card_light_uniform = device.create_buffer(&wgpu::BufferDescriptor { label: Some("card_light_uniform"), size: std::mem::size_of::() as u64, @@ -5476,33 +6525,44 @@ impl Renderer { label: Some("sdf_bake_layout"), entries: &[ wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::COMPUTE, + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::COMPUTE, + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::COMPUTE, + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, format: wgpu::TextureFormat::R32Float, view_dimension: wgpu::TextureViewDimension::D3, - }, count: None, + }, + count: None, }, ], }); @@ -5543,47 +6603,54 @@ impl Renderer { }, count: None, }; - let sdf_clipmap_bake_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("sdf_clipmap_bake_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - storage_ro(1), - storage_ro(2), - wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::StorageTexture { - access: wgpu::StorageTextureAccess::WriteOnly, - format: wgpu::TextureFormat::R32Float, - view_dimension: wgpu::TextureViewDimension::D3, - }, count: None, - }, - storage_ro(4), - storage_ro(5), - ], - }); - let sdf_clipmap_bake_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("sdf_clipmap_bake_pl_layout"), - bind_group_layouts: &[Some(&sdf_clipmap_bake_layout)], - immediate_size: 0, - }); - let sdf_clipmap_bake_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("sdf_clipmap_bake_pipeline"), - layout: Some(&sdf_clipmap_bake_pl_layout), - module: &sdf_clipmap_bake_shader, - entry_point: Some("cs_main"), - compilation_options: Default::default(), - cache: None, - }); + let sdf_clipmap_bake_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("sdf_clipmap_bake_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + storage_ro(1), + storage_ro(2), + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: wgpu::TextureFormat::R32Float, + view_dimension: wgpu::TextureViewDimension::D3, + }, + count: None, + }, + storage_ro(4), + storage_ro(5), + ], + }); + let sdf_clipmap_bake_pl_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sdf_clipmap_bake_pl_layout"), + bind_group_layouts: &[Some(&sdf_clipmap_bake_layout)], + immediate_size: 0, + }); + let sdf_clipmap_bake_pipeline = + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("sdf_clipmap_bake_pipeline"), + layout: Some(&sdf_clipmap_bake_pl_layout), + module: &sdf_clipmap_bake_shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }); // --- Ticket 014 V2: scene clipmap --- - let (scene_sdf_clipmap_tex, scene_sdf_clipmap_view) = - create_scene_sdf_clipmap(&device); + let (scene_sdf_clipmap_tex, scene_sdf_clipmap_view) = create_scene_sdf_clipmap(&device); let (scene_sdf_clipmap_staging_tex, scene_sdf_clipmap_staging_view) = create_scene_sdf_clipmap_staging(&device); @@ -5607,48 +6674,60 @@ impl Renderer { label: Some("wsrc_bake_layout"), entries: &[ wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::COMPUTE, + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Depth, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::COMPUTE, + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Depth, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::COMPUTE, + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Depth, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 4, visibility: wgpu::ShaderStages::COMPUTE, + binding: 4, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison), count: None, }, wgpu::BindGroupLayoutEntry { - binding: 5, visibility: wgpu::ShaderStages::COMPUTE, + binding: 5, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, format: HDR_FORMAT, view_dimension: wgpu::TextureViewDimension::D3, - }, count: None, + }, + count: None, }, ], }); @@ -5679,8 +6758,10 @@ impl Renderer { // rays sample Mesh Cards at hit. let (wsrc_bake_hw_pipeline, wsrc_bake_hw_layout) = if hw_rt_enabled { let hw_source = format!( - "enable wgpu_ray_query;\n{}{}", - PROBE_HELPERS_WGSL, WSRC_BAKE_HW_WGSL, + "enable wgpu_ray_query;\n{}{}{}", + ray_query_backend_variant(&device), + PROBE_HELPERS_WGSL, + WSRC_BAKE_HW_WGSL, ); let hw_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("wsrc_bake_hw_shader"), @@ -5690,71 +6771,92 @@ impl Renderer { label: Some("wsrc_bake_hw_layout"), entries: &[ wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::COMPUTE, + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Depth, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::COMPUTE, + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Depth, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::COMPUTE, + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Depth, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 4, visibility: wgpu::ShaderStages::COMPUTE, + binding: 4, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison), count: None, }, wgpu::BindGroupLayoutEntry { - binding: 5, visibility: wgpu::ShaderStages::COMPUTE, + binding: 5, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, format: HDR_FORMAT, view_dimension: wgpu::TextureViewDimension::D3, - }, count: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 6, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::AccelerationStructure { vertex_return: false }, + binding: 6, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::AccelerationStructure { + vertex_return: false, + }, count: None, }, wgpu::BindGroupLayoutEntry { - binding: 7, visibility: wgpu::ShaderStages::COMPUTE, + binding: 7, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 8, visibility: wgpu::ShaderStages::COMPUTE, + binding: 8, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 9, visibility: wgpu::ShaderStages::COMPUTE, + binding: 9, + visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, @@ -5768,155 +6870,199 @@ impl Renderer { let hw_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: Some("wsrc_bake_hw_pipeline"), layout: Some(&hw_pl_layout), - module: &hw_shader, entry_point: Some("cs_main"), - compilation_options: Default::default(), cache: None, + module: &hw_shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }); + (Some(hw_pipeline), Some(hw_layout)) + } else { + (None, None) + }; + + // --- Scene-compose pipeline --- + // Merges HDR + SSR + SSGI*albedo + bloom + fog + shafts into + // composed_rt. Both TAA and composite downstream read from + // this single output so atmospherics behave identically + // whether TAA is on or off. + let scene_compose_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_compose_shader"), + source: wgpu::ShaderSource::Wgsl(SCENE_COMPOSE_SHADER_WGSL.into()), + }); + let scene_compose_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("scene_compose_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // hdr, ssr, ssgi, bloom, albedo each: tex + sampler. + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 7, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 8, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 9, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 10, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + // depth (tex + sampler) + wgpu::BindGroupLayoutEntry { + binding: 11, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 12, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }, + // EN-005 V2 — aerial-perspective 3D LUT (tex + sampler). + // Sampled per fragment when `misc.y > 0` (procedural sky + // active); otherwise unused but bound to keep the layout + // shape stable across frames. + wgpu::BindGroupLayoutEntry { + binding: 13, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D3, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 14, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let scene_compose_pl_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("scene_compose_pl_layout"), + bind_group_layouts: &[Some(&scene_compose_layout)], + immediate_size: 0, }); - (Some(hw_pipeline), Some(hw_layout)) - } else { - (None, None) - }; - - // --- Scene-compose pipeline --- - // Merges HDR + SSR + SSGI*albedo + bloom + fog + shafts into - // composed_rt. Both TAA and composite downstream read from - // this single output so atmospherics behave identically - // whether TAA is on or off. - let scene_compose_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("scene_compose_shader"), - source: wgpu::ShaderSource::Wgsl(SCENE_COMPOSE_SHADER_WGSL.into()), - }); - let scene_compose_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("scene_compose_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - // hdr, ssr, ssgi, bloom, albedo each: tex + sampler. - wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 4, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 5, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 6, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 7, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 8, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 9, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 10, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - // depth (tex + sampler) - wgpu::BindGroupLayoutEntry { - binding: 11, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Depth, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 12, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), - count: None, - }, - // EN-005 V2 — aerial-perspective 3D LUT (tex + sampler). - // Sampled per fragment when `misc.y > 0` (procedural sky - // active); otherwise unused but bound to keep the layout - // shape stable across frames. - wgpu::BindGroupLayoutEntry { - binding: 13, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D3, multisampled: false, - }, count: None, + let scene_compose_pipeline = + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("scene_compose_pipeline"), + layout: Some(&scene_compose_pl_layout), + vertex: wgpu::VertexState { + module: &scene_compose_shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), }, - wgpu::BindGroupLayoutEntry { - binding: 14, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, + fragment: Some(wgpu::FragmentState { + module: &scene_compose_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, }, - ], - }); - let scene_compose_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("scene_compose_pl_layout"), - bind_group_layouts: &[Some(&scene_compose_layout)], - immediate_size: 0, - }); - let scene_compose_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("scene_compose_pipeline"), - layout: Some(&scene_compose_pl_layout), - vertex: wgpu::VertexState { - module: &scene_compose_shader, entry_point: Some("vs_main"), - buffers: &[], compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &scene_compose_shader, entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: HDR_FORMAT, blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, front_face: wgpu::FrontFace::Ccw, - cull_mode: None, polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, cache: None, - }); + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); let scene_compose_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("scene_compose_uniform_buffer"), size: std::mem::size_of::() as u64, @@ -6031,63 +7177,65 @@ impl Renderer { label: Some("motion_blur_shader"), source: wgpu::ShaderSource::Wgsl(MOTION_BLUR_SHADER_WGSL.into()), }); - let motion_blur_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("motion_blur_layout"), - entries: &[ - // binding 0: MotionBlurParams uniform - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, + let motion_blur_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("motion_blur_layout"), + entries: &[ + // binding 0: MotionBlurParams uniform + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, - count: None, - }, - // binding 1: color input (upstream HDR) - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, + // binding 1: color input (upstream HDR) + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, - count: None, - }, - // binding 2: color sampler - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - // binding 3: velocity texture - wgpu::BindGroupLayoutEntry { - binding: 3, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, + // binding 2: color sampler + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, }, - count: None, - }, - // binding 4: velocity sampler - wgpu::BindGroupLayoutEntry { - binding: 4, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); - let motion_blur_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("motion_blur_pl_layout"), - bind_group_layouts: &[Some(&motion_blur_layout)], - immediate_size: 0, - }); + // binding 3: velocity texture + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + // binding 4: velocity sampler + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let motion_blur_pl_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("motion_blur_pl_layout"), + bind_group_layouts: &[Some(&motion_blur_layout)], + immediate_size: 0, + }); let motion_blur_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("motion_blur_pipeline"), layout: Some(&motion_blur_pl_layout), @@ -6239,33 +7387,44 @@ impl Renderer { label: Some("exposure_layout"), entries: &[ wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, - }, count: None, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, wgpu::BindGroupLayoutEntry { - binding: 4, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, @@ -6280,11 +7439,14 @@ impl Renderer { label: Some("exposure_pipeline"), layout: Some(&exposure_pl_layout), vertex: wgpu::VertexState { - module: &exposure_shader, entry_point: Some("vs_main"), - buffers: &[], compilation_options: Default::default(), + module: &exposure_shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), }, fragment: Some(wgpu::FragmentState { - module: &exposure_shader, entry_point: Some("fs_main"), + module: &exposure_shader, + entry_point: Some("fs_main"), targets: &[Some(wgpu::ColorTargetState { // Rg16Float: .g carries the anchored AE target // (flicker fix — see EXPOSURE_SHADER_WGSL). @@ -6296,13 +7458,17 @@ impl Renderer { }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, front_face: wgpu::FrontFace::Ccw, - cull_mode: None, polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, conservative: false, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, }, depth_stencil: None, multisample: wgpu::MultisampleState::default(), - multiview_mask: None, cache: None, + multiview_mask: None, + cache: None, }); let exposure_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("exposure_uniform_buffer"), @@ -6314,10 +7480,33 @@ impl Renderer { // Phase 1c — material system: the new shader-ABI draw path. // Runs alongside pipeline_3d / scene_pipeline without disturbing // them; games opt in via compile_material + submit_material_draw. - let material_system = material_system::MaterialSystem::new(&device, &queue, &joint_buffer); + let mut material_system = + material_system::MaterialSystem::new(&device, &queue, &joint_buffer); + let global_linear_sampler_id = material_system + .indirection + .register_sampler(sampler.clone()); + let global_nearest_sampler_id = material_system + .indirection + .register_sampler(nearest_sampler.clone()); + material_system.indirection.flush(&device, &queue); + let gpu_driven = gpu_driven::GpuDrivenRenderer::new( + &device, + &lighting_layout, + &joint_layout, + material_system.indirection.global_layout.as_ref(), + &gpu_scene_shader_source, + ); let transient_pool = transient::TransientPool::new(); + let frame_plan_cache = graph::PlanCache::new(); let impulse_field = impulse_field::ImpulseField::new(&device); let material_hot_reload = hot_reload::MaterialHotReload::new(); + let imported_refraction_enabled = crate::models::physical_transmission_requested(); + if !imported_refraction_enabled { + log::warn!( + "bloom materials: imported physical refraction disabled by \ + BLOOM_GLTF_REFRACTION; using the legacy mirror fallback" + ); + } // EN-017 — non-filtering sampler shared by every post-pass // shader for the depth binding. Created up front because the @@ -6354,7 +7543,9 @@ impl Renderer { } else { None }, + headless_in_flight: std::collections::VecDeque::with_capacity(4), device, + device_negotiation_report: None, queue, surface, surface_config, @@ -6370,18 +7561,27 @@ impl Renderer { uniform_buffer_3d, uniform_bind_group_3d, lighting_uniforms, + lighting_upload_tracker: lighting_upload::LightingUploadTracker::new(lighting_uniforms), + frame_resource_stats: frame_resource_stats::FrameResourceStats::default(), + steady_state_frame_resource_stats: Default::default(), + pipeline_creation_count: 0, lighting_buffer, lighting_bind_group, joint_buffer, joint_prev_buffer, + joint_layout, joint_bind_group, material_per_view_bg_live: false, texture_bind_group_layout, texture_bind_groups, textures, texture_sizes, + global_texture_ids: vec![material_indirection::TextureId::FALLBACK], + mask_coverage_texture_count: 0, sampler, nearest_sampler, + global_linear_sampler_id, + global_nearest_sampler_id, depth_texture, depth_view, hdr_rt_texture, @@ -6395,9 +7595,11 @@ impl Renderer { scene_compose_pipeline, scene_compose_layout, scene_compose_uniform_buffer, + scene_compose_bind_group_cache: std::array::from_fn(|_| None), composite_pipeline, composite_layout, composite_sampler, + composite_bind_group_cache: std::array::from_fn(|_| None), // 1 = AgX (Troy Sobotka 2022). Matches Blender 4.0+ and // UE5 "PBR Neutral" look — softer highlight rolloff and // better hue preservation than the Narkowicz ACES fit, @@ -6411,7 +7613,7 @@ impl Renderer { // Faster than a camera pan; slow enough to not "hunt" on // scene detail as the camera moves between bright sky // and dark geometry. - auto_exposure_rate: 0.05, // ~0.3 second half-life at 60fps + auto_exposure_rate: 0.05, // ~0.3 second half-life at 60fps chromatic_aberration: 0.0, vignette_strength: 0.0, vignette_softness: 0.25, @@ -6424,9 +7626,12 @@ impl Renderer { exposure_textures, exposure_views, exposure_current_idx: 0, + exposure_history_valid: false, + exposure_history_written: false, exposure_pipeline, exposure_layout, exposure_uniform_buffer, + exposure_bind_group_cache: std::array::from_fn(|_| None), composite_uniform_buffer, bloom_chain_textures, bloom_mip_views, @@ -6435,6 +7640,11 @@ impl Renderer { bloom_pipeline_downsample, bloom_pipeline_upsample, bloom_layout, + bloom_downsample_param_buffers: Vec::new(), + bloom_downsample_bind_groups: Vec::new(), + bloom_upsample_param_buffers: Vec::new(), + bloom_upsample_bind_groups: Vec::new(), + bloom_threshold_written: f32::NAN, bloom_intensity: 0.04, wind: [0.0, 0.0, 0.0, 0.0], ssao_rt_texture, @@ -6473,6 +7683,7 @@ impl Renderer { ssao_history_idx: 0, ssao_history_frame: 0, ssr_bg_cache: None, + ssr_layered_bg_cache: None, // World-space AO radius in meters. Sponza-scale arches // and columns span 3-5m, so 4m catches proper architectural // occlusion. @@ -6487,19 +7698,30 @@ impl Renderer { taa_pipeline, taa_layout, taa_uniform_buffer, + taa_bind_group_cache: [None, None], + #[cfg(not(target_arch = "wasm32"))] + temporal_diagnostics: None, + taa_reactive_pipeline: None, + taa_reactive_layout: None, + taa_reactive_bind_group_cache: [None, None], + taa_reactive_bind_group_cache_keys: [None, None], taa_frame_index: 0, taa_enabled: true, - render_scale: 0.5, + taa_history_valid: false, + taa_history_written: false, + taa_history_pt_owned: false, + render_scale: quality_preset::DEFAULT_RENDER_SCALE, // 1.0 = native output. Games that never touch it are unaffected. output_scale: 1.0, native_width: 0, native_height: 0, - render_scale_explicit: false, prev_vp_matrix: IDENTITY_MAT4, prev_proj_matrix_unjittered: IDENTITY_MAT4, prev_view_matrix: IDENTITY_MAT4, current_jitter_ndc: [0.0, 0.0], velocity_ref_vp: IDENTITY_MAT4, + temporal_camera_cut_pending: false, + temporal_camera_cut_active: false, fog_color: [0.7, 0.75, 0.82], fog_color_user_override: false, fog_density: 0.0, @@ -6518,9 +7740,13 @@ impl Renderer { ssr_history_textures, ssr_history_views, ssr_history_idx: 0, + ssr_history_valid: false, ssr_temporal_pipeline, ssr_temporal_layout, ssr_temporal_uniform_buffer, + ssr_temporal_bind_group_cache: [None, None], + #[cfg(not(target_arch = "wasm32"))] + ssr_temporal_diagnostics: None, ssgi_rt_texture, ssgi_rt_view, // 1.0 — stronger bounce than the earlier 0.5 default so @@ -6543,6 +7769,9 @@ impl Renderer { .min(2), ssgi_backend_logged: None, gi_scene_avg_albedo: [0.35, 0.35, 0.35], + transparent_gi_active: false, + transparent_gi_instance_count: 0, + transparent_gi_force_probe_refresh: false, probe_grid_w, probe_grid_h, probe_header_buffer, @@ -6551,12 +7780,16 @@ impl Renderer { probe_history_textures, probe_history_views, probe_history_idx: 0, + probe_history_valid: false, + #[cfg(not(target_arch = "wasm32"))] + ssgi_temporal_diagnostics: None, hw_rt_enabled, tlas: None, tlas_max_instances: 1024, tlas_created_cap: 0, tlas_built_version: 0, tlas_instance_data_buffer: None, + tlas_instance_data_count: 0, probe_place_pipeline, probe_place_layout, probe_place_uniform, @@ -6566,10 +7799,12 @@ impl Renderer { probe_trace_uniform, probe_trace_bg_cache: [None, None], probe_trace_hw_pipeline, + probe_trace_hw_transparent_pipeline: None, probe_trace_hw_layout, probe_trace_hw_bg_cache: [None, None], pt_pipeline, pt_layout, + pt_layered: Default::default(), pt_bg: [None, None], pt_uniform_buffer, pt_accum_buffers: [None, None], @@ -6580,6 +7815,9 @@ impl Renderer { pt_last_tlas_version: 0, pt_debug, pt_frame_index: 0, + pt_rng_seed: 0, + #[cfg(not(target_arch = "wasm32"))] + pt_temporal_diagnostics: None, pt_restir, pt_resv_buffers: [None, None], pt_dynamic_draws: Vec::new(), @@ -6603,8 +7841,12 @@ impl Renderer { pt_atrous_params_bufs, pt_atrous_scratch: None, pt_atrous_scratch2: None, - pt_atrous_bgs: [[None, None, None, None, None, None], [None, None, None, None, None, None]], + pt_atrous_bgs: [ + [None, None, None, None, None, None], + [None, None, None, None, None, None], + ], probe_trace_sdf_pipeline, + probe_trace_sdf_transparent_pipeline: None, probe_trace_sdf_layout, probe_trace_sdf_bg_cache: [None, None], mesh_card_atlas, @@ -6635,6 +7877,8 @@ impl Renderer { scene_sdf_clipmap_view, scene_sdf_clipmap_built: false, scene_sdf_clipmap_origin: [0.0, 0.0, 0.0], + scene_sdf_clipmap_scene_version: 0, + scene_sdf_clipmap_transparent_gi: false, sdf_clipmap_bake_pipeline, sdf_clipmap_bake_layout, scene_sdf_clipmap_staging_tex, @@ -6649,6 +7893,7 @@ impl Renderer { wsrc_bake_uniform, wsrc_bake_bg_cache: None, wsrc_bake_hw_pipeline, + wsrc_bake_hw_transparent_pipeline: None, wsrc_bake_hw_layout, wsrc_bake_hw_bg_cache: None, wsrc_built: [false; 3], @@ -6669,6 +7914,7 @@ impl Renderer { dof_pipeline, dof_layout, dof_uniform_buffer, + dof_bind_group_cache: std::array::from_fn(|_| None), dof_enabled: false, dof_focus_distance: 10.0, dof_aperture: 0.0, @@ -6680,6 +7926,7 @@ impl Renderer { motion_blur_pipeline, motion_blur_layout, motion_blur_uniform_buffer, + motion_blur_bind_group_cache: std::array::from_fn(|_| None), motion_blur_enabled: false, motion_blur_strength: 1.0, motion_blur_max_blur: 0.05, @@ -6688,6 +7935,7 @@ impl Renderer { sss_pipeline, sss_layout, sss_uniform_buffer, + sss_bind_group_cache: std::array::from_fn(|_| None), sss_enabled: false, sss_strength: 0.5, sss_width: 0.01, @@ -6696,12 +7944,14 @@ impl Renderer { upscale_pipeline, upscale_layout, upscale_uniform_buffer, + upscale_bind_group_cache: None, upscale_mode: 1, // Catmull-Rom by default cas_rt_texture, cas_rt_view, cas_pipeline, cas_layout, cas_uniform_buffer, + cas_bind_group_cache: std::array::from_fn(|_| None), cas_strength: 0.0, // off by default vertices_2d: Vec::with_capacity(4096), indices_2d: Vec::with_capacity(8192), @@ -6709,6 +7959,7 @@ impl Renderer { vertices_3d: Vec::with_capacity(16384), indices_3d: Vec::with_capacity(32768), draw_calls_3d: Vec::new(), + immediate_motion: Default::default(), current_texture_3d: 0, persistent_vb_2d, persistent_ib_2d, @@ -6719,8 +7970,17 @@ impl Renderer { persistent_vb_3d_capacity: vb_3d_cap, persistent_ib_3d_capacity: ib_3d_cap, model_gpu_cache: HashMap::new(), + gpu_driven, model_skinned: std::collections::HashSet::new(), + model_blended: std::collections::HashSet::new(), + model_layered_blended: std::collections::HashSet::new(), + model_refractive: std::collections::HashSet::new(), model_draw_commands: Vec::with_capacity(64), + cached_model_motion: Default::default(), + has_blend_model_draws: false, + has_layered_blend_model_draws: false, + has_refractive_model_draws: false, + has_refractive_scene_nodes: false, model_uniform_pool, model_uniform_pool_capacity: model_uniform_count, model_uniform_scratch: Vec::new(), @@ -6747,6 +8007,11 @@ impl Renderer { frame_joint_data: Vec::with_capacity(256), pending_skin_groups_prev: Vec::with_capacity(8), frame_joint_data_prev: Vec::with_capacity(256), + skin_unkeyed_previous: Vec::new(), + skin_unkeyed_current: Vec::new(), + skin_unkeyed_previous_count: 0, + skin_unkeyed_slot: 0, + skin_motion_epoch: 0, skin_prev_palettes: std::collections::HashMap::new(), model_skin_scale: 1.0, clear_color: wgpu::Color::BLACK, @@ -6755,6 +8020,7 @@ impl Renderer { screenshot_requested: false, screenshot_data: None, pending_screenshot_path: None, + pending_quality_capture_dir: None, rt_color_view: None, rt_depth_view: None, rt_depth_texture: None, @@ -6805,10 +8071,49 @@ impl Renderer { aerial_perspective_sampler, env_diffuse_texture: None, scene_pipeline, + scene_transparent_pipeline, + scene_transparent_double_sided_pipeline, + scene_transparent_reactive_pipeline: None, + scene_transparent_reactive_double_sided_pipeline: None, + scene_weighted_transparent_pipeline: None, + scene_weighted_transparent_double_sided_pipeline: None, + weighted_transparency_resolve_pipeline: None, + weighted_transparency_reactive_resolve_pipeline: None, + weighted_transparency_resolve_layout: None, + weighted_transparency_resolve_bind_group: None, + weighted_transparency_resolve_bind_group_key: None, + transparency_composition_preference: + TransparencyCompositionPreference::from_environment(), + weighted_transparency_active: false, + temporal_reactive_active: false, + transmitted_shadow_resources: None, + transmitted_shadows_active: false, + scene_refractive_pipeline: None, + scene_refractive_double_sided_pipeline: None, + scene_refractive_uv1_pipeline: None, + scene_refractive_uv1_double_sided_pipeline: None, + scene_refractive_reactive_pipeline: None, + scene_refractive_reactive_double_sided_pipeline: None, + scene_refractive_reactive_uv1_pipeline: None, + scene_refractive_reactive_uv1_double_sided_pipeline: None, + #[cfg(not(fold_scene_inputs))] + scene_refractive_inputs_layout: None, + #[cfg(not(fold_scene_inputs))] + scene_refractive_inputs_bg: None, + #[cfg(not(fold_scene_inputs))] + scene_refractive_reflection_params_buffer: None, + refractive_reflections_active: false, scene_depth_pipeline, scene_pipeline_prepassed, froxel, scene_material_layout, + scene_sheen_albedo_lut: None, + scene_layered_pbr_resources: None, + scene_iridescence_ssr_resources: None, + iridescence_ssr_active: false, + scene_layered_refractive_resources: None, + scene_refractive_material_layout: None, + imported_refraction_enabled, _scene_env_default_texture: scene_env_default_texture, scene_env_default_view, env_sampler, @@ -6829,6 +8134,9 @@ impl Renderer { default_normal_view, material_system, transient_pool, + frame_plan_cache, + last_frame_plan: None, + dumped_frame_plans: std::collections::HashSet::new(), impulse_field, planar_probes: Vec::new(), reflect_scene_pipeline: None, @@ -6848,7 +8156,7 @@ impl Renderer { }; // The targets above are sized to the full surface, but the pass - // chain runs at `render_extent()` (surface * render_scale, 0.5 by + // chain runs at `render_extent()` (surface * render_scale, 0.75 by // default) and `resize` is the only thing that reconciles the two. // Run it once here rather than trusting the host to: every // `attach_engine` platform (macOS/iOS/tvOS/Linux/Android) resizes @@ -6856,7 +8164,10 @@ impl Renderer { // those hosts rendered against targets that ignored render_scale, // dropping post-FX and failing the depth-snapshot copy outright // with a scene-reading translucent material in view. - let (pw, ph) = (renderer.surface_config.width, renderer.surface_config.height); + let (pw, ph) = ( + renderer.surface_config.width, + renderer.surface_config.height, + ); let (lw, lh) = (renderer.logical_width, renderer.logical_height); renderer.resize(pw, ph, lw, lh); renderer @@ -6868,7 +8179,11 @@ impl Renderer { let color_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); let depth_tex = self.device.create_texture(&wgpu::TextureDescriptor { label: Some("rt_depth"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -6895,17 +8210,25 @@ impl Renderer { self.rt_height = 0; } - // ============================================================ // Lifecycle - // ============================================================ /// Resize the swapchain and all post-process render targets. // Debug accessors for diagnosing draw call issues - pub fn vertices_2d_count(&self) -> usize { self.vertices_2d.len() } - pub fn indices_2d_count(&self) -> usize { self.indices_2d.len() } - pub fn draw_calls_2d_count(&self) -> usize { self.draw_calls_2d.len() } - pub fn texture_count(&self) -> usize { self.texture_bind_groups.len() } - pub fn texture_sizes_debug(&self) -> Vec<(u32, u32)> { self.texture_sizes.clone() } + pub fn vertices_2d_count(&self) -> usize { + self.vertices_2d.len() + } + pub fn indices_2d_count(&self) -> usize { + self.indices_2d.len() + } + pub fn draw_calls_2d_count(&self) -> usize { + self.draw_calls_2d.len() + } + pub fn texture_count(&self) -> usize { + self.texture_bind_groups.len() + } + pub fn texture_sizes_debug(&self) -> Vec<(u32, u32)> { + self.texture_sizes.clone() + } /// `width`/`height` are PHYSICAL pixels (the actual GPU surface). /// `logical_width`/`logical_height` are the points size reported to @@ -6922,7 +8245,9 @@ impl Renderer { /// Re-applies immediately against the last known window size. pub fn set_output_scale(&mut self, scale: f32) { let s = scale.clamp(0.25, 1.0); - if (s - self.output_scale).abs() < 1e-4 { return; } + if (s - self.output_scale).abs() < 1e-4 { + return; + } self.output_scale = s; let (w, h) = (self.native_width, self.native_height); let (lw, lh) = (self.logical_width, self.logical_height); @@ -6931,12 +8256,18 @@ impl Renderer { } } - pub fn output_scale(&self) -> f32 { self.output_scale } + pub fn output_scale(&self) -> f32 { + self.output_scale + } pub fn render_extent(&self) -> (u32, u32) { let sw = self.surface_config.width as f32; let sh = self.surface_config.height as f32; - let s = self.render_scale.clamp(0.5, 1.0); + // SH-055 — floor lowered from 0.5 to 0.15. On a weak mobile GPU (Adreno + // 618) the full-screen terrain/material fill in main_hdr_pass is the + // frame cost, and 0.5 was too high a floor to reach a playable rate; + // TSR still reconstructs (softer, but a real trade the caller opts into). + let s = self.render_scale.clamp(0.15, 1.0); ( ((sw * s).round() as u32).max(1), ((sh * s).round() as u32).max(1), @@ -6966,22 +8297,23 @@ impl Renderer { Some(surface) => surface.configure(&self.device, &self.surface_config), None => { // Recreate the offscreen target at the new size. - self.headless_target = Some(self.device.create_texture(&wgpu::TextureDescriptor { - label: Some("headless_swapchain"), - size: wgpu::Extent3d { - width: self.surface_config.width, - height: self.surface_config.height, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: self.output_format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::COPY_SRC - | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - })); + self.headless_target = + Some(self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("headless_swapchain"), + size: wgpu::Extent3d { + width: self.surface_config.width, + height: self.surface_config.height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: self.output_format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + })); } } @@ -6992,6 +8324,24 @@ impl Renderer { // at full surface. let (rw, rh) = self.render_extent(); + // Drop every cache that owns a render-target view before replacing + // those views. Source/history indices select distinct cache slots. + self.composite_bind_group_cache = std::array::from_fn(|_| None); + self.exposure_bind_group_cache = std::array::from_fn(|_| None); + self.scene_compose_bind_group_cache = std::array::from_fn(|_| None); + self.ssr_temporal_bind_group_cache = [None, None]; + self.taa_bind_group_cache = [None, None]; + self.taa_reactive_bind_group_cache = [None, None]; + self.taa_reactive_bind_group_cache_keys = [None, None]; + self.upscale_bind_group_cache = None; + self.dof_bind_group_cache = std::array::from_fn(|_| None); + self.motion_blur_bind_group_cache = std::array::from_fn(|_| None); + self.sss_bind_group_cache = std::array::from_fn(|_| None); + self.cas_bind_group_cache = std::array::from_fn(|_| None); + for post_pass in self.post_passes.iter_mut() { + post_pass.bind_group_cache = [None, None]; + } + let (dt, dv) = create_depth_texture(&self.device, rw, rh); self.depth_texture = dt; self.depth_view = dv; @@ -7017,6 +8367,7 @@ impl Renderer { self.bloom_chain_textures = bt; self.bloom_mip_views = bm; self.bloom_full_view = bf; + self.rebuild_bloom_pass_resources(); let (st, sv) = create_ssao_rt(&self.device, rw, rh); self.ssao_rt_texture = st; self.ssao_rt_view = sv; @@ -7036,7 +8387,10 @@ impl Renderer { let (taa_t, taa_v) = create_taa_textures(&self.device, width, height); self.taa_textures = taa_t; self.taa_views = taa_v; + self.taa_current_idx = 0; self.taa_frame_index = 0; // reset jitter sequence on resize + self.taa_history_valid = false; + self.taa_history_written = false; let (sr_t, sr_v) = create_ssr_rt(&self.device, rw, rh); self.ssr_rt_texture = sr_t; self.ssr_rt_view = sr_v; @@ -7044,6 +8398,7 @@ impl Renderer { self.ssr_history_textures = ssr_ht; self.ssr_history_views = ssr_hv; self.ssr_history_idx = 0; + self.ssr_history_valid = false; let (ssgi_t, ssgi_v) = create_ssgi_rt(&self.device, rw, rh); self.ssgi_rt_texture = ssgi_t; self.ssgi_rt_view = ssgi_v; @@ -7060,10 +8415,10 @@ impl Renderer { self.probe_history_textures = pht; self.probe_history_views = phv; self.probe_history_idx = 0; + self.probe_history_valid = false; self.probe_header_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("probe_header_buffer"), - size: (pg_w * pg_h) as u64 - * std::mem::size_of::() as u64, + size: (pg_w * pg_h) as u64 * std::mem::size_of::() as u64, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -7093,6 +8448,9 @@ impl Renderer { self.ssao_bg_cache = [None, None]; self.ssao_blur_bg_cache = None; self.ssr_bg_cache = None; + self.ssr_layered_bg_cache = None; + self.scene_iridescence_ssr_resources = None; + self.iridescence_ssr_active = false; self.probe_place_bg_cache = None; self.probe_trace_bg_cache = [None, None]; // V3 — HW + SDF trace BGs reference the prev-frame @@ -7112,9 +8470,15 @@ impl Renderer { self.pt_accum_count = 0; self.pt_atrous_scratch = None; self.pt_atrous_scratch2 = None; - self.pt_atrous_bgs = [[None, None, None, None, None, None], [None, None, None, None, None, None]]; + self.pt_atrous_bgs = [ + [None, None, None, None, None, None], + [None, None, None, None, None, None], + ]; + if let Some(resources) = self.transmitted_shadow_resources.as_mut() { + resources.invalidate_resolve_bind_group(); + } self.hiz_linearize_bg_cache = None; - self.occlusion.invalidate_bindings(); + self.occlusion.invalidate_bindings(); for slot in self.hiz_downsample_bg_cache.iter_mut() { *slot = None; } @@ -7126,14 +8490,20 @@ impl Renderer { // pass only pay for slot A. if self.composite_ldr_rt_a.is_some() { let (t, v) = post_pass::create_composite_ldr_rt( - &self.device, width, height, self.output_format, + &self.device, + width, + height, + self.output_format, ); self.composite_ldr_rt_a = Some(t); self.composite_ldr_rt_a_view = Some(v); } if self.composite_ldr_rt_b.is_some() { let (t, v) = post_pass::create_composite_ldr_rt( - &self.device, width, height, self.output_format, + &self.device, + width, + height, + self.output_format, ); self.composite_ldr_rt_b = Some(t); self.composite_ldr_rt_b_view = Some(v); @@ -7287,31 +8657,23 @@ impl Renderer { ]; } - /// Toggle TAA on/off. Off = no jitter, no history blend, no - /// extra texture writes. Until `set_render_scale` is called - /// explicitly this also flips `render_scale` between 0.5 (on) - /// and 1.0 (off) for backwards compat with the former TSR - /// coupling; once the user sets scale explicitly that choice - /// sticks across subsequent TAA toggles. + /// Toggle TAA on/off. Resolution is an independent quality control: + /// this never changes `render_scale` or reallocates render targets. pub fn set_taa_enabled(&mut self, enabled: bool) { if enabled != self.taa_enabled { self.taa_enabled = enabled; - if !self.render_scale_explicit { - self.render_scale = if enabled { 0.5 } else { 1.0 }; - } + self.taa_current_idx = 0; self.taa_frame_index = 0; - let (w, h) = (self.surface_config.width, self.surface_config.height); - self.resize(w, h, self.logical_width, self.logical_height); + self.taa_history_valid = false; + self.taa_history_written = false; } } /// Set the render-resolution multiplier explicitly. Clamped to - /// [0.5, 1.0]. Triggers a resize so render-res intermediates - /// pick up the new extent. Marks the scale as user-set so future - /// `set_taa_enabled` calls leave it alone. + /// [0.15, 1.0]. Triggers a resize so render-res intermediates + /// pick up the new extent. pub fn set_render_scale(&mut self, scale: f32) { - let s = scale.clamp(0.5, 1.0); - self.render_scale_explicit = true; + let s = scale.clamp(0.15, 1.0); // SH-055 — see render_extent() if (s - self.render_scale).abs() > 1e-4 { self.render_scale = s; self.taa_frame_index = 0; @@ -7333,34 +8695,8 @@ impl Renderer { } /// Current render-resolution multiplier in [0.5, 1.0]. - pub fn render_scale(&self) -> f32 { self.render_scale } - - /// Toggle SSR on/off. SSR contributes nothing in scenes with - /// no on-screen geometry to reflect (e.g., single object - /// against sky) — turning it off there saves a fullscreen - /// pass. - pub fn set_ssr_enabled(&mut self, enabled: bool) { - self.ssr_enabled = enabled; - } - - /// SSR strength multiplier (0 = off, 0.5 = default, 1+ = strong). - /// Applies on top of the prefiltered IBL specular reflection, - /// adding sharp on-screen reflections where they exist. - pub fn set_ssr_strength(&mut self, strength: f32) { - self.ssr_strength = strength.max(0.0); - } - - /// Toggle SSGI (screen-space global illumination) on/off. Off - /// (default) = no SSGI pass, zero perf cost. On = single-bounce - /// indirect diffuse lighting via screen-space ray marching. - pub fn set_ssgi_enabled(&mut self, on: bool) { - self.ssgi_enabled = on; - } - - /// Path-tracing mode request (0 off / 1 progressive / 2 realtime). - /// Clamped; anything above realtime means realtime. - pub fn set_path_tracing(&mut self, mode: u32) { - self.pt_mode = mode.min(2); + pub fn render_scale(&self) -> f32 { + self.render_scale } /// Whether path tracing can run at all on this device: it needs the @@ -7419,14 +8755,36 @@ impl Renderer { let mut slot_meta_updates: Vec<(u32, CardSlotMetaCpu)> = Vec::new(); for handle in pending { - let (first_slot, bmin, bmax, transform, has_tex, bc, tex_idx, has_em, em_factor, em_idx, vb_ptr, ib_ptr, index_count) = { - let Some(node) = scene.nodes.get(handle) else { continue; }; - let Some(first_slot) = node.card_first_slot else { continue; }; + let ( + first_slot, + bmin, + bmax, + transform, + has_tex, + bc, + tex_idx, + has_em, + em_factor, + em_idx, + vb_ptr, + ib_ptr, + index_count, + ) = { + let Some(node) = scene.nodes.get(handle) else { + continue; + }; + let Some(first_slot) = node.card_first_slot else { + continue; + }; if first_slot + CARD_AXES_PER_MESH > CARD_MAX_SLOTS { continue; } - let Some(vb) = node.gpu_vb.as_ref() else { continue; }; - let Some(ib) = node.gpu_ib.as_ref() else { continue; }; + let Some(vb) = node.gpu_vb.as_ref() else { + continue; + }; + let Some(ib) = node.gpu_ib.as_ref() else { + continue; + }; let has_tex = node.material.texture_idx != 0; let em_idx = node.material.emissive_texture_idx; let has_em = em_idx != 0; @@ -7494,11 +8852,8 @@ impl Renderer { base_color: [bc[0], bc[1], bc[2], bc_w], emissive: [em_factor[0], em_factor[1], em_factor[2], em_w], }; - self.queue.write_buffer( - &self.card_capture_uniform, - 0, - bytemuck::bytes_of(¶ms), - ); + self.queue + .write_buffer(&self.card_capture_uniform, 0, bytemuck::bytes_of(¶ms)); let uniform_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("card_capture_uniform_bg"), layout: &self.card_capture_uniform_layout, @@ -7566,14 +8921,14 @@ impl Renderer { _ => n_os[2] = -1.0, } let t = &transform; - let nx = t[0][0]*n_os[0] + t[1][0]*n_os[1] + t[2][0]*n_os[2]; - let ny = t[0][1]*n_os[0] + t[1][1]*n_os[1] + t[2][1]*n_os[2]; - let nz = t[0][2]*n_os[0] + t[1][2]*n_os[1] + t[2][2]*n_os[2]; - let len = (nx*nx + ny*ny + nz*nz).sqrt().max(1e-4); + let nx = t[0][0] * n_os[0] + t[1][0] * n_os[1] + t[2][0] * n_os[2]; + let ny = t[0][1] * n_os[0] + t[1][1] * n_os[1] + t[2][1] * n_os[2]; + let nz = t[0][2] * n_os[0] + t[1][2] * n_os[1] + t[2][2] * n_os[2]; + let len = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-4); slot_meta_updates.push(( slot, CardSlotMetaCpu { - normal_ws: [nx/len, ny/len, nz/len, axis as f32], + normal_ws: [nx / len, ny / len, nz / len, axis as f32], aabb_min: [bmin[0], bmin[1], bmin[2], 0.0], aabb_max: [bmax[0], bmax[1], bmax[2], 0.0], transform, @@ -7596,7 +8951,6 @@ impl Renderer { } } - /// Ticket 022 — drain pending SDF cache writes after the frame's /// main submit. Maps each staging buffer in one pass (single /// `device.poll(Wait)` covers all of them), unpads the row-aligned @@ -7604,7 +8958,9 @@ impl Renderer { /// to the cache. Best-effort throughout: a write failure is /// silently ignored — the next cold launch just rebakes. pub fn flush_sdf_cache_writes(&mut self) { - if self.sdf_cache_writes.is_empty() { return; } + if self.sdf_cache_writes.is_empty() { + return; + } let entries = std::mem::take(&mut self.sdf_cache_writes); // Issue map_async on every buffer up front so a single poll @@ -7614,7 +8970,10 @@ impl Renderer { let slice = buf.slice(..); slice.map_async(wgpu::MapMode::Read, |_| { /* polled below */ }); } - let _ = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + let _ = self.device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); let row_tight = (MESH_SDF_RES * 4) as usize; let row_padded = ((MESH_SDF_RES * 4 + 255) & !255) as usize; @@ -7658,8 +9017,11 @@ impl Renderer { return; } let ld = self.lighting_uniforms.light_dir; - let inv_len = 1.0 / (ld[0]*ld[0] + ld[1]*ld[1] + ld[2]*ld[2]).sqrt().max(1e-4); - let sun_dir_ws = [-ld[0]*inv_len, -ld[1]*inv_len, -ld[2]*inv_len, ld[3]]; + let inv_len = 1.0 + / (ld[0] * ld[0] + ld[1] * ld[1] + ld[2] * ld[2]) + .sqrt() + .max(1e-4); + let sun_dir_ws = [-ld[0] * inv_len, -ld[1] * inv_len, -ld[2] * inv_len, ld[3]]; let lc = self.lighting_uniforms.light_color; let sun_intensity = ld[3].max(0.0); let sun_color = [ @@ -7721,35 +9083,80 @@ impl Renderer { sun_dir: sun_dir_ws, sun_color, sky_color, - atlas_info: [CARD_ATLAS_SIZE, CARD_SLOT_SIZE, CARD_SLOTS_PER_ROW, scene.next_card_slot], + atlas_info: [ + CARD_ATLAS_SIZE, + CARD_SLOT_SIZE, + CARD_SLOTS_PER_ROW, + scene.next_card_slot, + ], shadow_vps, shadow_splits, view_matrix: self.current_view_matrix, flags: [0.002, if shadows_enabled { 1.0 } else { 0.0 }, 0.0, 0.0], }; - self.queue.write_buffer( - &self.card_light_uniform, - 0, - bytemuck::bytes_of(¶ms), - ); + self.queue + .write_buffer(&self.card_light_uniform, 0, bytemuck::bytes_of(¶ms)); if self.card_light_bg_cache.is_none() { - self.card_light_bg_cache = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("card_light_bg"), - layout: &self.card_light_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.card_light_uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.mesh_card_atlas_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.mesh_card_atlas_sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: self.card_slot_meta_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self.mesh_card_radiance_view) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&self.mesh_card_emissive_view) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[0]) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[1]) }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[2]) }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&self.shadow_map.sampler) }, - ], - })); + self.card_light_bg_cache = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("card_light_bg"), + layout: &self.card_light_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.card_light_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView( + &self.mesh_card_atlas_view, + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.mesh_card_atlas_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: self.card_slot_meta_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView( + &self.mesh_card_radiance_view, + ), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView( + &self.mesh_card_emissive_view, + ), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView( + &self.shadow_map.depth_views[0], + ), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView( + &self.shadow_map.depth_views[1], + ), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView( + &self.shadow_map.depth_views[2], + ), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::Sampler(&self.shadow_map.sampler), + }, + ], + })); } let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { @@ -7786,24 +9193,25 @@ impl Renderer { &mut self, scene: &crate::scene::SceneGraph, instance_handles: &[f64], + transparent_transport_enabled: bool, ) -> (u32, bool) { // PT-6 — dynamic (skinned) instances ride after the node // instances in both the instance-data buffer and the TLAS. - let instance_count = - instance_handles.len() as u32 + self.pt_dynamic_draws.len() as u32; + let instance_count = instance_handles.len() as u32 + self.pt_dynamic_draws.len() as u32; let mut resized = false; let needed_cap = instance_count.max(self.tlas_max_instances).max(64); - if self.tlas_instance_data_buffer.is_none() - || needed_cap > self.tlas_max_instances - { + if self.tlas_instance_data_buffer.is_none() || needed_cap > self.tlas_max_instances { let new_cap = needed_cap.next_power_of_two(); - self.tlas_instance_data_buffer = Some(self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("tlas_instance_data"), - size: (new_cap as u64) * std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - })); + self.tlas_instance_data_buffer = + Some(self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("tlas_instance_data"), + size: (new_cap as u64) * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + })); self.tlas_max_instances = new_cap; + // Newly-created WebGPU buffers are zero initialized. + self.tlas_instance_data_count = 0; self.probe_trace_hw_bg_cache = [None, None]; // V4 — SDF bind group also references instance_data, so // invalidate when the buffer is re-allocated. @@ -7816,11 +9224,11 @@ impl Renderer { resized = true; } - let mut instance_data: Vec = - Vec::with_capacity(instance_count as usize); + let mut instance_data: Vec = Vec::with_capacity(instance_count as usize); // EN-023 — running mean of instance albedos feeds the SW WSRC // bake's ground-bounce term. let mut albedo_sum = [0.0f32; 3]; + let mut opaque_albedo_count = 0_u32; // PT-2 — geometry megabuffers: every instance's Vertex3D + index // data concatenated so the path-trace kernel can interpolate // real normals/UVs at ray hits. Raw Vertex3D stride (24 f32); @@ -7830,6 +9238,22 @@ impl Renderer { // vertices); arena-scale worlds measure a few tens of MB. let mut geo_vertices: Vec = Vec::new(); let mut geo_indices: Vec = Vec::new(); + let mut geo_secondary_uvs = None; + let mut pt_layered_records = None; + let mut pt_layered_texture_records = None; + let mut pt_clearcoat_texture_records = None; + let mut pt_clearcoat_normal_records = None; + let mut pt_sheen_texture_records = None; + let mut pt_iridescence_texture_records = None; + let mut pt_anisotropy_texture_records = None; + // Array-incapable adapters cannot bind any PT material texture. + // Passing an empty runtime table preserves their exact fallback and + // avoids building otherwise unreachable CPU texture sidecars. + let runtime_texture_count = if self.pt_texture_arrays_enabled { + self.textures.len() + } else { + 0 + }; for &h in instance_handles { let n = scene.nodes.get(h).unwrap(); let e = n.material.emissive; @@ -7856,19 +9280,67 @@ impl Renderer { } else { (n.bounds_min, n.bounds_max) }; - albedo_sum[0] += n.flat_albedo[0]; - albedo_sum[1] += n.flat_albedo[1]; - albedo_sum[2] += n.flat_albedo[2]; + let transport = transparent_gi::instance_transport( + &n.material, + &n.transform, + transparent_transport_enabled, + ); + if !transport.active() { + albedo_sum[0] += n.flat_albedo[0]; + albedo_sum[1] += n.flat_albedo[1]; + albedo_sum[2] += n.flat_albedo[2]; + opaque_albedo_count = opaque_albedo_count.saturating_add(1); + } + let uses_uv1 = layered_pbr_pt::append_record( + &mut pt_layered_records, + &mut pt_layered_texture_records, + &mut pt_clearcoat_texture_records, + &mut pt_clearcoat_normal_records, + &mut pt_sheen_texture_records, + &mut pt_iridescence_texture_records, + &mut pt_anisotropy_texture_records, + instance_data.len(), + n.material.layered_pbr, + runtime_texture_count, + self.pt_texture_arrays_enabled + && index_count != 0 + && n.secondary_tex_coords.is_some(), + ); + pt_geometry::append_pt_secondary_uvs( + &mut geo_secondary_uvs, + vertex_base as usize, + if index_count != 0 { + n.vertices.len() + } else { + 0 + }, + if index_count != 0 { + n.secondary_tex_coords.as_deref() + } else { + None + }, + uses_uv1, + ); instance_data.push(InstanceGiDataCpu { albedo: n.flat_albedo, emissive_luma: (e[0] + e[1] + e[2]) * (1.0 / 3.0), normal_ws: n.flat_normal_ws, _pad0: 0.0, card_slot: [first_slot, 0.0, 0.0, has_card], - card_aabb_min: [n.bounds_min[0], n.bounds_min[1], n.bounds_min[2], 0.0], - card_aabb_max: [n.bounds_max[0], n.bounds_max[1], n.bounds_max[2], 0.0], - world_aabb_min: [wmin[0], wmin[1], wmin[2], 0.0], - world_aabb_max: [wmax[0], wmax[1], wmax[2], 0.0], + card_aabb_min: [ + n.bounds_min[0], + n.bounds_min[1], + n.bounds_min[2], + transport.absorption[0], + ], + card_aabb_max: [ + n.bounds_max[0], + n.bounds_max[1], + n.bounds_max[2], + transport.absorption[1], + ], + world_aabb_min: [wmin[0], wmin[1], wmin[2], transport.absorption[2]], + world_aabb_max: [wmax[0], wmax[1], wmax[2], transport.coverage], geo: [ vertex_base, index_base, @@ -7881,7 +9353,12 @@ impl Renderer { 0 }, ], - mat_params: [n.material.roughness, n.material.metalness, 0.0, 0.0], + mat_params: [ + n.material.roughness, + n.material.metalness, + transport.transmission_weight, + transport.fresnel_pass, + ], }); } // PT-6 — dynamic skinned instances: append the BIND-POSE @@ -7908,6 +9385,26 @@ impl Renderer { let ib = geo_indices.len() as u32; geo_vertices.extend_from_slice(bytemuck::cast_slice(cv)); geo_indices.extend_from_slice(ci); + let uses_uv1 = layered_pbr_pt::append_record( + &mut pt_layered_records, + &mut pt_layered_texture_records, + &mut pt_clearcoat_texture_records, + &mut pt_clearcoat_normal_records, + &mut pt_sheen_texture_records, + &mut pt_iridescence_texture_records, + &mut pt_anisotropy_texture_records, + instance_data.len(), + mesh.layered_pbr, + runtime_texture_count, + self.pt_texture_arrays_enabled && mesh.cpu_secondary_uvs.is_some(), + ); + pt_geometry::append_pt_secondary_uvs( + &mut geo_secondary_uvs, + vb as usize, + cv.len(), + mesh.cpu_secondary_uvs.as_deref(), + uses_uv1, + ); instance_data.push(InstanceGiDataCpu { albedo: [1.0, 1.0, 1.0], emissive_luma: 0.0, @@ -7941,59 +9438,19 @@ impl Renderer { mesh_idx: d.mesh_idx, }); } - // PT-2 — upload the megabuffers (grow-only; a minimum size keeps - // bind-group creation valid before any geometry is committed). - { - let v_bytes = (geo_vertices.len() * 4).max(16) as u64; - let i_bytes = (geo_indices.len() * 4).max(16) as u64; - let v_recreate = self.pt_geo_vertex_buffer.as_ref().map_or(true, |b| b.size() < v_bytes); - let i_recreate = self.pt_geo_index_buffer.as_ref().map_or(true, |b| b.size() < i_bytes); - // PT-6 — on RT adapters the megabuffers double as BLAS - // geometry inputs for the dynamic skinned instances (the - // BLAS reads a window via first_vertex/first_index). - let mega_usage = if self.hw_rt_enabled { - wgpu::BufferUsages::STORAGE - | wgpu::BufferUsages::COPY_DST - | wgpu::BufferUsages::BLAS_INPUT - } else { - wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST - }; - if v_recreate { - self.pt_geo_vertex_buffer = Some(self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("pt_geo_vertices"), - size: v_bytes.next_power_of_two(), - usage: mega_usage, - mapped_at_creation: false, - })); - } - if i_recreate { - self.pt_geo_index_buffer = Some(self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("pt_geo_indices"), - size: i_bytes.next_power_of_two(), - usage: mega_usage, - mapped_at_creation: false, - })); - } - if v_recreate || i_recreate { - self.pt_bg = [None, None]; - } - if !geo_vertices.is_empty() { - self.queue.write_buffer( - self.pt_geo_vertex_buffer.as_ref().unwrap(), - 0, - bytemuck::cast_slice(&geo_vertices), - ); - } - if !geo_indices.is_empty() { - self.queue.write_buffer( - self.pt_geo_index_buffer.as_ref().unwrap(), - 0, - bytemuck::cast_slice(&geo_indices), - ); - } - } - if !instance_data.is_empty() { - let inv = 1.0 / instance_data.len() as f32; + self.set_pt_layered_records( + pt_layered_records, + pt_layered_texture_records, + pt_clearcoat_texture_records, + pt_clearcoat_normal_records, + pt_sheen_texture_records, + pt_iridescence_texture_records, + pt_anisotropy_texture_records, + instance_data.len(), + ); + self.upload_pt_geometry(&geo_vertices, &geo_indices, geo_secondary_uvs.as_deref()); + if opaque_albedo_count > 0 { + let inv = 1.0 / opaque_albedo_count as f32; self.gi_scene_avg_albedo = [ albedo_sum[0] * inv, albedo_sum[1] * inv, @@ -8022,9 +9479,21 @@ impl Renderer { bytemuck::cast_slice(&instance_data), ); } + let written_count = instance_data.len().min(u32::MAX as usize) as u32; + if written_count < self.tlas_instance_data_count { + let stale_count = self.tlas_instance_data_count - written_count; + let record_size = std::mem::size_of::(); + let zeros = vec![0_u8; stale_count as usize * record_size]; + self.queue.write_buffer( + self.tlas_instance_data_buffer.as_ref().unwrap(), + written_count as u64 * record_size as u64, + &zeros, + ); + } + self.tlas_instance_data_count = written_count; // Actual written count (dynamic draws with a missing cache // entry were skipped, so this can be below the requested cap). - (instance_data.len() as u32, resized) + (written_count, resized) } fn rebuild_acceleration_structures( @@ -8052,7 +9521,15 @@ impl Renderer { } } let node_count = instance_handles.len(); - let (instance_count, _resized) = self.rebuild_instance_data(scene, &instance_handles); + // Preserve the opaque path's constant work: only inspect the process + // kill switch when prepare() found a retained transmission instance. + // Reuse the decision for metadata and TLAS masks so a frame cannot + // observe two environment reads with different results. + let transparent_transport_enabled = self.imported_refraction_enabled + && scene.has_transmission_gi_nodes() + && transparent_gi::transparent_gi_enabled(); + let (instance_count, _resized) = + self.rebuild_instance_data(scene, &instance_handles, transparent_transport_enabled); // Dynamic instances occupy the slots after the nodes. let dyn_count = (instance_count as usize).saturating_sub(node_count); @@ -8101,15 +9578,22 @@ impl Renderer { // (rows × columns), i.e. [m00, m01, m02, m03, m10, ...]. // Bloom stores column-major mat4 with translation in row 3. let transform_3x4 = [ - t[0][0], t[1][0], t[2][0], t[3][0], - t[0][1], t[1][1], t[2][1], t[3][1], + t[0][0], t[1][0], t[2][0], t[3][0], t[0][1], t[1][1], t[2][1], t[3][1], t[0][2], t[1][2], t[2][2], t[3][2], ]; tlas[slot] = Some(wgpu::TlasInstance::new( blas, transform_3x4, slot as u32, - 0xff, + // Bit 0 is reserved for opaque-only continuation. The + // ordinary query uses 0xff and therefore sees both paths; + // only the lazy transparent specialization issues a + // second query with mask 0x01. + if transparent_transport_enabled && n.material.has_gi_transmission() { + 0x02 + } else { + 0xff + }, )); } // PT-6 — dynamic instances: IDENTITY transform (the joint @@ -8120,11 +9604,7 @@ impl Renderer { let blas = &self.pt_dyn_blas[i].0; tlas[slot] = Some(wgpu::TlasInstance::new( blas, - [ - 1.0, 0.0, 0.0, 0.0, - 0.0, 1.0, 0.0, 0.0, - 0.0, 0.0, 1.0, 0.0, - ], + [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], slot as u32, 0xff, )); @@ -8159,16 +9639,24 @@ impl Renderer { }) }) .collect(); - let mut build_entries: Vec = - Vec::with_capacity(size_descs.len()); + let mut build_entries: Vec = Vec::with_capacity(size_descs.len()); for (i, h) in pending_handles.iter().enumerate() { let n = match scene.nodes.get(*h) { Some(n) => n, None => continue, }; - let blas = match n.blas.as_ref() { Some(b) => b, None => continue }; - let vb = match n.gpu_vb.as_ref() { Some(b) => b, None => continue }; - let ib = match n.gpu_ib.as_ref() { Some(b) => b, None => continue }; + let blas = match n.blas.as_ref() { + Some(b) => b, + None => continue, + }; + let vb = match n.gpu_vb.as_ref() { + Some(b) => b, + None => continue, + }; + let ib = match n.gpu_ib.as_ref() { + Some(b) => b, + None => continue, + }; build_entries.push(wgpu::BlasBuildEntry { blas, geometry: wgpu::BlasGeometries::TriangleGeometries(vec![ @@ -8223,10 +9711,7 @@ impl Renderer { } let tlas_ref = self.tlas.as_ref().unwrap(); - encoder.build_acceleration_structures( - build_entries.iter(), - std::iter::once(tlas_ref), - ); + encoder.build_acceleration_structures(build_entries.iter(), std::iter::once(tlas_ref)); self.tlas_built_version = scene.tlas_version; } @@ -8237,58 +9722,71 @@ impl Renderer { /// BLAS when its geometry size changed. Runs before the combined /// BLAS+TLAS build on the same encoder, so the build reads posed /// vertices. - fn encode_dynamic_skin( - &mut self, - encoder: &mut wgpu::CommandEncoder, - dyn_count: usize, - ) { + fn encode_dynamic_skin(&mut self, encoder: &mut wgpu::CommandEncoder, dyn_count: usize) { if dyn_count == 0 { return; } - // Lazy pipeline: first dynamic draw on an RT adapter. if self.pt_skin_pipeline.is_none() { - let shader = self.device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("pt_skin_shader"), - source: wgpu::ShaderSource::Wgsl(shaders::PT_SKIN_WGSL.into()), - }); - let layout = self.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("pt_skin_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: false }, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 3, visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, count: None, - }, - ], - }); - let pl = self.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("pt_skin_pl"), - bind_group_layouts: &[Some(&layout)], - immediate_size: 0, - }); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("pt_skin_shader"), + source: wgpu::ShaderSource::Wgsl(shaders::PT_SKIN_WGSL.into()), + }); + let layout = self + .device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("pt_skin_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + let pl = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("pt_skin_pl"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); self.pt_skin_pipeline = Some(self.device.create_compute_pipeline( &wgpu::ComputePipelineDescriptor { label: Some("pt_skin_pipeline"), @@ -8300,15 +9798,16 @@ impl Renderer { }, )); self.pt_skin_layout = Some(layout); + self.created_pipelines(1); } - // Params pool (mat4 model + vec4 = 80 bytes per slot). while self.pt_skin_params.len() < dyn_count { - self.pt_skin_params.push(self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("pt_skin_params"), - size: 80, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - })); + self.pt_skin_params + .push(self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pt_skin_params"), + size: 80, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + })); } // Per-slot: params upload, BLAS pool upkeep, bind group. @@ -8326,11 +9825,8 @@ impl Renderer { words[17] = f32::from_bits(w.v_count); words[18] = f32::from_bits(w.joint_offset.max(0.0) as u32); words[19] = 0.0; - self.queue.write_buffer( - &self.pt_skin_params[i], - 0, - bytemuck::cast_slice(&words), - ); + self.queue + .write_buffer(&self.pt_skin_params[i], 0, bytemuck::cast_slice(&words)); let need_new = match self.pt_dyn_blas.get(i) { Some((_, vc, ic)) => *vc != w.v_count || *ic != w.i_count, @@ -8362,21 +9858,44 @@ impl Renderer { let src_vb = match self.model_gpu_cache.get(&w.cache_handle) { Some(Some(meshes)) => match meshes.get(w.mesh_idx) { - Some(m) => &m.vb, + Some(m) => match &m.geometry { + gpu_driven::MeshGeometry::Dedicated { vertex, .. } => vertex, + // Dynamic PT windows are registered only for skinned + // cached meshes, which always retain dedicated input. + gpu_driven::MeshGeometry::Shared(_) => continue, + }, None => continue, }, _ => continue, }; - bind_groups.push(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("pt_skin_bg"), - layout: self.pt_skin_layout.as_ref().unwrap(), - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.pt_skin_params[i].as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: src_vb.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: self.pt_geo_vertex_buffer.as_ref().unwrap().as_entire_binding() }, - wgpu::BindGroupEntry { binding: 3, resource: self.joint_buffer.as_entire_binding() }, - ], - })); + bind_groups.push( + self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pt_skin_bg"), + layout: self.pt_skin_layout.as_ref().unwrap(), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.pt_skin_params[i].as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: src_vb.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: self + .pt_geo_vertex_buffer + .as_ref() + .unwrap() + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: self.joint_buffer.as_entire_binding(), + }, + ], + }), + ); dispatch_counts.push(w.v_count); } @@ -8391,19 +9910,6 @@ impl Renderer { } } - /// SSGI intensity multiplier (0 = off, 0.5 = default, 1+ = strong). - /// Controls the brightness of indirect bounce light. - pub fn set_ssgi_intensity(&mut self, intensity: f32) { - self.ssgi_intensity = intensity.max(0.0); - } - - /// SSGI max march distance in view-space meters (default 20). - /// Tune to the scene scale: small for tight rooms, large for - /// open-world interiors. - pub fn set_ssgi_radius(&mut self, radius: f32) { - self.ssgi_radius = radius.max(0.1); - } - /// Toggle depth of field on/off. Off (default) = no DoF pass, /// zero perf cost. On = variable-radius Poisson disc blur driven /// by circle of confusion from the depth buffer. @@ -8473,38 +9979,6 @@ impl Renderer { self.tonemap_kind = kind; } - /// Toggle auto-exposure. Off (default) = manual exposure - /// multiplier. On = per-frame average scene luminance drives - /// exposure toward `auto_exposure_key` (0.18 photography - /// standard). Instant adapt — no inter-frame smoothing yet, - /// so scene cuts pop. Fine for static or slow-motion cameras. - pub fn set_auto_exposure(&mut self, on: bool) { - self.auto_exposure = on; - } - - /// Manual exposure multiplier. Applied when auto_exposure - /// is off. 1.0 = no change. 2.0 = twice as bright. Clamp is - /// [0, +∞) — negative silently becomes 0. - pub fn set_manual_exposure(&mut self, value: f32) { - self.manual_exposure = value.max(0.0); - } - - /// Auto-exposure target scene key (average luminance to drive - /// toward). Lower = darker overall, higher = brighter. 0.18 - /// is the 18%-gray photography standard; 0.14 gives a slightly - /// moodier look, 0.25 a brighter one. - pub fn set_auto_exposure_key(&mut self, key: f32) { - self.auto_exposure_key = key.clamp(0.01, 1.0); - } - - /// Auto-exposure smoothing rate per frame. 0 = no adapt (stuck - /// at whatever the current texture holds), 0.05 ≈ 20-frame - /// half-life at 60 fps (default — feels natural for camera - /// moves), 1 = instant (pops on scene cuts). - pub fn set_auto_exposure_rate(&mut self, rate: f32) { - self.auto_exposure_rate = rate.clamp(0.0, 1.0); - } - /// Fog color that distant geometry fades to (rgb, 0-1). pub fn set_fog_color(&mut self, r: f32, g: f32, b: f32) { self.fog_color = [r, g, b]; @@ -8550,7 +10024,7 @@ impl Renderer { self.grain_strength = strength.max(0.0); } - /// Composite-pass unsharp mask. Default 0.8; 0 disables the 4 extra + /// Composite-pass unsharp mask. Default 0.5; 0 disables the 4 extra /// HDR taps + extra tonemap entirely. Round-2 audit: this was /// hardcoded with no runtime control while visibly haloing /// silhouettes at 4K output (F3/F8). @@ -8558,28 +10032,6 @@ impl Renderer { self.sharpen_strength = strength.max(0.0); } - /// Present mode: 0 = Fifo (vsync), 1 = Mailbox (uncapped, no tearing), - /// 2 = Immediate (uncapped, tearing allowed). Round-2 audit F6: the - /// mode was hardcoded Fifo, which also made `set_target_fps` inert - /// (its sleep-based cap only engages when vsync is off). All three - /// modes are supported by DXGI on Windows; on other backends an - /// unsupported request falls back to Fifo at configure time. - pub fn set_present_mode(&mut self, mode: u32) { - let requested = match mode { - 1 => wgpu::PresentMode::Mailbox, - 2 => wgpu::PresentMode::Immediate, - _ => wgpu::PresentMode::Fifo, - }; - if self.surface_config.present_mode == requested { - return; - } - self.surface_config.present_mode = requested; - if let Some(surface) = &self.surface { - surface.configure(&self.device, &self.surface_config); - } - eprintln!("bloom: present mode = {:?}", requested); - } - /// Sun shaft (screen-space god ray) strength. 0 (default) = off. /// 0.4 = subtle haze, 1.0+ = obvious cinematic shafts. The /// shafts are sampled from the depth buffer along a screen-space @@ -8603,21 +10055,18 @@ impl Renderer { pub fn set_env_intensity(&mut self, intensity: f32) { self.lighting_uniforms.camera_pos[3] = intensity; - self.queue.write_buffer( - &self.lighting_buffer, - 0, - bytemuck::bytes_of(&self.lighting_uniforms), - ); } - // ============================================================ // Render quality toggles — control individual post-FX / lighting // features at runtime. Games call these directly for fine-tuning // or use `apply_quality_preset()` for batch configuration. - // ============================================================ pub fn set_shadows_enabled(&mut self, on: bool) { - if on { self.shadow_map.enable(); } else { self.shadow_map.disable(); } + if on { + self.shadow_map.enable(); + } else { + self.shadow_map.disable(); + } } /// Disables the ticket-004 shadow-cache and re-renders cascades @@ -8631,7 +10080,9 @@ impl Renderer { self.shadow_map.invalidate(); } } - pub fn set_bloom_enabled(&mut self, on: bool) { self.bloom_enabled = on; } + pub fn set_bloom_enabled(&mut self, on: bool) { + self.bloom_enabled = on; + } pub fn set_ssao_enabled(&mut self, on: bool) { if on && !self.ssao_enabled { // History was frozen while SSAO was off; reset the counter @@ -8642,38 +10093,6 @@ impl Renderer { self.ssao_enabled = on; } - /// Batch-configure every quality flag based on a preset level. - /// Presets: - /// 0 = Off — bare minimum, for the slowest integrated GPUs. - /// No shadows, no SSAO, no bloom, no TAA, no SSR/SSGI, - /// no DoF/motion blur/SSS, no chromatic aberration. - /// 1 = Low — shadows off, SSAO off, bloom low, TAA off. Keeps - /// the base HDR/tonemap pipeline only. - /// 2 = Medium — shadows on, SSAO on, bloom on, TAA on. No SSR/SSGI - /// or cinematic effects. - /// 3 = High — adds SSR + SSGI + subtle chromatic aberration. - /// 4 = Ultra — everything on (plus DoF if aperture > 0). - /// Individual setters override preset choices on the current frame — - /// call `apply_quality_preset` first, then customize as needed. - pub fn apply_quality_preset(&mut self, preset: u32) { - let (shadows, ssao, bloom, taa, ssr, ssgi, motion_blur, sss, ca) = match preset { - 0 => (false, false, false, false, false, false, false, false, 0.0), - 1 => (false, false, true, false, false, false, false, false, 0.0), - 2 => (true, true, true, true, false, false, false, false, 0.0), - 3 => (true, true, true, true, true, true, false, false, 0.002), - _ => (true, true, true, true, true, true, true, true, 0.003), - }; - self.set_shadows_enabled(shadows); - self.set_ssao_enabled(ssao); - self.set_bloom_enabled(bloom); - self.set_taa_enabled(taa); - self.set_ssr_enabled(ssr); - self.set_ssgi_enabled(ssgi); - self.set_motion_blur_enabled(motion_blur); - self.set_sss_enabled(sss); - self.set_chromatic_aberration(ca); - } - /// Upload an HDR equirectangular environment map. The `data` is /// Load an environment from a `.hdr` file on disk and install it as /// the clear environment. Decode errors and I/O errors are silently @@ -8742,71 +10161,83 @@ impl Renderer { /// at any roughness without per-frame importance sampling. /// Mip 0 is the original radiance (used by the sky pass). pub fn load_env_from_hdr(&mut self, width: u32, height: u32, rgb_f32: &[f32]) { + let source_mips = env_prefilter::build_radiance_mip_chain(width, height, rgb_f32); + if source_mips.is_empty() { + return; + } let max_dim = width.max(height); let mip_count = ((max_dim as f32).log2().floor() as u32 + 1).min(7); - // Pack f32 RGB → packed f16 RGBA for the GPU. - let texel_count = (width as usize) * (height as usize); - let mut packed: Vec = Vec::with_capacity(texel_count * 4); - for px in 0..texel_count { - packed.push(half::f16::from_f32(rgb_f32[px * 3]).to_bits()); - packed.push(half::f16::from_f32(rgb_f32[px * 3 + 1]).to_bits()); - packed.push(half::f16::from_f32(rgb_f32[px * 3 + 2]).to_bits()); - packed.push(half::f16::from_f32(1.0).to_bits()); - } - - // Source texture — single mip, holds the original radiance. - // We sample from this when prefiltering each output mip so a - // single texture isn't both read and written in the same pass. + // Source texture — ordinary solid-angle-weighted radiance mips. The + // GGX convolution selects among these according to each importance + // sample's PDF, which preserves tiny HDR emitters without fireflies. + // It remains separate because a texture cannot be sampled while one of + // its mip levels is also a render attachment. let src_texture = self.device.create_texture(&wgpu::TextureDescriptor { label: Some("sky_env_src"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba16Float, - usage: wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::COPY_DST - | wgpu::TextureUsages::COPY_SRC, - view_formats: &[], - }); - self.queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &src_texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - bytemuck::cast_slice(&packed), - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(width * 8), - rows_per_image: Some(height), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, }, - wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, - ); + mip_level_count: source_mips.len() as u32, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + for (level, mip) in source_mips.iter().enumerate() { + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &src_texture, + mip_level: level as u32, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + bytemuck::cast_slice(&mip.rgba16), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(mip.width * 8), + rows_per_image: Some(mip.height), + }, + wgpu::Extent3d { + width: mip.width, + height: mip.height, + depth_or_array_layers: 1, + }, + ); + } // Destination texture — full mip chain, RENDER_ATTACHMENT for // the prefilter passes plus TEXTURE_BINDING for sampling at // draw time. let texture = self.device.create_texture(&wgpu::TextureDescriptor { label: Some("sky_env_texture"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: mip_count, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba16Float, usage: wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::COPY_DST - | wgpu::TextureUsages::COPY_SRC - | wgpu::TextureUsages::RENDER_ATTACHMENT, + | wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }); // Mip 0 = exact copy of source (mirror reflection — no convolution). - let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("env_prefilter_encoder"), - }); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("env_prefilter_encoder"), + }); encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { texture: &src_texture, @@ -8820,7 +10251,11 @@ impl Renderer { origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, - wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, ); // Source bind group — same for every mip's prefilter pass. @@ -8829,9 +10264,18 @@ impl Renderer { label: Some("prefilter_src_bg"), layout: &self.prefilter_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.prefilter_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&src_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.env_sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: self.prefilter_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&src_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.env_sampler), + }, ], }); @@ -8850,7 +10294,11 @@ impl Renderer { let uniforms = PrefilterUniforms { params: [roughness, sample_count, mip_w as f32, mip_h as f32], }; - self.queue.write_buffer(&self.prefilter_uniform_buffer, 0, bytemuck::bytes_of(&uniforms)); + self.queue.write_buffer( + &self.prefilter_uniform_buffer, + 0, + bytemuck::bytes_of(&uniforms), + ); let mip_view = texture.create_view(&wgpu::TextureViewDescriptor { label: Some("prefilter_dst_mip_view"), @@ -8888,19 +10336,26 @@ impl Renderer { let diffuse_h: u32 = 64; let diffuse_texture = self.device.create_texture(&wgpu::TextureDescriptor { label: Some("sky_env_diffuse_texture"), - size: wgpu::Extent3d { width: diffuse_w, height: diffuse_h, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: diffuse_w, + height: diffuse_h, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba16Float, - usage: wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::RENDER_ATTACHMENT, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }); let diffuse_uniforms = PrefilterUniforms { params: [1.0, 1024.0, diffuse_w as f32, diffuse_h as f32], }; - self.queue.write_buffer(&self.prefilter_uniform_buffer, 0, bytemuck::bytes_of(&diffuse_uniforms)); + self.queue.write_buffer( + &self.prefilter_uniform_buffer, + 0, + bytemuck::bytes_of(&diffuse_uniforms), + ); let diffuse_view_rt = diffuse_texture.create_view(&wgpu::TextureViewDescriptor::default()); { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { @@ -8954,7 +10409,9 @@ impl Renderer { let new_lighting_bg = self.make_lighting_bind_group("lighting_bg", &view, &diffuse_view_bg); // Env textures feed the cached per-probe PerView bind groups. - for bg in self.planar_probe_view_bgs.iter_mut() { *bg = None; } + for bg in self.planar_probe_view_bgs.iter_mut() { + *bg = None; + } self.sky_texture = Some(texture); self.sky_bind_group = Some(bg); self.env_diffuse_texture = Some(diffuse_texture); @@ -8962,6 +10419,7 @@ impl Renderer { // EN-021 — the SSR bind group holds an env view; rebuild it when // a new HDR panorama is uploaded. self.ssr_bg_cache = None; + self.ssr_layered_bg_cache = None; // The material path's per-view bind group holds env/diffuse views // too — re-wire it next frame so material surfaces pick up the // new panorama (and the live shadow views on first load). @@ -8996,7 +10454,7 @@ impl Renderer { // row 2 = -camera forward (right-handed lookAt convention) // We want forward in world space, so negate row 2. let right_world = [v[0][0], v[1][0], v[2][0]]; - let up_world = [v[0][1], v[1][1], v[2][1]]; + let up_world = [v[0][1], v[1][1], v[2][1]]; let forward_world = [-v[0][2], -v[1][2], -v[2][2]]; // Pre-scale by tan(fovy/2) and aspect so the shader is a @@ -9006,7 +10464,11 @@ impl Renderer { // standard perspective P, P[1][1] = 1 / tan(fovy/2). So // tan(fovy/2) = 1 / P[1][1]. let p = self.current_proj_matrix; - let tan_half = if p[1][1].abs() > 1e-6 { 1.0 / p[1][1] } else { 1.0 }; + let tan_half = if p[1][1].abs() > 1e-6 { + 1.0 / p[1][1] + } else { + 1.0 + }; // Camera position in the spare .w lanes and the clock in intensity.y, so // this path fills the same buffer layout as the procedural one. The @@ -9040,9 +10502,7 @@ impl Renderer { pass.draw(0..3, 0..1); } - // ======================================================================== // EN-005 Phase 2 — procedural sky public surface - // ======================================================================== /// Toggle the procedural-atmosphere sky. When enabled, the HDR /// pass renders a Hillaire 2020 atmosphere driven by `set_sun_direction` @@ -9102,14 +10562,24 @@ impl Renderer { self.sun_direction[2], self.sun_intensity, ], - knobs: [self.rayleigh_density, self.mie_density, self.ground_albedo, 0.0], + knobs: [ + self.rayleigh_density, + self.mie_density, + self.ground_albedo, + 0.0, + ], }; - self.queue - .write_buffer(&self.sky_view_uniform_buffer, 0, bytemuck::bytes_of(¶ms)); + self.queue.write_buffer( + &self.sky_view_uniform_buffer, + 0, + bytemuck::bytes_of(¶ms), + ); - let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("sky_view_lut_encoder"), - }); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("sky_view_lut_encoder"), + }); { let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("sky_view_lut_compute"), @@ -9158,11 +10628,17 @@ impl Renderer { // Update the bake-pipeline uniform (just dest dims). let dims = [proc_w as f32, proc_h as f32, 0.0_f32, 0.0_f32]; - self.queue.write_buffer(&self.procedural_ibl_uniform_buffer, 0, bytemuck::cast_slice(&dims)); + self.queue.write_buffer( + &self.procedural_ibl_uniform_buffer, + 0, + bytemuck::cast_slice(&dims), + ); - let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("procedural_ibl_encoder"), - }); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("procedural_ibl_encoder"), + }); // --- Step 1: render sky-view LUT → mip 0 of equirect --- { @@ -9196,17 +10672,20 @@ impl Renderer { let uniforms = PrefilterUniforms { params: [roughness, sample_count, mip_w as f32, mip_h as f32], }; - self.queue - .write_buffer(&self.prefilter_uniform_buffer, 0, bytemuck::bytes_of(&uniforms)); + self.queue.write_buffer( + &self.prefilter_uniform_buffer, + 0, + bytemuck::bytes_of(&uniforms), + ); - let mip_view = self - .procedural_sky_equirect_texture - .create_view(&wgpu::TextureViewDescriptor { - label: Some("procedural_ibl_dst_mip"), - base_mip_level: level, - mip_level_count: Some(1), - ..Default::default() - }); + let mip_view = + self.procedural_sky_equirect_texture + .create_view(&wgpu::TextureViewDescriptor { + label: Some("procedural_ibl_dst_mip"), + base_mip_level: level, + mip_level_count: Some(1), + ..Default::default() + }); let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("procedural_ibl_prefilter_pass"), @@ -9235,8 +10714,11 @@ impl Renderer { let diffuse_uniforms = PrefilterUniforms { params: [1.0, 1024.0, diffuse_w as f32, diffuse_h as f32], }; - self.queue - .write_buffer(&self.prefilter_uniform_buffer, 0, bytemuck::bytes_of(&diffuse_uniforms)); + self.queue.write_buffer( + &self.prefilter_uniform_buffer, + 0, + bytemuck::bytes_of(&diffuse_uniforms), + ); { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("procedural_ibl_diffuse_pass"), @@ -9282,12 +10764,14 @@ impl Renderer { self._scene_env_default_texture .create_view(&wgpu::TextureViewDescriptor::default()) }); - let new_bg = self.make_lighting_bind_group("lighting_bg_panorama", &env_view, &diffuse_view); + let new_bg = + self.make_lighting_bind_group("lighting_bg_panorama", &env_view, &diffuse_view); self.lighting_bind_group = new_bg; self.lighting_bg_is_procedural = false; // EN-021 — the SSR bind group holds an env view; rebuild it when // the env source swaps. self.ssr_bg_cache = None; + self.ssr_layered_bg_cache = None; } /// EN-005 Phase 3 — rebuild `lighting_bind_group` so PBR materials @@ -9309,6 +10793,7 @@ impl Renderer { // cache here at least rebuilds against the current state on the // next frame.) self.ssr_bg_cache = None; + self.ssr_layered_bg_cache = None; } /// Sample the transmittance LUT for the current sun direction @@ -9326,7 +10811,8 @@ impl Renderer { let r0 = 6360.0 + 0.5; // matches sky-view shader let mu = self.sun_direction[1]; let lut = &self.transmittance_lut_cpu; - let t = atmosphere_lut::sample_transmittance_lut(lut, TRANSMITTANCE_W, TRANSMITTANCE_H, r0, mu); + let t = + atmosphere_lut::sample_transmittance_lut(lut, TRANSMITTANCE_W, TRANSMITTANCE_H, r0, mu); // Multiply by sun radiance scalar; matches sun-disk's `vec3(20) // * t_sun * intensity` so shafts and the disk warm together. let radiance_scale = 1.0_f32; // shafts are tinted, not lit-additive — keep unscaled @@ -9378,12 +10864,17 @@ impl Renderer { ], knobs: [self.rayleigh_density, self.mie_density, 0.0, 0.0], }; - self.queue - .write_buffer(&self.aerial_perspective_uniform_buffer, 0, bytemuck::bytes_of(¶ms)); + self.queue.write_buffer( + &self.aerial_perspective_uniform_buffer, + 0, + bytemuck::bytes_of(¶ms), + ); - let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("aerial_perspective_encoder"), - }); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("aerial_perspective_encoder"), + }); { let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("aerial_perspective_compute"), @@ -9435,11 +10926,7 @@ impl Renderer { // Two end-points the model interpolates between as the sun // descends. Numbers tuned to read well at default exposure. let midday_blue = [0.45_f32, 0.55, 0.70]; - let sunset_warm = [ - horizon_t[0] * 0.9, - horizon_t[1] * 0.55, - horizon_t[2] * 0.25, - ]; + let sunset_warm = [horizon_t[0] * 0.9, horizon_t[1] * 0.55, horizon_t[2] * 0.25]; let mix_factor = (1.0 - day_factor).powf(0.5); let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t; @@ -9471,7 +10958,11 @@ impl Renderer { let forward_world = [-v[0][2], -v[1][2], -v[2][2]]; let aspect = self.surface_config.width as f32 / self.surface_config.height as f32; let p = self.current_proj_matrix; - let tan_half = if p[1][1].abs() > 1e-6 { 1.0 / p[1][1] } else { 1.0 }; + let tan_half = if p[1][1].abs() > 1e-6 { + 1.0 / p[1][1] + } else { + 1.0 + }; // Camera position rides in the spare .w lanes: the cloud deck is anchored // in WORLD space (so its shadow lands under it), which means the sky pass @@ -9484,14 +10975,20 @@ impl Renderer { right_world[2] * tan_half * aspect, cam[0], ], - up: [up_world[0] * tan_half, up_world[1] * tan_half, up_world[2] * tan_half, cam[1]], + up: [ + up_world[0] * tan_half, + up_world[1] * tan_half, + up_world[2] * tan_half, + cam[1], + ], forward: [forward_world[0], forward_world[1], forward_world[2], cam[2]], // .y carries current time (seconds) so the cloud deck can drift. intensity: [intensity, self.lighting_uniforms.wind[3], 0.0, 0.0], cloud: self.cloud_params, wind: self.lighting_uniforms.wind, }; - self.queue.write_buffer(&self.sky_uniform_buffer, 0, bytemuck::bytes_of(&uniforms)); + self.queue + .write_buffer(&self.sky_uniform_buffer, 0, bytemuck::bytes_of(&uniforms)); // Sun disk parameters. Real solar disk is ~0.27° (4.7 mrad); // we exaggerate slightly for visibility at default exposure. @@ -9504,7 +11001,11 @@ impl Renderer { ], params: [0.012, 0.6, 0.0, 0.0], // ~0.7° disk, moderate limb darkening }; - self.queue.write_buffer(&self.procedural_sun_uniform_buffer, 0, bytemuck::bytes_of(&sun)); + self.queue.write_buffer( + &self.procedural_sun_uniform_buffer, + 0, + bytemuck::bytes_of(&sun), + ); pass.set_pipeline(&self.procedural_sky_pipeline); pass.set_bind_group(0, &self.procedural_sky_bind_group, &[]); @@ -9536,6 +11037,287 @@ impl Renderer { &self.texture_bind_groups } + pub(crate) fn imported_refraction_enabled(&self) -> bool { + self.imported_refraction_enabled + } + + /// Selected imported-transmission mode: 0 = legacy opt-out, 1 = native + /// scene-color/depth refraction, 2 = constrained environment refraction. + pub fn imported_refraction_mode_code(&self) -> u32 { + if !self.imported_refraction_enabled { + 0 + } else if cfg!(fold_scene_inputs) { + 2 + } else { + 1 + } + } + + /// Compile the imported-refraction shader/layout only when a contributing + /// material first appears. Opaque-only applications therefore pay no + /// shader-compilation, pipeline, or bind-group cost for this feature. + fn ensure_scene_refraction_resources(&mut self) { + if !self.imported_refraction_enabled || self.scene_refractive_pipeline.is_some() { + return; + } + + let material_layout = create_scene_refractive_material_layout(&self.device); + let base_source = specialized_scene_shader_source( + self.froxel.is_some(), + self.shadow_map.virtual_map.requested(), + ); + let screen_space_reflections = cfg!(not(fold_scene_inputs)) + && refractive_reflections::refractive_reflection_hierarchy_enabled(); + #[cfg(not(fold_scene_inputs))] + if screen_space_reflections { + self.scene_refractive_inputs_layout = + Some(refractive_reflections::create_inputs_layout(&self.device)); + self.scene_refractive_reflection_params_buffer = + Some(refractive_reflections::create_params_buffer(&self.device)); + } + let source = scene_refractive_shader_source( + &base_source, + cfg!(fold_scene_inputs), + screen_space_reflections, + false, + ); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_refractive_shader"), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + #[cfg(fold_scene_inputs)] + let bind_group_layouts = vec![ + Some(&self.uniform_3d_layout), + Some(&self.lighting_layout), + Some(&material_layout), + Some(&self.joint_layout), + ]; + #[cfg(not(fold_scene_inputs))] + let bind_group_layouts = vec![ + Some(&self.uniform_3d_layout), + Some(&self.lighting_layout), + Some(&material_layout), + Some(&self.joint_layout), + Some( + self.scene_refractive_inputs_layout + .as_ref() + .unwrap_or(&self.material_system.layouts.scene_inputs), + ), + ]; + let pipeline_layout = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("scene_refractive_pipeline_layout"), + bind_group_layouts: &bind_group_layouts, + immediate_size: 0, + }); + + let create_pipeline = |label: &'static str, cull_mode: Option| { + self.device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main_scene"), + buffers: &[Vertex3D::desc()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_refractive_scene"), + targets: &[ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + // The shader composites against the immutable + // pre-translucency snapshot and emits alpha=1. + // Replace avoids applying the background twice. + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + ], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) + }; + self.scene_refractive_pipeline = Some(create_pipeline( + "scene_refractive_pipeline", + Some(wgpu::Face::Back), + )); + self.scene_refractive_double_sided_pipeline = Some(create_pipeline( + "scene_refractive_double_sided_pipeline", + None, + )); + self.created_pipelines(2); + self.scene_refractive_material_layout = Some(material_layout); + + log::info!( + "bloom materials: imported refraction enabled (source={}, \ + reflection={}, \ + directional_shadow=lazy-nearest-layer-rgb-depth, \ + temporal_velocity=per-object)", + if cfg!(fold_scene_inputs) { + "environment fallback: four-bind-group target" + } else { + "scene-color/depth snapshot" + }, + if screen_space_reflections { + "bounded-screen-space-then-environment" + } else { + "environment" + }, + ); + } + + /// Compile the two-stream TEXCOORD_1 variant only after a usable physical + /// texture requests it. The established UV0 pipelines and `Vertex3D` + /// layout remain untouched. + fn ensure_scene_refraction_uv1_resources(&mut self) { + if !self.imported_refraction_enabled || self.scene_refractive_uv1_pipeline.is_some() { + return; + } + self.ensure_scene_refraction_resources(); + let material_layout = self + .scene_refractive_material_layout + .as_ref() + .expect("ordinary imported-refraction resources initialize first"); + let base_source = specialized_scene_shader_source( + self.froxel.is_some(), + self.shadow_map.virtual_map.requested(), + ); + #[cfg(fold_scene_inputs)] + let screen_space_reflections = false; + #[cfg(not(fold_scene_inputs))] + let screen_space_reflections = self.scene_refractive_inputs_layout.is_some(); + let source = scene_refractive_shader_source( + &base_source, + cfg!(fold_scene_inputs), + screen_space_reflections, + true, + ); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_refractive_uv1_shader"), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + #[cfg(fold_scene_inputs)] + let bind_group_layouts = vec![ + Some(&self.uniform_3d_layout), + Some(&self.lighting_layout), + Some(material_layout), + Some(&self.joint_layout), + ]; + #[cfg(not(fold_scene_inputs))] + let bind_group_layouts = vec![ + Some(&self.uniform_3d_layout), + Some(&self.lighting_layout), + Some(material_layout), + Some(&self.joint_layout), + Some( + self.scene_refractive_inputs_layout + .as_ref() + .unwrap_or(&self.material_system.layouts.scene_inputs), + ), + ]; + let layout = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("scene_refractive_uv1_pipeline_layout"), + bind_group_layouts: &bind_group_layouts, + immediate_size: 0, + }); + let vertex_layouts = [Vertex3D::desc(), secondary_uv_desc()]; + let create_pipeline = |label: &'static str, cull_mode: Option| { + self.device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main_scene"), + buffers: &vertex_layouts, + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_refractive_scene"), + targets: &[ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + ], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) + }; + self.scene_refractive_uv1_pipeline = Some(create_pipeline( + "scene_refractive_uv1_pipeline", + Some(wgpu::Face::Back), + )); + self.scene_refractive_uv1_double_sided_pipeline = Some(create_pipeline( + "scene_refractive_uv1_double_sided_pipeline", + None, + )); + self.created_pipelines(2); + log::info!( + "bloom materials: lazy physical TEXCOORD_1 stream enabled \ + (8 bytes/vertex on UV1 refractors only)" + ); + } + /// Build a scene-pipeline material uniform buffer holding the /// per-material scalar factors. Called once per material — the /// bind group below references this buffer. @@ -9546,14 +11328,23 @@ impl Renderer { emissive: [f32; 3], has_mr_texture: bool, alpha_cutoff: f32, + alpha_coverage_mips: bool, ) -> wgpu::Buffer { use wgpu::util::DeviceExt; - let uniforms = SceneMaterialUniforms::new(metallic, roughness, emissive, has_mr_texture, alpha_cutoff); - self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("scene_material_uniform"), - contents: bytemuck::bytes_of(&uniforms), - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - }) + let uniforms = SceneMaterialUniforms::new( + metallic, + roughness, + emissive, + has_mr_texture, + alpha_cutoff, + alpha_coverage_mips, + ); + self.device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_material_uniform"), + contents: bytemuck::bytes_of(&uniforms), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }) } /// Alpha-tested shadow-caster bind group (base colour + sampler + @@ -9561,13 +11352,25 @@ impl Renderer { /// cached-model path uses; exposed so scene-graph nodes with MASK /// materials cast dappled shadows instead of solid card silhouettes. /// The cutoff uniform buffer stays alive via the bind group. - pub fn create_shadow_cutout_bg(&self, base_color_idx: u32, cutoff: f32) -> wgpu::BindGroup { + pub fn create_shadow_cutout_bg( + &self, + base_color_idx: u32, + cutoff: f32, + alpha_coverage_mips: bool, + ) -> wgpu::BindGroup { use wgpu::util::DeviceExt; - let cutoff_buf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("shadow_cutout_cutoff"), - contents: bytemuck::cast_slice(&[cutoff, 0.0f32, 0.0, 0.0]), - usage: wgpu::BufferUsages::UNIFORM, - }); + let cutoff_buf = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("shadow_cutout_cutoff"), + contents: bytemuck::cast_slice(&[ + cutoff, + if alpha_coverage_mips { 1.0 } else { 0.0 }, + 0.0, + 0.0, + ]), + usage: wgpu::BufferUsages::UNIFORM, + }); let bi = base_color_idx as usize; let base_tex = if base_color_idx == 0 || bi >= self.textures.len() { &self.textures[0] @@ -9579,9 +11382,18 @@ impl Renderer { label: Some("shadow_cutout_bg"), layout: &self.shadow_map.cutout_tex_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&base_view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) }, - wgpu::BindGroupEntry { binding: 2, resource: cutoff_buf.as_entire_binding() }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&base_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: cutoff_buf.as_entire_binding(), + }, ], }) } @@ -9628,11 +11440,15 @@ impl Renderer { // decodes to (1, 1, 1) in tangent space instead of (0, 0, 1)). // All four view locals live until after create_bind_group, so // taking references to them is safe. - let normal_view_owned = if normal_tex_idx == 0 || (normal_tex_idx as usize) >= self.textures.len() { - None - } else { - Some(self.textures[normal_tex_idx as usize].create_view(&wgpu::TextureViewDescriptor::default())) - }; + let normal_view_owned = + if normal_tex_idx == 0 || (normal_tex_idx as usize) >= self.textures.len() { + None + } else { + Some( + self.textures[normal_tex_idx as usize] + .create_view(&wgpu::TextureViewDescriptor::default()), + ) + }; let normal_view_ref: &wgpu::TextureView = normal_view_owned .as_ref() .unwrap_or(&self.default_normal_view); @@ -9641,29 +11457,268 @@ impl Renderer { label: Some("scene_material_bg"), layout: &self.scene_material_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&base_view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(normal_view_ref) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self.sampler) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&mr_view) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(&self.sampler) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&em_view) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::Sampler(&self.sampler) }, - wgpu::BindGroupEntry { binding: 8, resource: material_uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::TextureView(&occ_view) }, - wgpu::BindGroupEntry { binding: 10, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&base_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(normal_view_ref), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&mr_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&em_view), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: material_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::TextureView(&occ_view), + }, + wgpu::BindGroupEntry { + binding: 10, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }) + } + + /// Build the dedicated material group for a contributing transmission + /// material. The ordinary group remains intact and is still used by the + /// kill-switch fallback; this allocation exists only for refractive draws. + pub(crate) fn create_scene_refractive_material_bg( + &mut self, + base_color_tex_idx: u32, + normal_tex_idx: u32, + metallic_roughness_tex_idx: u32, + emissive_tex_idx: u32, + occlusion_tex_idx: u32, + material_uniform: &wgpu::Buffer, + transmission: crate::models::MaterialTransmission, + has_secondary_tex_coords: bool, + ) -> Option<(wgpu::Buffer, wgpu::BindGroup, bool)> { + if !self.imported_refraction_enabled || !transmission.is_active() { + return None; + } + self.ensure_scene_refraction_resources(); + + let usable_texture = |binding: Option| { + binding.and_then(|binding| { + match binding.transform.tex_coord { + 0 => {} + 1 if has_secondary_tex_coords => {} + 1 => { + log::warn!( + "bloom materials: physical texture requests TEXCOORD_1 but \ + this primitive has no valid secondary UV stream; using the \ + scalar physical factor" + ); + return None; + } + tex_coord => { + log::warn!( + "bloom materials: physical texture TEXCOORD_{tex_coord} is \ + preserved but only TEXCOORD_0/1 are renderable; using the \ + scalar physical factor" + ); + return None; + } + } + binding + .runtime_texture_idx + .filter(|index| *index != 0 && (*index as usize) < self.textures.len()) + .map(|index| (index, binding.transform.tex_coord)) + }) + }; + let transmission_texture_binding = usable_texture(transmission.texture); + let thickness_texture_binding = usable_texture(transmission.thickness_texture); + let uses_uv1 = [transmission_texture_binding, thickness_texture_binding] + .into_iter() + .flatten() + .any(|(_, tex_coord)| tex_coord == 1); + if uses_uv1 { + self.ensure_scene_refraction_uv1_resources(); + if self.scene_refractive_reactive_pipeline.is_some() { + self.ensure_scene_refraction_reactive_uv1_resources(); + } + } + if self.shadow_map.enabled { + self.ensure_transmitted_shadow_resources(); + if uses_uv1 { + self.ensure_transmitted_shadow_uv1_resources(); + } + } + let layout = self + .scene_refractive_material_layout + .as_ref() + .expect("refractive material layout is initialized with its pipelines"); + let transmission_texture = transmission_texture_binding.map(|(index, _)| index); + let thickness_texture = thickness_texture_binding.map(|(index, _)| index); + let physical = SceneTransmissionUniforms::new( + transmission, + transmission_texture.is_some(), + thickness_texture.is_some(), + ); + let physical_uniform = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_transmission_uniform"), + contents: bytemuck::bytes_of(&physical), + usage: wgpu::BufferUsages::UNIFORM, + }); + + let view_or_white = |index: u32| { + let texture = self + .textures + .get(index as usize) + .unwrap_or(&self.textures[0]); + texture.create_view(&wgpu::TextureViewDescriptor::default()) + }; + let base_view = view_or_white(base_color_tex_idx); + let mr_view = view_or_white(metallic_roughness_tex_idx); + let emissive_view = view_or_white(emissive_tex_idx); + let occlusion_view = view_or_white(occlusion_tex_idx); + let transmission_view = view_or_white(transmission_texture.unwrap_or(0)); + let thickness_view = view_or_white(thickness_texture.unwrap_or(0)); + let normal_view_owned = self + .textures + .get(normal_tex_idx as usize) + .filter(|_| normal_tex_idx != 0) + .map(|texture| texture.create_view(&wgpu::TextureViewDescriptor::default())); + let normal_view = normal_view_owned + .as_ref() + .unwrap_or(&self.default_normal_view); + + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("scene_refractive_material_bg"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&base_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(normal_view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&mr_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&emissive_view), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: material_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::TextureView(&occlusion_view), + }, + wgpu::BindGroupEntry { + binding: 10, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: wgpu::BindingResource::TextureView(&transmission_view), + }, + wgpu::BindGroupEntry { + binding: 12, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 13, + resource: wgpu::BindingResource::TextureView(&thickness_view), + }, + wgpu::BindGroupEntry { + binding: 14, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 15, + resource: physical_uniform.as_entire_binding(), + }, ], - }) + }); + Some((physical_uniform, bind_group, uses_uv1)) + } + + fn flush_lighting_uniforms(&mut self) { + let batch = self.lighting_upload_tracker.plan(self.lighting_uniforms); + let bytes = bytemuck::bytes_of(&self.lighting_uniforms); + for range in batch.ranges() { + self.queue.write_buffer( + &self.lighting_buffer, + range.start as u64, + &bytes[range.clone()], + ); + } } pub fn begin_frame(&mut self) { + self.lighting_upload_tracker.begin_frame(); + self.frame_resource_stats + .begin_frame(self.total_pipeline_creation_count()); self.vertices_2d.clear(); self.indices_2d.clear(); self.draw_calls_2d.clear(); self.vertices_3d.clear(); self.indices_3d.clear(); self.draw_calls_3d.clear(); - self.model_draw_commands.clear(); + self.immediate_motion.begin_frame(); + self.begin_skin_motion_frame(); + self.begin_cached_model_frame(); + self.has_blend_model_draws = false; + self.has_layered_blend_model_draws = false; + self.has_refractive_model_draws = false; + self.has_refractive_scene_nodes = false; + self.iridescence_ssr_active = false; + self.weighted_transparency_active = false; + self.temporal_reactive_active = false; + self.taa_history_written = false; + self.exposure_history_written = false; + self.temporal_camera_cut_active = false; // PT-6 — normally consumed by rebuild_instance_data (mem::take); // this clear covers frames where the accel path is skipped // (PT and Lumen both off) so the registry can't grow unbounded. @@ -9690,7 +11745,8 @@ impl Renderer { _pad: [0.0; 2], view_proj: IDENTITY_MAT4, }; - self.queue.write_buffer(&self.uniform_buffers[0], 0, bytemuck::bytes_of(&uniforms)); + self.queue + .write_buffer(&self.uniform_buffers[0], 0, bytemuck::bytes_of(&uniforms)); // Reset lighting to defaults (clears additional lights too). // Preserve env_intensity — it's set once at app init via @@ -9700,7 +11756,6 @@ impl Renderer { let preserved_env_intensity = self.lighting_uniforms.camera_pos[3]; self.lighting_uniforms = LightingUniforms::defaults(); self.lighting_uniforms.camera_pos[3] = preserved_env_intensity; - self.queue.write_buffer(&self.lighting_buffer, 0, bytemuck::bytes_of(&self.lighting_uniforms)); self.clear_additional_lights(); // DEBUG: joint animation disabled for iOS port @@ -9710,6 +11765,33 @@ impl Renderer { // self.set_joint_test(5, (angle * 1.5).sin() * 0.5); } + /// Submit a rendered frame while preserving the swapchain's normal + /// frames-in-flight back-pressure for surface-less targets. Without + /// this, an uncapped batch can enqueue hundreds of frames faster than + /// Metal completes them; command-owned transient buffers then stay live + /// until the queue catches up and eventually exhaust GPU address space. + fn submit_frame_commands(&mut self, command_buffer: wgpu::CommandBuffer) { + const MAX_HEADLESS_FRAMES_IN_FLIGHT: usize = 3; + + let submission = self.queue.submit(std::iter::once(command_buffer)); + if self.surface.is_some() { + return; + } + + self.headless_in_flight.push_back(submission); + if self.headless_in_flight.len() > MAX_HEADLESS_FRAMES_IN_FLIGHT { + let oldest = self + .headless_in_flight + .pop_front() + .expect("headless submission queue length was checked"); + #[cfg(not(target_arch = "wasm32"))] + let _ = self.device.poll(wgpu::PollType::Wait { + submission_index: Some(oldest), + timeout: None, + }); + } + } + /// Rebuild the material system's per-view bind group with the LIVE /// env / BRDF / shadow resources. The group created at material-system /// init binds 1×1 stubs ("Phase 2 wires them" — this is that wiring): @@ -9720,34 +11802,98 @@ impl Renderer { /// showed correct shadows in reflections. Mirrors that probe group /// exactly (mod.rs "planar_probe_per_view_bg_live"). fn refresh_material_per_view_bg(&mut self) { - let sky_view_owned: Option = self.sky_texture + let sky_view_owned: Option = self + .sky_texture .as_ref() .map(|t| t.create_view(&Default::default())); let env_view: &wgpu::TextureView = sky_view_owned .as_ref() .unwrap_or(&self.scene_env_default_view); - let diffuse_view_owned: Option = self.env_diffuse_texture + let diffuse_view_owned: Option = self + .env_diffuse_texture .as_ref() .map(|t| t.create_view(&Default::default())); let env_diffuse_view: &wgpu::TextureView = diffuse_view_owned .as_ref() .unwrap_or(&self.scene_env_default_view); + let mut entries = vec![ + wgpu::BindGroupEntry { + binding: 0, + resource: self.material_system.per_view_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(env_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.env_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(env_diffuse_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&self.brdf_lut_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler(&self.brdf_lut_sampler), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[0]), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[1]), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[2]), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::Sampler(&self.shadow_map.sampler), + }, + ]; + if self.shadow_map.virtual_map.requested() { + entries.extend([ + wgpu::BindGroupEntry { + binding: 10, + resource: wgpu::BindingResource::TextureView( + self.shadow_map + .virtual_map + .page_table_view() + .expect("requested VSM requires a page table"), + ), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: wgpu::BindingResource::TextureView( + self.shadow_map + .virtual_map + .physical_array_view() + .expect("requested VSM requires physical pages"), + ), + }, + wgpu::BindGroupEntry { + binding: 12, + resource: self + .shadow_map + .virtual_map + .sampling_params_buffer() + .expect("requested VSM requires sampling parameters") + .as_entire_binding(), + }, + ]); + } let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("material_per_view_bg_live"), layout: &self.material_system.layouts.per_view, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.material_system.per_view_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(env_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.env_sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(env_diffuse_view) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self.brdf_lut_view) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(&self.brdf_lut_sampler) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[0]) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[1]) }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[2]) }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&self.shadow_map.sampler) }, - ], + entries: &entries, }); self.material_system.per_view_bg = bg; self.material_per_view_bg_live = true; @@ -9790,20 +11936,27 @@ impl Renderer { view = rt_view.clone(); owned_depth_view = rt_depth.as_ref().unwrap().clone(); } else { - view = self.frame_texture(surface_output.as_ref().unwrap()).create_view(&wgpu::TextureViewDescriptor { - format: Some(self.output_format), - ..Default::default() - }); - owned_depth_view = self.depth_texture.create_view(&wgpu::TextureViewDescriptor::default()); + view = self + .frame_texture(surface_output.as_ref().unwrap()) + .create_view(&wgpu::TextureViewDescriptor { + format: Some(self.output_format), + ..Default::default() + }); + owned_depth_view = self + .depth_texture + .create_view(&wgpu::TextureViewDescriptor::default()); } // Restore RT views so they persist across frames. self.rt_color_view = rt_color; self.rt_depth_view = rt_depth; - let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("bloom_encoder"), - }); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("bloom_encoder"), + }); + self.frame_resource_stats.created_command_encoder(); // Upload 2D data to persistent GPU buffers let has_2d = !self.vertices_2d.is_empty(); @@ -9811,8 +11964,16 @@ impl Renderer { let vb_size = std::mem::size_of_val(self.vertices_2d.as_slice()); let ib_size = std::mem::size_of_val(self.indices_2d.as_slice()); self.ensure_buffer_capacity_2d(vb_size, ib_size); - self.queue.write_buffer(&self.persistent_vb_2d, 0, bytemuck::cast_slice(&self.vertices_2d)); - self.queue.write_buffer(&self.persistent_ib_2d, 0, bytemuck::cast_slice(&self.indices_2d)); + self.queue.write_buffer( + &self.persistent_vb_2d, + 0, + bytemuck::cast_slice(&self.vertices_2d), + ); + self.queue.write_buffer( + &self.persistent_ib_2d, + 0, + bytemuck::cast_slice(&self.indices_2d), + ); } // Upload 3D data to persistent GPU buffers @@ -9821,8 +11982,16 @@ impl Renderer { let vb_size = std::mem::size_of_val(self.vertices_3d.as_slice()); let ib_size = std::mem::size_of_val(self.indices_3d.as_slice()); self.ensure_buffer_capacity_3d(vb_size, ib_size); - self.queue.write_buffer(&self.persistent_vb_3d, 0, bytemuck::cast_slice(&self.vertices_3d)); - self.queue.write_buffer(&self.persistent_ib_3d, 0, bytemuck::cast_slice(&self.indices_3d)); + self.queue.write_buffer( + &self.persistent_vb_3d, + 0, + bytemuck::cast_slice(&self.vertices_3d), + ); + self.queue.write_buffer( + &self.persistent_ib_3d, + 0, + bytemuck::cast_slice(&self.indices_3d), + ); } { @@ -9883,7 +12052,9 @@ impl Renderer { self.indices_3d.len() as u32 }; let count = next_start - call.index_start; - if count == 0 { continue; } + if count == 0 { + continue; + } let tex_idx = call.texture_idx as usize; if tex_idx < self.texture_bind_groups.len() { pass.set_bind_group(2, &self.texture_bind_groups[tex_idx], &[]); @@ -9908,11 +12079,16 @@ impl Renderer { if let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.cache_handle) { if cmd.mesh_idx < meshes.len() { let mesh = &meshes[cmd.mesh_idx]; - pass.set_bind_group(0, &self.model_uniform_bind_groups[cmd.uniform_slot], &[]); + let draw = self.gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count); + pass.set_bind_group( + 0, + &self.model_uniform_bind_groups[cmd.uniform_slot], + &[], + ); pass.set_bind_group(2, &mesh.material_bg, &[]); - pass.set_vertex_buffer(0, mesh.vb.slice(..)); - pass.set_index_buffer(mesh.ib.slice(..), wgpu::IndexFormat::Uint32); - pass.draw_indexed(0..mesh.index_count, 0, 0..1); + pass.set_vertex_buffer(0, draw.vertex.slice(..)); + pass.set_index_buffer(draw.index.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(draw.index_range(), draw.base_vertex, 0..1); } } } @@ -9933,23 +12109,61 @@ impl Renderer { self.indices_2d.len() as u32 }; let count = next_start - call.index_start; - if count == 0 { continue; } + if count == 0 { + continue; + } - pass.set_bind_group(0, &self.uniform_bind_groups[call.uniform_idx as usize], &[]); + pass.set_bind_group( + 0, + &self.uniform_bind_groups[call.uniform_idx as usize], + &[], + ); if (call.texture_idx as usize) < self.texture_bind_groups.len() { - pass.set_bind_group(1, &self.texture_bind_groups[call.texture_idx as usize], &[]); + pass.set_bind_group( + 1, + &self.texture_bind_groups[call.texture_idx as usize], + &[], + ); } pass.draw_indexed(call.index_start..next_start, 0, 0..1); } } } - self.queue.submit(std::iter::once(encoder.finish())); - if let Some(out) = surface_output { self.present_frame(out); } + self.flush_lighting_uniforms(); + self.submit_frame_commands(encoder.finish()); + if let Some(out) = surface_output { + self.present_frame(out); + } + self.finish_frame_resource_stats(); } /// Like end_frame, but also renders retained scene graph nodes. - pub fn end_frame_with_scene(&mut self, scene: &mut crate::scene::SceneGraph, profiler: &mut crate::profiler::Profiler) { + /// SH-055 diag — frame-graph bisection lever. `BLOOM_SKIP` is a comma + /// list of pass-node names (e.g. "hdr_scene,translucent"); a listed node's + /// closure returns immediately. On Android the env var is populated from + /// the `debug.bloom.skip` system property in JNI_OnLoad, so passes can be + /// eliminated per-run with `adb shell setprop` + app restart, no rebuild. + /// Read once per process; visually destructive by design — it exists to + /// attribute frame time with SurfaceFlinger, not to look right. + pub(crate) fn dbg_skip(&self, tag: &str) -> bool { + static SKIP: std::sync::OnceLock = std::sync::OnceLock::new(); + let list = SKIP.get_or_init(|| std::env::var("BLOOM_SKIP").unwrap_or_default()); + !list.is_empty() && list.split(',').any(|t| t.trim() == tag) + } + pub fn end_frame_with_scene( + &mut self, + scene: &mut crate::scene::SceneGraph, + profiler: &mut crate::profiler::Profiler, + ) { + self.has_refractive_scene_nodes = + self.imported_refraction_enabled && scene.has_refractive_nodes(); + ( + self.weighted_transparency_active, + self.temporal_reactive_active, + ) = self.select_transparency_routes(scene); + self.select_transmitted_shadow_route(scene); + self.select_transparent_gi_route(scene); if !self.material_per_view_bg_live { self.refresh_material_per_view_bg(); } @@ -9993,80 +12207,22 @@ impl Renderer { // created as `output_format` (the swapchain's view format), not the // texture's own, or the final blit format-mismatches on wasm. On // native the two are equal, so this is a no-op there. - self.frame_texture(output.as_ref().unwrap()).create_view(&wgpu::TextureViewDescriptor { - format: Some(self.output_format), - ..Default::default() - }) + self.frame_texture(output.as_ref().unwrap()) + .create_view(&wgpu::TextureViewDescriptor { + format: Some(self.output_format), + ..Default::default() + }) }; // Restore so the views persist until end_texture_mode clears them. self.rt_color_view = rt_color; self.rt_depth_view = rt_depth; - let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("bloom_encoder"), - }); - - // Ticket 017: gate the entire Lumen warmup + per-frame - // pipeline on ssgi_enabled. Every pass below exists solely - // to feed the SSGI probe trace — when SSGI is off, running - // them is pure waste and breaks the --quality 0 regression - // guard (the Mesh-Cards backlog + per-frame card relight - // cost held the "off" preset below 60 fps). Pending queues - // (BLAS / SDF / card-capture) stay populated so a runtime - // setSsgiEnabled(true) flip resumes baking from where it - // paused instead of leaving the caches empty. - if self.ssgi_enabled { - // Ticket 007b: build any freshly-created BLASes and refresh - // the TLAS before the SSGI probe trace pass can sample it. - // No-op when HW RT is off or when nothing has changed. - profiler.begin("accel_rebuild"); - self.rebuild_acceleration_structures(scene, &mut encoder); - profiler.end("accel_rebuild"); - - // Ticket 013: rasterise any new mesh cards into the shared - // atlas. Drains `scene.pending_card_captures` — empty and - // free after the first frame on a static scene. - profiler.begin("card_capture"); - self.capture_pending_mesh_cards(scene, &mut encoder); - profiler.end("card_capture"); - - // Ticket 014 V1: bake per-mesh UDFs in the same frame encoder. - // Runs alongside card capture until the scene's pending queue - // is drained; no-op thereafter. - profiler.begin("sdf_bake"); - self.bake_pending_sdfs(scene, &mut encoder); - profiler.end("sdf_bake"); - - // Ticket 014 V5: check if the camera has wandered past the - // rebake threshold from the current clipmap centre; if so, - // clear the `built` flag so the bake below fires again with - // a re-centred origin. SDF trace falls back to Hi-Z for the - // single frame between invalidation and re-bake completion. - self.maybe_invalidate_sdf_clipmap(); - - // Ticket 014 V2: bake the scene-wide SDF clipmap when it's - // not already built. First frame after startup, and any - // frame after V5 invalidation. - profiler.begin("scene_sdf_clipmap"); - self.bake_scene_sdf_clipmap(scene, &mut encoder); - profiler.end("scene_sdf_clipmap"); - - // Ticket 014 V6: World-Space Radiance Cache. Invalidate on - // camera travel past 30 m; re-bake when not built. Runs even - // when HW RT is active so a future V7 can wire the HW miss - // path into the same cache. - self.maybe_invalidate_wsrc(); - profiler.begin("wsrc_bake"); - self.bake_wsrc(&mut encoder); - profiler.end("wsrc_bake"); - - // Ticket 013 V2: re-light the card atlas every frame so the - // HW probe trace can sample pre-lit radiance at hit instead - // of running the sun/sky math per ray. - profiler.begin("card_light"); - self.light_mesh_cards(scene, &mut encoder); - profiler.end("card_light"); - } + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("bloom_encoder"), + }); + self.frame_resource_stats.created_command_encoder(); // Upload immediate-mode 2D data profiler.begin("upload_geometry"); @@ -10075,8 +12231,16 @@ impl Renderer { let vb_size = std::mem::size_of_val(self.vertices_2d.as_slice()); let ib_size = std::mem::size_of_val(self.indices_2d.as_slice()); self.ensure_buffer_capacity_2d(vb_size, ib_size); - self.queue.write_buffer(&self.persistent_vb_2d, 0, bytemuck::cast_slice(&self.vertices_2d)); - self.queue.write_buffer(&self.persistent_ib_2d, 0, bytemuck::cast_slice(&self.indices_2d)); + self.queue.write_buffer( + &self.persistent_vb_2d, + 0, + bytemuck::cast_slice(&self.vertices_2d), + ); + self.queue.write_buffer( + &self.persistent_ib_2d, + 0, + bytemuck::cast_slice(&self.indices_2d), + ); } // Upload immediate-mode 3D data @@ -10085,8 +12249,16 @@ impl Renderer { let vb_size = std::mem::size_of_val(self.vertices_3d.as_slice()); let ib_size = std::mem::size_of_val(self.indices_3d.as_slice()); self.ensure_buffer_capacity_3d(vb_size, ib_size); - self.queue.write_buffer(&self.persistent_vb_3d, 0, bytemuck::cast_slice(&self.vertices_3d)); - self.queue.write_buffer(&self.persistent_ib_3d, 0, bytemuck::cast_slice(&self.indices_3d)); + self.queue.write_buffer( + &self.persistent_vb_3d, + 0, + bytemuck::cast_slice(&self.vertices_3d), + ); + self.queue.write_buffer( + &self.persistent_ib_3d, + 0, + bytemuck::cast_slice(&self.indices_3d), + ); } profiler.end("upload_geometry"); @@ -10098,38 +12270,26 @@ impl Renderer { let (surf_w, surf_h) = self.render_extent(); let exposure_src_idx = self.exposure_current_idx; let exposure_dst_idx = 1 - self.exposure_current_idx; + #[cfg(not(target_arch = "wasm32"))] + let mut frame_readback: Option = None; - // ============================================================ - // Frame render graph (RFC 0001 Phase 2b — complete). - // - // Every render pass between geometry upload and the terminal - // composite runs as a PassNode. Reads/writes document the real - // data dependencies; in addition, each node carries a with_after - // pin to its predecessor so the schedule reproduces the - // hand-tuned order exactly. Loosening those pins (to let the - // scheduler interleave independent passes) is the documented - // next refinement — do it dependency-by-dependency with the - // golden tests watching. - // - // The context owns &mut Renderer, so node closures borrow - // nothing at build time and can call the record_* methods. - // Feature toggles (ssao/ssr/ssgi/bloom) are checked inside the - // closures (or inside the methods), never by omitting nodes — - // with_after on a missing node is a schedule error. - // ============================================================ + // The immutable topology owns resource contracts, optional-pass + // selection, and deterministic order. Per-frame work only binds + // closures to its cached pass slots; no scheduler/declaration graph + // is rebuilt here. { - use graph::{Graph, PassInput, PassNode, PassOutput}; - // Transient ordering tokens for resources the enum doesn't - // name. The textures themselves are persistent renderer - // fields; these ids only express producer→consumer edges. - const HIZ_PYRAMID: u32 = 0; - const SSAO_TEX: u32 = 1; - const SSR_TEX: u32 = 2; - const SSGI_TEX: u32 = 3; - const BLOOM_CHAIN: u32 = 4; - const COMPOSED: u32 = 5; - const LDR_FINAL: u32 = 6; - const FROXEL_CLUSTERS: u32 = 7; + use graph::ExecutableGraph; + let compiled_plan = match self.compiled_frame_plan(using_rt) { + Ok(plan) => plan, + Err(error) => { + // A declaration/compiler mismatch is a programming error. + // Continuing with a partial command buffer would hide the + // defect as a black or stale frame. + panic!("render graph compilation failed: {error}"); + } + }; + #[cfg(not(target_arch = "wasm32"))] + let capture_pass_present = compiled_plan.pass("capture_readback").is_some(); struct FrameCtx2<'a> { r: &'a mut Renderer, @@ -10138,529 +12298,243 @@ impl Renderer { scene: &'a mut crate::scene::SceneGraph, surf: (u32, u32), exposure_idx: (usize, usize), + output_view: &'a wgpu::TextureView, + #[cfg(not(target_arch = "wasm32"))] + output_target: Option<&'a draw2d::FrameTarget>, + #[cfg(not(target_arch = "wasm32"))] + frame_readback: &'a mut Option, } + impl graph::GraphDebugMarkerContext for FrameCtx2<'_> { + fn push_graph_debug_group(&mut self, label: &str) { + self.encoder.push_debug_group(label); + } - let mut g: Graph = Graph::new(); - g.push( - PassNode::new("froxel_assign", Box::new(|c: &mut FrameCtx2| { - // No-op when self.froxel is None (the method gates); - // the node stays in the graph so with_after pins - // never dangle. - c.r.record_froxel_assign(c.encoder); - })) - .with_writes(&[PassOutput::Transient(FROXEL_CLUSTERS)]), - ); - g.push( - PassNode::new("shadow", Box::new(|c: &mut FrameCtx2| { - c.r.record_shadow_pass(c.encoder, c.profiler, c.scene); - })) - .with_writes(&[PassOutput::Shadow(0), PassOutput::Shadow(1), PassOutput::Shadow(2)]), - ); - g.push( - PassNode::new("hdr_scene", Box::new(|c: &mut FrameCtx2| { - c.r.record_hdr_scene_pass(c.encoder, c.profiler, c.scene); - })) - .with_reads(&[ - PassInput::Shadow(0), - PassInput::Shadow(1), - PassInput::Shadow(2), - PassInput::Transient(FROXEL_CLUSTERS), - ]) - .with_writes(&[ - PassOutput::HdrColor, - PassOutput::MaterialRt, - PassOutput::VelocityRt, - PassOutput::AlbedoRt, - PassOutput::Depth, - ]) - .with_after(&["shadow", "froxel_assign"]), - ); - g.push( - PassNode::new("pt", Box::new(|c: &mut FrameCtx2| { - // No-op unless pt_active() (the method gates). Sits - // between the opaque scene and translucency so water / - // glass still composite over the traced opaques, and - // sky pixels (never written by the kernel) keep the - // raster sky. - c.r.record_pt_pass(c.encoder, c.profiler, c.surf.0, c.surf.1); - })) - .with_reads(&[PassInput::SceneDepth]) - .with_after(&["hdr_scene"]), - ); - g.push( - PassNode::new("translucent", Box::new(|c: &mut FrameCtx2| { - c.r.record_translucent_pass(c.encoder, c.profiler); - })) - // Reads the opaque HDR + depth and alpha-blends back into - // HdrColor; the pin (not a second HdrColor write) keeps a - // single declared writer per resource. - .with_after(&["pt"]), - ); - g.push( - PassNode::new("hiz_build", Box::new(|c: &mut FrameCtx2| { - if !c.r.ssao_enabled { - return; - } - let (hw, hh) = ((c.surf.0 / 2).max(1), (c.surf.1 / 2).max(1)); - let p22 = c.r.current_proj_matrix[2][2]; - let p32 = c.r.current_proj_matrix[3][2]; - c.r.record_hiz_chain(c.encoder, c.profiler, hw, hh, p22, p32); - })) - .with_reads(&[PassInput::SceneDepth]) - .with_writes(&[PassOutput::Transient(HIZ_PYRAMID)]) - .with_after(&["translucent"]), - ); - g.push( - PassNode::new("occlusion_capture", Box::new(|c: &mut FrameCtx2| { - if !c.r.ssao_enabled { - return; - } - let (hw, hh) = ((c.surf.0 / 2).max(1), (c.surf.1 / 2).max(1)); - let vp = c.r.vp_matrix(); - let occlusion = &mut c.r.occlusion as *mut OcclusionCuller; - unsafe { - (*occlusion).record(&c.r.device, &c.r.queue, c.encoder, &c.r.hiz_views[0], (hw, hh), vp); - } - })) - .with_reads(&[PassInput::Transient(HIZ_PYRAMID)]) - .with_after(&["hiz_build"]), - ); - g.push( - PassNode::new("gtao", Box::new(|c: &mut FrameCtx2| { - if !c.r.ssao_enabled { - return; - } - let (hw, hh) = ((c.surf.0 / 2).max(1), (c.surf.1 / 2).max(1)); - let p = &c.r.current_proj_matrix; - let (p00, p11, p20, p21) = (p[0][0], p[1][1], p[2][0], p[2][1]); - c.r.record_gtao(c.encoder, c.profiler, hw, hh, p00, p11, p20, p21); - })) - .with_reads(&[PassInput::Transient(HIZ_PYRAMID)]) - .with_after(&["occlusion_capture"]), - ); - g.push( - PassNode::new("ssao_blur", Box::new(|c: &mut FrameCtx2| { - c.r.record_ssao_blur(c.encoder, c.surf.0, c.surf.1); - })) - .with_writes(&[PassOutput::Transient(SSAO_TEX)]) - .with_after(&["gtao"]), - ); - g.push( - PassNode::new("ssr_march", Box::new(|c: &mut FrameCtx2| { - c.r.record_ssr_march(c.encoder, c.profiler); - })) - .with_reads(&[PassInput::SceneColor, PassInput::SceneDepth]) - .with_after(&["ssao_blur"]), - ); - g.push( - PassNode::new("ssr_temporal", Box::new(|c: &mut FrameCtx2| { - c.r.record_ssr_temporal(c.encoder); - })) - .with_writes(&[PassOutput::Transient(SSR_TEX)]) - .with_after(&["ssr_march"]), - ); - g.push( - PassNode::new("ssgi", Box::new(|c: &mut FrameCtx2| { - c.r.record_ssgi_passes(c.encoder, c.profiler, c.surf.0, c.surf.1); - })) - .with_reads(&[PassInput::SceneColor, PassInput::SceneDepth]) - .with_writes(&[PassOutput::Transient(SSGI_TEX)]) - .with_after(&["ssr_temporal"]), - ); - g.push( - PassNode::new("bloom", Box::new(|c: &mut FrameCtx2| { - c.r.record_bloom_chain(c.encoder, c.profiler, c.surf.0, c.surf.1); - })) - .with_reads(&[PassInput::SceneColor]) - .with_writes(&[PassOutput::Transient(BLOOM_CHAIN)]) - .with_after(&["ssgi"]), - ); - g.push( - PassNode::new("compose", Box::new(|c: &mut FrameCtx2| { - c.r.record_scene_compose(c.encoder); - })) - .with_reads(&[ - PassInput::SceneColor, - PassInput::Transient(SSAO_TEX), - PassInput::Transient(SSR_TEX), - PassInput::Transient(SSGI_TEX), - PassInput::Transient(BLOOM_CHAIN), - ]) - .with_writes(&[PassOutput::Transient(COMPOSED)]) - .with_after(&["bloom"]), - ); - g.push( - PassNode::new("postfx_tail", Box::new(|c: &mut FrameCtx2| { - c.r.record_postfx_tail(c.encoder, c.profiler); - })) - .with_reads(&[PassInput::Transient(COMPOSED), PassInput::MotionVectors]) - .with_writes(&[PassOutput::Transient(LDR_FINAL)]) - .with_after(&["compose"]), - ); - g.push( - PassNode::new("auto_exposure", Box::new(|c: &mut FrameCtx2| { - let (src, dst) = c.exposure_idx; - c.r.record_auto_exposure(c.encoder, src, dst); - })) - .with_reads(&[PassInput::Transient(LDR_FINAL)]) - .with_after(&["postfx_tail"]), - ); - - let mut ctx = FrameCtx2 { - r: self, - encoder: &mut encoder, - profiler, - scene, - surf: (surf_w, surf_h), - exposure_idx: (exposure_src_idx, exposure_dst_idx), - }; - if let Err(e) = g.execute(&mut ctx) { - // A schedule error means a malformed graph (cycle / - // unknown pin) — a programming error, not a runtime - // condition. Surface loudly; the frame still presents - // whatever was encoded before the failure. - eprintln!("[graph] frame graph failed: {:?}", e); + fn pop_graph_debug_group(&mut self) { + self.encoder.pop_debug_group(); + } } - } - - // CPU bracket over the composite + custom post-pass encode tail. - // The matching `end("post_fx")` below predates this begin — it was - // orphaned (a no-op) for months while comments claimed the phase - // covered scene_compose's cost. - profiler.begin("post_fx"); - let composite_src_view = self.composite_source_view(); - - // composite_uniform_buffer carries per-frame composite state. - // x = tonemap kind (0 ACES / 1 AgX) - // y = auto-exposure toggle - // z = manual exposure multiplier - // w = auto-exposure target key value - let cp = CompositeParams { - params: [ - self.tonemap_kind as f32, - if self.auto_exposure { 1.0 } else { 0.0 }, - self.manual_exposure, - self.auto_exposure_key, - ], - filmic: [ - self.chromatic_aberration, - self.vignette_strength, - self.vignette_softness, - self.grain_strength, - ], - misc: [self.taa_frame_index as f32, self.sharpen_strength, 0.0, 0.0], - }; - self.queue.write_buffer(&self.composite_uniform_buffer, 0, bytemuck::bytes_of(&cp)); - - let composite_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("composite_bg"), - layout: &self.composite_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(composite_src_view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 2, resource: self.composite_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.exposure_views[exposure_dst_idx]) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&self.ssao_blur_rt_view) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - ], - }); - // EN-017 V2 — when at least one post-pass is installed the - // composite writes into ping-pong slot A, then each pass - // ping-pongs A/B with the LAST pass writing the swapchain. - // Otherwise composite still writes the swapchain directly - // (zero-cost original path). - let composite_target_view: &wgpu::TextureView = if !self.post_passes.is_empty() { - self.composite_ldr_rt_a_view.as_ref().unwrap_or(&view) - } else { - &view - }; - { - let final_composite_ts = profiler.pass_timestamp_writes("final_composite_pass"); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("bloom_composite_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: composite_target_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - // Composite covers the full surface anyway, - // but Clear is safer than Load (cheaper too — - // tile-based GPUs love Clear). - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: final_composite_ts, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(&self.composite_pipeline); - pass.set_bind_group(0, &composite_bg, &[]); - pass.draw(0..3, 0..1); - } - // EN-017 V2 — fullscreen post-pass STACK. Each pass samples - // the previous pass's output (or composite_ldr_rt_a for - // pass 0) + scene depth, then writes either the next ping- - // pong intermediate or — for the last pass — the swapchain. - // Runs after composite + tonemapping but before the 2D - // overlay so the HUD stays crisp. - // - // Bind groups are built transiently per-frame: BG creation - // is cheap (~µs), and ping-ponging requires per-direction - // BGs anyway so caching adds bookkeeping for marginal gain. - let n_passes = self.post_passes.len(); - if n_passes > 0 { - for i in 0..n_passes { - // input view = (i % 2 == 0) ? A : B - // output view = last ? swapchain - // : (i % 2 == 0) ? B : A - let input_view: &wgpu::TextureView = if i % 2 == 0 { - // Always defined: composite wrote A above. - self.composite_ldr_rt_a_view.as_ref().unwrap_or(&view) - } else { - // Should be defined: add_post_pass allocated B - // when the stack grew to >= 2. Fallback to view - // is defensive. - self.composite_ldr_rt_b_view.as_ref().unwrap_or(&view) + let mut g: ExecutableGraph = ExecutableGraph::new(compiled_plan); + macro_rules! bind_pass { + ($name:literal, $run:expr) => { + g.bind($name, Box::new($run)) + .expect("required frame pass exists and is bound once") }; - let is_last = i == n_passes - 1; - let output_view: &wgpu::TextureView = if is_last { - &view - } else if i % 2 == 0 { - self.composite_ldr_rt_b_view.as_ref().unwrap_or(&view) - } else { - self.composite_ldr_rt_a_view.as_ref().unwrap_or(&view) + } + macro_rules! bind_optional_pass { + ($name:literal, $run:expr) => { + if g.contains($name) { + g.bind($name, Box::new($run)) + .expect("selected optional frame pass is bound once") + } }; - - let pp = &self.post_passes[i]; - let pp_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("post_pass_bg"), - layout: &pp.bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(input_view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&self.composite_sampler), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: wgpu::BindingResource::TextureView(&self.depth_view), - }, - wgpu::BindGroupEntry { - binding: 3, - resource: wgpu::BindingResource::Sampler(&self.post_pass_depth_sampler), - }, - ], - }); - let pp_ts = profiler.pass_timestamp_writes("post_pass"); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("bloom_post_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: output_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: pp_ts, - occlusion_query_set: None, - multiview_mask: None, + } + bind_optional_pass!("accel_rebuild", |c: &mut FrameCtx2| { + c.profiler.begin("accel_rebuild"); + c.r.rebuild_acceleration_structures(c.scene, c.encoder); + c.profiler.end("accel_rebuild"); + }); + bind_optional_pass!("card_capture", |c: &mut FrameCtx2| { + c.profiler.begin("card_capture"); + c.r.capture_pending_mesh_cards(c.scene, c.encoder); + c.profiler.end("card_capture"); + }); + bind_optional_pass!("sdf_bake", |c: &mut FrameCtx2| { + c.profiler.begin("sdf_bake"); + c.r.bake_pending_sdfs(c.scene, c.encoder); + c.profiler.end("sdf_bake"); + }); + bind_optional_pass!("scene_sdf_clipmap", |c: &mut FrameCtx2| { + c.r.maybe_invalidate_sdf_clipmap(); + c.profiler.begin("scene_sdf_clipmap"); + c.r.bake_scene_sdf_clipmap(c.scene, c.encoder); + c.profiler.end("scene_sdf_clipmap"); + }); + bind_optional_pass!("wsrc_bake", |c: &mut FrameCtx2| { + c.r.maybe_invalidate_wsrc(); + c.profiler.begin("wsrc_bake"); + c.r.bake_wsrc(c.encoder, c.profiler); + c.profiler.end("wsrc_bake"); + }); + bind_optional_pass!("card_light", |c: &mut FrameCtx2| { + c.profiler.begin("card_light"); + c.r.light_mesh_cards(c.scene, c.encoder); + c.profiler.end("card_light"); + }); + bind_pass!("froxel_assign", |c: &mut FrameCtx2| { + if c.r.dbg_skip("froxel_assign") { + return; + } + c.r.record_froxel_assign(c.encoder); + }); + bind_pass!("shadow", |c: &mut FrameCtx2| { + if c.r.dbg_skip("shadow") { + // The resolve must never sample stale/uninitialized lazy + // maps when the diagnostic intentionally suppresses their + // producer. + c.r.transmitted_shadows_active = false; + return; + } + c.r.record_shadow_pass(c.encoder, c.profiler, c.scene); + c.r.record_transmitted_shadow_maps(c.encoder, c.profiler, c.scene); + }); + bind_pass!("hdr_scene", |c: &mut FrameCtx2| { + if c.r.dbg_skip("hdr_scene") { + return; + } + c.r.record_hdr_scene_pass(c.encoder, c.profiler, c.scene); + }); + bind_pass!("pt", |c: &mut FrameCtx2| { + if c.r.dbg_skip("pt") { + return; + } + // No-op unless pt_active(). It remains between opaque and + // translucency so water/glass composite over traced opaques. + c.r.record_pt_pass(c.encoder, c.profiler, c.surf.0, c.surf.1); + }); + bind_optional_pass!("transmitted_shadow_resolve", |c: &mut FrameCtx2| { + c.r.record_transmitted_shadow_resolve(c.encoder, c.profiler); + }); + bind_pass!("translucent", |c: &mut FrameCtx2| { + if c.r.dbg_skip("translucent") { + // The debug skip bypasses the producer that materializes + // and clears the lazy reactive target. Keep the diagnostic + // usable by selecting the established TAA path rather than + // sampling an uninitialized graph allocation. + c.r.temporal_reactive_active = false; + return; + } + c.r.record_translucent_pass(c.encoder, c.profiler, c.scene); + }); + bind_optional_pass!("hiz_build", |c: &mut FrameCtx2| { + let (hw, hh) = ((c.surf.0 / 2).max(1), (c.surf.1 / 2).max(1)); + let p22 = c.r.current_proj_matrix[2][2]; + let p32 = c.r.current_proj_matrix[3][2]; + c.r.record_hiz_chain(c.encoder, c.profiler, hw, hh, p22, p32); + }); + bind_optional_pass!("occlusion_capture", |c: &mut FrameCtx2| { + let (hw, hh) = ((c.surf.0 / 2).max(1), (c.surf.1 / 2).max(1)); + let vp = c.r.vp_matrix(); + let occlusion = &mut c.r.occlusion as *mut OcclusionCuller; + unsafe { + (*occlusion).record( + &c.r.device, + &c.r.queue, + c.encoder, + &c.r.hiz_views[0], + (hw, hh), + vp, + ); + } + }); + bind_optional_pass!("gtao", |c: &mut FrameCtx2| { + let (hw, hh) = ((c.surf.0 / 2).max(1), (c.surf.1 / 2).max(1)); + let p = &c.r.current_proj_matrix; + let (p00, p11, p20, p21) = (p[0][0], p[1][1], p[2][0], p[2][1]); + c.r.record_gtao(c.encoder, c.profiler, hw, hh, p00, p11, p20, p21); + }); + bind_pass!("ssao_blur", |c: &mut FrameCtx2| { + c.r.record_ssao_blur(c.encoder, c.surf.0, c.surf.1); + }); + bind_optional_pass!("ssr_march", |c: &mut FrameCtx2| { + c.r.record_ssr_march(c.encoder, c.profiler); + }); + bind_optional_pass!("ssr_temporal", |c: &mut FrameCtx2| { + c.r.record_ssr_temporal(c.encoder); + }); + bind_optional_pass!("ssgi", |c: &mut FrameCtx2| { + c.r.record_ssgi_passes(c.encoder, c.profiler, c.surf.0, c.surf.1); + }); + bind_optional_pass!("bloom", |c: &mut FrameCtx2| { + if c.r.dbg_skip("bloom") { + return; + } + c.r.record_bloom_chain(c.encoder, c.profiler, c.surf.0, c.surf.1); + }); + bind_pass!("compose", |c: &mut FrameCtx2| { + if c.r.dbg_skip("compose") { + return; + } + c.r.record_scene_compose(c.encoder); + }); + bind_pass!("postfx_tail", |c: &mut FrameCtx2| { + if c.r.dbg_skip("postfx_tail") { + return; + } + c.r.record_postfx_tail(c.encoder, c.profiler); + }); + bind_pass!("auto_exposure", |c: &mut FrameCtx2| { + if c.r.dbg_skip("auto_exposure") { + return; + } + let (src, dst) = c.exposure_idx; + c.r.record_auto_exposure(c.encoder, src, dst); + }); + bind_pass!("final_composite", |c: &mut FrameCtx2| { + let (exposure_src, exposure_dst) = c.exposure_idx; + let exposure_idx = if c.r.exposure_history_written { + exposure_dst + } else { + exposure_src + }; + c.r.record_final_composite_pass(c.encoder, c.profiler, c.output_view, exposure_idx); + }); + bind_pass!("overlay_2d", |c: &mut FrameCtx2| { + c.r.record_overlay_2d_pass(c.encoder, c.profiler, c.output_view); + }); + #[cfg(not(target_arch = "wasm32"))] + if capture_pass_present { + bind_pass!("capture_readback", |c: &mut FrameCtx2| { + let Some(target) = c.output_target else { + return; + }; + let output_texture = c.r.frame_texture(target); + *c.frame_readback = Some(c.r.record_frame_readback(c.encoder, output_texture)); }); - pass.set_pipeline(&pp.pipeline); - pass.set_bind_group(0, &pp_bg, &[]); - pass.draw(0..3, 0..1); - } - } - profiler.end("post_fx"); - - // ============================================================ - // 2D pass: immediate-mode 2D geometry on top of composited image - // ============================================================ - // Phase 2d — overlay_2d ported to a graph node. Second real - // consumer of `renderer::graph` after material_pass. Lives in - // its own one-node graph here at the end of the frame because - // it needs to run AFTER the hand-encoded composite has - // written the swapchain. Phase 2e+ will merge into a single - // frame-wide graph as more passes port. - if has_2d { - use graph::{Graph, PassNode, PassOutput}; - - let pipeline_2d = &self.pipeline_2d; - let persistent_vb_2d = &self.persistent_vb_2d; - let persistent_ib_2d = &self.persistent_ib_2d; - let uniform_bind_groups = &self.uniform_bind_groups; - let texture_bind_groups = &self.texture_bind_groups; - let draw_calls_2d = &self.draw_calls_2d; - let indices_2d_len = self.indices_2d.len() as u32; - let view_ref = &view; - - struct OverlayCtx<'a> { - encoder: &'a mut wgpu::CommandEncoder, - profiler: &'a mut crate::profiler::Profiler, } - let mut graph: Graph> = Graph::new(); - graph.push( - PassNode::new("overlay_2d", Box::new(move |ctx: &mut OverlayCtx| { - ctx.profiler.begin("overlay_2d"); - { - let mut pass = ctx.encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("bloom_2d_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: view_ref, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Load, - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(pipeline_2d); - pass.set_vertex_buffer(0, persistent_vb_2d.slice(..)); - pass.set_index_buffer(persistent_ib_2d.slice(..), wgpu::IndexFormat::Uint32); - - let num_calls = draw_calls_2d.len(); - for i in 0..num_calls { - let call = &draw_calls_2d[i]; - let next_start = if i + 1 < num_calls { - draw_calls_2d[i + 1].index_start - } else { - indices_2d_len - }; - let count = next_start - call.index_start; - if count == 0 { continue; } - - pass.set_bind_group(0, &uniform_bind_groups[call.uniform_idx as usize], &[]); - if (call.texture_idx as usize) < texture_bind_groups.len() { - pass.set_bind_group(1, &texture_bind_groups[call.texture_idx as usize], &[]); - } - pass.draw_indexed(call.index_start..next_start, 0, 0..1); - } - } - ctx.profiler.end("overlay_2d"); - })) - .with_writes(&[PassOutput::Swapchain]), - ); - - let mut ctx = OverlayCtx { encoder: &mut encoder, profiler: &mut *profiler }; - if let Err(e) = graph.execute(&mut ctx) { - eprintln!("[graph] overlay_2d failed: {:?}", e); + let graph_debug_markers = self.graph_debug_markers_enabled(); + let mut ctx = FrameCtx2 { + r: self, + encoder: &mut encoder, + profiler, + scene, + surf: (surf_w, surf_h), + exposure_idx: (exposure_src_idx, exposure_dst_idx), + output_view: &view, + #[cfg(not(target_arch = "wasm32"))] + output_target: output.as_ref(), + #[cfg(not(target_arch = "wasm32"))] + frame_readback: &mut frame_readback, + }; + let result = if graph_debug_markers { + g.execute_marked(&mut ctx) + } else { + g.execute(&mut ctx) + }; + if let Err(e) = result { + panic!("compiled render graph execution binding failed: {e}"); } - } else { - // Empty graphs are still valid — execute a no-op so the - // profiler bracket is symmetric with the populated path. - profiler.begin("overlay_2d"); - profiler.end("overlay_2d"); } + // Queue writes are ordered before the command buffer submitted below. + // Flush once after every pass has finalized the CPU lighting snapshot + // (notably the shadow cascade fit), so all encoded consumers see one + // coherent set of dirty ranges. + self.flush_lighting_uniforms(); profiler.resolve(&mut encoder); + self.finish_frame_resource_stats(); - // If screenshot requested, copy rendered texture to staging buffer before submitting. - // Synchronous GPU readback is not available on WASM (device.poll(Wait) blocks). - // RT frames have no surface — the request stays pending for the next real frame. + // Capture copies were encoded by the terminal graph node. Mapping must + // happen after submission; synchronous GPU readback remains unavailable + // on WASM and RT-only frames leave the request pending. #[cfg(not(target_arch = "wasm32"))] - if self.screenshot_requested && !using_rt { - eprintln!("bloom: screenshot readback branch running"); - // Use actual texture dimensions (accounts for Retina/DPI scaling) - let tex_size = self.frame_texture(output.as_ref().unwrap()).size(); - let width = tex_size.width; - let height = tex_size.height; - let bytes_per_pixel = 4u32; - let unpadded_bpr = width * bytes_per_pixel; - let padded_bpr = (unpadded_bpr + 255) & !255; - let buf_size = (padded_bpr * height) as u64; - - let staging = self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("screenshot_staging"), - size: buf_size, - usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, - mapped_at_creation: false, - }); - - encoder.copy_texture_to_buffer( - wgpu::TexelCopyTextureInfo { - texture: self.frame_texture(output.as_ref().unwrap()), - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyBufferInfo { - buffer: &staging, - layout: wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(padded_bpr), - rows_per_image: Some(height), - }, - }, - wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, - ); - + if let Some(readback) = frame_readback.take() { self.queue.submit(std::iter::once(encoder.finish())); - - // Read back pixels synchronously - let slice = staging.slice(..); - let (tx, rx) = std::sync::mpsc::channel(); - slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); - let _ = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); - - if let Ok(Ok(())) = rx.recv() { - let data = slice.get_mapped_range(); - let mut rgba = Vec::with_capacity((width * height * bytes_per_pixel) as usize); - for row in 0..height { - let start = (row * padded_bpr) as usize; - let end = start + (width * bytes_per_pixel) as usize; - rgba.extend_from_slice(&data[start..end]); - } - drop(data); - // If the user requested an inline file write (via - // bloom_take_screenshot), do that here. RGBA is in - // BGRA order on Metal/DX12 surfaces — swap channels - // before encoding to PNG so colors match what was on - // screen rather than blue-and-red flipped. - if let Some(path) = self.pending_screenshot_path.take() { - let mut rgb = Vec::with_capacity((width * height * 3) as usize); - for chunk in rgba.chunks_exact(4) { - // BGRA → RGB. (Surface format is bgra8unorm - // on the platforms we care about today.) - rgb.push(chunk[2]); - rgb.push(chunk[1]); - rgb.push(chunk[0]); - } - // Failures were silently swallowed here for months — - // takeScreenshot "worked" while writing nothing. - match encode_png_simple(width, height, &rgb) { - Some(png) => { - if let Err(e) = std::fs::write(&path, &png) { - eprintln!("bloom: screenshot write '{}' failed: {}", path, e); - } - } - None => eprintln!( - "bloom: screenshot PNG encode failed ({}x{})", - width, height - ), - } - } - self.screenshot_data = Some((width, height, rgba)); - } - staging.unmap(); - self.screenshot_requested = false; + self.finish_frame_readback(readback); } else { profiler.begin("queue_submit"); - self.queue.submit(std::iter::once(encoder.finish())); + self.submit_frame_commands(encoder.finish()); profiler.end("queue_submit"); } @@ -10696,10 +12570,12 @@ impl Renderer { // offset and the just-written texture becomes the history. // Snapshot current VP into prev_vp so next frame's TAA pass // can reproject through it. - if self.taa_enabled { + if self.taa_enabled && self.taa_history_written { self.taa_current_idx = 1 - self.taa_current_idx; self.taa_frame_index = self.taa_frame_index.wrapping_add(1); self.prev_vp_matrix = self.current_vp_matrix; + } else if self.taa_enabled { + self.taa_history_valid = false; } // EN-022 fix — velocity reference inputs roll over every frame // regardless of TAA state (velocity also feeds motion blur). @@ -10708,39 +12584,41 @@ impl Renderer { // Swap probe-history ping-pong so next frame reads what we // just blended as the "previous" history and writes to the // other buffer. Ticket 007a. - if self.ssgi_enabled { + if self.ssgi_enabled && !self.pt_owns_frame() && self.probe_history_valid { self.probe_history_idx = 1 - self.probe_history_idx; } // Same ping-pong for SSR temporal accumulation. - if self.ssr_enabled { + if self.ssr_enabled && !self.pt_owns_frame() && self.ssr_history_valid { self.ssr_history_idx = 1 - self.ssr_history_idx; } // Swap exposure ping-pong so next frame's exposure pass // reads what we just wrote. - if self.auto_exposure { + if self.auto_exposure && self.exposure_history_written { self.exposure_current_idx = 1 - self.exposure_current_idx; } } - pub fn get_texture_width(&self, idx: u32) -> u32 { - self.texture_sizes.get(idx as usize).map(|s| s.0).unwrap_or(0) + self.texture_sizes + .get(idx as usize) + .map(|s| s.0) + .unwrap_or(0) } pub fn get_texture_height(&self, idx: u32) -> u32 { - self.texture_sizes.get(idx as usize).map(|s| s.1).unwrap_or(0) + self.texture_sizes + .get(idx as usize) + .map(|s| s.1) + .unwrap_or(0) } - // ============================================================ // 2D drawing internals - // ============================================================ fn ensure_draw_state(&mut self, texture_idx: u32) { - let needs_new = self.draw_calls_2d.is_empty() - || { - let last = self.draw_calls_2d.last().unwrap(); - last.texture_idx != texture_idx || last.uniform_idx != self.current_uniform_idx - }; + let needs_new = self.draw_calls_2d.is_empty() || { + let last = self.draw_calls_2d.last().unwrap(); + last.texture_idx != texture_idx || last.uniform_idx != self.current_uniform_idx + }; if needs_new { self.draw_calls_2d.push(DrawCall2D { texture_idx, @@ -10755,38 +12633,89 @@ impl Renderer { /// changing their interpretation would silently re-tint every /// existing drawCube/drawModel call in shipped games. fn color_to_f32(r: f64, g: f64, b: f64, a: f64) -> [f32; 4] { - [(r / 255.0) as f32, (g / 255.0) as f32, (b / 255.0) as f32, (a / 255.0) as f32] + [ + (r / 255.0) as f32, + (g / 255.0) as f32, + (b / 255.0) as f32, + (a / 255.0) as f32, + ] } /// 2D variant: sRGB-decodes rgb so the sRGB swapchain view's hardware /// encode does not double-encode (see `srgb_u8_to_linear`). fn color_to_f32_srgb(r: f64, g: f64, b: f64, a: f64) -> [f32; 4] { - [srgb_u8_to_linear(r), srgb_u8_to_linear(g), srgb_u8_to_linear(b), (a / 255.0) as f32] + [ + srgb_u8_to_linear(r), + srgb_u8_to_linear(g), + srgb_u8_to_linear(b), + (a / 255.0) as f32, + ] } - - pub fn draw_triangle(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64, r: f64, g: f64, b: f64, a: f64) { + pub fn draw_triangle( + &mut self, + x1: f64, + y1: f64, + x2: f64, + y2: f64, + x3: f64, + y3: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { self.ensure_draw_state(0); let color = Self::color_to_f32_srgb(r, g, b, a); let base = self.vertices_2d.len() as u32; - self.vertices_2d.push(Vertex2D { position: [x1 as f32, y1 as f32], uv: [0.0, 0.0], color }); - self.vertices_2d.push(Vertex2D { position: [x2 as f32, y2 as f32], uv: [0.0, 0.0], color }); - self.vertices_2d.push(Vertex2D { position: [x3 as f32, y3 as f32], uv: [0.0, 0.0], color }); + self.vertices_2d.push(Vertex2D { + position: [x1 as f32, y1 as f32], + uv: [0.0, 0.0], + color, + }); + self.vertices_2d.push(Vertex2D { + position: [x2 as f32, y2 as f32], + uv: [0.0, 0.0], + color, + }); + self.vertices_2d.push(Vertex2D { + position: [x3 as f32, y3 as f32], + uv: [0.0, 0.0], + color, + }); - self.indices_2d.extend_from_slice(&[base, base + 1, base + 2]); + self.indices_2d + .extend_from_slice(&[base, base + 1, base + 2]); } - pub fn draw_poly(&mut self, cx: f64, cy: f64, sides: f64, radius: f64, rotation: f64, r: f64, g: f64, b: f64, a: f64) { + pub fn draw_poly( + &mut self, + cx: f64, + cy: f64, + sides: f64, + radius: f64, + rotation: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { self.ensure_draw_state(0); let color = Self::color_to_f32_srgb(r, g, b, a); let n = sides as u32; - if n < 3 { return; } + if n < 3 { + return; + } let base = self.vertices_2d.len() as u32; let (cx, cy, radius) = (cx as f32, cy as f32, radius as f32); let rot_rad = (rotation as f32).to_radians(); - self.vertices_2d.push(Vertex2D { position: [cx, cy], uv: [0.0, 0.0], color }); + self.vertices_2d.push(Vertex2D { + position: [cx, cy], + uv: [0.0, 0.0], + color, + }); for i in 0..n { let angle = rot_rad + (i as f32) / (n as f32) * std::f32::consts::TAU; self.vertices_2d.push(Vertex2D { @@ -10797,62 +12726,153 @@ impl Renderer { } for i in 0..n { let next = if i + 1 < n { i + 1 } else { 0 }; - self.indices_2d.extend_from_slice(&[base, base + 1 + i, base + 1 + next]); + self.indices_2d + .extend_from_slice(&[base, base + 1 + i, base + 1 + next]); } } - // ============================================================ // Textured 2D drawing (for text atlas, sprites, etc.) - // ============================================================ pub fn draw_textured_quad( &mut self, - x: f32, y: f32, w: f32, h: f32, - u0: f32, v0: f32, u1: f32, v1: f32, + x: f32, + y: f32, + w: f32, + h: f32, + u0: f32, + v0: f32, + u1: f32, + v1: f32, color: [f32; 4], texture_idx: u32, ) { self.ensure_draw_state(texture_idx); let base = self.vertices_2d.len() as u32; - self.vertices_2d.push(Vertex2D { position: [x, y], uv: [u0, v0], color }); - self.vertices_2d.push(Vertex2D { position: [x + w, y], uv: [u1, v0], color }); - self.vertices_2d.push(Vertex2D { position: [x + w, y + h], uv: [u1, v1], color }); - self.vertices_2d.push(Vertex2D { position: [x, y + h], uv: [u0, v1], color }); - self.indices_2d.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); + self.vertices_2d.push(Vertex2D { + position: [x, y], + uv: [u0, v0], + color, + }); + self.vertices_2d.push(Vertex2D { + position: [x + w, y], + uv: [u1, v0], + color, + }); + self.vertices_2d.push(Vertex2D { + position: [x + w, y + h], + uv: [u1, v1], + color, + }); + self.vertices_2d.push(Vertex2D { + position: [x, y + h], + uv: [u0, v1], + color, + }); + self.indices_2d + .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); } - pub fn draw_texture(&mut self, bind_group_idx: u32, x: f64, y: f64, tint_r: f64, tint_g: f64, tint_b: f64, tint_a: f64) { - let (tw, th) = self.texture_sizes.get(bind_group_idx as usize).copied().unwrap_or((0, 0)); - if tw == 0 { return; } + pub fn draw_texture( + &mut self, + bind_group_idx: u32, + x: f64, + y: f64, + tint_r: f64, + tint_g: f64, + tint_b: f64, + tint_a: f64, + ) { + let (tw, th) = self + .texture_sizes + .get(bind_group_idx as usize) + .copied() + .unwrap_or((0, 0)); + if tw == 0 { + return; + } let color = Self::color_to_f32_srgb(tint_r, tint_g, tint_b, tint_a); - self.draw_textured_quad(x as f32, y as f32, tw as f32, th as f32, 0.0, 0.0, 1.0, 1.0, color, bind_group_idx); + self.draw_textured_quad( + x as f32, + y as f32, + tw as f32, + th as f32, + 0.0, + 0.0, + 1.0, + 1.0, + color, + bind_group_idx, + ); } pub fn draw_texture_rec( - &mut self, bind_group_idx: u32, - src_x: f64, src_y: f64, src_w: f64, src_h: f64, - dst_x: f64, dst_y: f64, - tint_r: f64, tint_g: f64, tint_b: f64, tint_a: f64, + &mut self, + bind_group_idx: u32, + src_x: f64, + src_y: f64, + src_w: f64, + src_h: f64, + dst_x: f64, + dst_y: f64, + tint_r: f64, + tint_g: f64, + tint_b: f64, + tint_a: f64, ) { - let (tw, th) = self.texture_sizes.get(bind_group_idx as usize).copied().unwrap_or((0, 0)); - if tw == 0 { return; } + let (tw, th) = self + .texture_sizes + .get(bind_group_idx as usize) + .copied() + .unwrap_or((0, 0)); + if tw == 0 { + return; + } let color = Self::color_to_f32_srgb(tint_r, tint_g, tint_b, tint_a); let u0 = src_x as f32 / tw as f32; let v0 = src_y as f32 / th as f32; let u1 = (src_x + src_w) as f32 / tw as f32; let v1 = (src_y + src_h) as f32 / th as f32; - self.draw_textured_quad(dst_x as f32, dst_y as f32, src_w as f32, src_h as f32, u0, v0, u1, v1, color, bind_group_idx); + self.draw_textured_quad( + dst_x as f32, + dst_y as f32, + src_w as f32, + src_h as f32, + u0, + v0, + u1, + v1, + color, + bind_group_idx, + ); } pub fn draw_texture_pro( - &mut self, bind_group_idx: u32, - src_x: f64, src_y: f64, src_w: f64, src_h: f64, - dst_x: f64, dst_y: f64, dst_w: f64, dst_h: f64, - origin_x: f64, origin_y: f64, rotation: f64, - tint_r: f64, tint_g: f64, tint_b: f64, tint_a: f64, + &mut self, + bind_group_idx: u32, + src_x: f64, + src_y: f64, + src_w: f64, + src_h: f64, + dst_x: f64, + dst_y: f64, + dst_w: f64, + dst_h: f64, + origin_x: f64, + origin_y: f64, + rotation: f64, + tint_r: f64, + tint_g: f64, + tint_b: f64, + tint_a: f64, ) { - let (tw, th) = self.texture_sizes.get(bind_group_idx as usize).copied().unwrap_or((0, 0)); - if tw == 0 { return; } + let (tw, th) = self + .texture_sizes + .get(bind_group_idx as usize) + .copied() + .unwrap_or((0, 0)); + if tw == 0 { + return; + } let color = Self::color_to_f32_srgb(tint_r, tint_g, tint_b, tint_a); let u0 = src_x as f32 / tw as f32; let v0 = src_y as f32 / th as f32; @@ -10878,18 +12898,31 @@ impl Renderer { for (c, uv) in corners.iter().zip(uvs.iter()) { let rx = c[0] * cos_r - c[1] * sin_r + ox; let ry = c[0] * sin_r + c[1] * cos_r + oy; - self.vertices_2d.push(Vertex2D { position: [rx, ry], uv: *uv, color }); + self.vertices_2d.push(Vertex2D { + position: [rx, ry], + uv: *uv, + color, + }); } - self.indices_2d.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); + self.indices_2d + .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); } - // ============================================================ // Camera 2D - // ============================================================ - pub fn begin_mode_2d(&mut self, offset_x: f32, offset_y: f32, target_x: f32, target_y: f32, rotation: f32, zoom: f32) { + pub fn begin_mode_2d( + &mut self, + offset_x: f32, + offset_y: f32, + target_x: f32, + target_y: f32, + rotation: f32, + zoom: f32, + ) { self.uniform_slot_count += 1; - if self.uniform_slot_count >= MAX_UNIFORM_SLOTS { return; } + if self.uniform_slot_count >= MAX_UNIFORM_SLOTS { + return; + } self.current_uniform_idx = self.uniform_slot_count as u32; let cos_r = rotation.to_radians().cos(); @@ -10898,16 +12931,23 @@ impl Renderer { let ty = target_y; let view_proj: [[f32; 4]; 4] = [ [zoom * cos_r, -zoom * sin_r, 0.0, 0.0], - [zoom * sin_r, zoom * cos_r, 0.0, 0.0], + [zoom * sin_r, zoom * cos_r, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], - [offset_x - zoom * (cos_r * tx + sin_r * ty), - offset_y + zoom * (sin_r * tx - cos_r * ty), - 0.0, 1.0], + [ + offset_x - zoom * (cos_r * tx + sin_r * ty), + offset_y + zoom * (sin_r * tx - cos_r * ty), + 0.0, + 1.0, + ], ]; let w = self.logical_width as f32; let h = self.logical_height as f32; - let uniforms = Uniforms2D { screen_size: [w, h], _pad: [0.0; 2], view_proj }; + let uniforms = Uniforms2D { + screen_size: [w, h], + _pad: [0.0; 2], + view_proj, + }; self.queue.write_buffer( &self.uniform_buffers[self.current_uniform_idx as usize], 0, @@ -10921,16 +12961,21 @@ impl Renderer { self.render_mode = RenderMode::ScreenSpace; } - // ============================================================ // Camera 3D - // ============================================================ pub fn begin_mode_3d( &mut self, - pos_x: f32, pos_y: f32, pos_z: f32, - target_x: f32, target_y: f32, target_z: f32, - up_x: f32, up_y: f32, up_z: f32, - fovy: f32, projection: f32, + pos_x: f32, + pos_y: f32, + pos_z: f32, + target_x: f32, + target_y: f32, + target_z: f32, + up_x: f32, + up_y: f32, + up_z: f32, + fovy: f32, + projection: f32, ) { let aspect = self.surface_config.width as f32 / self.surface_config.height as f32; let mut proj = if projection < 0.5 { @@ -10983,7 +13028,22 @@ impl Renderer { self.current_inv_vp_matrix = mat4_invert(vp); self.current_camera_pos = [pos_x, pos_y, pos_z]; - // EN-022 fix — compose the velocity reference VP: the previous + if self.temporal_camera_cut_pending { + // A discontinuous camera has no meaningful predecessor. Pin every + // camera-space motion owner to this new view; effect histories were + // already invalidated by reset_temporal_history(). + self.prev_vp_matrix = vp; + self.prev_proj_matrix_unjittered = self.current_proj_matrix_unjittered; + self.prev_view_matrix = view; + self.pt_prev_vp = mat4_multiply( + self.current_proj_matrix_unjittered, + self.current_view_matrix, + ); + self.reset_model_motion_history(); + self.temporal_camera_cut_pending = false; + self.temporal_camera_cut_active = true; + } + // EN-022 fix — compose the velocity reference VP: previous // frame's UNJITTERED projection with the CURRENT frame's jitter // re-applied, times the previous view. Every prev_mvp built // from this cancels the jitter term exactly in the shader's @@ -10992,11 +13052,13 @@ impl Renderer { // wobbled TAA history reprojection and cycled fine detail // between sharp and soft — the periodic material-surface // flicker). - let mut prev_proj_j = self.prev_proj_matrix_unjittered; - prev_proj_j[2][0] += self.current_jitter_ndc[0]; - prev_proj_j[2][1] += self.current_jitter_ndc[1]; + let prev_proj_j = temporal_history::velocity_reference_projection( + self.prev_proj_matrix_unjittered, + self.current_jitter_ndc, + ); self.velocity_ref_vp = mat4_multiply(prev_proj_j, self.prev_view_matrix); - self.material_system.set_velocity_reference_vp(self.velocity_ref_vp); + self.material_system + .set_velocity_reference_vp(self.velocity_ref_vp); // Mirror camera pos into lighting uniforms so the scene shader // can compute V for GGX specular. Preserve the .w slot — it @@ -11019,16 +13081,17 @@ impl Renderer { 0.0, ]; self.lighting_uniforms.shadow_view_matrix = self.current_view_matrix; - self.queue.write_buffer( - &self.lighting_buffer, - 0, - bytemuck::bytes_of(&self.lighting_uniforms), - ); self.queue.write_buffer( &self.uniform_buffer_3d, 0, - bytemuck::bytes_of(&Uniforms3D { mvp: vp, model: IDENTITY_MAT4, prev_mvp: self.velocity_ref_vp, model_tint: [1.0, 1.0, 1.0, 1.0], misc: [0.0; 4] }), + bytemuck::bytes_of(&Uniforms3D { + mvp: vp, + model: IDENTITY_MAT4, + prev_mvp: self.velocity_ref_vp, + model_tint: [1.0, 1.0, 1.0, 1.0], + misc: [0.0; 4], + }), ); self.render_mode = RenderMode::Mode3D; } @@ -11037,79 +13100,6 @@ impl Renderer { self.render_mode = RenderMode::ScreenSpace; } - // ============================================================ - // Joint matrices (GPU skinning) - // ============================================================ - - /// Set a single joint matrix for testing (joint_index 0-63, angle in radians around X axis) - pub fn set_joint_test(&mut self, joint_index: usize, angle: f32) { - if joint_index >= 128 { return; } - let c = angle.cos(); - let s = angle.sin(); - // Rotation around X axis, column-major m[col][row] - let mat: [[f32; 4]; 4] = [ - [1.0, 0.0, 0.0, 0.0], // column 0 - [0.0, c, s, 0.0], // column 1 - [0.0, -s, c, 0.0], // column 2 - [0.0, 0.0, 0.0, 1.0], // column 3 - ]; - self.queue.write_buffer(&self.joint_buffer, (joint_index * 64) as u64, bytemuck::cast_slice(&mat)); - } - - pub fn set_joint_matrices(&mut self, matrices: &[[[f32; 4]; 4]]) { - // Unkeyed path (editor/tests): no palette history, so previous - // pose = current pose (zero skeletal velocity). - self.pending_skin_groups_prev.push(matrices.to_vec()); - self.pending_skin_groups.push(matrices.to_vec()); - } - - pub fn set_model_skin_scale(&mut self, scale: f32) { - self.model_skin_scale = scale; - } - - /// `key` pairs this pose with the SAME model's pose last frame - /// (PT-7 motion vectors) — pass the FFI animation handle. The - /// world placement is baked into every joint matrix, so the prev - /// palette carries last frame's position/rotation too: skeletal - /// AND locomotion motion both land in the velocity buffer. - pub fn set_joint_matrices_scaled(&mut self, key: u64, matrices: &[[[f32; 4]; 4]], scale: f32, position: [f32; 3], rot_sin: f32, rot_cos: f32) { - let cos_r = rot_cos; - let sin_r = rot_sin; - let mut scaled = Vec::with_capacity(matrices.len()); - for m in matrices { - let mut sm = *m; - // Scale - for col in 0..4 { - sm[col][0] *= scale; - sm[col][1] *= scale; - sm[col][2] *= scale; - } - // Rotate around Y axis - for col in 0..4 { - let x = sm[col][0]; - let z = sm[col][2]; - sm[col][0] = cos_r * x + sin_r * z; - sm[col][2] = -sin_r * x + cos_r * z; - } - // Translate - sm[3][0] += position[0]; - sm[3][1] += position[1]; - sm[3][2] += position[2]; - scaled.push(sm); - } - - // First sighting of a key (spawn) has no history: previous = - // current, i.e. zero velocity — correct for something that - // just appeared. - let prev = match self.skin_prev_palettes.get(&key) { - Some(p) if p.len() == scaled.len() => p.clone(), - _ => scaled.clone(), - }; - self.pending_skin_groups_prev.push(prev); - self.skin_prev_palettes.insert(key, scaled.clone()); - self.pending_skin_groups.push(scaled); - } - /// Ensure persistent 3D buffers are large enough. Grows with doubling strategy. fn ensure_buffer_capacity_3d(&mut self, vb_bytes: usize, ib_bytes: usize) { if vb_bytes > self.persistent_vb_3d_capacity { @@ -11158,9 +13148,7 @@ impl Renderer { } } - // ============================================================ // Cached model GPU buffers - // ============================================================ /// Check if a model's GPU buffers are cached (or marked uncacheable). pub fn is_model_in_cache(&self, handle_bits: u64) -> bool { @@ -11182,114 +13170,365 @@ impl Renderer { /// stay for the callers; skinned-ness is remembered separately so /// the FFI can route to the skinned cached draw in O(1). #[cfg(feature = "models3d")] - pub fn cache_model_if_static(&mut self, handle_bits: u64, meshes: &[crate::models::MeshData]) -> bool { + pub fn cache_model_if_static( + &mut self, + handle_bits: u64, + meshes: &[crate::models::MeshData], + ) -> bool { if let Some(entry) = self.model_gpu_cache.get(&handle_bits) { return entry.is_some(); } // Check if any vertex is skinned — cached alongside the meshes so // per-frame draws don't rescan every vertex. - let is_skinned = meshes.iter().any(|m| - m.vertices.iter().any(|v| v.weights[0] + v.weights[1] + v.weights[2] + v.weights[3] > 0.01)); + let is_skinned = meshes.iter().any(|m| { + m.vertices + .iter() + .any(|v| v.weights[0] + v.weights[1] + v.weights[2] + v.weights[3] > 0.01) + }); if is_skinned { self.model_skinned.insert(handle_bits); } + if meshes + .iter() + .any(|mesh| mesh.alpha_mode == MaterialAlphaMode::Blend) + { + self.model_blended.insert(handle_bits); + } + if meshes + .iter() + .any(|mesh| mesh.alpha_mode == MaterialAlphaMode::Blend && mesh.layered_pbr.is_active()) + { + self.model_layered_blended.insert(handle_bits); + } + if meshes.iter().any(|mesh| mesh.transmission.is_active()) { + self.model_refractive.insert(handle_bits); + } - let gpu_meshes: Vec = meshes.iter().map(|mesh| { - // Object-space AABB for per-pass frustum culling. Sentinel - // (min > max) when the mesh has no vertices. - let mut local_min = [f32::MAX; 3]; - let mut local_max = [f32::MIN; 3]; - for v in mesh.vertices.iter() { - for a in 0..3 { - if v.position[a] < local_min[a] { local_min[a] = v.position[a]; } - if v.position[a] > local_max[a] { local_max[a] = v.position[a]; } + let gpu_meshes: Vec = meshes + .iter() + .map(|mesh| { + // Object-space AABB for per-pass frustum culling. Sentinel + // (min > max) when the mesh has no vertices. + let mut local_min = [f32::MAX; 3]; + let mut local_max = [f32::MIN; 3]; + for v in mesh.vertices.iter() { + for a in 0..3 { + if v.position[a] < local_min[a] { + local_min[a] = v.position[a]; + } + if v.position[a] > local_max[a] { + local_max[a] = v.position[a]; + } + } } - } - if mesh.vertices.is_empty() { - local_min = [1.0; 3]; - local_max = [-1.0; 3]; - } - // PT-6: skinned VBs are also the compute pre-skin pass's - // INPUT (read as a raw storage array), hence STORAGE. - let vb = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("cached_model_vb"), - contents: bytemuck::cast_slice(&mesh.vertices), - usage: if is_skinned { - wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE + if mesh.vertices.is_empty() { + local_min = [1.0; 3]; + local_max = [-1.0; 3]; + } + // Static geometry enters the shared arena once, eliminating one + // buffer pair per primitive and enabling indexed multi-draw. + // PT-6 skinned meshes retain dedicated STORAGE-capable bind-pose + // buffers because the compute pre-skin pass reads them directly. + let geometry = if is_skinned { + gpu_driven::MeshGeometry::Dedicated { + vertex: self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("cached_skinned_model_vb"), + contents: bytemuck::cast_slice(&mesh.vertices), + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE, + }), + index: self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("cached_skinned_model_ib"), + contents: bytemuck::cast_slice(&mesh.indices), + usage: wgpu::BufferUsages::INDEX, + }), + } } else { - wgpu::BufferUsages::VERTEX - }, - }); - let ib = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("cached_model_ib"), - contents: bytemuck::cast_slice(&mesh.indices), - usage: wgpu::BufferUsages::INDEX, - }); - let base_color_idx = mesh.texture_idx.unwrap_or(0); - let normal_idx = mesh.normal_texture_idx.unwrap_or(0); - let mr_idx = mesh.metallic_roughness_texture_idx.unwrap_or(0); - let em_idx = mesh.emissive_texture_idx.unwrap_or(0); - let occ_idx = mesh.occlusion_texture_idx.unwrap_or(0); - let material_uniform = self.create_scene_material_uniform( - mesh.metallic_factor, - mesh.roughness_factor, - mesh.emissive_factor, - mesh.metallic_roughness_texture_idx.is_some(), - mesh.alpha_cutoff, - ); - let material_bg = self.create_scene_material_bg( - base_color_idx, normal_idx, mr_idx, em_idx, occ_idx, &material_uniform, - ); - // Cutout casters get an alpha-test shadow bind group (base colour + - // sampler + cutoff). wgpu keeps the buffer alive via the bind group, - // but we hold the strong ref too (matches _material_uniform). - let (shadow_cutout_bg, shadow_cutoff_buf) = if mesh.alpha_cutoff > 0.0 { - let cutoff_buf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("shadow_cutout_cutoff"), - contents: bytemuck::cast_slice(&[mesh.alpha_cutoff, 0.0f32, 0.0, 0.0]), - usage: wgpu::BufferUsages::UNIFORM, - }); - let bi = base_color_idx as usize; - let base_tex = if base_color_idx == 0 || bi >= self.textures.len() { - &self.textures[0] + gpu_driven::MeshGeometry::Shared(self.gpu_driven.upload_static( + &self.device, + &self.queue, + &mesh.vertices, + &mesh.indices, + )) + }; + let base_color_idx = mesh.texture_idx.unwrap_or(0); + let normal_idx = mesh.normal_texture_idx.unwrap_or(0); + let mr_idx = mesh.metallic_roughness_texture_idx.unwrap_or(0); + let em_idx = mesh.emissive_texture_idx.unwrap_or(0); + let occ_idx = mesh.occlusion_texture_idx.unwrap_or(0); + let texture_id = |index: u32| { + self.global_texture_ids + .get(index as usize) + .copied() + .unwrap_or(material_indirection::TextureId::FALLBACK) + }; + let mut gpu_material = material_indirection::GpuMaterialRecord::default(); + let shader_alpha = mesh.alpha_mode.shader_alpha_value(mesh.alpha_cutoff); + gpu_material.metal_rough = [ + mesh.metallic_factor, + mesh.roughness_factor, + mesh.metallic_roughness_texture_idx.is_some() as u8 as f32, + shader_alpha, + ]; + gpu_material.emissive = [ + mesh.emissive_factor[0], + mesh.emissive_factor[1], + mesh.emissive_factor[2], + if mesh.alpha_coverage_mips { 1.0 } else { 0.0 }, + ]; + gpu_material.texture_ids_0 = [ + texture_id(base_color_idx).raw(), + texture_id(normal_idx).raw(), + texture_id(mr_idx).raw(), + texture_id(em_idx).raw(), + ]; + gpu_material.texture_ids_1[0] = texture_id(occ_idx).raw(); + gpu_material.sampler_ids_0 = [self.global_linear_sampler_id.raw(); 4]; + gpu_material.sampler_ids_1[0] = self.global_linear_sampler_id.raw(); + let material_id = if (self.imported_refraction_enabled + && mesh.transmission.is_active()) + || mesh.layered_pbr.is_active() + { + material_indirection::MaterialId::FALLBACK } else { - &self.textures[bi] + self.material_system + .indirection + .allocate_material(&self.device, gpu_material) }; - let base_view = base_tex.create_view(&wgpu::TextureViewDescriptor::default()); - let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("shadow_cutout_bg"), - layout: &self.shadow_map.cutout_tex_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&base_view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) }, - wgpu::BindGroupEntry { binding: 2, resource: cutoff_buf.as_entire_binding() }, - ], - }); - (Some(bg), Some(cutoff_buf)) - } else { - (None, None) - }; - GpuMesh { - vb, - ib, - index_count: mesh.indices.len() as u32, - local_min, - local_max, - material_bg, - _material_uniform: material_uniform, - shadow_cutout_bg, - _shadow_cutoff_buf: shadow_cutoff_buf, - // PT-6 — bind-pose geometry retained for the dynamic - // TLAS path (skinned only; a handful of enemy models, - // a few thousand verts each). - cpu_vertices: if is_skinned { Some(mesh.vertices.clone()) } else { None }, - cpu_indices: if is_skinned { Some(mesh.indices.clone()) } else { None }, - base_color_idx: base_color_idx as u32, - metallic_factor: mesh.metallic_factor, - roughness_factor: mesh.roughness_factor, - } - }).collect(); + let mesh_id = self.material_system.indirection.register_mesh( + material_indirection::ResidentMesh { + vertex_count: mesh.vertices.len() as u32, + index_count: mesh.indices.len() as u32, + }, + ); + let (vertex_byte_offset, index_byte_offset) = match &geometry { + gpu_driven::MeshGeometry::Shared(slice) => { + (slice.vertex_offset, slice.index_offset) + } + gpu_driven::MeshGeometry::Dedicated { .. } => (0, 0), + }; + let vertex_buffer_view_id = self.material_system.indirection.register_buffer_view( + material_indirection::ResidentBufferView { + byte_offset: vertex_byte_offset, + byte_size: (mesh.vertices.len() * std::mem::size_of::()) as u64, + }, + ); + let index_buffer_view_id = self.material_system.indirection.register_buffer_view( + material_indirection::ResidentBufferView { + byte_offset: index_byte_offset, + byte_size: (mesh.indices.len() * std::mem::size_of::()) as u64, + }, + ); + let material_uniform = self.create_scene_material_uniform( + mesh.metallic_factor, + mesh.roughness_factor, + mesh.emissive_factor, + mesh.metallic_roughness_texture_idx.is_some(), + shader_alpha, + mesh.alpha_coverage_mips, + ); + let material_bg = self.create_scene_material_bg( + base_color_idx, + normal_idx, + mr_idx, + em_idx, + occ_idx, + &material_uniform, + ); + let valid_secondary_tex_coords = mesh + .secondary_tex_coords + .as_ref() + .filter(|coords| coords.len() == mesh.vertices.len()); + if mesh.secondary_tex_coords.is_some() && valid_secondary_tex_coords.is_none() { + log::warn!( + "bloom materials: cached physical TEXCOORD_1 stream length does not \ + match the mesh vertex count; using scalar physical factors" + ); + } + let combined_refractive = self.create_scene_layered_refractive_material_bg( + base_color_idx, + normal_idx, + mr_idx, + em_idx, + occ_idx, + &material_uniform, + mesh.transmission, + mesh.layered_pbr, + valid_secondary_tex_coords.is_some(), + ); + let ( + refractive_uniform, + refractive_layered_uniform, + refractive_material_bg, + refractive_uses_uv1, + refractive_layered, + ) = if let Some((physical, layered, bind_group, uses_uv1)) = combined_refractive { + ( + Some(physical), + Some(layered), + Some(bind_group), + uses_uv1, + true, + ) + } else { + self.create_scene_refractive_material_bg( + base_color_idx, + normal_idx, + mr_idx, + em_idx, + occ_idx, + &material_uniform, + mesh.transmission, + valid_secondary_tex_coords.is_some(), + ) + .map(|(uniform, bind_group, uses_uv1)| { + (Some(uniform), None, Some(bind_group), uses_uv1, false) + }) + .unwrap_or((None, None, None, false, false)) + }; + let refractive_uv1_buffer = if refractive_uses_uv1 { + valid_secondary_tex_coords.map(|coords| { + self.device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("cached_refractive_uv1_vb"), + contents: bytemuck::cast_slice(coords), + usage: wgpu::BufferUsages::VERTEX, + }) + }) + } else { + None + }; + let (layered_uniform, layered_material_bg, layered_uses_uv1) = if refractive_layered + { + (None, None, false) + } else { + self.create_scene_layered_pbr_material_bg( + base_color_idx, + normal_idx, + mr_idx, + em_idx, + occ_idx, + &material_uniform, + mesh.layered_pbr, + valid_secondary_tex_coords.is_some(), + ) + .map(|(uniform, bind_group, uses_uv1)| { + (Some(uniform), Some(bind_group), uses_uv1) + }) + .unwrap_or((None, None, false)) + }; + let layered_uv1_buffer = if layered_uses_uv1 { + valid_secondary_tex_coords.map(|coords| { + self.device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("cached_layered_pbr_uv1_vb"), + contents: bytemuck::cast_slice(coords), + usage: wgpu::BufferUsages::VERTEX, + }) + }) + } else { + None + }; + // Cutout casters get an alpha-test shadow bind group (base colour + + // sampler + cutoff). wgpu keeps the buffer alive via the bind group, + // but we hold the strong ref too (matches _material_uniform). + let (shadow_cutout_bg, shadow_cutoff_buf) = if mesh.alpha_cutoff > 0.0 { + let cutoff_buf = + self.device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("shadow_cutout_cutoff"), + contents: bytemuck::cast_slice(&[ + mesh.alpha_cutoff, + if mesh.alpha_coverage_mips { 1.0 } else { 0.0 }, + 0.0, + 0.0, + ]), + usage: wgpu::BufferUsages::UNIFORM, + }); + let bi = base_color_idx as usize; + let base_tex = if base_color_idx == 0 || bi >= self.textures.len() { + &self.textures[0] + } else { + &self.textures[bi] + }; + let base_view = base_tex.create_view(&wgpu::TextureViewDescriptor::default()); + let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("shadow_cutout_bg"), + layout: &self.shadow_map.cutout_tex_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&base_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: cutoff_buf.as_entire_binding(), + }, + ], + }); + (Some(bg), Some(cutoff_buf)) + } else { + (None, None) + }; + GpuMesh { + geometry, + index_count: mesh.indices.len() as u32, + local_min, + local_max, + alpha_mode: mesh.alpha_mode, + double_sided: mesh.double_sided, + transmission: mesh.transmission, + refractive_material_bg, + _refractive_uniform: refractive_uniform, + _refractive_layered_uniform: refractive_layered_uniform, + refractive_layered, + refractive_uv1_buffer, + refractive_uses_uv1, + layered_pbr: mesh.layered_pbr, + layered_material_bg, + _layered_uniform: layered_uniform, + layered_uv1_buffer, + layered_uses_uv1, + material_bg, + _material_uniform: material_uniform, + shadow_cutout_bg, + _shadow_cutoff_buf: shadow_cutoff_buf, + // PT-6 — bind-pose geometry retained for the dynamic + // TLAS path (skinned only; a handful of enemy models, + // a few thousand verts each). + cpu_vertices: if is_skinned { + Some(mesh.vertices.clone()) + } else { + None + }, + cpu_indices: if is_skinned { + Some(mesh.indices.clone()) + } else { + None + }, + cpu_secondary_uvs: if is_skinned { + valid_secondary_tex_coords.cloned() + } else { + None + }, + base_color_idx: base_color_idx as u32, + metallic_factor: mesh.metallic_factor, + roughness_factor: mesh.roughness_factor, + material_id, + mesh_id, + vertex_buffer_view_id, + index_buffer_view_id, + } + }) + .collect(); self.model_gpu_cache.insert(handle_bits, Some(gpu_meshes)); true @@ -11307,7 +13546,8 @@ impl Renderer { for i in 0..count { all_data[i] = self.frame_joint_data[i]; } - self.queue.write_buffer(&self.joint_buffer, 0, bytemuck::cast_slice(&all_data)); + self.queue + .write_buffer(&self.joint_buffer, 0, bytemuck::cast_slice(&all_data)); // PT-7 — previous frame's palette, same offsets. let prev_count = self.frame_joint_data_prev.len().min(count); for i in 0..count { @@ -11317,7 +13557,8 @@ impl Renderer { self.frame_joint_data[i] }; } - self.queue.write_buffer(&self.joint_prev_buffer, 0, bytemuck::cast_slice(&all_data)); + self.queue + .write_buffer(&self.joint_prev_buffer, 0, bytemuck::cast_slice(&all_data)); } self.frame_joint_data.clear(); self.frame_joint_data_prev.clear(); @@ -11326,9 +13567,7 @@ impl Renderer { self.pending_skin_groups_prev.clear(); } - // ============================================================ // 3D texture tracking - // ============================================================ fn ensure_draw_state_3d(&mut self, texture_idx: u32) { let needs_new = self.draw_calls_3d.is_empty() @@ -11367,7 +13606,9 @@ impl Renderer { pub(crate) fn scan_unbounded_segments_3d(&mut self) { let num_calls = self.draw_calls_3d.len(); for ci in 0..num_calls { - if self.draw_calls_3d[ci].bounded { continue; } + if self.draw_calls_3d[ci].bounded { + continue; + } let vstart = self.draw_calls_3d[ci].vertex_start as usize; let vend = if ci + 1 < num_calls { self.draw_calls_3d[ci + 1].vertex_start as usize @@ -11379,15 +13620,18 @@ impl Renderer { let mut hash = FNV_OFFSET; let mut has_skinned = false; for v in &self.vertices_3d[vstart.min(vend)..vend] { - let is_skinned = - v.weights[0] + v.weights[1] + v.weights[2] + v.weights[3] > 0.01; + let is_skinned = v.weights[0] + v.weights[1] + v.weights[2] + v.weights[3] > 0.01; if is_skinned { has_skinned = true; continue; } for a in 0..3 { - if v.position[a] < wmin[a] { wmin[a] = v.position[a]; } - if v.position[a] > wmax[a] { wmax[a] = v.position[a]; } + if v.position[a] < wmin[a] { + wmin[a] = v.position[a]; + } + if v.position[a] > wmax[a] { + wmax[a] = v.position[a]; + } } hash = fnv1a_bytes(hash, bytemuck::bytes_of(&v.position)); } @@ -11411,44 +13655,82 @@ impl Renderer { // ============================================================ pub fn set_ambient_light(&mut self, r: f64, g: f64, b: f64, intensity: f64) { - self.lighting_uniforms.ambient = [(r / 255.0) as f32, (g / 255.0) as f32, (b / 255.0) as f32, intensity as f32]; - self.queue.write_buffer(&self.lighting_buffer, 0, bytemuck::bytes_of(&self.lighting_uniforms)); + self.lighting_uniforms.ambient = [ + (r / 255.0) as f32, + (g / 255.0) as f32, + (b / 255.0) as f32, + intensity as f32, + ]; } - pub fn set_directional_light(&mut self, dx: f64, dy: f64, dz: f64, r: f64, g: f64, b: f64, intensity: f64) { + pub fn set_directional_light( + &mut self, + dx: f64, + dy: f64, + dz: f64, + r: f64, + g: f64, + b: f64, + intensity: f64, + ) { // Note: the shadow cache reads `lighting_uniforms.light_dir` // directly at gate time, so no explicit invalidate is needed // here. Doing it via a setter would miss light changes that // happen through other paths (preset reset, etc.) anyway. self.lighting_uniforms.light_dir = [dx as f32, dy as f32, dz as f32, intensity as f32]; - self.lighting_uniforms.light_color = [(r / 255.0) as f32, (g / 255.0) as f32, (b / 255.0) as f32, 0.0]; - self.queue.write_buffer(&self.lighting_buffer, 0, bytemuck::bytes_of(&self.lighting_uniforms)); + self.lighting_uniforms.light_color = [ + (r / 255.0) as f32, + (g / 255.0) as f32, + (b / 255.0) as f32, + 0.0, + ]; } /// Add an additional directional light (up to MAX_DIR_LIGHTS). /// Color is 0-1 range (not 0-255). - pub fn add_directional_light(&mut self, dx: f32, dy: f32, dz: f32, r: f32, g: f32, b: f32, intensity: f32) { + pub fn add_directional_light( + &mut self, + dx: f32, + dy: f32, + dz: f32, + r: f32, + g: f32, + b: f32, + intensity: f32, + ) { let idx = self.lighting_uniforms.dir_light_count[0] as usize; - if idx >= MAX_DIR_LIGHTS { return; } + if idx >= MAX_DIR_LIGHTS { + return; + } self.lighting_uniforms.dir_lights[idx] = DirLight { direction: [dx, dy, dz, intensity], color: [r, g, b, 0.0], }; self.lighting_uniforms.dir_light_count[0] = (idx + 1) as f32; - self.queue.write_buffer(&self.lighting_buffer, 0, bytemuck::bytes_of(&self.lighting_uniforms)); } /// Add a point light (up to MAX_POINT_LIGHTS). /// Color is 0-1 range. - pub fn add_point_light(&mut self, x: f32, y: f32, z: f32, range: f32, r: f32, g: f32, b: f32, intensity: f32) { + pub fn add_point_light( + &mut self, + x: f32, + y: f32, + z: f32, + range: f32, + r: f32, + g: f32, + b: f32, + intensity: f32, + ) { let idx = self.lighting_uniforms.point_light_count[0] as usize; - if idx >= MAX_POINT_LIGHTS { return; } + if idx >= MAX_POINT_LIGHTS { + return; + } self.lighting_uniforms.point_lights[idx] = PointLight { position: [x, y, z, range], color: [r, g, b, intensity], }; self.lighting_uniforms.point_light_count[0] = (idx + 1) as f32; - self.queue.write_buffer(&self.lighting_buffer, 0, bytemuck::bytes_of(&self.lighting_uniforms)); } /// Clear all additional lights (called at begin_frame). @@ -11463,7 +13745,11 @@ impl Renderer { // dir_light_count.z carries SSR's ownership share for EN-021's // IBL-specular complement in fs_main_scene: strength while SSR // runs, 0 when disabled (full IBL specular returns). - let ssr_share = if self.ssr_enabled { self.ssr_strength } else { 0.0 }; + let ssr_share = if self.ssr_enabled { + self.ssr_strength + } else { + 0.0 + }; self.lighting_uniforms.dir_light_count = [0.0, shadows_flag, ssr_share, 0.0]; self.lighting_uniforms.point_light_count = [0.0; 4]; } @@ -11476,9 +13762,11 @@ impl Renderer { let dx = end[0] - start[0]; let dy = end[1] - start[1]; let dz = end[2] - start[2]; - let len = (dx*dx + dy*dy + dz*dz).sqrt(); - if len < 0.0001 { return; } - let (dx, dy, dz) = (dx/len, dy/len, dz/len); + let len = (dx * dx + dy * dy + dz * dz).sqrt(); + if len < 0.0001 { + return; + } + let (dx, dy, dz) = (dx / len, dy / len, dz / len); // Find perpendicular using cross product with best reference axis let (px, py, pz) = if dy.abs() > 0.9 { @@ -11488,68 +13776,218 @@ impl Renderer { // Cross with Y axis: (-dz, 0, dx) (-dz, 0.0, dx) }; - let plen = (px*px + py*py + pz*pz).sqrt(); + let plen = (px * px + py * py + pz * pz).sqrt(); let ht = thickness * 0.5; - let (px, py, pz) = (px/plen * ht, py/plen * ht, pz/plen * ht); - let normal = [px/ht, py/ht, pz/ht]; + let (px, py, pz) = (px / plen * ht, py / plen * ht, pz / plen * ht); + let normal = [px / ht, py / ht, pz / ht]; let base = self.vertices_3d.len() as u32; - self.vertices_3d.push(Vertex3D { position: [start[0]+px, start[1]+py, start[2]+pz], normal, color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.vertices_3d.push(Vertex3D { position: [start[0]-px, start[1]-py, start[2]-pz], normal, color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.vertices_3d.push(Vertex3D { position: [end[0]-px, end[1]-py, end[2]-pz], normal, color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.vertices_3d.push(Vertex3D { position: [end[0]+px, end[1]+py, end[2]+pz], normal, color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.indices_3d.extend_from_slice(&[base, base+1, base+2, base, base+2, base+3]); - } - - pub fn draw_cube(&mut self, x: f64, y: f64, z: f64, w: f64, h: f64, d: f64, r: f64, g: f64, b: f64, a: f64) { + self.vertices_3d.push(Vertex3D { + position: [start[0] + px, start[1] + py, start[2] + pz], + normal, + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.vertices_3d.push(Vertex3D { + position: [start[0] - px, start[1] - py, start[2] - pz], + normal, + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.vertices_3d.push(Vertex3D { + position: [end[0] - px, end[1] - py, end[2] - pz], + normal, + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.vertices_3d.push(Vertex3D { + position: [end[0] + px, end[1] + py, end[2] + pz], + normal, + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.indices_3d + .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); + } + + pub fn draw_cube( + &mut self, + x: f64, + y: f64, + z: f64, + w: f64, + h: f64, + d: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { + let motion_start = self.vertices_3d.len(); self.ensure_draw_state_3d(self.current_texture_3d); let color = Self::color_to_f32(r, g, b, a); let (x, y, z) = (x as f32, y as f32, z as f32); let (hw, hh, hd) = (w as f32 * 0.5, h as f32 * 0.5, d as f32 * 0.5); let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [ - ([0.0, 0.0, -1.0], [[x-hw,y-hh,z-hd],[x+hw,y-hh,z-hd],[x+hw,y+hh,z-hd],[x-hw,y+hh,z-hd]]), // front - ([0.0, 0.0, 1.0], [[x+hw,y-hh,z+hd],[x-hw,y-hh,z+hd],[x-hw,y+hh,z+hd],[x+hw,y+hh,z+hd]]), // back - ([-1.0, 0.0, 0.0], [[x-hw,y-hh,z+hd],[x-hw,y-hh,z-hd],[x-hw,y+hh,z-hd],[x-hw,y+hh,z+hd]]), // left - ([1.0, 0.0, 0.0], [[x+hw,y-hh,z-hd],[x+hw,y-hh,z+hd],[x+hw,y+hh,z+hd],[x+hw,y+hh,z-hd]]), // right - ([0.0, 1.0, 0.0], [[x-hw,y+hh,z-hd],[x+hw,y+hh,z-hd],[x+hw,y+hh,z+hd],[x-hw,y+hh,z+hd]]), // top - ([0.0, -1.0, 0.0], [[x-hw,y-hh,z+hd],[x+hw,y-hh,z+hd],[x+hw,y-hh,z-hd],[x-hw,y-hh,z-hd]]), // bottom + ( + [0.0, 0.0, -1.0], + [ + [x - hw, y - hh, z - hd], + [x + hw, y - hh, z - hd], + [x + hw, y + hh, z - hd], + [x - hw, y + hh, z - hd], + ], + ), // front + ( + [0.0, 0.0, 1.0], + [ + [x + hw, y - hh, z + hd], + [x - hw, y - hh, z + hd], + [x - hw, y + hh, z + hd], + [x + hw, y + hh, z + hd], + ], + ), // back + ( + [-1.0, 0.0, 0.0], + [ + [x - hw, y - hh, z + hd], + [x - hw, y - hh, z - hd], + [x - hw, y + hh, z - hd], + [x - hw, y + hh, z + hd], + ], + ), // left + ( + [1.0, 0.0, 0.0], + [ + [x + hw, y - hh, z - hd], + [x + hw, y - hh, z + hd], + [x + hw, y + hh, z + hd], + [x + hw, y + hh, z - hd], + ], + ), // right + ( + [0.0, 1.0, 0.0], + [ + [x - hw, y + hh, z - hd], + [x + hw, y + hh, z - hd], + [x + hw, y + hh, z + hd], + [x - hw, y + hh, z + hd], + ], + ), // top + ( + [0.0, -1.0, 0.0], + [ + [x - hw, y - hh, z + hd], + [x + hw, y - hh, z + hd], + [x + hw, y - hh, z - hd], + [x - hw, y - hh, z - hd], + ], + ), // bottom ]; for (normal, verts) in &faces { let base = self.vertices_3d.len() as u32; for v in verts { - self.vertices_3d.push(Vertex3D { position: *v, normal: *normal, color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); + self.vertices_3d.push(Vertex3D { + position: *v, + normal: *normal, + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); } // Outward winding (matches the declared normals). The old // order wound every face inward: with back-face culling you // saw each cube's interior — same bug that made draw_plane // invisible from above. - self.indices_3d.extend_from_slice(&[base, base+2, base+1, base, base+3, base+2]); + self.indices_3d.extend_from_slice(&[ + base, + base + 2, + base + 1, + base, + base + 3, + base + 2, + ]); } + self.record_immediate_motion(immediate_motion::PrimitiveKind::Cube, motion_start); } - pub fn draw_cube_wires(&mut self, x: f64, y: f64, z: f64, w: f64, h: f64, d: f64, r: f64, g: f64, b: f64, a: f64) { + pub fn draw_cube_wires( + &mut self, + x: f64, + y: f64, + z: f64, + w: f64, + h: f64, + d: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { + let motion_start = self.vertices_3d.len(); let color = Self::color_to_f32(r, g, b, a); let (x, y, z) = (x as f32, y as f32, z as f32); let (hw, hh, hd) = (w as f32 * 0.5, h as f32 * 0.5, d as f32 * 0.5); let t = 0.02f32; let corners = [ - [x-hw,y-hh,z-hd],[x+hw,y-hh,z-hd],[x+hw,y+hh,z-hd],[x-hw,y+hh,z-hd], - [x-hw,y-hh,z+hd],[x+hw,y-hh,z+hd],[x+hw,y+hh,z+hd],[x-hw,y+hh,z+hd], + [x - hw, y - hh, z - hd], + [x + hw, y - hh, z - hd], + [x + hw, y + hh, z - hd], + [x - hw, y + hh, z - hd], + [x - hw, y - hh, z + hd], + [x + hw, y - hh, z + hd], + [x + hw, y + hh, z + hd], + [x - hw, y + hh, z + hd], ]; let edges = [ - (0,1),(1,2),(2,3),(3,0), // front - (4,5),(5,6),(6,7),(7,4), // back - (0,4),(1,5),(2,6),(3,7), // connecting + (0, 1), + (1, 2), + (2, 3), + (3, 0), // front + (4, 5), + (5, 6), + (6, 7), + (7, 4), // back + (0, 4), + (1, 5), + (2, 6), + (3, 7), // connecting ]; for (a_idx, b_idx) in &edges { self.add_line_3d(corners[*a_idx], corners[*b_idx], color, t); } + self.record_immediate_motion(immediate_motion::PrimitiveKind::CubeWires, motion_start); } - pub fn draw_sphere(&mut self, cx: f64, cy: f64, cz: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) { + pub fn draw_sphere( + &mut self, + cx: f64, + cy: f64, + cz: f64, + radius: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { + let motion_start = self.vertices_3d.len(); self.ensure_draw_state_3d(self.current_texture_3d); let color = Self::color_to_f32(r, g, b, a); let (cx, cy, cz, radius) = (cx as f32, cy as f32, cz as f32, radius as f32); @@ -11567,7 +14005,10 @@ impl Renderer { let nx = theta.sin() * phi.cos(); let ny = theta.cos(); let nz = theta.sin() * phi.sin(); - ([cx + radius * nx, cy + radius * ny, cz + radius * nz], [nx, ny, nz]) + ( + [cx + radius * nx, cy + radius * ny, cz + radius * nz], + [nx, ny, nz], + ) }; let (p00, n00) = p(theta1, phi1); @@ -11576,16 +14017,67 @@ impl Renderer { let (p01, n01) = p(theta1, phi2); let base = self.vertices_3d.len() as u32; - self.vertices_3d.push(Vertex3D { position: p00, normal: n00, color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.vertices_3d.push(Vertex3D { position: p10, normal: n10, color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.vertices_3d.push(Vertex3D { position: p11, normal: n11, color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.vertices_3d.push(Vertex3D { position: p01, normal: n01, color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.indices_3d.extend_from_slice(&[base, base+1, base+2, base, base+2, base+3]); + self.vertices_3d.push(Vertex3D { + position: p00, + normal: n00, + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.vertices_3d.push(Vertex3D { + position: p10, + normal: n10, + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.vertices_3d.push(Vertex3D { + position: p11, + normal: n11, + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.vertices_3d.push(Vertex3D { + position: p01, + normal: n01, + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.indices_3d.extend_from_slice(&[ + base, + base + 1, + base + 2, + base, + base + 2, + base + 3, + ]); } } + self.record_immediate_motion(immediate_motion::PrimitiveKind::Sphere, motion_start); } - pub fn draw_sphere_wires(&mut self, cx: f64, cy: f64, cz: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) { + pub fn draw_sphere_wires( + &mut self, + cx: f64, + cy: f64, + cz: f64, + radius: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { + let motion_start = self.vertices_3d.len(); let color = Self::color_to_f32(r, g, b, a); let (cx, cy, cz, radius) = (cx as f32, cy as f32, cz as f32, radius as f32); let segments = 16u32; @@ -11597,24 +14089,41 @@ impl Renderer { self.add_line_3d( [cx + radius * a1.cos(), cy + radius * a1.sin(), cz], [cx + radius * a2.cos(), cy + radius * a2.sin(), cz], - color, 0.02, + color, + 0.02, ); // XZ ring self.add_line_3d( [cx + radius * a1.cos(), cy, cz + radius * a1.sin()], [cx + radius * a2.cos(), cy, cz + radius * a2.sin()], - color, 0.02, + color, + 0.02, ); // YZ ring self.add_line_3d( [cx, cy + radius * a1.cos(), cz + radius * a1.sin()], [cx, cy + radius * a2.cos(), cz + radius * a2.sin()], - color, 0.02, + color, + 0.02, ); } + self.record_immediate_motion(immediate_motion::PrimitiveKind::SphereWires, motion_start); } - pub fn draw_cylinder(&mut self, x: f64, y: f64, z: f64, radius_top: f64, radius_bottom: f64, height: f64, r: f64, g: f64, b: f64, a: f64) { + pub fn draw_cylinder( + &mut self, + x: f64, + y: f64, + z: f64, + radius_top: f64, + radius_bottom: f64, + height: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { + let motion_start = self.vertices_3d.len(); self.ensure_draw_state_3d(self.current_texture_3d); let color = Self::color_to_f32(r, g, b, a); let (x, y, z) = (x as f32, y as f32, z as f32); @@ -11629,29 +14138,131 @@ impl Renderer { // Side face let base = self.vertices_3d.len() as u32; - self.vertices_3d.push(Vertex3D { position: [x + rb*c1, y, z + rb*s1], normal: [c1, 0.0, s1], color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.vertices_3d.push(Vertex3D { position: [x + rb*c2, y, z + rb*s2], normal: [c2, 0.0, s2], color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.vertices_3d.push(Vertex3D { position: [x + rt*c2, y+h, z + rt*s2], normal: [c2, 0.0, s2], color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.vertices_3d.push(Vertex3D { position: [x + rt*c1, y+h, z + rt*s1], normal: [c1, 0.0, s1], color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.indices_3d.extend_from_slice(&[base, base+1, base+2, base, base+2, base+3]); + self.vertices_3d.push(Vertex3D { + position: [x + rb * c1, y, z + rb * s1], + normal: [c1, 0.0, s1], + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.vertices_3d.push(Vertex3D { + position: [x + rb * c2, y, z + rb * s2], + normal: [c2, 0.0, s2], + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.vertices_3d.push(Vertex3D { + position: [x + rt * c2, y + h, z + rt * s2], + normal: [c2, 0.0, s2], + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.vertices_3d.push(Vertex3D { + position: [x + rt * c1, y + h, z + rt * s1], + normal: [c1, 0.0, s1], + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.indices_3d.extend_from_slice(&[ + base, + base + 1, + base + 2, + base, + base + 2, + base + 3, + ]); // Top cap let base = self.vertices_3d.len() as u32; - self.vertices_3d.push(Vertex3D { position: [x, y+h, z], normal: [0.0, 1.0, 0.0], color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.vertices_3d.push(Vertex3D { position: [x+rt*c1, y+h, z+rt*s1], normal: [0.0, 1.0, 0.0], color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.vertices_3d.push(Vertex3D { position: [x+rt*c2, y+h, z+rt*s2], normal: [0.0, 1.0, 0.0], color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.indices_3d.extend_from_slice(&[base, base+1, base+2]); + self.vertices_3d.push(Vertex3D { + position: [x, y + h, z], + normal: [0.0, 1.0, 0.0], + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.vertices_3d.push(Vertex3D { + position: [x + rt * c1, y + h, z + rt * s1], + normal: [0.0, 1.0, 0.0], + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.vertices_3d.push(Vertex3D { + position: [x + rt * c2, y + h, z + rt * s2], + normal: [0.0, 1.0, 0.0], + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.indices_3d + .extend_from_slice(&[base, base + 1, base + 2]); // Bottom cap let base = self.vertices_3d.len() as u32; - self.vertices_3d.push(Vertex3D { position: [x, y, z], normal: [0.0, -1.0, 0.0], color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.vertices_3d.push(Vertex3D { position: [x+rb*c2, y, z+rb*s2], normal: [0.0, -1.0, 0.0], color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.vertices_3d.push(Vertex3D { position: [x+rb*c1, y, z+rb*s1], normal: [0.0, -1.0, 0.0], color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.indices_3d.extend_from_slice(&[base, base+1, base+2]); + self.vertices_3d.push(Vertex3D { + position: [x, y, z], + normal: [0.0, -1.0, 0.0], + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.vertices_3d.push(Vertex3D { + position: [x + rb * c2, y, z + rb * s2], + normal: [0.0, -1.0, 0.0], + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.vertices_3d.push(Vertex3D { + position: [x + rb * c1, y, z + rb * s1], + normal: [0.0, -1.0, 0.0], + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.indices_3d + .extend_from_slice(&[base, base + 1, base + 2]); } + self.record_immediate_motion(immediate_motion::PrimitiveKind::Cylinder, motion_start); } - pub fn draw_plane(&mut self, cx: f64, cy: f64, cz: f64, w: f64, d: f64, r: f64, g: f64, b: f64, a: f64) { + pub fn draw_plane( + &mut self, + cx: f64, + cy: f64, + cz: f64, + w: f64, + d: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { + let motion_start = self.vertices_3d.len(); self.ensure_draw_state_3d(self.current_texture_3d); let color = Self::color_to_f32(r, g, b, a); let (cx, cy, cz) = (cx as f32, cy as f32, cz as f32); @@ -11659,17 +14270,52 @@ impl Renderer { let normal = [0.0f32, 1.0, 0.0]; let base = self.vertices_3d.len() as u32; - self.vertices_3d.push(Vertex3D { position: [cx-hw, cy, cz-hd], normal, color, uv: [0.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.vertices_3d.push(Vertex3D { position: [cx+hw, cy, cz-hd], normal, color, uv: [1.0, 0.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.vertices_3d.push(Vertex3D { position: [cx+hw, cy, cz+hd], normal, color, uv: [1.0, 1.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); - self.vertices_3d.push(Vertex3D { position: [cx-hw, cy, cz+hd], normal, color, uv: [0.0, 1.0], joints: [0.0; 4], weights: [0.0; 4], tangent: [0.0; 4] }); + self.vertices_3d.push(Vertex3D { + position: [cx - hw, cy, cz - hd], + normal, + color, + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.vertices_3d.push(Vertex3D { + position: [cx + hw, cy, cz - hd], + normal, + color, + uv: [1.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.vertices_3d.push(Vertex3D { + position: [cx + hw, cy, cz + hd], + normal, + color, + uv: [1.0, 1.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + self.vertices_3d.push(Vertex3D { + position: [cx - hw, cy, cz + hd], + normal, + color, + uv: [0.0, 1.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); // Wind so the +Y-normal side is the front face when seen from // above — the previous order back-face-culled the plane from // every camera above it (only visible from underneath). - self.indices_3d.extend_from_slice(&[base, base+2, base+1, base, base+3, base+2]); + self.indices_3d + .extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]); + self.record_immediate_motion(immediate_motion::PrimitiveKind::Plane, motion_start); } pub fn draw_grid(&mut self, slices: i32, spacing: f64) { + let motion_start = self.vertices_3d.len(); let color = [0.5f32, 0.5, 0.5, 1.0]; let spacing = spacing as f32; let half = slices as f32 * spacing / 2.0; @@ -11679,13 +14325,32 @@ impl Renderer { self.add_line_3d([-half, 0.0, pos], [half, 0.0, pos], color, 0.01); self.add_line_3d([pos, 0.0, -half], [pos, 0.0, half], color, 0.01); } + self.record_immediate_motion(immediate_motion::PrimitiveKind::Grid, motion_start); } - pub fn draw_ray(&mut self, origin_x: f64, origin_y: f64, origin_z: f64, dir_x: f64, dir_y: f64, dir_z: f64, r: f64, g: f64, b: f64, a: f64) { + pub fn draw_ray( + &mut self, + origin_x: f64, + origin_y: f64, + origin_z: f64, + dir_x: f64, + dir_y: f64, + dir_z: f64, + r: f64, + g: f64, + b: f64, + a: f64, + ) { + let motion_start = self.vertices_3d.len(); let color = Self::color_to_f32(r, g, b, a); let start = [origin_x as f32, origin_y as f32, origin_z as f32]; - let end = [(origin_x + dir_x) as f32, (origin_y + dir_y) as f32, (origin_z + dir_z) as f32]; + let end = [ + (origin_x + dir_x) as f32, + (origin_y + dir_y) as f32, + (origin_z + dir_z) as f32, + ]; self.add_line_3d(start, end, color, 0.02); + self.record_immediate_motion(immediate_motion::PrimitiveKind::Ray, motion_start); } // ============================================================ @@ -11757,9 +14422,11 @@ impl Renderer { mapped_at_creation: false, }); - let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("screenshot_encoder"), - }); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("screenshot_encoder"), + }); encoder.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { @@ -11776,7 +14443,11 @@ impl Renderer { rows_per_image: Some(height), }, }, - wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, ); self.queue.submit(std::iter::once(encoder.finish())); @@ -11787,7 +14458,10 @@ impl Renderer { buffer_slice.map_async(wgpu::MapMode::Read, move |result| { let _ = tx.send(result); }); - let _ = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + let _ = self.device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); if rx.recv().ok()?.is_err() { return None; @@ -11832,9 +14506,11 @@ impl Renderer { mapped_at_creation: false, }); - let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("shadow_dump_encoder"), - }); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("shadow_dump_encoder"), + }); encoder.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { @@ -11851,15 +14527,24 @@ impl Renderer { rows_per_image: Some(size), }, }, - wgpu::Extent3d { width: size, height: size, depth_or_array_layers: 1 }, + wgpu::Extent3d { + width: size, + height: size, + depth_or_array_layers: 1, + }, ); self.queue.submit(std::iter::once(encoder.finish())); let slice = staging.slice(..); let (tx, rx) = std::sync::mpsc::channel(); - slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); - let _ = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = self.device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); if let Ok(Ok(())) = rx.recv() { let data = slice.get_mapped_range(); @@ -11870,7 +14555,10 @@ impl Renderer { for col in 0..size { let offset = row_start + (col * bytes_per_pixel) as usize; let depth = f32::from_le_bytes([ - data[offset], data[offset+1], data[offset+2], data[offset+3], + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], ]); // depth 0 = near (white), depth 1 = far/clear (black) let gray = ((1.0 - depth.clamp(0.0, 1.0)) * 255.0) as u8; @@ -11889,668 +14577,85 @@ impl Renderer { /// Returns true if vsync is active (Fifo or FifoRelaxed present mode). pub fn vsync_active(&self) -> bool { - matches!(self.surface_config.present_mode, - wgpu::PresentMode::Fifo | wgpu::PresentMode::FifoRelaxed) + matches!( + self.surface_config.present_mode, + wgpu::PresentMode::Fifo | wgpu::PresentMode::FifoRelaxed + ) } pub fn load_custom_shader(&mut self, wgsl_source: &str) -> usize { - let shader_module = self.device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("custom_shader"), - source: wgpu::ShaderSource::Wgsl(wgsl_source.into()), - }); + let shader_module = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("custom_shader"), + source: wgpu::ShaderSource::Wgsl(wgsl_source.into()), + }); // Create layout matching the default 3D pipeline - let bind_group_layout = self.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("custom_shader_bgl"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }], - }); - let pipeline_layout = self.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("custom_pipeline_layout"), - bind_group_layouts: &[Some(&bind_group_layout)], - immediate_size: 0, - }); + let bind_group_layout = + self.device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("custom_shader_bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let pipeline_layout = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("custom_pipeline_layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); - let pipeline = self.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("custom_pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader_module, - entry_point: Some("vs_main_3d"), - buffers: &[Vertex3D::desc()], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader_module, - entry_point: Some("fs_main_3d"), - targets: &[Some(wgpu::ColorTargetState { - format: self.output_format, - blend: Some(wgpu::BlendState::ALPHA_BLENDING), - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - front_face: wgpu::FrontFace::Ccw, - cull_mode: Some(wgpu::Face::Back), - ..Default::default() - }, - depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, - depth_write_enabled: Some(true), - depth_compare: Some(wgpu::CompareFunction::Less), - stencil: wgpu::StencilState::default(), - bias: wgpu::DepthBiasState::default(), - }), - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); + let pipeline = self + .device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("custom_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader_module, + entry_point: Some("vs_main_3d"), + buffers: &[Vertex3D::desc()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader_module, + entry_point: Some("fs_main_3d"), + targets: &[Some(wgpu::ColorTargetState { + format: self.output_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + front_face: wgpu::FrontFace::Ccw, + cull_mode: Some(wgpu::Face::Back), + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); self.custom_pipelines.push(pipeline); + self.created_pipelines(1); self.custom_pipelines.len() // 1-based index } } - -// ============================================================ -// Phase 1c — material system (new shader-ABI draw path) -// ============================================================ -// -// Separate impl block so material_system's public surface on Renderer -// stays co-located and easy to audit. All public methods either -// compile a material, submit a draw, or sync per-frame uniforms. - -impl Renderer { - /// Compile a material from user-supplied WGSL source. Returns a - /// handle to use with `submit_material_draw`. The source may - /// `#include "material_abi.wgsl"` and any `common/*.wgsl` header. - pub fn compile_material( - &mut self, wgsl_source: &str, - ) -> Result { - self.compile_material_with_options( - wgsl_source, - material_pipeline::FragmentProfile::Opaque, - material_pipeline::Bucket::Opaque, - false, - false, - ) - } - - /// Phase 4a — full-control material compile. Games that want a - /// translucent / refractive / additive material (or a non-default - /// bucket) call this directly. Plain `compile_material` is a - /// convenience for Opaque + no scene reads. - /// - /// `wants_instancing` adds a per-instance vertex buffer layout at - /// slot 1 (EN-001). Materials compiled with it must be drawn via - /// `submit_material_draw_instanced` + a buffer from - /// `create_instance_buffer`. - pub fn compile_material_with_options( - &mut self, wgsl_source: &str, - profile: material_pipeline::FragmentProfile, - bucket: material_pipeline::Bucket, - reads_scene: bool, - wants_instancing: bool, - ) -> Result { - self.material_system.compile( - &self.device, - wgsl_source, - profile, - bucket, - reads_scene, - wants_instancing, - formats::HDR_FORMAT, - formats::MATERIAL_FORMAT, - formats::VELOCITY_FORMAT, - wgpu::TextureFormat::Rgba8Unorm, - formats::DEPTH_FORMAT, - ) - } - - /// EN-001 — compile a material that opts into the standard per-instance - /// vertex layout (Opaque profile + Opaque bucket + wants_instancing). - /// Pair with `create_instance_buffer` + `submit_material_draw_instanced`. - /// The game shader's VertexInput must declare the instance attribute - /// locations (see `material_abi.wgsl` for the layout). - pub fn compile_material_instanced( - &mut self, wgsl_source: &str, - ) -> Result { - self.compile_material_instanced_bucket(wgsl_source, 0, false) - } - - /// EN-026/027 — instanced compile into a chosen bucket. - /// - /// The original instanced path was hardcoded to Opaque, which is right for - /// grass and wrong for the two things that most want instancing: particles - /// (additive, thousands of quads) and decals (cutout, alpha-tested against - /// the atlas). `bucket`: 0 = opaque, 1 = cutout, 2 = additive, - /// 3 = transparent. - /// - /// `reads_scene` binds the scene colour/depth snapshot group. Soft - /// particles NEED it — a billboard that intersects the ground shows a hard - /// straight seam otherwise, which is the single biggest tell that a "puff" - /// is a flat card — and without this flag the group is absent from the - /// pipeline layout and the shader fails validation at create time. - pub fn compile_material_instanced_bucket( - &mut self, wgsl_source: &str, bucket: u32, reads_scene: bool, - ) -> Result { - let (profile, bucket) = match bucket { - 1 => (material_pipeline::FragmentProfile::Opaque, material_pipeline::Bucket::Cutout), - 2 => (material_pipeline::FragmentProfile::Translucent, material_pipeline::Bucket::Additive), - 3 => (material_pipeline::FragmentProfile::Translucent, material_pipeline::Bucket::Transparent), - _ => (material_pipeline::FragmentProfile::Opaque, material_pipeline::Bucket::Opaque), - }; - self.material_system.compile( - &self.device, - wgsl_source, - profile, - bucket, - reads_scene, - true, // wants_instancing - formats::HDR_FORMAT, - formats::MATERIAL_FORMAT, - formats::VELOCITY_FORMAT, - wgpu::TextureFormat::Rgba8Unorm, - formats::DEPTH_FORMAT, - ) - } - - /// EN-001 — upload a CPU-side per-instance buffer to GPU memory. - /// `raw` is laid out as 9 floats per instance (pos.xyz, rot_y, - /// scale, tint.rgba). Returns a handle for use with - /// `submit_material_draw_instanced`. - pub fn create_instance_buffer(&mut self, raw: &[f32], count: u32) -> u32 { - self.material_system.create_instance_buffer(&self.device, &self.queue, raw, count) - } - - /// EN-001 — release the GPU memory backing an instance buffer. - /// Safe to call with handle 0 or stale handles (no-op). - pub fn destroy_instance_buffer(&mut self, handle: u32) { - self.material_system.destroy_instance_buffer(handle); - } - - /// EN-017 V2 — append a fullscreen WGSL post-pass to the stack. - /// Compiles the shader, lazily allocates ping-pong LDR - /// intermediates as the stack grows, and pushes onto the stack. - /// Returns the 1-based handle of the newly added pass on success - /// (so callers can treat 0 as "compile failed"), or Err on - /// shader-compile failure; the existing stack is left intact. - /// - /// The fragment shader sees `scene_color_tex` (LDR, post-tonemap) - /// + `scene_depth_tex` at `@group(0)` — see - /// `post_pass::POST_PASS_PRELUDE` for the exact ABI. - /// - /// Stack order matters: the first added pass runs first, the - /// next sees the first's output, and so on. The last pass writes - /// the swapchain. - pub fn add_post_pass( - &mut self, wgsl_source: &str, - ) -> Result { - let pipeline = post_pass::compile_post_pass( - &self.device, wgsl_source, self.output_format, - )?; - - // Slot A is needed the moment we have any post-pass at all - // (composite redirects into A). Allocate lazily so titles - // without a post-pass pay zero memory. - if self.composite_ldr_rt_a.is_none() { - let (t, v) = post_pass::create_composite_ldr_rt( - &self.device, - self.surface_config.width, - self.surface_config.height, - self.output_format, - ); - self.composite_ldr_rt_a = Some(t); - self.composite_ldr_rt_a_view = Some(v); - } - // Slot B is only needed once the stack reaches 2 passes - // (single-pass setups read A and write the swapchain). - // Pushing brings len to >= 2 ⇒ we'll need B next dispatch. - if self.post_passes.len() + 1 >= 2 && self.composite_ldr_rt_b.is_none() { - let (t, v) = post_pass::create_composite_ldr_rt( - &self.device, - self.surface_config.width, - self.surface_config.height, - self.output_format, - ); - self.composite_ldr_rt_b = Some(t); - self.composite_ldr_rt_b_view = Some(v); - } - - self.post_passes.push(pipeline); - // 1-based handle: 0 reserved for "failed" at the FFI layer. - Ok(self.post_passes.len() as u32) - } - - /// EN-017 V2 — wipe the post-pass stack. The composite output - /// goes directly to the swapchain again (zero post-pass cost). - /// LDR intermediates stay allocated (cheap to keep around — at - /// most two full-screen RGBA8 — and avoids re-alloc if the game - /// toggles the stack frequently). - pub fn clear_all_post_passes(&mut self) { - self.post_passes.clear(); - } - - /// EN-017 V1 backward-compat — replace the entire stack with a - /// single post-pass. Equivalent to `clear_all_post_passes()` + - /// `add_post_pass(wgsl)` but ignores the returned handle so old - /// callers keep their `Result<(), _>` ABI. - pub fn set_post_pass( - &mut self, wgsl_source: &str, - ) -> Result<(), post_pass::PostPassCompileError> { - self.clear_all_post_passes(); - self.add_post_pass(wgsl_source)?; - Ok(()) - } - - /// EN-017 V1 backward-compat — equivalent to - /// `clear_all_post_passes()`. Kept so existing FFI symbols on - /// every platform continue to compile against the renderer. - pub fn clear_post_pass(&mut self) { - self.clear_all_post_passes(); - } - - /// Phase 6 — compile a material from a WGSL file on disk and - /// register the path with the hot-reload watcher. Subsequent - /// edits to that file fire a recompile in the next - /// `poll_material_hot_reload` call (drained from `end_frame`). - pub fn compile_material_from_file( - &mut self, - path: &std::path::Path, - profile: material_pipeline::FragmentProfile, - bucket: material_pipeline::Bucket, - reads_scene: bool, - ) -> Result { - let canonical = std::fs::canonicalize(path) - .map_err(|e| format!("canonicalize {path:?}: {e}"))?; - let source = std::fs::read_to_string(&canonical) - .map_err(|e| format!("read {canonical:?}: {e}"))?; - let handle = self.compile_material_with_options(&source, profile, bucket, reads_scene, false) - .map_err(|e| format!("compile {canonical:?}: {e:?}"))?; - self.material_hot_reload.register( - handle, - hot_reload::FileMaterialDesc { - path: canonical, profile, bucket, reads_scene, - wants_instancing: false, - }, - ); - Ok(handle) - } - - /// Drain pending hot-reload events and rebuild affected pipelines. - /// Logs failures and keeps the previous pipeline in place — never - /// kills the running game. - pub fn poll_material_hot_reload(&mut self) { - let pending = self.material_hot_reload.drain_pending(); - for (handle, desc) in pending { - let source = match std::fs::read_to_string(&desc.path) { - Ok(s) => s, - Err(e) => { - eprintln!("[hot_reload] read {:?} failed: {e}", desc.path); - continue; - } - }; - // Compile fresh; on success, replace the slot. On failure, - // log and keep the old pipeline running. - match self.material_system.compile( - &self.device, &source, - desc.profile, desc.bucket, desc.reads_scene, - desc.wants_instancing, - formats::HDR_FORMAT, - formats::MATERIAL_FORMAT, - formats::VELOCITY_FORMAT, - wgpu::TextureFormat::Rgba8Unorm, - formats::DEPTH_FORMAT, - ) { - Ok(new_handle) => { - // material_system.compile pushes a NEW slot. - // Move the new pipeline into the original slot - // and clear the trailing one so handles don't - // leak. - let new_idx = (new_handle - 1) as usize; - let old_idx = (handle - 1) as usize; - if let Some(p) = self.material_system.pipelines.get_mut(new_idx).and_then(|s| s.take()) { - if let Some(slot) = self.material_system.pipelines.get_mut(old_idx) { - *slot = Some(p); - } - } - eprintln!("[hot_reload] reloaded {:?} (handle {handle})", desc.path); - } - Err(e) => { - eprintln!("[hot_reload] compile {:?} failed: {e:?} — keeping previous", desc.path); - } - } - } - } - - /// Submit a material draw against a cached mesh. `mesh_handle` is - /// the value returned by `cache_model_if_static` (same as the - /// scene pipeline's cached-model path). - pub fn submit_material_draw( - &mut self, - material: material_system::MaterialHandle, - mesh_handle: u64, - mesh_idx: usize, - position: [f32; 3], - scale: f32, - tint: [f32; 4], - ) { - let model = mat4_multiply( - mat4_translate(IDENTITY_MAT4, position), - mat4_scale(IDENTITY_MAT4, [scale, scale, scale]), - ); - let mvp = mat4_multiply(self.current_vp_matrix, model); - self.material_system.submit_draw( - &self.device, &self.queue, &self.joint_buffer, - material, mesh_handle, mesh_idx, - mvp, model, mvp, tint, [0, 0, 0, 0], - ); - } - - /// EN-001 — submit an instanced material draw. The mesh is drawn - /// `instance_count` times via a single `draw_indexed` with the - /// instance buffer bound at vertex slot 1. Per-draw model/MVP are - /// identity / current VP — per-instance pos/rot_y/scale dominate - /// from the buffer; the per-draw `tint` is multiplied against the - /// per-instance tint in the shader. - pub fn submit_material_draw_instanced( - &mut self, - material: material_system::MaterialHandle, - mesh_handle: u64, - mesh_idx: usize, - instance_buffer: u32, - instance_count: u32, - ) { - let model = IDENTITY_MAT4; - let mvp = self.current_vp_matrix; - self.material_system.submit_draw_instanced( - &self.device, &self.queue, &self.joint_buffer, - material, mesh_handle, mesh_idx, - instance_buffer, instance_count, - mvp, model, mvp, [1.0, 1.0, 1.0, 1.0], [0, 0, 0, 0], - ); - } - - // ============================================================ - // EN-011 — planar reflection probes - // ============================================================ - - /// Create a planar reflection probe and return its 1-based handle - /// (0 on failure). The probe owns a square HDR colour RT + depth - /// at `resolution²`; pass `0` for `resolution` to default to half - /// the swapchain width (matches the V1 ticket spec). The engine - /// renders the world (minus excluded materials) into the probe's - /// RT every frame in `dispatch_planar_reflections`. - /// - /// Materials opt into sampling a probe via - /// `set_material_reflection_probe(material, probe)`. - pub fn create_planar_reflection( - &mut self, - plane_y: f32, - normal: [f32; 3], - resolution: u32, - ) -> u32 { - let res = if resolution == 0 { - (self.surface_config.width / 2).max(16) - } else { - resolution - }; - let probe = planar_reflection::PlanarReflectionProbe::new( - &self.device, plane_y, normal, res, - ); - - // Allocate the per-probe PerView UBO. The matching bind group - // is built EACH FRAME inside `dispatch_planar_reflections` - // (EN-011 V2) so it picks up the live env / BRDF / shadow - // views from the renderer — bound stubs in V1 left mirrored - // draws without IBL or sun shadows. We still keep - // `planar_probe_view_bgs` allocated as `None` to preserve - // index-parity with `planar_probes`, but never store anything - // into the slot. - let view_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("planar_probe_per_view"), - size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - - self.planar_probes.push(Some(probe)); - self.planar_probe_view_buffers.push(Some(view_buffer)); - self.planar_probe_view_bgs.push(None); - self.planar_probes.len() as u32 - } - - /// Link a material handle to a planar reflection probe. Subsequent - /// draws of `material` see the probe's colour RT at - /// `@group(2) @binding(12)`. Pass `probe = 0` to revert to the - /// default 1×1 black texture. - /// - /// No-op when either handle is invalid. - pub fn set_material_reflection_probe(&mut self, material: u32, probe: u32) { - if material == 0 { return; } - if probe == 0 { - // Unlink — rebind binding 12 to the default black view by - // routing through the same helper with our own default. - let view = self.material_system.default_black_view.clone(); - // Cloning a wgpu TextureView is a cheap Arc bump. - if let Err(e) = self.material_system.set_reflection_probe( - &self.device, material, 0, &view, - ) { - eprintln!("[planar_reflection] unlink failed for material {material}: {e}"); - } - return; - } - let idx = probe as usize - 1; - let probe_view = match self.planar_probes.get(idx).and_then(|p| p.as_ref()) { - Some(p) => p.color_view.clone(), - None => { - eprintln!("[planar_reflection] unknown probe handle {probe}"); - return; - } - }; - if let Err(e) = self.material_system.set_reflection_probe( - &self.device, material, probe, &probe_view, - ) { - eprintln!("[planar_reflection] set failed for material {material}: {e}"); - } - } - - // ============================================================ - // EN-014 — texture-array slots for splat-mapped terrain - // ============================================================ - - /// Create a texture array from a slice of layer source data - /// (`(rgba8 bytes, width, height)` per layer). All layers must - /// share the same `width × height`. V1 caps the layer count at - /// `MAX_TEXTURE_ARRAY_LAYERS` (16). Returns a 1-based handle, or - /// 0 on failure (empty / mismatched extents / zero-sized layer). - pub fn create_texture_array(&mut self, layers: &[(&[u8], u32, u32)]) -> u32 { - self.material_system.create_texture_array(&self.device, &self.queue, layers) - } - - /// EN-014 V2 — create a texture array with explicit format + mip - /// control. See `MaterialSystem::create_texture_array_ex` for the - /// format / mip_levels semantics. The V1 `create_texture_array` - /// remains as a thin wrapper that forwards `(format=0, mip_levels=1)`. - pub fn create_texture_array_ex( - &mut self, - layers: &[(&[u8], u32, u32)], - format: u32, - mip_levels: u32, - ) -> u32 { - self.material_system.create_texture_array_ex( - &self.device, &self.queue, layers, format, mip_levels, - ) - } - - /// Link a texture array to a material at one of three slots: - /// - 0 = albedo (binding 14) - /// - 1 = normal (binding 15) - /// - 2 = MR (binding 16) - /// Pass `array = 0` to revert the slot to the engine's 1×1×1 - /// stub. No-op for unknown handles or out-of-range slots. - /// - /// Resolves the material's currently-linked planar reflection - /// probe (if any) so binding 12 stays pointing at the probe's - /// RT across the BG rebuild — otherwise an EN-014 rebind would - /// silently drop an EN-011 reflection link. - pub fn set_material_texture_array(&mut self, material: u32, slot: u32, array: u32) { - let probe_view = match self.material_system.material_reflection_probe_handle(material) { - Some(probe) if probe != 0 => { - let idx = probe as usize - 1; - self.planar_probes.get(idx) - .and_then(|p| p.as_ref()) - .map(|p| p.color_view.clone()) - .unwrap_or_else(|| self.material_system.default_black_view.clone()) - } - _ => self.material_system.default_black_view.clone(), - }; - self.material_system.set_material_texture_array( - &self.device, material, slot, array, &probe_view, - ); - } - - // ============================================================ - // EN-012 — Foliage shading model - // ============================================================ - - /// EN-012 — set the shading model for `material`. Pass `0` for - /// default lit (standard PBR), `1` for foliage (wrap-lambert + - /// transmission), or `2` for subsurface (V2 stub — currently - /// behaves as default lit; the shader wouldn't branch on it - /// until V2 ships). Lazily allocates a per-material - /// `MaterialFactors` UBO and rebuilds the per-material BG so - /// subsequent draws see the new shading model. - /// - /// Resolves the material's currently-linked planar-reflection - /// probe (if any) so binding 12 stays pointing at the probe's - /// RT across the BG rebuild — same precedent as - /// `set_material_texture_array`. - pub fn set_material_shading_model(&mut self, material: u32, model: u32) { - let probe_view = self.resolve_probe_view_for_material(material); - if let Err(e) = self.material_system.set_material_shading_model( - &self.device, &self.queue, material, model, &probe_view, - ) { - eprintln!("[foliage] set_material_shading_model failed: {e}"); - } - } - - /// EN-012 — set the foliage shading parameters for `material`. - /// Only takes effect when `shading_model == 1` (foliage). - /// `trans_color` is the rgb tint for back-lit foliage, - /// `trans_amount` is 0..1 (how much sun bleeds through), and - /// `wrap_factor` is 0..1 (wrap-lambert intensity). See the - /// `shade_foliage` helper in `common/pbr.wgsl`. - pub fn set_material_foliage( - &mut self, - material: u32, - trans_color: [f32; 3], - trans_amount: f32, - wrap_factor: f32, - ) { - let probe_view = self.resolve_probe_view_for_material(material); - if let Err(e) = self.material_system.set_material_foliage( - &self.device, &self.queue, material, - trans_color, trans_amount, wrap_factor, &probe_view, - ) { - eprintln!("[foliage] set_material_foliage failed: {e}"); - } - } - - /// EN-012 — shared helper: resolve the planar-reflection probe - /// view for the material's currently-linked probe (or the - /// default 1×1 black view when none is linked). Used by - /// `set_material_shading_model` / `set_material_foliage` so an - /// EN-012 BG rebuild doesn't drop an EN-011 link. Mirrors the - /// pattern in `set_material_texture_array`. - fn resolve_probe_view_for_material(&self, material: u32) -> wgpu::TextureView { - match self.material_system.material_reflection_probe_handle(material) { - Some(probe) if probe != 0 => { - let idx = probe as usize - 1; - self.planar_probes.get(idx) - .and_then(|p| p.as_ref()) - .map(|p| p.color_view.clone()) - .unwrap_or_else(|| self.material_system.default_black_view.clone()) - } - _ => self.material_system.default_black_view.clone(), - } - } - - /// Authoring control: whether a material's draws render into - /// planar-reflection probes (default true). Use for content that - /// is sub-pixel at probe resolution (e.g. instanced grass). - pub fn set_material_probe_visible(&mut self, material: u32, visible: bool) { - self.material_system.set_probe_visible(material, visible); - } - - /// Sync PerFrame + PerView uniforms from current renderer state. - /// FFI callers drive this from their frame boundary so `PerFrame.time` - /// reflects the real process-uptime clock. - pub fn material_system_begin_frame(&mut self, time_seconds: f32, delta_time: f32) { - // Feed wind + time to the built-in scene shader (foliage sway). Set - // here each frame so it's current before the per-frame lighting upload. - self.lighting_uniforms.wind = - [self.wind[0], self.wind[1], self.wind[2], time_seconds]; - self.lighting_uniforms.cloud = self.cloud_params; - self.lighting_uniforms.frame_misc = [delta_time, 0.0, 0.0, 0.0]; - let screen_w = self.surface_config.width as f32; - let screen_h = self.surface_config.height as f32; - let (rw, rh) = self.render_extent(); - let per_frame = material_system::PerFrameUniforms { - time: time_seconds, - delta_time, - frame_index: self.taa_frame_index as u32, - _pad0: 0, - screen_resolution: [screen_w, screen_h], - render_resolution: [rw as f32, rh as f32], - taa_jitter: [0.0, 0.0], - _pad1: [0.0, 0.0], - wind: self.wind, - cloud: self.cloud_params, - }; - let per_view = material_system::PerViewUniforms { - view: self.current_view_matrix, - proj: self.current_proj_matrix, - view_proj: self.current_vp_matrix, - // EN-022 fix: velocity reference — see the main PerView - // build above. - prev_view_proj: self.velocity_ref_vp, - inv_proj: self.current_inv_proj_matrix, - camera_pos: [ - self.current_camera_pos[0], - self.current_camera_pos[1], - self.current_camera_pos[2], - self.lighting_uniforms.camera_pos[3], - ], - camera_dir: [0.0, 0.0, -1.0, 70.0_f32.to_radians()], - ambient: self.lighting_uniforms.ambient, - fog: [self.fog_color[0], self.fog_color[1], self.fog_color[2], self.fog_density], - sun_dir: self.lighting_uniforms.light_dir, - sun_color: self.lighting_uniforms.light_color, - dir_light_count: self.lighting_uniforms.dir_light_count, - dir_lights: std::array::from_fn(|i| material_system::PerViewDirLight { - direction: self.lighting_uniforms.dir_lights[i].direction, - color: self.lighting_uniforms.dir_lights[i].color, - }), - point_light_count: self.lighting_uniforms.point_light_count, - point_lights: std::array::from_fn(|i| material_system::PerViewPointLight { - position: self.lighting_uniforms.point_lights[i].position, - color: self.lighting_uniforms.point_lights[i].color, - }), - shadow_splits: self.lighting_uniforms.shadow_cascade_splits, - shadow_view: self.lighting_uniforms.shadow_view_matrix, - shadow_cascades: self.lighting_uniforms.shadow_cascade_vps, - }; - self.material_system.update_frame_uniforms(&self.queue, &per_frame, &per_view); - } -} - diff --git a/native/shared/src/renderer/model_draw.rs b/native/shared/src/renderer/model_draw.rs index 45b71e2e..37ef0654 100644 --- a/native/shared/src/renderer/model_draw.rs +++ b/native/shared/src/renderer/model_draw.rs @@ -5,15 +5,227 @@ use super::*; +/// Previous transform for one ordinary cached-model instance. Calls are paired +/// by stable submission slot, with the handle preventing a newly spawned +/// different model from inheriting the departed instance's velocity. +#[derive(Clone, Copy)] +struct CachedModelMotionEntry { + handle_bits: u64, + model: [[f32; 4]; 4], +} + +#[derive(Default)] +pub(super) struct CachedModelMotionHistory { + // CPU metadata only: feeds the existing prev_mvp uniform and adds no GPU + // allocation or pass. Skinned draws own keyed palette history instead. + previous: Vec, + current: Vec, + slot: usize, +} + impl Renderer { + pub(super) fn begin_skin_motion_frame(&mut self) { + self.skin_motion_epoch = self.skin_motion_epoch.wrapping_add(1); + std::mem::swap( + &mut self.skin_unkeyed_previous, + &mut self.skin_unkeyed_current, + ); + self.skin_unkeyed_previous_count = self.skin_unkeyed_slot; + self.skin_unkeyed_slot = 0; + } + + pub(super) fn begin_cached_model_frame(&mut self) { + self.model_draw_commands.clear(); + std::mem::swap( + &mut self.cached_model_motion.previous, + &mut self.cached_model_motion.current, + ); + self.cached_model_motion.current.clear(); + self.cached_model_motion.slot = 0; + } + + pub(super) fn reset_model_motion_history(&mut self) { + self.material_system.reset_motion_history(); + self.cached_model_motion.previous.clear(); + self.skin_unkeyed_previous.clear(); + self.skin_unkeyed_current.clear(); + self.skin_unkeyed_previous_count = 0; + self.skin_unkeyed_slot = 0; + self.skin_prev_palettes.clear(); + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn cached_model_motion_stats(&self) -> (usize, usize) { + let history = &self.cached_model_motion; + let bytes = (history.previous.capacity() + history.current.capacity()) + * std::mem::size_of::(); + (history.current.len(), bytes) + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn unkeyed_skin_motion_stats(&self) -> (usize, usize) { + let palettes = [&self.skin_unkeyed_previous, &self.skin_unkeyed_current]; + let outer = palettes + .iter() + .map(|values| { + values + .capacity() + .saturating_mul(std::mem::size_of::>()) + }) + .sum::(); + let inner = palettes + .iter() + .flat_map(|values| values.iter()) + .map(|palette| { + palette + .capacity() + .saturating_mul(std::mem::size_of::<[[f32; 4]; 4]>()) + }) + .sum::(); + (self.skin_unkeyed_slot, outer.saturating_add(inner)) + } + + /// Set a single joint matrix for testing (joint_index 0-127). + pub fn set_joint_test(&mut self, joint_index: usize, angle: f32) { + if joint_index >= 128 { + return; + } + let c = angle.cos(); + let s = angle.sin(); + let mat: [[f32; 4]; 4] = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, c, s, 0.0], + [0.0, -s, c, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + self.queue.write_buffer( + &self.joint_buffer, + (joint_index * 64) as u64, + bytemuck::cast_slice(&mat), + ); + } + + /// Stage a skin pose using stable submission order as its identity. + pub fn set_joint_matrices(&mut self, matrices: &[[[f32; 4]; 4]]) { + let slot = self.skin_unkeyed_slot; + self.skin_unkeyed_slot += 1; + let previous = self + .skin_unkeyed_previous + .get(slot) + .filter(|palette| { + slot < self.skin_unkeyed_previous_count && palette.len() == matrices.len() + }) + .cloned() + .unwrap_or_else(|| matrices.to_vec()); + if let Some(current) = self.skin_unkeyed_current.get_mut(slot) { + current.clear(); + current.extend_from_slice(matrices); + } else { + self.skin_unkeyed_current.push(matrices.to_vec()); + } + self.pending_skin_groups_prev.push(previous); + self.pending_skin_groups.push(matrices.to_vec()); + } + + pub fn set_model_skin_scale(&mut self, scale: f32) { + self.model_skin_scale = scale; + } + + /// `key` pairs this pose with the same model's pose in the immediately + /// preceding frame. World placement is baked into each matrix, so the + /// previous palette captures both skeletal deformation and locomotion. + pub fn set_joint_matrices_scaled( + &mut self, + key: u64, + matrices: &[[[f32; 4]; 4]], + scale: f32, + position: [f32; 3], + rot_sin: f32, + rot_cos: f32, + ) { + let mut scaled = Vec::with_capacity(matrices.len()); + for matrix in matrices { + let mut transformed = *matrix; + for col in 0..4 { + transformed[col][0] *= scale; + transformed[col][1] *= scale; + transformed[col][2] *= scale; + } + for col in 0..4 { + let x = transformed[col][0]; + let z = transformed[col][2]; + transformed[col][0] = rot_cos * x + rot_sin * z; + transformed[col][2] = -rot_sin * x + rot_cos * z; + } + transformed[3][0] += position[0]; + transformed[3][1] += position[1]; + transformed[3][2] += position[2]; + scaled.push(transformed); + } + + let previous = match self.skin_prev_palettes.get(&key) { + Some((epoch, palette)) + if epoch.wrapping_add(1) == self.skin_motion_epoch + && palette.len() == scaled.len() => + { + palette.clone() + } + _ => scaled.clone(), + }; + self.pending_skin_groups_prev.push(previous); + self.skin_prev_palettes + .insert(key, (self.skin_motion_epoch, scaled.clone())); + self.pending_skin_groups.push(scaled); + } + + /// Pair an ordinary cached instance with the same submission slot from the + /// prior frame. A missing/mismatched slot is a spawn and therefore seeds + /// with the current transform (zero object velocity). + fn track_cached_model_motion( + &mut self, + handle_bits: u64, + model: [[f32; 4]; 4], + ) -> [[f32; 4]; 4] { + let slot = self.cached_model_motion.slot; + self.cached_model_motion.slot += 1; + let previous = self + .cached_model_motion + .previous + .get(slot) + .filter(|entry| entry.handle_bits == handle_bits) + .map(|entry| entry.model) + .unwrap_or(model); + self.cached_model_motion + .current + .push(CachedModelMotionEntry { handle_bits, model }); + previous + } + /// Record a cached model draw command. The actual rendering happens in end_frame(). - pub fn draw_model_cached(&mut self, handle_bits: u64, position: [f32; 3], scale: f32, tint: [f32; 4]) { + pub fn draw_model_cached( + &mut self, + handle_bits: u64, + position: [f32; 3], + scale: f32, + tint: [f32; 4], + ) { + self.has_blend_model_draws |= self.model_blended.contains(&handle_bits); + self.has_layered_blend_model_draws |= self.model_layered_blended.contains(&handle_bits); + self.has_refractive_model_draws |= + self.imported_refraction_enabled && self.model_refractive.contains(&handle_bits); let mesh_count = match self.model_gpu_cache.get(&handle_bits) { Some(Some(meshes)) => meshes.len(), _ => return, }; // Foliage wind amount for this model (0 = not a plant). Rides in misc.z. let foliage = self.foliage_wind.get(&handle_bits).copied().unwrap_or(0.0); + let model_matrix = mat4_multiply( + mat4_translate(IDENTITY_MAT4, position), + mat4_scale(IDENTITY_MAT4, [scale, scale, scale]), + ); + let model_mvp = mat4_multiply(self.current_vp_matrix, model_matrix); + let previous_model = self.track_cached_model_motion(handle_bits, model_matrix); + let previous_mvp = mat4_multiply(self.velocity_ref_vp, previous_model); for mesh_idx in 0..mesh_count { let slot = self.next_model_uniform_slot; @@ -22,25 +234,24 @@ impl Renderer { // Grow uniform pool if needed self.ensure_model_uniform_slot(slot); - // Compute model MVP: VP * translate(position) * scale(s) - let model_matrix = mat4_multiply( - mat4_translate(IDENTITY_MAT4, position), - mat4_scale(IDENTITY_MAT4, [scale, scale, scale]), - ); - let model_mvp = mat4_multiply(self.current_vp_matrix, model_matrix); - // Stage uniform for this draw (flushed in one write at end-frame) - self.stage_model_uniform(slot, &Uniforms3D { - mvp: model_mvp, model: model_matrix, - prev_mvp: model_mvp, model_tint: tint, - misc: [0.0, 0.0, foliage, 0.0], - }); + self.stage_model_uniform( + slot, + &Uniforms3D { + mvp: model_mvp, + model: model_matrix, + prev_mvp: previous_mvp, + model_tint: tint, + misc: [0.0, 0.0, foliage, 0.0], + }, + ); self.model_draw_commands.push(CachedModelDraw { uniform_slot: slot, cache_handle: handle_bits, mesh_idx, model: model_matrix, + tint, skinned: false, joint_offset: 0.0, bounds_override: None, @@ -64,6 +275,10 @@ impl Renderer { rot_y: f32, tint: [f32; 4], ) { + self.has_blend_model_draws |= self.model_blended.contains(&handle_bits); + self.has_layered_blend_model_draws |= self.model_layered_blended.contains(&handle_bits); + self.has_refractive_model_draws |= + self.imported_refraction_enabled && self.model_refractive.contains(&handle_bits); let mesh_count = match self.model_gpu_cache.get(&handle_bits) { Some(Some(meshes)) => meshes.len(), _ => return, @@ -82,24 +297,32 @@ impl Renderer { mat4_translate(IDENTITY_MAT4, position), mat4_multiply(rot, mat4_scale(IDENTITY_MAT4, [scale, scale, scale])), ); + let previous_model = self.track_cached_model_motion(handle_bits, model_matrix); + let model_mvp = mat4_multiply(self.current_vp_matrix, model_matrix); + let previous_mvp = mat4_multiply(self.velocity_ref_vp, previous_model); for mesh_idx in 0..mesh_count { let slot = self.next_model_uniform_slot; self.next_model_uniform_slot += 1; self.ensure_model_uniform_slot(slot); - let model_mvp = mat4_multiply(self.current_vp_matrix, model_matrix); - self.stage_model_uniform(slot, &Uniforms3D { - mvp: model_mvp, model: model_matrix, - prev_mvp: model_mvp, model_tint: tint, - misc: [0.0, 0.0, foliage, 0.0], - }); + self.stage_model_uniform( + slot, + &Uniforms3D { + mvp: model_mvp, + model: model_matrix, + prev_mvp: previous_mvp, + model_tint: tint, + misc: [0.0, 0.0, foliage, 0.0], + }, + ); self.model_draw_commands.push(CachedModelDraw { uniform_slot: slot, cache_handle: handle_bits, mesh_idx, model: model_matrix, + tint, skinned: false, joint_offset: 0.0, bounds_override: None, @@ -121,30 +344,42 @@ impl Renderer { model_matrix: [[f32; 4]; 4], tint: [f32; 4], ) { + self.has_blend_model_draws |= self.model_blended.contains(&handle_bits); + self.has_layered_blend_model_draws |= self.model_layered_blended.contains(&handle_bits); + self.has_refractive_model_draws |= + self.imported_refraction_enabled && self.model_refractive.contains(&handle_bits); let mesh_count = match self.model_gpu_cache.get(&handle_bits) { Some(Some(meshes)) => meshes.len(), _ => return, }; let foliage = self.foliage_wind.get(&handle_bits).copied().unwrap_or(0.0); + let previous_model = self.track_cached_model_motion(handle_bits, model_matrix); + let model_mvp = mat4_multiply(self.current_vp_matrix, model_matrix); + let previous_mvp = mat4_multiply(self.velocity_ref_vp, previous_model); for mesh_idx in 0..mesh_count { let slot = self.next_model_uniform_slot; self.next_model_uniform_slot += 1; self.ensure_model_uniform_slot(slot); - let model_mvp = mat4_multiply(self.current_vp_matrix, model_matrix); - self.stage_model_uniform(slot, &Uniforms3D { - mvp: model_mvp, model: model_matrix, - prev_mvp: model_mvp, model_tint: tint, - misc: [0.0, 0.0, foliage, 0.0], - }); + self.stage_model_uniform( + slot, + &Uniforms3D { + mvp: model_mvp, + model: model_matrix, + prev_mvp: previous_mvp, + model_tint: tint, + misc: [0.0, 0.0, foliage, 0.0], + }, + ); self.model_draw_commands.push(CachedModelDraw { uniform_slot: slot, cache_handle: handle_bits, mesh_idx, model: model_matrix, + tint, skinned: false, joint_offset: 0.0, bounds_override: None, @@ -170,6 +405,10 @@ impl Renderer { scale: f32, tint: [f32; 4], ) { + self.has_blend_model_draws |= self.model_blended.contains(&handle_bits); + self.has_layered_blend_model_draws |= self.model_layered_blended.contains(&handle_bits); + self.has_refractive_model_draws |= + self.imported_refraction_enabled && self.model_refractive.contains(&handle_bits); // Union of the cached meshes' local AABBs = the model's rest-pose // AABB (sentinel min > max when every mesh is empty). let (mesh_count, rest_min, rest_max) = match self.model_gpu_cache.get(&handle_bits) { @@ -177,17 +416,25 @@ impl Renderer { let mut rmin = [f32::MAX; 3]; let mut rmax = [f32::MIN; 3]; for mesh in meshes.iter() { - if mesh.local_min[0] > mesh.local_max[0] { continue; } + if mesh.local_min[0] > mesh.local_max[0] { + continue; + } for a in 0..3 { - if mesh.local_min[a] < rmin[a] { rmin[a] = mesh.local_min[a]; } - if mesh.local_max[a] > rmax[a] { rmax[a] = mesh.local_max[a]; } + if mesh.local_min[a] < rmin[a] { + rmin[a] = mesh.local_min[a]; + } + if mesh.local_max[a] > rmax[a] { + rmax[a] = mesh.local_max[a]; + } } } (meshes.len(), rmin, rmax) } _ => return, }; - if mesh_count == 0 { return; } + if mesh_count == 0 { + return; + } let joint_offset = self.take_staged_skin_offset().unwrap_or(0.0); @@ -209,17 +456,23 @@ impl Renderer { let (mut wmin, mut wmax) = super::transform_aabb(&model_matrix, rest_min, rest_max); let jstart = (joint_offset.max(0.0) as usize).min(self.frame_joint_data.len()); for j in jstart..self.frame_joint_data.len() { - let (m0, m1) = super::transform_aabb( - &self.frame_joint_data[j], rest_min, rest_max, - ); + let (m0, m1) = super::transform_aabb(&self.frame_joint_data[j], rest_min, rest_max); for a in 0..3 { - if m0[a] < wmin[a] { wmin[a] = m0[a]; } - if m1[a] > wmax[a] { wmax[a] = m1[a]; } + if m0[a] < wmin[a] { + wmin[a] = m0[a]; + } + if m1[a] > wmax[a] { + wmax[a] = m1[a]; + } } } // Sentinel (min > max) → no bounds; the passes treat the draw as // uncullable rather than culling it away. - let bounds_override = if wmin[0] <= wmax[0] { Some((wmin, wmax)) } else { None }; + let bounds_override = if wmin[0] <= wmax[0] { + Some((wmin, wmax)) + } else { + None + }; let vp = self.current_vp_matrix; for mesh_idx in 0..mesh_count { @@ -235,17 +488,23 @@ impl Renderer { // the VS this yields REAL skeletal+locomotion velocity // (it used to be the current VP -> exactly zero velocity, // which is why enemies ghosted under TAA/TSR). - self.stage_model_uniform(slot, &Uniforms3D { - mvp: vp, model: model_matrix, - prev_mvp: self.velocity_ref_vp, model_tint: tint, - misc: [joint_offset, 1.0, 0.0, 0.0], - }); + self.stage_model_uniform( + slot, + &Uniforms3D { + mvp: vp, + model: model_matrix, + prev_mvp: self.velocity_ref_vp, + model_tint: tint, + misc: [joint_offset, 1.0, 0.0, 0.0], + }, + ); self.model_draw_commands.push(CachedModelDraw { uniform_slot: slot, cache_handle: handle_bits, mesh_idx, model: model_matrix, + tint, skinned: true, joint_offset, bounds_override, @@ -256,8 +515,7 @@ impl Renderer { // per-frame BLAS build makes it visible to every ray // (traced shadows, bounce light, reflections). if self.hw_rt_enabled { - let (wmin, wmax) = bounds_override - .unwrap_or(([0.0; 3], [-1.0; 3])); + let (wmin, wmax) = bounds_override.unwrap_or(([0.0; 3], [-1.0; 3])); self.pt_dynamic_draws.push(super::PtDynamicDraw { cache_handle: handle_bits, mesh_idx, @@ -285,9 +543,7 @@ impl Renderer { resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: pool, offset: (slot * MODEL_UNIFORM_STRIDE) as u64, - size: std::num::NonZeroU64::new( - std::mem::size_of::() as u64, - ), + size: std::num::NonZeroU64::new(std::mem::size_of::() as u64), }), }], }) @@ -299,7 +555,8 @@ impl Renderer { self.ensure_model_uniform_slot(slot); let off = slot * MODEL_UNIFORM_STRIDE; if self.model_uniform_scratch.len() < off + MODEL_UNIFORM_STRIDE { - self.model_uniform_scratch.resize(off + MODEL_UNIFORM_STRIDE, 0); + self.model_uniform_scratch + .resize(off + MODEL_UNIFORM_STRIDE, 0); } self.model_uniform_scratch[off..off + std::mem::size_of::()] .copy_from_slice(bytemuck::bytes_of(uniforms)); @@ -309,7 +566,9 @@ impl Renderer { /// per frame from the end-frame paths, before passes execute (queued /// writes land at submit, ahead of all encoded passes). pub(super) fn flush_model_uniforms(&mut self) { - if self.next_model_uniform_slot == 0 { return; } + if self.next_model_uniform_slot == 0 { + return; + } let used = (self.next_model_uniform_slot * MODEL_UNIFORM_STRIDE) .min(self.model_uniform_scratch.len()); if used > 0 { @@ -337,22 +596,34 @@ impl Renderer { mapped_at_creation: false, }); self.model_uniform_bind_groups = (0..new_cap) - .map(|s| Self::model_uniform_bg_for_slot( - &self.device, &self.uniform_3d_layout, &self.model_uniform_pool, s, - )) + .map(|s| { + Self::model_uniform_bg_for_slot( + &self.device, + &self.uniform_3d_layout, + &self.model_uniform_pool, + s, + ) + }) .collect(); self.model_uniform_pool_capacity = new_cap; } - pub fn draw_model_mesh(&mut self, vertices: &[Vertex3D], indices: &[u32], position: [f32; 3], scale: f32) { + pub fn draw_model_mesh( + &mut self, + vertices: &[Vertex3D], + indices: &[u32], + position: [f32; 3], + scale: f32, + ) { self.draw_model_mesh_tinted(vertices, indices, position, scale, [1.0, 1.0, 1.0, 1.0], 0); } /// True if any vertex carries skin weights (same test the cache and /// the per-vertex draw loops use). fn mesh_has_skin(vertices: &[Vertex3D]) -> bool { - vertices.iter().any(|v| - v.weights[0] + v.weights[1] + v.weights[2] + v.weights[3] > 0.01) + vertices + .iter() + .any(|v| v.weights[0] + v.weights[1] + v.weights[2] + v.weights[3] > 0.01) } /// Pop the next staged skin pose (FIFO) and pack it into the frame @@ -377,7 +648,11 @@ impl Renderer { // back to the current pose = zero skeletal velocity. let prev_group = if !self.pending_skin_groups_prev.is_empty() { let p = self.pending_skin_groups_prev.remove(0); - if p.len() == group.len() { p } else { group.clone() } + if p.len() == group.len() { + p + } else { + group.clone() + } } else { group.clone() }; @@ -400,7 +675,16 @@ impl Renderer { /// drive their orientation. CPU-side baking mirrors the unrotated /// path so callers can mix rotated and unrotated draws freely /// without extra GPU state. - pub fn draw_model_mesh_tinted_rotated(&mut self, vertices: &[Vertex3D], indices: &[u32], position: [f32; 3], scale: f32, tint: [f32; 4], texture_idx: u32, rot_y: f32) { + pub fn draw_model_mesh_tinted_rotated( + &mut self, + vertices: &[Vertex3D], + indices: &[u32], + position: [f32; 3], + scale: f32, + tint: [f32; 4], + texture_idx: u32, + rot_y: f32, + ) { // Mirror the joint-pose plumbing in the non-rotated path so a // skinned mesh drawn here still consumes its pending pose. let joint_offset = if Self::mesh_has_skin(vertices) { @@ -409,14 +693,32 @@ impl Renderer { None }; self.draw_model_mesh_tinted_rotated_with_joints( - vertices, indices, position, scale, tint, texture_idx, rot_y, joint_offset); + vertices, + indices, + position, + scale, + tint, + texture_idx, + rot_y, + joint_offset, + ); } /// Rotated-path body with an explicit joint-buffer offset. Callers /// that draw multiple primitives of ONE skinned model pop the staged /// pose once (`take_staged_skin_offset`) and pass the same offset to /// every primitive. - pub fn draw_model_mesh_tinted_rotated_with_joints(&mut self, vertices: &[Vertex3D], indices: &[u32], position: [f32; 3], scale: f32, tint: [f32; 4], texture_idx: u32, rot_y: f32, joint_offset: Option) { + pub fn draw_model_mesh_tinted_rotated_with_joints( + &mut self, + vertices: &[Vertex3D], + indices: &[u32], + position: [f32; 3], + scale: f32, + tint: [f32; 4], + texture_idx: u32, + rot_y: f32, + joint_offset: Option, + ) { // Own bounded segment (even if the texture matches) so the shadow // pass can cull + cache this draw independently of neighbours. self.push_draw_call_3d(texture_idx, true); @@ -435,11 +737,13 @@ impl Renderer { let lx = v.position[0]; let ly = v.position[1]; let lz = v.position[2]; - let rx = cos_y * lx + sin_y * lz; + let rx = cos_y * lx + sin_y * lz; let rz = -sin_y * lx + cos_y * lz; - [rx * scale + position[0], - ly * scale + position[1], - rz * scale + position[2]] + [ + rx * scale + position[0], + ly * scale + position[1], + rz * scale + position[2], + ] }; // Rotate the surface normal too so lighting matches the new // orientation. Y-axis rotation leaves normal.y untouched. @@ -447,25 +751,31 @@ impl Renderer { let normal = if is_skinned { n } else { - [ cos_y * n[0] + sin_y * n[2], - n[1], - -sin_y * n[0] + cos_y * n[2] ] + [ + cos_y * n[0] + sin_y * n[2], + n[1], + -sin_y * n[0] + cos_y * n[2], + ] }; // Rotate tangent.xyz the same way; preserve handedness in w. let t = v.tangent; let tangent = if is_skinned { t } else { - [ cos_y * t[0] + sin_y * t[2], - t[1], - -sin_y * t[0] + cos_y * t[2], - t[3] ] + [ + cos_y * t[0] + sin_y * t[2], + t[1], + -sin_y * t[0] + cos_y * t[2], + t[3], + ] }; let joints_out = if is_skinned { - [v.joints[0] + joint_offset, - v.joints[1] + joint_offset, - v.joints[2] + joint_offset, - v.joints[3] + joint_offset] + [ + v.joints[0] + joint_offset, + v.joints[1] + joint_offset, + v.joints[2] + joint_offset, + v.joints[3] + joint_offset, + ] } else { v.joints }; @@ -491,7 +801,15 @@ impl Renderer { self.finish_model_segment(seg, joint_offset); } - pub fn draw_model_mesh_tinted(&mut self, vertices: &[Vertex3D], indices: &[u32], position: [f32; 3], scale: f32, tint: [f32; 4], texture_idx: u32) { + pub fn draw_model_mesh_tinted( + &mut self, + vertices: &[Vertex3D], + indices: &[u32], + position: [f32; 3], + scale: f32, + tint: [f32; 4], + texture_idx: u32, + ) { // If this mesh is skinned, consume the next pending pose // (FIFO) and pack its matrices into the frame accumulator at // the current cursor. Each vertex's joint indices then get @@ -504,14 +822,30 @@ impl Renderer { None }; self.draw_model_mesh_tinted_with_joints( - vertices, indices, position, scale, tint, texture_idx, joint_offset); + vertices, + indices, + position, + scale, + tint, + texture_idx, + joint_offset, + ); } /// Body of `draw_model_mesh_tinted` with an explicit joint-buffer /// offset. Callers that draw multiple primitives of ONE skinned model /// pop the staged pose once (`take_staged_skin_offset`) and pass the /// same offset to every primitive. - pub fn draw_model_mesh_tinted_with_joints(&mut self, vertices: &[Vertex3D], indices: &[u32], position: [f32; 3], scale: f32, tint: [f32; 4], texture_idx: u32, joint_offset: Option) { + pub fn draw_model_mesh_tinted_with_joints( + &mut self, + vertices: &[Vertex3D], + indices: &[u32], + position: [f32; 3], + scale: f32, + tint: [f32; 4], + texture_idx: u32, + joint_offset: Option, + ) { // Own bounded segment — see the rotated variant. self.push_draw_call_3d(texture_idx, true); let joint_offset: f32 = joint_offset.unwrap_or(0.0); @@ -526,15 +860,19 @@ impl Renderer { v.position } else { // Unskinned: apply CPU-side position + scale - [v.position[0] * scale + position[0], - v.position[1] * scale + position[1], - v.position[2] * scale + position[2]] + [ + v.position[0] * scale + position[0], + v.position[1] * scale + position[1], + v.position[2] * scale + position[2], + ] }; let joints_out = if is_skinned { - [v.joints[0] + joint_offset, - v.joints[1] + joint_offset, - v.joints[2] + joint_offset, - v.joints[3] + joint_offset] + [ + v.joints[0] + joint_offset, + v.joints[1] + joint_offset, + v.joints[2] + joint_offset, + v.joints[3] + joint_offset, + ] } else { v.joints }; @@ -575,16 +913,21 @@ impl Renderer { // primitives and the next model's group hasn't been staged yet. let jstart = (joint_offset.max(0.0) as usize).min(self.frame_joint_data.len()); for j in jstart..self.frame_joint_data.len() { - let (m0, m1) = super::transform_aabb( - &self.frame_joint_data[j], seg.rest_min, seg.rest_max, - ); + let (m0, m1) = + super::transform_aabb(&self.frame_joint_data[j], seg.rest_min, seg.rest_max); for a in 0..3 { - if m0[a] < wmin[a] { wmin[a] = m0[a]; } - if m1[a] > wmax[a] { wmax[a] = m1[a]; } + if m0[a] < wmin[a] { + wmin[a] = m0[a]; + } + if m1[a] > wmax[a] { + wmax[a] = m1[a]; + } } } } - let call = self.draw_calls_3d.last_mut() + let call = self + .draw_calls_3d + .last_mut() .expect("finish_model_segment: segment was pushed at fn start"); call.has_skinned = seg.any_skinned; call.content_hash = seg.hash; @@ -627,13 +970,21 @@ impl SegBounds { if is_skinned { self.any_skinned = true; for a in 0..3 { - if pos[a] < self.rest_min[a] { self.rest_min[a] = pos[a]; } - if pos[a] > self.rest_max[a] { self.rest_max[a] = pos[a]; } + if pos[a] < self.rest_min[a] { + self.rest_min[a] = pos[a]; + } + if pos[a] > self.rest_max[a] { + self.rest_max[a] = pos[a]; + } } } else { for a in 0..3 { - if pos[a] < self.world_min[a] { self.world_min[a] = pos[a]; } - if pos[a] > self.world_max[a] { self.world_max[a] = pos[a]; } + if pos[a] < self.world_min[a] { + self.world_min[a] = pos[a]; + } + if pos[a] > self.world_max[a] { + self.world_max[a] = pos[a]; + } } self.hash = super::types::fnv1a_bytes(self.hash, bytemuck::bytes_of(&pos)); } diff --git a/native/shared/src/renderer/occlusion.rs b/native/shared/src/renderer/occlusion.rs index 94171771..8372e6ac 100644 --- a/native/shared/src/renderer/occlusion.rs +++ b/native/shared/src/renderer/occlusion.rs @@ -146,7 +146,11 @@ impl OcclusionCuller { }); let grid_tex = device.create_texture(&wgpu::TextureDescriptor { label: Some("occlusion_grid"), - size: wgpu::Extent3d { width: GRID_W, height: GRID_H, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: GRID_W, + height: GRID_H, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -257,9 +261,18 @@ impl OcclusionCuller { label: Some("occlusion_reduce_bg"), layout: &self.layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(src) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.grid_view) }, + wgpu::BindGroupEntry { + binding: 0, + resource: self.uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(src), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&self.grid_view), + }, ], })); } @@ -287,7 +300,11 @@ impl OcclusionCuller { rows_per_image: Some(GRID_H), }, }, - wgpu::Extent3d { width: GRID_W, height: GRID_H, depth_or_array_layers: 1 }, + wgpu::Extent3d { + width: GRID_W, + height: GRID_H, + depth_or_array_layers: 1, + }, ); rb.vp = vp; self.recorded_this_frame = true; @@ -302,14 +319,16 @@ impl OcclusionCuller { let rb = &mut self.readbacks[self.parity]; let done = rb.map_done.clone(); rb.in_flight = true; - rb.buffer.slice(..).map_async(wgpu::MapMode::Read, move |res| { - if res.is_ok() { - done.store(true, std::sync::atomic::Ordering::Release); - } - // On error the buffer stays flagged in-flight until the next - // successful cycle on the other parity; culling simply keeps - // using the older grid. - }); + rb.buffer + .slice(..) + .map_async(wgpu::MapMode::Read, move |res| { + if res.is_ok() { + done.store(true, std::sync::atomic::Ordering::Release); + } + // On error the buffer stays flagged in-flight until the next + // successful cycle on the other parity; culling simply keeps + // using the older grid. + }); self.parity = 1 - self.parity; self.recorded_this_frame = false; } diff --git a/native/shared/src/renderer/opaque_material_pass.rs b/native/shared/src/renderer/opaque_material_pass.rs new file mode 100644 index 00000000..67bea280 --- /dev/null +++ b/native/shared/src/renderer/opaque_material_pass.rs @@ -0,0 +1,114 @@ +//! Opaque user-material recording inside the compiled `hdr_scene` node. + +use super::*; + +impl Renderer { + pub(super) fn record_opaque_material_pass( + &self, + encoder: &mut wgpu::CommandEncoder, + profiler: &mut crate::profiler::Profiler, + ) { + if self.material_system.commands.is_empty() || self.dbg_skip("material_pass") { + return; + } + let camera_planes = crate::scene::extract_frustum_planes(&mat4_multiply( + self.current_proj_matrix_unjittered, + self.current_view_matrix, + )); + profiler.begin("material_pass"); + { + let timestamp_writes = profiler.pass_timestamp_writes("material_pass"); + #[cfg(lean_mrt)] + let attachments: &[Option>] = &[ + Some(wgpu::RenderPassColorAttachment { + view: &self.hdr_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + None, + Some(wgpu::RenderPassColorAttachment { + view: &self.velocity_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + None, + ]; + #[cfg(not(lean_mrt))] + let attachments: &[Option>] = &[ + Some(wgpu::RenderPassColorAttachment { + view: &self.hdr_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: &self.material_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: &self.velocity_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: &self.albedo_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + ]; + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_material_pass"), + color_attachments: attachments, + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes, + occlusion_query_set: None, + multiview_mask: None, + }); + self.material_system + .dispatch(&mut pass, Some(&camera_planes), |handle, mesh_index| { + let mesh = self + .model_gpu_cache + .get(&handle)? + .as_ref()? + .get(mesh_index)?; + Some(( + self.gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count), + mesh.local_min, + mesh.local_max, + )) + }); + } + profiler.end("material_pass"); + } +} diff --git a/native/shared/src/renderer/planar_pass.rs b/native/shared/src/renderer/planar_pass.rs index cb11fbc4..57a0fc4a 100644 --- a/native/shared/src/renderer/planar_pass.rs +++ b/native/shared/src/renderer/planar_pass.rs @@ -32,14 +32,19 @@ impl Renderer { scene: &crate::scene::SceneGraph, profiler: &mut crate::profiler::Profiler, ) { - if self.planar_probes.iter().all(|p| p.is_none()) { return; } + if self.planar_probes.iter().all(|p| p.is_none()) { + return; + } // Scene-graph nodes render into the probe too (they share the // Vertex3D layout and the scene material bind-group layout), so a // fully retained-mode game gets real water reflections as well. - let scene_draws = scene.reflect_draw_list(); + let scene_draws = scene.reflect_draw_list_with_refraction(self.imported_refraction_enabled); if self.material_system.commands.is_empty() && self.model_draw_commands.is_empty() - && scene_draws.is_empty() { return; } + && scene_draws.is_empty() + { + return; + } // EN-011 — lazily build the single-target reflection pipeline + buffers // used to render cached models (trees/house) into the probe with a @@ -48,20 +53,24 @@ impl Renderer { const REFLECT_STRIDE: u64 = 256; const REFLECT_MAX_DRAWS: usize = 1024; if self.reflect_scene_pipeline.is_none() { - let model_dyn_layout = self.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("reflect_model_dyn_layout"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::VERTEX, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: true, - min_binding_size: std::num::NonZeroU64::new(128), - }, - count: None, - }], - }); + let model_dyn_layout = + self.device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("reflect_model_dyn_layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: true, + min_binding_size: std::num::NonZeroU64::new(128), + }, + count: None, + }], + }); let shadow_tex_entry = |binding: u32| wgpu::BindGroupLayoutEntry { - binding, visibility: wgpu::ShaderStages::FRAGMENT, + binding, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Depth, view_dimension: wgpu::TextureViewDimension::D2, @@ -69,65 +78,92 @@ impl Renderer { }, count: None, }; - let light_layout = self.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("reflect_light_layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, - }, - count: None, + let light_layout = + self.device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("reflect_light_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // Shadow cascades + comparison sampler so the mirrored + // scene is sun-shadowed like the real one (the probe + // previously rendered everything fully lit, which made + // water reflections disagree with the scene above them). + shadow_tex_entry(1), + shadow_tex_entry(2), + shadow_tex_entry(3), + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler( + wgpu::SamplerBindingType::Comparison, + ), + count: None, + }, + ], + }); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("reflect_scene_shader"), + source: wgpu::ShaderSource::Wgsl(REFLECT_SCENE_WGSL.into()), + }); + let pl = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("reflect_scene_pl"), + bind_group_layouts: &[ + Some(&model_dyn_layout), + Some(&light_layout), + Some(&self.scene_material_layout), + ], + immediate_size: 0, + }); + let pipeline = self + .device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("reflect_scene_pipeline"), + layout: Some(&pl), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_reflect"), + buffers: &[Vertex3D::desc()], + compilation_options: Default::default(), }, - // Shadow cascades + comparison sampler so the mirrored - // scene is sun-shadowed like the real one (the probe - // previously rendered everything fully lit, which made - // water reflections disagree with the scene above them). - shadow_tex_entry(1), - shadow_tex_entry(2), - shadow_tex_entry(3), - wgpu::BindGroupLayoutEntry { - binding: 4, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison), - count: None, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_reflect"), + targets: &[Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: None, + ..Default::default() }, - ], - }); - let shader = self.device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("reflect_scene_shader"), - source: wgpu::ShaderSource::Wgsl(REFLECT_SCENE_WGSL.into()), - }); - let pl = self.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("reflect_scene_pl"), - bind_group_layouts: &[Some(&model_dyn_layout), Some(&light_layout), Some(&self.scene_material_layout)], - immediate_size: 0, - }); - let pipeline = self.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("reflect_scene_pipeline"), - layout: Some(&pl), - vertex: wgpu::VertexState { - module: &shader, entry_point: Some("vs_reflect"), - buffers: &[Vertex3D::desc()], compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, entry_point: Some("fs_reflect"), - targets: &[Some(wgpu::ColorTargetState { - format: HDR_FORMAT, blend: None, write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - cull_mode: None, ..Default::default() - }, - depth_stencil: Some(wgpu::DepthStencilState { - format: DEPTH_FORMAT, depth_write_enabled: Some(true), - depth_compare: Some(wgpu::CompareFunction::Less), - stencil: Default::default(), bias: Default::default(), - }), - multisample: Default::default(), multiview_mask: None, cache: None, - }); + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: Default::default(), + multiview_mask: None, + cache: None, + }); let model_buf = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("reflect_model_buf"), size: REFLECT_STRIDE * REFLECT_MAX_DRAWS as u64, @@ -135,29 +171,55 @@ impl Renderer { mapped_at_creation: false, }); let model_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("reflect_model_bg"), layout: &model_dyn_layout, + label: Some("reflect_model_bg"), + layout: &model_dyn_layout, entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { - buffer: &model_buf, offset: 0, size: std::num::NonZeroU64::new(128), + buffer: &model_buf, + offset: 0, + size: std::num::NonZeroU64::new(128), }), }], }); // sun_dir + sun_color + ambient + cam_pos + shadow_splits (5 vec4) // + 3 cascade mat4s = 80 + 192 = 272 bytes. let light_buf = self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("reflect_light_buf"), size: 272, + label: Some("reflect_light_buf"), + size: 272, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let light_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("reflect_light_bg"), layout: &light_layout, + label: Some("reflect_light_bg"), + layout: &light_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: light_buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[0]) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[1]) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[2]) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.shadow_map.sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: light_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView( + &self.shadow_map.depth_views[0], + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView( + &self.shadow_map.depth_views[1], + ), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView( + &self.shadow_map.depth_views[2], + ), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.shadow_map.sampler), + }, ], }); self.reflect_scene_pipeline = Some(pipeline); @@ -165,6 +227,7 @@ impl Renderer { self.reflect_model_bg = Some(model_bg); self.reflect_light_buf = Some(light_buf); self.reflect_light_bg = Some(light_bg); + self.created_pipelines(1); } // Sun/ambient + shadow data for the reflection shading (same values // as the main pass, so the mirrored scene is lit AND shadowed like @@ -192,7 +255,8 @@ impl Renderer { } } if let Some(buf) = &self.reflect_light_buf { - self.queue.write_buffer(buf, 0, bytemuck::cast_slice(&light_data)); + self.queue + .write_buffer(buf, 0, bytemuck::cast_slice(&light_data)); } } @@ -202,7 +266,12 @@ impl Renderer { // surface). let mut excluded: std::collections::HashSet = std::collections::HashSet::new(); - for (i, probe_link) in self.material_system.material_reflection_probe.iter().enumerate() { + for (i, probe_link) in self + .material_system + .material_reflection_probe + .iter() + .enumerate() + { if probe_link.is_some() { excluded.insert((i + 1) as material_system::MaterialHandle); } @@ -210,43 +279,50 @@ impl Renderer { // Cache main-pass per-view inputs once outside the loop. let main_view = self.current_view_matrix; - let proj = self.current_proj_matrix; - let cam_pos = self.current_camera_pos; + let proj = self.current_proj_matrix; + let cam_pos = self.current_camera_pos; // Snapshot the existing PerView uniforms by reconstructing // the same struct material_system_begin_frame writes — we // need a fresh copy per probe to swap view/view_proj. let base_per_view = material_system::PerViewUniforms { - view: main_view, + view: main_view, proj, - view_proj: self.current_vp_matrix, + view_proj: self.current_vp_matrix, // EN-022 fix: velocity reference (prev unjittered VP + // current jitter), so material shaders computing // `prev_view_proj * world` get true zero velocity on // static geometry instead of TAA jitter-delta noise. prev_view_proj: self.velocity_ref_vp, - inv_proj: self.current_inv_proj_matrix, + inv_proj: self.current_inv_proj_matrix, camera_pos: [ - cam_pos[0], cam_pos[1], cam_pos[2], + cam_pos[0], + cam_pos[1], + cam_pos[2], self.lighting_uniforms.camera_pos[3], ], camera_dir: [0.0, 0.0, -1.0, 70.0_f32.to_radians()], - ambient: self.lighting_uniforms.ambient, - fog: [self.fog_color[0], self.fog_color[1], self.fog_color[2], self.fog_density], - sun_dir: self.lighting_uniforms.light_dir, - sun_color: self.lighting_uniforms.light_color, - dir_light_count: self.lighting_uniforms.dir_light_count, - dir_lights: std::array::from_fn(|i| material_system::PerViewDirLight { + ambient: self.lighting_uniforms.ambient, + fog: [ + self.fog_color[0], + self.fog_color[1], + self.fog_color[2], + self.fog_density, + ], + sun_dir: self.lighting_uniforms.light_dir, + sun_color: self.lighting_uniforms.light_color, + dir_light_count: self.lighting_uniforms.dir_light_count, + dir_lights: std::array::from_fn(|i| material_system::PerViewDirLight { direction: self.lighting_uniforms.dir_lights[i].direction, - color: self.lighting_uniforms.dir_lights[i].color, + color: self.lighting_uniforms.dir_lights[i].color, }), point_light_count: self.lighting_uniforms.point_light_count, - point_lights: std::array::from_fn(|i| material_system::PerViewPointLight { + point_lights: std::array::from_fn(|i| material_system::PerViewPointLight { position: self.lighting_uniforms.point_lights[i].position, - color: self.lighting_uniforms.point_lights[i].color, + color: self.lighting_uniforms.point_lights[i].color, }), - shadow_splits: self.lighting_uniforms.shadow_cascade_splits, - shadow_view: self.lighting_uniforms.shadow_view_matrix, + shadow_splits: self.lighting_uniforms.shadow_cascade_splits, + shadow_view: self.lighting_uniforms.shadow_view_matrix, shadow_cascades: self.lighting_uniforms.shadow_cascade_vps, }; @@ -254,21 +330,34 @@ impl Renderer { // commands view while iterating, so collect the work first. let probe_count = self.planar_probes.len(); for i in 0..probe_count { - let (plane_y, normal, color_view, depth_view, - aux_material_view, aux_velocity_view, aux_albedo_view) = - match &self.planar_probes[i] { - Some(p) => (p.plane_y, p.normal, p.color_view.clone(), p.depth_view.clone(), - p.aux_material_view.clone(), p.aux_velocity_view.clone(), - p.aux_albedo_view.clone()), - None => continue, - }; + let ( + plane_y, + normal, + color_view, + depth_view, + aux_material_view, + aux_velocity_view, + aux_albedo_view, + ) = match &self.planar_probes[i] { + Some(p) => ( + p.plane_y, + p.normal, + p.color_view.clone(), + p.depth_view.clone(), + p.aux_material_view.clone(), + p.aux_velocity_view.clone(), + p.aux_albedo_view.clone(), + ), + None => continue, + }; let view_buf = match self.planar_probe_view_buffers[i].as_ref() { - Some(b) => b, None => continue, + Some(b) => b, + None => continue, }; // Mirror the camera + recompute view_proj for the probe. let mirror_view = planar_reflection::mirrored_view(main_view, plane_y, normal); - let mirror_cam = planar_reflection::mirrored_camera_pos(cam_pos, plane_y, normal); + let mirror_cam = planar_reflection::mirrored_camera_pos(cam_pos, plane_y, normal); // EN-011 V2 — oblique near-plane clip. Replace the // projection's near plane with the water plane (in @@ -286,15 +375,15 @@ impl Renderer { // reflection has rolled it through the view). let d_w = -(normal[0] * 0.0 + normal[1] * plane_y + normal[2] * 0.0); let plane_world = [normal[0], normal[1], normal[2], d_w]; - let plane_eye = planar_reflection::world_plane_to_eye_space(mirror_view, plane_world); + let plane_eye = planar_reflection::world_plane_to_eye_space(mirror_view, plane_world); let mirror_proj = planar_reflection::oblique_proj(proj, plane_eye); - let mirror_vp = mat4_multiply(mirror_proj, mirror_view); + let mirror_vp = mat4_multiply(mirror_proj, mirror_view); let mut per_view = base_per_view; - per_view.view = mirror_view; - per_view.proj = mirror_proj; + per_view.view = mirror_view; + per_view.proj = mirror_proj; per_view.view_proj = mirror_vp; - per_view.inv_proj = planar_reflection::inv_proj_for(mirror_proj); + per_view.inv_proj = planar_reflection::inv_proj_for(mirror_proj); per_view.camera_pos[0] = mirror_cam[0]; per_view.camera_pos[1] = mirror_cam[1]; per_view.camera_pos[2] = mirror_cam[2]; @@ -302,7 +391,8 @@ impl Renderer { // — TAA reprojection isn't meaningful for the reflection // probe (we don't temporally accumulate it), so this is // benign. - self.queue.write_buffer(view_buf, 0, bytemuck::bytes_of(&per_view)); + self.queue + .write_buffer(view_buf, 0, bytemuck::bytes_of(&per_view)); // EN-011 V2 — rebuild the per-probe PerView bind group with // the live env / BRDF / shadow views. V1 bound 1×1 stub @@ -327,40 +417,111 @@ impl Renderer { // shadow views, the per-probe UBO) are all stable objects; // env (re)loads and probe creation clear the cache slot. if self.planar_probe_view_bgs[i].is_none() { - let sky_view_owned: Option = self.sky_texture + let sky_view_owned: Option = self + .sky_texture .as_ref() .map(|t| t.create_view(&Default::default())); let env_view: &wgpu::TextureView = sky_view_owned .as_ref() .unwrap_or(&self.scene_env_default_view); - let diffuse_view_owned: Option = self.env_diffuse_texture + let diffuse_view_owned: Option = self + .env_diffuse_texture .as_ref() .map(|t| t.create_view(&Default::default())); let env_diffuse_view: &wgpu::TextureView = diffuse_view_owned .as_ref() .unwrap_or(&self.scene_env_default_view); - self.planar_probe_view_bgs[i] = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("planar_probe_per_view_bg_live"), - layout: &self.material_system.layouts.per_view, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: view_buf.as_entire_binding() }, - // env (specular) tex + sampler - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(env_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.env_sampler) }, - // env diffuse tex - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(env_diffuse_view) }, - // BRDF LUT tex + sampler - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self.brdf_lut_view) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(&self.brdf_lut_sampler) }, - // 3 shadow cascades — same depth views the main - // pass binds, so the reflection picks up sun - // shadows without re-rendering the cascades. - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[0]) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[1]) }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[2]) }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&self.shadow_map.sampler) }, - ], - })); + let mut entries = vec![ + wgpu::BindGroupEntry { + binding: 0, + resource: view_buf.as_entire_binding(), + }, + // env (specular) tex + sampler + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(env_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.env_sampler), + }, + // env diffuse tex + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(env_diffuse_view), + }, + // BRDF LUT tex + sampler + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&self.brdf_lut_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler(&self.brdf_lut_sampler), + }, + // 3 shadow cascades — same depth views the main + // pass binds, so the reflection picks up sun + // shadows without re-rendering the cascades. + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView( + &self.shadow_map.depth_views[0], + ), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView( + &self.shadow_map.depth_views[1], + ), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView( + &self.shadow_map.depth_views[2], + ), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::Sampler(&self.shadow_map.sampler), + }, + ]; + if self.shadow_map.virtual_map.requested() { + entries.extend([ + wgpu::BindGroupEntry { + binding: 10, + resource: wgpu::BindingResource::TextureView( + self.shadow_map + .virtual_map + .page_table_view() + .expect("requested VSM requires a page table"), + ), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: wgpu::BindingResource::TextureView( + self.shadow_map + .virtual_map + .physical_array_view() + .expect("requested VSM requires physical pages"), + ), + }, + wgpu::BindGroupEntry { + binding: 12, + resource: self + .shadow_map + .virtual_map + .sampling_params_buffer() + .expect("requested VSM requires sampling parameters") + .as_entire_binding(), + }, + ]); + } + self.planar_probe_view_bgs[i] = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("planar_probe_per_view_bg_live"), + layout: &self.material_system.layouts.per_view, + entries: &entries, + })); } let probe_view_bg = self.planar_probe_view_bgs[i].as_ref().unwrap(); @@ -384,14 +545,26 @@ impl Renderer { // mirror its bind pose at the origin. Skinned models // (enemies) were never reflected on the old immediate // path either, so skipping preserves that. - if cmd.skinned { continue; } + if cmd.skinned { + continue; + } let slot = reflect_draws.len(); - if slot >= REFLECT_MAX_DRAWS { break; } - let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.cache_handle) else { continue }; - if cmd.mesh_idx >= meshes.len() { continue; } + if slot >= REFLECT_MAX_DRAWS { + break; + } + let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.cache_handle) else { + continue; + }; + if cmd.mesh_idx >= meshes.len() { + continue; + } let mesh = &meshes[cmd.mesh_idx]; - let (wmin, wmax) = - transform_aabb(&cmd.model, mesh.local_min, mesh.local_max); + if mesh.alpha_mode == MaterialAlphaMode::Blend + || (self.imported_refraction_enabled && mesh.transmission.is_active()) + { + continue; + } + let (wmin, wmax) = transform_aabb(&cmd.model, mesh.local_min, mesh.local_max); if wmin[0] <= wmax[0] && crate::scene::aabb_outside_frustum(&mirror_planes, wmin, wmax) { @@ -406,7 +579,9 @@ impl Renderer { } for (i, (_vb, _ib, _ic, _bg, model, wmin, wmax)) in scene_draws.iter().enumerate() { let slot = reflect_draws.len() + node_slots.len(); - if slot >= REFLECT_MAX_DRAWS { break; } + if slot >= REFLECT_MAX_DRAWS { + break; + } if wmin[0] <= wmax[0] && crate::scene::aabb_outside_frustum(&mirror_planes, *wmin, *wmax) { @@ -423,14 +598,18 @@ impl Renderer { self.queue.write_buffer(model_buf, 0, &staged); } } - // Clear the probe to transparent black. Geometry fragments write // alpha 1, so the water shader can blend the probe over its analytic // sky by alpha (a=0 → no reflected geometry → show the sky dome). - let clear_color = wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }; + let clear_color = wgpu::Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.0, + }; let view_bg = probe_view_bg; - let cache = &self.model_gpu_cache; + let cache = &self.model_gpu_cache; let mat_sys = &self.material_system; let refl_pipeline = self.reflect_scene_pipeline.as_ref(); let refl_model_bg = self.reflect_model_bg.as_ref(); @@ -490,13 +669,13 @@ impl Renderer { multiview_mask: None, }); mat_sys.dispatch_with_view( - &mut pass, view_bg, + &mut pass, + view_bg, // Excluded: probe-linked materials (the water plane // itself) and anything flagged not-probe-visible // (authoring control — e.g. sub-pixel instanced // grass in a 512² probe). - |handle| !excluded.contains(&handle) - && mat_sys.material_probe_visible(handle), + |handle| !excluded.contains(&handle) && mat_sys.material_probe_visible(handle), // EN-011 V2 — swap to each material's sibling // pipeline with cull_mode flipped Front→Back. // Reflection mirrors world-space, which inverts @@ -517,8 +696,9 @@ impl Renderer { if idx < meshes.len() { let mesh = &meshes[idx]; return Some(( - &mesh.vb, &mesh.ib, mesh.index_count, - mesh.local_min, mesh.local_max, + self.gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count), + mesh.local_min, + mesh.local_max, )); } } @@ -565,11 +745,16 @@ impl Renderer { if let Some(Some(meshes)) = cache.get(handle) { if *midx < meshes.len() { let mesh = &meshes[*midx]; + let draw = + self.gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count); pass.set_bind_group(0, rmbg, &[*slot * REFLECT_STRIDE as u32]); pass.set_bind_group(2, &mesh.material_bg, &[]); - pass.set_vertex_buffer(0, mesh.vb.slice(..)); - pass.set_index_buffer(mesh.ib.slice(..), wgpu::IndexFormat::Uint32); - pass.draw_indexed(0..mesh.index_count, 0, 0..1); + pass.set_vertex_buffer(0, draw.vertex.slice(..)); + pass.set_index_buffer( + draw.index.slice(..), + wgpu::IndexFormat::Uint32, + ); + pass.draw_indexed(draw.index_range(), draw.base_vertex, 0..1); } } } diff --git a/native/shared/src/renderer/planar_reflection.rs b/native/shared/src/renderer/planar_reflection.rs index 6cc0b0b1..bb3bcf65 100644 --- a/native/shared/src/renderer/planar_reflection.rs +++ b/native/shared/src/renderer/planar_reflection.rs @@ -49,16 +49,16 @@ pub struct PlanarReflectionProbe { pub plane_y: f32, /// Unit normal of the plane in world space. The mirror matrix /// reflects across `n · p = d`, where `d = n · (0, plane_y, 0)`. - pub normal: [f32; 3], + pub normal: [f32; 3], /// Texture extent — width = height for a square probe; both /// dimensions get rounded to ≥ 16 px in `new` so a tiny RT can't /// crash the renderer. pub resolution: u32, - pub color_rt: wgpu::Texture, - pub color_view: wgpu::TextureView, - pub depth_rt: wgpu::Texture, - pub depth_view: wgpu::TextureView, + pub color_rt: wgpu::Texture, + pub color_view: wgpu::TextureView, + pub depth_rt: wgpu::Texture, + pub depth_view: wgpu::TextureView, /// Dummy G-buffer attachments for the user-material probe pass. /// Opaque-profile material pipelines target the full 4-attachment @@ -67,12 +67,12 @@ pub struct PlanarReflectionProbe { /// wgpu validates pipeline targets against pass attachments /// exactly. Only the hdr result is kept; these three are cleared /// each frame and their stores discarded. - pub aux_material_rt: wgpu::Texture, + pub aux_material_rt: wgpu::Texture, pub aux_material_view: wgpu::TextureView, - pub aux_velocity_rt: wgpu::Texture, + pub aux_velocity_rt: wgpu::Texture, pub aux_velocity_view: wgpu::TextureView, - pub aux_albedo_rt: wgpu::Texture, - pub aux_albedo_view: wgpu::TextureView, + pub aux_albedo_rt: wgpu::Texture, + pub aux_albedo_view: wgpu::TextureView, } impl PlanarReflectionProbe { @@ -84,12 +84,7 @@ impl PlanarReflectionProbe { /// reflected into the probe doesn't clamp to LDR /// - depth: `Depth32Float` so the mirrored draws can z-test /// against each other without a separate downsample - pub fn new( - device: &wgpu::Device, - plane_y: f32, - normal: [f32; 3], - resolution: u32, - ) -> Self { + pub fn new(device: &wgpu::Device, plane_y: f32, normal: [f32; 3], resolution: u32) -> Self { // Clamp absurd inputs — a 0-px texture is illegal in wgpu // and a 16k-px probe would cost more than the rest of the // frame combined. 16..=4096 covers every realistic case. @@ -97,20 +92,27 @@ impl PlanarReflectionProbe { let color_rt = device.create_texture(&wgpu::TextureDescriptor { label: Some("planar_reflection_color"), - size: wgpu::Extent3d { width: res, height: res, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: res, + height: res, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: super::formats::HDR_FORMAT, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let color_view = color_rt.create_view(&Default::default()); let depth_rt = device.create_texture(&wgpu::TextureDescriptor { label: Some("planar_reflection_depth"), - size: wgpu::Extent3d { width: res, height: res, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: res, + height: res, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -126,7 +128,11 @@ impl PlanarReflectionProbe { let make_aux = |label: &str, format: wgpu::TextureFormat| { let tex = device.create_texture(&wgpu::TextureDescriptor { label: Some(label), - size: wgpu::Extent3d { width: res, height: res, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: res, + height: res, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -137,12 +143,18 @@ impl PlanarReflectionProbe { let view = tex.create_view(&Default::default()); (tex, view) }; - let (aux_material_rt, aux_material_view) = - make_aux("planar_reflection_aux_material", super::formats::MATERIAL_FORMAT); - let (aux_velocity_rt, aux_velocity_view) = - make_aux("planar_reflection_aux_velocity", super::formats::VELOCITY_FORMAT); - let (aux_albedo_rt, aux_albedo_view) = - make_aux("planar_reflection_aux_albedo", wgpu::TextureFormat::Rgba8Unorm); + let (aux_material_rt, aux_material_view) = make_aux( + "planar_reflection_aux_material", + super::formats::MATERIAL_FORMAT, + ); + let (aux_velocity_rt, aux_velocity_view) = make_aux( + "planar_reflection_aux_velocity", + super::formats::VELOCITY_FORMAT, + ); + let (aux_albedo_rt, aux_albedo_view) = make_aux( + "planar_reflection_aux_albedo", + wgpu::TextureFormat::Rgba8Unorm, + ); // Normalise the supplied normal — caller may pass a non-unit // vector; downstream math (specifically the reflection @@ -150,11 +162,19 @@ impl PlanarReflectionProbe { let n = normalise(normal); Self { - plane_y, normal: n, resolution: res, - color_rt, color_view, depth_rt, depth_view, - aux_material_rt, aux_material_view, - aux_velocity_rt, aux_velocity_view, - aux_albedo_rt, aux_albedo_view, + plane_y, + normal: n, + resolution: res, + color_rt, + color_view, + depth_rt, + depth_view, + aux_material_rt, + aux_material_view, + aux_velocity_rt, + aux_velocity_view, + aux_albedo_rt, + aux_albedo_view, } } } @@ -174,12 +194,14 @@ pub fn reflection_matrix(plane_y: f32, normal: [f32; 3]) -> [[f32; 4]; 4] { let d = n[1] * plane_y; // Standard Householder-style reflection across the plane. // Column-major; multiplies p as `R * p` (post-mul). - let nx = n[0]; let ny = n[1]; let nz = n[2]; + let nx = n[0]; + let ny = n[1]; + let nz = n[2]; [ - [1.0 - 2.0 * nx * nx, -2.0 * nx * ny, -2.0 * nx * nz, 0.0], - [-2.0 * ny * nx, 1.0 - 2.0 * ny * ny, -2.0 * ny * nz, 0.0], - [-2.0 * nz * nx, -2.0 * nz * ny, 1.0 - 2.0 * nz * nz, 0.0], - [2.0 * nx * d, 2.0 * ny * d, 2.0 * nz * d, 1.0], + [1.0 - 2.0 * nx * nx, -2.0 * nx * ny, -2.0 * nx * nz, 0.0], + [-2.0 * ny * nx, 1.0 - 2.0 * ny * ny, -2.0 * ny * nz, 0.0], + [-2.0 * nz * nx, -2.0 * nz * ny, 1.0 - 2.0 * nz * nz, 0.0], + [2.0 * nx * d, 2.0 * ny * d, 2.0 * nz * d, 1.0], ] } @@ -235,10 +257,7 @@ pub fn inv_proj_for(proj: [[f32; 4]; 4]) -> [[f32; 4]; 4] { /// For Bloom's column-major / post-mul convention, applying the /// inverse-transpose of `view` to a plane `(N, d)` equates to the /// standard direct-3D / OpenGL formulation. -pub fn world_plane_to_eye_space( - view: [[f32; 4]; 4], - plane_world: [f32; 4], -) -> [f32; 4] { +pub fn world_plane_to_eye_space(view: [[f32; 4]; 4], plane_world: [f32; 4]) -> [f32; 4] { let inv = mat4_invert(view); // Inverse-transpose, column-major. Multiplying the *transpose* of // `inv` by the plane vector is the same as multiplying `inv` @@ -268,10 +287,7 @@ pub fn world_plane_to_eye_space( /// `plane_eye_space` is `(Nx, Ny, Nz, d)` such that points on the /// plane satisfy `N · p_eye + d = 0`. Use `world_plane_to_eye_space` /// to convert from a world-space plane. -pub fn oblique_proj( - proj: [[f32; 4]; 4], - plane_eye_space: [f32; 4], -) -> [[f32; 4]; 4] { +pub fn oblique_proj(proj: [[f32; 4]; 4], plane_eye_space: [f32; 4]) -> [[f32; 4]; 4] { let c = plane_eye_space; // Far-plane corner in clip space is in the direction of (sgn(c.x), @@ -283,11 +299,14 @@ pub fn oblique_proj( let inv_p = mat4_invert(proj); let q = mat4_mul_vec4(&inv_p, &q_clip); - // Scale `c` so the near-plane crosses through the supplied plane: - // M = (2 / dot(c, q)) · c - // Then the new third row of P (which controls the depth output) is - // P_row2 = M - P_row3 - // (P_row3 is the standard perspective w-row, all stays the same.) + // Scale `c` so the near plane crosses through the supplied plane. wgpu + // uses D3D/Metal depth (`0 <= z <= w`), so the replacement z row is: + // + // P_row2 = c / dot(c, q) + // + // The common OpenGL form, `2c / dot(c,q) - P_row3`, instead targets + // `-w <= z <= w`. Using it here moves the wrong clip plane and can reject + // every above-water draw from a mirrored camera. let denom = c[0] * q[0] + c[1] * q[1] + c[2] * q[2] + c[3] * q[3]; if denom.abs() < 1e-10 { // Degenerate plane orientation w.r.t. the frustum — leave @@ -295,21 +314,16 @@ pub fn oblique_proj( // reflection still renders, just without near-plane clipping. return proj; } - let scale = 2.0 / denom; + let scale = 1.0 / denom; let m = [c[0] * scale, c[1] * scale, c[2] * scale, c[3] * scale]; // proj is column-major: proj[col][row]. The "third row" we want - // to replace is at row index 2 across all four columns. The - // "fourth row" is at row index 3. New row-2 = M - row3 — but - // since wgpu's clip-space z range is [0, 1] (not [-1, 1] like - // OpenGL), the depth-rescaling pre-step is `M - P_row3` exactly - // as in Lengyel's original derivation; the [-1, 1] vs [0, 1] - // difference is absorbed by the scale. + // to replace is at row index 2 across all four columns. let mut out = proj; - out[0][2] = m[0] - proj[0][3]; - out[1][2] = m[1] - proj[1][3]; - out[2][2] = m[2] - proj[2][3]; - out[3][2] = m[3] - proj[3][3]; + out[0][2] = m[0]; + out[1][2] = m[1]; + out[2][2] = m[2]; + out[3][2] = m[3]; out } @@ -352,7 +366,11 @@ mod tests { let r = reflection_matrix(2.0, [0.0, 1.0, 0.0]); let p = [0.0_f32, 10.0, 0.0, 1.0]; let out = mat4_mul_vec4(&r, &p); - assert!((out[1] - (-6.0)).abs() < 1e-4, "y reflected across y=2 (got {})", out[1]); + assert!( + (out[1] - (-6.0)).abs() < 1e-4, + "y reflected across y=2 (got {})", + out[1] + ); } /// Camera position helper agrees with applying the matrix to a @@ -397,15 +415,15 @@ mod tests { power_preference: wgpu::PowerPreference::LowPower, compatible_surface: None, force_fallback_adapter: true, - })).ok()?; - let (device, queue) = pollster::block_on(adapter.request_device( - &wgpu::DeviceDescriptor { - label: Some("planar-reflection-test-device"), - required_features: wgpu::Features::empty(), - required_limits: wgpu::Limits::downlevel_defaults(), - ..Default::default() - }, - )).ok()?; + })) + .ok()?; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("planar-reflection-test-device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_defaults(), + ..Default::default() + })) + .ok()?; Some((device, queue)) } @@ -415,7 +433,9 @@ mod tests { /// `Renderer::dispatch_planar_reflections`. #[test] fn probe_creation_allocates_hdr_and_depth_attachments() { - let Some((device, _queue)) = try_create_device() else { return; }; + let Some((device, _queue)) = try_create_device() else { + return; + }; let probe = PlanarReflectionProbe::new(&device, 0.5, [0.0, 1.0, 0.0], 256); assert_eq!(probe.resolution, 256, "resolution clamped to caller value"); assert_eq!(probe.normal, [0.0, 1.0, 0.0], "normalised +Y stays +Y"); @@ -429,16 +449,17 @@ mod tests { /// Tiny resolutions are clamped up to 16 px to keep wgpu happy. #[test] fn probe_resolution_clamps_minimum() { - let Some((device, _queue)) = try_create_device() else { return; }; + let Some((device, _queue)) = try_create_device() else { + return; + }; let probe = PlanarReflectionProbe::new(&device, 0.0, [0.0, 1.0, 0.0], 4); assert_eq!(probe.resolution, 16, "clamps to 16 px floor"); } /// EN-011 V2 — oblique projection clips a point on the wrong side - /// of the supplied plane. Point ABOVE the eye-space plane should - /// project inside the [-w, w] z range (clip-space-z divides to - /// [0, 1] in wgpu); point BELOW the plane projects with z > w - /// (i.e. ndc.z > 1 in wgpu) so the rasterizer clips it. + /// of the supplied plane. A point ON the plane must map to z=0, + /// point ABOVE it must project inside the wgpu [0, w] depth range, + /// and point BELOW it must project outside that range. /// /// Setup: identity view (eye-space == world-space), a horizontal /// plane y = 0 (so eye-space plane = (0, 1, 0, 0)), and a perspective @@ -448,23 +469,40 @@ mod tests { fn oblique_proj_clips_below_plane() { use crate::renderer::util::mat4_perspective; // Standard perspective matching mat4_perspective conventions. - let proj = mat4_perspective(70.0_f32.to_radians(), 16.0/9.0, 0.1, 100.0); + let proj = mat4_perspective(70.0_f32.to_radians(), 16.0 / 9.0, 0.1, 100.0); // Horizontal plane y = 0 in eye-space, normal +Y, points above // satisfy y > 0 → N·p + d > 0 (kept). The plane equation // (Nx, Ny, Nz, d) for "y >= 0 is above" is (0, 1, 0, 0). let plane_eye = [0.0_f32, 1.0, 0.0, 0.0]; let oblique = oblique_proj(proj, plane_eye); + // This assertion distinguishes wgpu's [0,1] replacement from the + // familiar OpenGL formula, which incorrectly maps the plane to z=-w. + let p_on_plane = [3.0_f32, 0.0, -10.0, 1.0]; + let clip_on_plane = mat4_mul_vec4(&oblique, &p_on_plane); + assert!( + clip_on_plane[2].abs() < 1e-5, + "oblique plane is the wgpu z=0 near plane (got z={})", + clip_on_plane[2] + ); + // A point in eye space ABOVE the plane, in front of camera: // y = +5 (above), z = -10 (in front, right-handed view). let p_above = [3.0_f32, 5.0, -10.0, 1.0]; let clip_above = mat4_mul_vec4(&oblique, &p_above); // wgpu / D3D / Metal clip space: visible when 0 <= z <= w. // For a point above the plane, z/w should be inside [0, 1]. - assert!(clip_above[3] > 0.0, "point above plane has positive w (got {})", clip_above[3]); + assert!( + clip_above[3] > 0.0, + "point above plane has positive w (got {})", + clip_above[3] + ); let ndc_z_above = clip_above[2] / clip_above[3]; - assert!(ndc_z_above >= 0.0 && ndc_z_above <= 1.0, - "above-plane point ndc.z within [0,1] (got {})", ndc_z_above); + assert!( + ndc_z_above >= 0.0 && ndc_z_above <= 1.0, + "above-plane point ndc.z within [0,1] (got {})", + ndc_z_above + ); // A point BELOW the plane: y = -5 (below), z = -10 (in front). // Oblique projection moves the near plane to coincide with @@ -474,12 +512,12 @@ mod tests { // the fragment. let p_below = [3.0_f32, -5.0, -10.0, 1.0]; let clip_below = mat4_mul_vec4(&oblique, &p_below); - let visible = clip_below[3] > 0.0 - && clip_below[2] >= 0.0 - && clip_below[2] <= clip_below[3]; - assert!(!visible, + let visible = clip_below[3] > 0.0 && clip_below[2] >= 0.0 && clip_below[2] <= clip_below[3]; + assert!( + !visible, "below-plane point should be clipped; got clip = ({}, {}, {}, {})", - clip_below[0], clip_below[1], clip_below[2], clip_below[3]); + clip_below[0], clip_below[1], clip_below[2], clip_below[3] + ); } /// `world_plane_to_eye_space` round-trip: a horizontal world plane @@ -491,9 +529,13 @@ mod tests { let plane_world = [0.0_f32, 1.0, 0.0, 0.0]; // y = 0 plane let plane_eye = world_plane_to_eye_space(IDENTITY_MAT4, plane_world); for i in 0..4 { - assert!((plane_eye[i] - plane_world[i]).abs() < 1e-5, + assert!( + (plane_eye[i] - plane_world[i]).abs() < 1e-5, "identity view leaves plane unchanged (comp {}: {} vs {})", - i, plane_eye[i], plane_world[i]); + i, + plane_eye[i], + plane_world[i] + ); } } } diff --git a/native/shared/src/renderer/post_pass.rs b/native/shared/src/renderer/post_pass.rs index 7e0f5573..d9182240 100644 --- a/native/shared/src/renderer/post_pass.rs +++ b/native/shared/src/renderer/post_pass.rs @@ -21,6 +21,10 @@ use wgpu; pub struct PostPassPipeline { pub pipeline: wgpu::RenderPipeline, pub bind_group_layout: wgpu::BindGroupLayout, + /// Both possible LDR ping-pong inputs. A pass normally uses only its + /// index parity, but retaining both identities keeps the cache complete + /// if stack editing grows beyond append/clear. + pub bind_group_cache: [Option; 2], } #[derive(Debug)] @@ -173,7 +177,11 @@ pub fn compile_post_pass( cache: None, }); - Ok(PostPassPipeline { pipeline, bind_group_layout }) + Ok(PostPassPipeline { + pipeline, + bind_group_layout, + bind_group_cache: [None, None], + }) } /// Allocate the LDR intermediate render target the composite pass @@ -188,13 +196,16 @@ pub fn create_composite_ldr_rt( ) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("composite_ldr_rt"), - size: wgpu::Extent3d { width: width.max(1), height: height.max(1), depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: width.max(1), + height: height.max(1), + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -216,15 +227,15 @@ mod tests { power_preference: wgpu::PowerPreference::LowPower, compatible_surface: None, force_fallback_adapter: true, - })).ok()?; - let (device, queue) = pollster::block_on(adapter.request_device( - &wgpu::DeviceDescriptor { - label: Some("post-pass-test-device"), - required_features: wgpu::Features::empty(), - required_limits: wgpu::Limits::downlevel_defaults(), - ..Default::default() - }, - )).ok()?; + })) + .ok()?; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("post-pass-test-device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_defaults(), + ..Default::default() + })) + .ok()?; Some((device, queue)) } @@ -233,7 +244,9 @@ mod tests { /// where `try_create_device` returns None (CI without a GPU). #[test] fn underwater_tint_compiles() { - let Some((device, _queue)) = try_create_device() else { return; }; + let Some((device, _queue)) = try_create_device() else { + return; + }; let wgsl = r#" @fragment fn fs_main(@location(0) uv: vec2) -> @location(0) vec4 { @@ -241,9 +254,11 @@ mod tests { return vec4(scene.rgb * vec3(0.4, 0.7, 0.9), 1.0); } "#; - let result = compile_post_pass( - &device, wgsl, wgpu::TextureFormat::Bgra8UnormSrgb, + let result = compile_post_pass(&device, wgsl, wgpu::TextureFormat::Bgra8UnormSrgb); + assert!( + result.is_ok(), + "underwater tint should compile: {:?}", + result.err() ); - assert!(result.is_ok(), "underwater tint should compile: {:?}", result.err()); } } diff --git a/native/shared/src/renderer/postfx_chain.rs b/native/shared/src/renderer/postfx_chain.rs index 5484c957..510e9bb8 100644 --- a/native/shared/src/renderer/postfx_chain.rs +++ b/native/shared/src/renderer/postfx_chain.rs @@ -6,7 +6,213 @@ use super::*; use wgpu::util::DeviceExt; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(usize)] +pub(super) enum CompositeSource { + Hdr, + Upscale, + Taa0, + Taa1, + DepthOfField, + MotionBlur, + SubsurfaceScattering, + ContrastAdaptiveSharpen, +} + +impl CompositeSource { + pub(super) const COUNT: usize = 8; + + pub(super) const fn bind_group_cache_index(self, exposure_idx: usize) -> usize { + self as usize * 2 + exposure_idx + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(usize)] +pub(super) enum SsrCompositeSource { + Fallback, + History0, + History1, +} + +impl SsrCompositeSource { + pub(super) const COUNT: usize = 3; +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(usize)] +pub(super) enum PostFxSource { + Hdr, + Composed, + Upscale, + Taa0, + Taa1, + DepthOfField, + MotionBlur, + SubsurfaceScattering, +} + +impl PostFxSource { + pub(super) const COUNT: usize = 8; +} + +#[inline] +fn bloom_threshold(auto_exposure: bool, manual_exposure: f32) -> f32 { + if auto_exposure { + 2.5 + } else { + 2.5 / manual_exposure.max(0.05) + } +} + +#[inline] +fn taa_current_weight(history_valid: bool, frame_index: u32, render_scale: f32) -> f32 { + if !history_valid || frame_index < 4 { + 1.0 + } else { + let s2 = render_scale * render_scale; + 0.05 + 0.05 * s2 + } +} + +fn reactive_taa_cache_key(plan_id: u64, rebuild_epoch: u64) -> (u64, u64) { + (plan_id, rebuild_epoch) +} + +#[inline] +fn exposure_update_rate(history_valid: bool, authored_rate: f32) -> f32 { + if history_valid { + authored_rate + } else { + -1.0 + } +} + +#[inline] +fn bloom_mip_extent(width: u32, height: u32, mip_index: usize) -> (u32, u32) { + ( + ((width / 2) >> mip_index).max(1), + ((height / 2) >> mip_index).max(1), + ) +} + impl Renderer { + /// Build the stable bloom pass bindings after the mip chain is created or + /// resized. Each pass needs its own uniform buffer because all CPU writes + /// happen before the shared command encoder is submitted. + pub(super) fn rebuild_bloom_pass_resources(&mut self) { + let downsample_count = BLOOM_MIP_COUNT as usize; + let upsample_count = downsample_count.saturating_sub(1); + let (render_width, render_height) = self.render_extent(); + let filter_radius = 1.0_f32; + let threshold = bloom_threshold(self.auto_exposure, self.manual_exposure); + + // These texel sizes are immutable until the next resize. Initialize + // the buffers directly rather than overwriting all eleven every + // frame; reusing an in-flight Metal buffer can otherwise serialize + // the resource scheduler. + self.bloom_downsample_param_buffers = (0..downsample_count) + .map(|i| { + let (source_width, source_height) = if i == 0 { + (render_width, render_height) + } else { + bloom_mip_extent(render_width, render_height, i - 1) + }; + let params = BloomParams { + params: [ + 1.0 / source_width as f32, + 1.0 / source_height as f32, + filter_radius, + threshold, + ], + }; + let usage = if i == 0 { + wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST + } else { + wgpu::BufferUsages::UNIFORM + }; + self.device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("bloom_downsample_params"), + contents: bytemuck::bytes_of(¶ms), + usage, + }) + }) + .collect(); + self.bloom_upsample_param_buffers = (0..upsample_count) + .map(|i| { + let (source_width, source_height) = + bloom_mip_extent(render_width, render_height, i + 1); + let params = BloomParams { + params: [ + 1.0 / source_width as f32, + 1.0 / source_height as f32, + filter_radius, + 0.0, + ], + }; + self.device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("bloom_upsample_params"), + contents: bytemuck::bytes_of(¶ms), + usage: wgpu::BufferUsages::UNIFORM, + }) + }) + .collect(); + self.bloom_threshold_written = threshold; + + self.bloom_downsample_bind_groups = (0..downsample_count) + .map(|i| { + let src_view = if i == 0 { + &self.hdr_rt_view + } else { + &self.bloom_mip_views[i - 1] + }; + self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("bloom_downsample_bg"), + layout: &self.bloom_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.bloom_downsample_param_buffers[i].as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(src_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + ], + }) + }) + .collect(); + self.bloom_upsample_bind_groups = (0..upsample_count) + .map(|i| { + self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("bloom_upsample_bg"), + layout: &self.bloom_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.bloom_upsample_param_buffers[i].as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView( + &self.bloom_mip_views[i + 1], + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + ], + }) + }) + .collect(); + } /// Bloom: progressive downsample (Karis-thresholded first tap) /// followed by additive upsample back up the chain. No-op (clears /// nothing) when disabled — compose skips the bloom sample. @@ -17,158 +223,210 @@ impl Renderer { surf_w: u32, surf_h: u32, ) { - // ============================================================ - // Bloom: progressive downsample (Karis-thresholded first tap) - // followed by additive upsample back up the chain. - // ============================================================ - if self.bloom_enabled { - let mip_dims: Vec<(u32, u32)> = (0..BLOOM_MIP_COUNT) - .map(|i| ( - ((surf_w / 2) >> i).max(1), - ((surf_h / 2) >> i).max(1), - )) - .collect(); - - // Build per-pass bind groups + uniform writes. Each downsample - // reads the previous mip (or hdr_rt for the first) and writes - // to the current mip. Each upsample reads mip i+1 and blends - // additively into mip i. - let bloom_filter_radius = 1.0_f32; // upsample tent radius - - // Downsample chain: mip 0 reads HDR, mips 1..N read previous mip. - for i in 0..BLOOM_MIP_COUNT as usize { - let (src_view, src_w, src_h, threshold_pass) = if i == 0 { - (&self.hdr_rt_view, surf_w as f32, surf_h as f32, true) - } else { - let prev = &self.bloom_mip_views[i - 1]; - let (pw, ph) = mip_dims[i - 1]; - (prev, pw as f32, ph as f32, false) - }; + // ============================================================ + // Bloom: progressive downsample (Karis-thresholded first tap) + // followed by additive upsample back up the chain. + // ============================================================ + if self.bloom_enabled { + let bloom_filter_radius = 1.0_f32; // upsample tent radius + // Texel sizes are fixed until resize. Only the threshold in the + // first pass can change at runtime, and only when exposure does. + let threshold = bloom_threshold(self.auto_exposure, self.manual_exposure); + if threshold.to_bits() != self.bloom_threshold_written.to_bits() { + let params = BloomParams { + params: [ + 1.0 / surf_w as f32, + 1.0 / surf_h as f32, + bloom_filter_radius, + threshold, + ], + }; + self.queue.write_buffer( + &self.bloom_downsample_param_buffers[0], + 0, + bytemuck::bytes_of(¶ms), + ); + self.bloom_threshold_written = threshold; + } - // Threshold in pre-exposure HDR units. The 2.5 reference was tuned - // at exposure 1.0 (Sponza), so scale it inversely with the game's - // manual exposure to keep bloom triggering at the same DISPLAY - // brightness regardless of exposure. Auto-exposure keeps the legacy - // constant — 2.5 was tuned against auto-normalised HDR originally. - let thr = if self.auto_exposure { - 2.5 - } else { - 2.5 / self.manual_exposure.max(0.05) - }; - let bp = BloomParams { - params: [1.0 / src_w, 1.0 / src_h, bloom_filter_radius, thr], - }; - // Per-pass uniform buffer. A single shared buffer written with - // queue.write_buffer once per pass does NOT work here: all writes - // land before the encoder executes at submit, so every pass in the - // chain read the LAST write (wrong texel sizes for every mip, and - // it would clobber the threshold above). - let ub = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("bloom_downsample_params"), - contents: bytemuck::bytes_of(&bp), - usage: wgpu::BufferUsages::UNIFORM, - }); - - let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("bloom_downsample_bg"), - layout: &self.bloom_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(src_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - ], - }); - - let bloom_ts = profiler.pass_timestamp_writes("bloom_pass"); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("bloom_downsample_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.bloom_mip_views[i], - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: bloom_ts, - occlusion_query_set: None, - multiview_mask: None, - }); - let pl = if threshold_pass { - &self.bloom_pipeline_threshold_downsample - } else { - &self.bloom_pipeline_downsample - }; - pass.set_pipeline(pl); - // Force the viewport to this mip's actual size — wgpu's - // auto-viewport derives from the surface config, not the - // mip-view attachment, so without this the bloom pass - // writes into a fraction of the mip and leaves the rest - // uninitialized. - let (mw, mh) = mip_dims[i]; - pass.set_viewport(0.0, 0.0, mw as f32, mh as f32, 0.0, 1.0); - pass.set_bind_group(0, &bg, &[]); - pass.draw(0..3, 0..1); + // Downsample chain: mip 0 reads HDR, mips 1..N read previous mip. + for i in 0..BLOOM_MIP_COUNT as usize { + let threshold_pass = i == 0; + + let bloom_ts = profiler.pass_timestamp_writes("bloom_pass"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_downsample_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.bloom_mip_views[i], + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: bloom_ts, + occlusion_query_set: None, + multiview_mask: None, + }); + let pl = if threshold_pass { + &self.bloom_pipeline_threshold_downsample + } else { + &self.bloom_pipeline_downsample + }; + pass.set_pipeline(pl); + // Force the viewport to this mip's actual size — wgpu's + // auto-viewport derives from the surface config, not the + // mip-view attachment, so without this the bloom pass + // writes into a fraction of the mip and leaves the rest + // uninitialized. + let (mw, mh) = bloom_mip_extent(surf_w, surf_h, i); + pass.set_viewport(0.0, 0.0, mw as f32, mh as f32, 0.0, 1.0); + pass.set_bind_group(0, &self.bloom_downsample_bind_groups[i], &[]); + pass.draw(0..3, 0..1); + } + + // Upsample chain: blend mip i+1 additively into mip i for + // i = N-2..0. Final mip 0 ends up with the full bloom result. + for i in (0..(BLOOM_MIP_COUNT as usize - 1)).rev() { + let bloom_up_ts = profiler.pass_timestamp_writes("bloom_pass"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_upsample_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.bloom_mip_views[i], + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + // Load — additive blend on top of what + // downsample wrote. + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: bloom_up_ts, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.bloom_pipeline_upsample); + // Same viewport fix as the downsample loop above — without + // this the upsample tents only cover a sub-region of the + // destination mip. + let (mw, mh) = bloom_mip_extent(surf_w, surf_h, i); + pass.set_viewport(0.0, 0.0, mw as f32, mh as f32, 0.0, 1.0); + pass.set_bind_group(0, &self.bloom_upsample_bind_groups[i], &[]); + pass.draw(0..3, 0..1); + } + } // end if self.bloom_enabled } +} - // Upsample chain: blend mip i+1 additively into mip i for - // i = N-2..0. Final mip 0 ends up with the full bloom result. - for i in (0..(BLOOM_MIP_COUNT as usize - 1)).rev() { - let src_view = &self.bloom_mip_views[i + 1]; - let (sw, sh) = mip_dims[i + 1]; +#[cfg(test)] +mod tests { + use super::{ + bloom_mip_extent, bloom_threshold, exposure_update_rate, reactive_taa_cache_key, + taa_current_weight, CompositeSource, PostFxSource, SsrCompositeSource, + }; - let bp = BloomParams { - params: [1.0 / sw as f32, 1.0 / sh as f32, bloom_filter_radius, 0.0], - }; - // Per-pass uniform buffer — see the downsample loop note: a shared - // buffer + write_buffer per pass aliases to the LAST write at submit. - let ub = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("bloom_upsample_params"), - contents: bytemuck::bytes_of(&bp), - usage: wgpu::BufferUsages::UNIFORM, - }); - - let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("bloom_upsample_bg"), - layout: &self.bloom_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(src_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - ], - }); - - let bloom_up_ts = profiler.pass_timestamp_writes("bloom_pass"); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("bloom_upsample_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.bloom_mip_views[i], - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - // Load — additive blend on top of what - // downsample wrote. - load: wgpu::LoadOp::Load, - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: bloom_up_ts, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(&self.bloom_pipeline_upsample); - // Same viewport fix as the downsample loop above — without - // this the upsample tents only cover a sub-region of the - // destination mip. - let (mw, mh) = mip_dims[i]; - pass.set_viewport(0.0, 0.0, mw as f32, mh as f32, 0.0, 1.0); - pass.set_bind_group(0, &bg, &[]); - pass.draw(0..3, 0..1); + #[test] + fn bloom_mip_extent_matches_half_resolution_chain_and_never_reaches_zero() { + assert_eq!(bloom_mip_extent(512, 256, 0), (256, 128)); + assert_eq!(bloom_mip_extent(512, 256, 3), (32, 16)); + assert_eq!(bloom_mip_extent(3, 1, 0), (1, 1)); + assert_eq!(bloom_mip_extent(3, 1, 8), (1, 1)); } - } // end if self.bloom_enabled + + #[test] + fn bloom_threshold_tracks_manual_exposure_and_clamps_black() { + assert_eq!(bloom_threshold(false, 1.0), 2.5); + assert_eq!(bloom_threshold(false, 2.0), 1.25); + assert_eq!(bloom_threshold(false, 0.0), 50.0); + assert_eq!(bloom_threshold(false, -1.0), 50.0); + } + + #[test] + fn bloom_threshold_stays_in_pre_exposed_units_for_auto_exposure() { + assert_eq!(bloom_threshold(true, 0.0), 2.5); + assert_eq!(bloom_threshold(true, 8.0), 2.5); + } + + #[test] + fn composite_cache_key_covers_every_source_and_exposure_slot() { + let sources = [ + CompositeSource::Hdr, + CompositeSource::Upscale, + CompositeSource::Taa0, + CompositeSource::Taa1, + CompositeSource::DepthOfField, + CompositeSource::MotionBlur, + CompositeSource::SubsurfaceScattering, + CompositeSource::ContrastAdaptiveSharpen, + ]; + let mut keys = Vec::new(); + for source in sources { + for exposure_idx in 0..2 { + keys.push(source.bind_group_cache_index(exposure_idx)); + } + } + keys.sort_unstable(); + keys.dedup(); + + assert_eq!(keys, (0..CompositeSource::COUNT * 2).collect::>()); + } + + #[test] + fn scene_compose_cache_has_one_slot_for_every_ssr_source() { + let sources = [ + SsrCompositeSource::Fallback, + SsrCompositeSource::History0, + SsrCompositeSource::History1, + ]; + let mut keys: Vec<_> = sources.into_iter().map(|source| source as usize).collect(); + keys.sort_unstable(); + keys.dedup(); + + assert_eq!(keys, (0..SsrCompositeSource::COUNT).collect::>()); + } + + #[test] + fn reactive_taa_cache_key_includes_plan_and_pool_generation() { + let key = reactive_taa_cache_key(41, 7); + assert_ne!(key, reactive_taa_cache_key(42, 7)); + assert_ne!(key, reactive_taa_cache_key(41, 8)); + } + + #[test] + fn postfx_source_keys_are_dense_and_unique() { + let sources = [ + PostFxSource::Hdr, + PostFxSource::Composed, + PostFxSource::Upscale, + PostFxSource::Taa0, + PostFxSource::Taa1, + PostFxSource::DepthOfField, + PostFxSource::MotionBlur, + PostFxSource::SubsurfaceScattering, + ]; + let mut keys: Vec<_> = sources.into_iter().map(|source| source as usize).collect(); + keys.sort_unstable(); + keys.dedup(); + assert_eq!(keys, (0..PostFxSource::COUNT).collect::>()); + } + + #[test] + fn invalid_taa_history_is_replaced_without_changing_steady_weights() { + assert_eq!(taa_current_weight(false, 99, 0.5), 1.0); + assert_eq!(taa_current_weight(true, 3, 1.0), 1.0); + assert_eq!(taa_current_weight(true, 4, 0.5), 0.0625); + assert_eq!(taa_current_weight(true, 4, 1.0), 0.1); + } + + #[test] + fn invalid_exposure_history_seeds_once_then_keeps_authored_adaptation() { + assert_eq!(exposure_update_rate(false, 0.0), -1.0); + assert_eq!(exposure_update_rate(false, 0.05), -1.0); + assert_eq!(exposure_update_rate(true, 0.05), 0.05); } } @@ -177,125 +435,230 @@ impl Renderer { /// shafts into composed_rt. Runs unconditionally so the TAA-on path /// (TAA consumes this) and the TAA-off path (composite consumes it) /// see the same atmospherics. - pub(super) fn record_scene_compose( - &mut self, - encoder: &mut wgpu::CommandEncoder, - ) { + pub(super) fn record_scene_compose(&mut self, encoder: &mut wgpu::CommandEncoder) { // Composite input views (were locals in end_frame_with_scene). // PT-1: while path tracing, the SSR passes are skipped entirely, // so history is stale — route compose to ssr_rt, which the march // else-branch keeps cleared to transparent black. - let ssr_composite_view = if self.ssr_enabled && !self.pt_owns_frame() { - &self.ssr_history_views[self.ssr_history_idx] + let ssr_composite_source = self.ssr_composite_source(); + // ============================================================ + // Scene-compose pass: merge HDR + SSR + SSGI*albedo + bloom + // + fog + sun shafts into composed_rt. Runs unconditionally + // so both the TAA-on path (TAA consumes this) and the + // TAA-off path (composite consumes this) get the same + // atmospherics + post-effects. + // ============================================================ + let inv_vp_current = self.current_inv_vp_matrix; + // Sun shaft screen-space position. Project a point far along + // the sun direction through the current VP. If behind the + // camera (clip.w ≤ 0), the sun is off-screen → disable. + let sun_dir = self.lighting_uniforms.light_dir; + let sun_world = [ + sun_dir[0] * 1000.0, + sun_dir[1] * 1000.0, + sun_dir[2] * 1000.0, + 1.0, + ]; + let clip = mat4_mul_vec4(&self.current_vp_matrix, &sun_world); + let (sun_uv, shaft_strength_eff) = if clip[3] > 0.0 { + let ndc_x = clip[0] / clip[3]; + let ndc_y = clip[1] / clip[3]; + let u = ndc_x * 0.5 + 0.5; + let v = 1.0 - (ndc_y * 0.5 + 0.5); + // Allow off-screen suns to still cast shafts that streak + // in from the edge — clamp to a small margin beyond ±[0,1] + // rather than disabling outright. + // `RangeInclusive::contains` also rejects NaN. Invalid projection + // input therefore disables shafts instead of forwarding NaN UVs. + let off = !(-1.0..=2.0).contains(&u) || !(-1.0..=2.0).contains(&v); + if off { + ([0.0, 0.0], 0.0) + } else { + ([u, v], self.sun_shaft_strength) + } } else { - &self.ssr_rt_view + ([0.0, 0.0], 0.0) }; - let ssgi_composite_view = &self.ssgi_rt_view; - // ============================================================ - // Scene-compose pass: merge HDR + SSR + SSGI*albedo + bloom - // + fog + sun shafts into composed_rt. Runs unconditionally - // so both the TAA-on path (TAA consumes this) and the - // TAA-off path (composite consumes this) get the same - // atmospherics + post-effects. - // ============================================================ - let inv_vp_current = self.current_inv_vp_matrix; - // Sun shaft screen-space position. Project a point far along - // the sun direction through the current VP. If behind the - // camera (clip.w ≤ 0), the sun is off-screen → disable. - let sun_dir = self.lighting_uniforms.light_dir; - let sun_world = [sun_dir[0] * 1000.0, sun_dir[1] * 1000.0, sun_dir[2] * 1000.0, 1.0]; - let clip = mat4_mul_vec4(&self.current_vp_matrix, &sun_world); - let (sun_uv, shaft_strength_eff) = if clip[3] > 0.0 { - let ndc_x = clip[0] / clip[3]; - let ndc_y = clip[1] / clip[3]; - let u = ndc_x * 0.5 + 0.5; - let v = 1.0 - (ndc_y * 0.5 + 0.5); - // Allow off-screen suns to still cast shafts that streak - // in from the edge — clamp to a small margin beyond ±[0,1] - // rather than disabling outright. - let off = u < -1.0 || u > 2.0 || v < -1.0 || v > 2.0; - if off { ([0.0, 0.0], 0.0) } else { ([u, v], self.sun_shaft_strength) } - } else { - ([0.0, 0.0], 0.0) - }; - // When bloom_enabled is false we skip the downsample/upsample - // chain entirely; forcing the composite's bloom multiplier to - // 0 here means stale bloom_mip_views[0] contents contribute - // nothing visually. - let effective_bloom_intensity = if self.bloom_enabled { self.bloom_intensity } else { 0.0 }; - let cp = SceneComposeParams { - // misc.y = procedural-sky aerial-perspective on/off flag. - // The scene_compose shader reads this to decide between - // the legacy 16-step fog march and the V2 3D LUT sample. - misc: [ - effective_bloom_intensity, - if self.procedural_sky_enabled { 1.0 } else { 0.0 }, - AERIAL_MAX_DIST_KM, - 0.0, - ], - inv_vp: inv_vp_current, - fog_color_density: [ - self.fog_color[0], self.fog_color[1], self.fog_color[2], self.fog_density, - ], - fog_params: [self.fog_height_ref, self.fog_height_falloff, 0.0, 0.0], - sun_shaft_uv_strength: [ - sun_uv[0], sun_uv[1], shaft_strength_eff, self.sun_shaft_decay, - ], - sun_shaft_color: [ - self.sun_shaft_color[0], self.sun_shaft_color[1], self.sun_shaft_color[2], 0.0, - ], - }; - self.queue.write_buffer(&self.scene_compose_uniform_buffer, 0, bytemuck::bytes_of(&cp)); - { - let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("scene_compose_bg"), - layout: &self.scene_compose_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.scene_compose_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.hdr_rt_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(ssr_composite_view) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(ssgi_composite_view) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&self.bloom_mip_views[0]) }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::TextureView(&self.albedo_rt_view) }, - wgpu::BindGroupEntry { binding: 10, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 11, resource: wgpu::BindingResource::TextureView(&self.depth_view) }, - wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler) }, - // EN-005 V2 — always bound; shader gates use on `misc.y`. - wgpu::BindGroupEntry { binding: 13, resource: wgpu::BindingResource::TextureView(&self.aerial_perspective_view) }, - wgpu::BindGroupEntry { binding: 14, resource: wgpu::BindingResource::Sampler(&self.aerial_perspective_sampler) }, - ], - }); - // NOTE: GPU timestamp deliberately not requested on this pass. - // Empirically (sponza, Metal) the reported delta was ~249 ms - // for what should be a sub-millisecond fullscreen pass. Likely - // the end-of-pass write is synchronized to a later barrier - // and includes idle time. CPU-side timing via the enclosing - // `post_fx` phase captures the cost adequately. - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("scene_compose_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.composed_rt_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, + // When bloom_enabled is false we skip the downsample/upsample + // chain entirely; forcing the composite's bloom multiplier to + // 0 here means stale bloom_mip_views[0] contents contribute + // nothing visually. + let effective_bloom_intensity = if self.bloom_enabled { + self.bloom_intensity + } else { + 0.0 + }; + let cp = SceneComposeParams { + // misc.y = procedural-sky aerial-perspective on/off flag. + // The scene_compose shader reads this to decide between + // the legacy 16-step fog march and the V2 3D LUT sample. + misc: [ + effective_bloom_intensity, + if self.procedural_sky_enabled { + 1.0 + } else { + 0.0 }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(&self.scene_compose_pipeline); - pass.set_bind_group(0, &bg, &[]); - pass.draw(0..3, 0..1); + AERIAL_MAX_DIST_KM, + 0.0, + ], + inv_vp: inv_vp_current, + fog_color_density: [ + self.fog_color[0], + self.fog_color[1], + self.fog_color[2], + self.fog_density, + ], + fog_params: [self.fog_height_ref, self.fog_height_falloff, 0.0, 0.0], + sun_shaft_uv_strength: [ + sun_uv[0], + sun_uv[1], + shaft_strength_eff, + self.sun_shaft_decay, + ], + sun_shaft_color: [ + self.sun_shaft_color[0], + self.sun_shaft_color[1], + self.sun_shaft_color[2], + 0.0, + ], + }; + self.queue.write_buffer( + &self.scene_compose_uniform_buffer, + 0, + bytemuck::bytes_of(&cp), + ); + { + let cache_index = ssr_composite_source as usize; + if self.scene_compose_bind_group_cache[cache_index].is_none() { + self.frame_resource_stats + .created_bind_group(frame_resource_stats::BindGroupCreationSite::SceneCompose); + let ssr_composite_view = self.ssr_composite_view_for(ssr_composite_source); + let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("scene_compose_bg"), + layout: &self.scene_compose_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.scene_compose_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&self.hdr_rt_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(ssr_composite_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&self.ssgi_rt_view), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(&self.bloom_mip_views[0]), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::TextureView(&self.albedo_rt_view), + }, + wgpu::BindGroupEntry { + binding: 10, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: wgpu::BindingResource::TextureView(&self.depth_view), + }, + wgpu::BindGroupEntry { + binding: 12, + resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler), + }, + // EN-005 V2 — always bound; shader gates use on `misc.y`. + wgpu::BindGroupEntry { + binding: 13, + resource: wgpu::BindingResource::TextureView( + &self.aerial_perspective_view, + ), + }, + wgpu::BindGroupEntry { + binding: 14, + resource: wgpu::BindingResource::Sampler( + &self.aerial_perspective_sampler, + ), + }, + ], + }); + self.scene_compose_bind_group_cache[cache_index] = Some(bg); + } + // NOTE: GPU timestamp deliberately not requested on this pass. + // Empirically (sponza, Metal) the reported delta was ~249 ms + // for what should be a sub-millisecond fullscreen pass. Likely + // the end-of-pass write is synchronized to a later barrier + // and includes idle time. CPU-side timing via the enclosing + // `post_fx` phase captures the cost adequately. + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("scene_compose_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.composed_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.scene_compose_pipeline); + pass.set_bind_group( + 0, + self.scene_compose_bind_group_cache[cache_index] + .as_ref() + .expect("scene-compose bind group was initialized"), + &[], + ); + pass.draw(0..3, 0..1); + } } + fn ssr_composite_source(&self) -> SsrCompositeSource { + if self.ssr_enabled && !self.pt_owns_frame() { + if self.ssr_history_idx == 0 { + SsrCompositeSource::History0 + } else { + SsrCompositeSource::History1 + } + } else { + SsrCompositeSource::Fallback + } + } + + fn ssr_composite_view_for(&self, source: SsrCompositeSource) -> &wgpu::TextureView { + match source { + SsrCompositeSource::Fallback => &self.ssr_rt_view, + SsrCompositeSource::History0 => &self.ssr_history_views[0], + SsrCompositeSource::History1 => &self.ssr_history_views[1], + } } } @@ -310,360 +673,760 @@ impl Renderer { encoder: &mut wgpu::CommandEncoder, profiler: &mut crate::profiler::Profiler, ) { - // ============================================================ - // Upscale pass: render-res composed_rt → full-surface upscale_rt. - // Engages only when render_scale < 1.0 AND TAA is off — when - // TAA runs it does its own Catmull-Rom upscale. Downstream - // post-FX (DoF/MB/SSS/composite) read upscale_rt instead of - // hdr_rt in this case so the chain operates at full surface - // resolution. - // ============================================================ - if self.render_scale < 0.999 && !self.taa_enabled { - let up = UpscaleParams { - params: [self.upscale_mode as f32, 0.0, 0.0, 0.0], - }; - self.queue.write_buffer(&self.upscale_uniform_buffer, 0, bytemuck::bytes_of(&up)); - let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("upscale_bg"), - layout: &self.upscale_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.upscale_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.composed_rt_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - ], - }); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("upscale_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.upscale_rt_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(&self.upscale_pipeline); - pass.set_bind_group(0, &bg, &[]); - pass.draw(0..3, 0..1); - } + // ============================================================ + // Upscale pass: render-res composed_rt → full-surface upscale_rt. + // Engages only when render_scale < 1.0 AND TAA is off — when + // TAA runs it does its own Catmull-Rom upscale. Downstream + // post-FX (DoF/MB/SSS/composite) read upscale_rt instead of + // hdr_rt in this case so the chain operates at full surface + // resolution. + // ============================================================ + if self.render_scale < 0.999 && !self.taa_enabled { + let up = UpscaleParams { + params: [self.upscale_mode as f32, 0.0, 0.0, 0.0], + }; + self.queue + .write_buffer(&self.upscale_uniform_buffer, 0, bytemuck::bytes_of(&up)); + if self.upscale_bind_group_cache.is_none() { + self.frame_resource_stats + .created_bind_group(frame_resource_stats::BindGroupCreationSite::Upscale); + self.upscale_bind_group_cache = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("upscale_bg"), + layout: &self.upscale_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.upscale_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView( + &self.composed_rt_view, + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + ], + })); + } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("upscale_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.upscale_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.upscale_pipeline); + pass.set_bind_group( + 0, + self.upscale_bind_group_cache + .as_ref() + .expect("upscale bind group was initialized"), + &[], + ); + pass.draw(0..3, 0..1); + } - // ============================================================ - // TAA pass: reprojection + neighborhood clamp on composed_rt. - // Skipped when TAA is off — composite reads composed_rt - // directly and gets the same composed / fog / shafts output. - // ============================================================ - let taa_dst_idx = self.taa_current_idx; - let taa_src_idx = 1 - self.taa_current_idx; - - if self.taa_enabled { - // Effective temporal window scales with per-pixel sample - // density (~render_scale²). At 0.5 → 0.0625 (~16-frame - // window, close to the prior 0.05/20-frame); at 1.0 → - // 0.10 (~10-frame), matching native TAA. - let s2 = self.render_scale * self.render_scale; - let steady = 0.05 + 0.05 * s2; - let alpha = if self.taa_frame_index < 4 { 1.0 } else { steady }; - // yz = the current jitter as a composed-UV offset. Content shifts by - // -jitter_ndc through the GL-convention perspective divide (w = -z), - // so the rendered position of a feature is uv + (-0.5*jx, +0.5*jy) - // (v axis flips). Empirically arbitrated by the variance rig: the - // wrong sign DOUBLES the effective jitter and the numbers scream. - let tp = TaaParams { - params: [alpha, - -0.5 * self.current_jitter_ndc[0], - 0.5 * self.current_jitter_ndc[1], - 0.0], - inv_vp: self.current_inv_vp_matrix, - prev_vp: self.prev_vp_matrix, - }; - self.queue.write_buffer(&self.taa_uniform_buffer, 0, bytemuck::bytes_of(&tp)); - - let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("taa_bg"), - layout: &self.taa_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.taa_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.composed_rt_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.taa_views[taa_src_idx]) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&self.depth_view) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&self.velocity_rt_view) }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - ], - }); - let taa_ts = profiler.pass_timestamp_writes("taa_pass"); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("taa_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.taa_views[taa_dst_idx], - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: taa_ts, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(&self.taa_pipeline); - pass.set_bind_group(0, &bg, &[]); - pass.draw(0..3, 0..1); - } + // ============================================================ + // TAA pass: reprojection + neighborhood clamp on composed_rt. + // Skipped when TAA is off — composite reads composed_rt + // directly and gets the same composed / fog / shafts output. + // ============================================================ + if self.taa_enabled { + let pt_owned = self.pt_owns_frame(); + if self.taa_history_pt_owned != pt_owned { + // Progressive PT can begin or stop owning frames without a + // mode change. Never blend across the raster/PT radiance seam. + self.taa_history_pt_owned = pt_owned; + self.taa_history_valid = false; + self.taa_current_idx = 0; + } + } + let taa_dst_idx = self.taa_current_idx; + let taa_src_idx = 1 - self.taa_current_idx; - // ============================================================ - // DoF pass: variable-radius Poisson disc blur driven by CoC - // Reads TAA output / upscale_rt / hdr_rt + depth → dof_rt - // ============================================================ - let pre_dof_view = if self.taa_enabled { - &self.taa_views[taa_dst_idx] - } else if self.render_scale < 0.999 { - &self.upscale_rt_view - } else { - &self.hdr_rt_view - }; + if self.taa_enabled { + // Effective temporal window scales with per-pixel sample + // density (~render_scale²). At 0.5 → 0.0625 (~16-frame + // window, close to the prior 0.05/20-frame); at 1.0 → + // 0.10 (~10-frame), matching native TAA. + let alpha = taa_current_weight( + self.taa_history_valid, + self.taa_frame_index, + self.render_scale, + ); + // yz = the current jitter as a composed-UV offset. Content shifts by + // -jitter_ndc through the GL-convention perspective divide (w = -z), + // so the rendered position of a feature is uv + (-0.5*jx, +0.5*jy) + // (v axis flips). Empirically arbitrated by the variance rig: the + // wrong sign DOUBLES the effective jitter and the numbers scream. + let tp = TaaParams { + params: [ + alpha, + -0.5 * self.current_jitter_ndc[0], + 0.5 * self.current_jitter_ndc[1], + 0.0, + ], + inv_vp: self.current_inv_vp_matrix, + prev_vp: self.prev_vp_matrix, + }; + self.queue + .write_buffer(&self.taa_uniform_buffer, 0, bytemuck::bytes_of(&tp)); + + if self.temporal_reactive_active { + self.ensure_taa_reactive_resources(); + } + let bg = if self.temporal_reactive_active { + let (plan_id, reactive) = { + let plan = self + .last_frame_plan + .as_ref() + .expect("reactive TAA has an active frame plan"); + ( + plan.plan_id, + plan.resource("transparency-reactive") + .expect("reactive topology declares its coverage input") + .id, + ) + }; + let cache_key = reactive_taa_cache_key(plan_id, self.transient_pool.rebuild_epoch); + if self.taa_reactive_bind_group_cache_keys[taa_src_idx] != Some(cache_key) { + let reactive_view = self + .transient_pool + .compiled_view(plan_id, reactive) + .expect("reactive coverage is materialized before post-FX"); + self.frame_resource_stats.created_bind_group( + frame_resource_stats::BindGroupCreationSite::TaaReactive, + ); + self.taa_reactive_bind_group_cache[taa_src_idx] = Some( + self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("taa_reactive_bg"), + layout: self + .taa_reactive_layout + .as_ref() + .expect("reactive TAA layout initialized"), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.taa_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView( + &self.composed_rt_view, + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler( + &self.composite_sampler, + ), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView( + &self.taa_views[taa_src_idx], + ), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler( + &self.composite_sampler, + ), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&self.depth_view), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler( + &self.ssao_depth_sampler, + ), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView( + &self.velocity_rt_view, + ), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::Sampler( + &self.composite_sampler, + ), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::TextureView(reactive_view), + }, + ], + }), + ); + self.taa_reactive_bind_group_cache_keys[taa_src_idx] = Some(cache_key); + } + self.taa_reactive_bind_group_cache[taa_src_idx] + .as_ref() + .expect("reactive TAA bind group was initialized") + .clone() + } else { + if self.taa_bind_group_cache[taa_src_idx].is_none() { + self.frame_resource_stats + .created_bind_group(frame_resource_stats::BindGroupCreationSite::Taa); + self.taa_bind_group_cache[taa_src_idx] = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("taa_bg"), + layout: &self.taa_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.taa_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView( + &self.composed_rt_view, + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler( + &self.composite_sampler, + ), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView( + &self.taa_views[taa_src_idx], + ), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler( + &self.composite_sampler, + ), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&self.depth_view), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler( + &self.ssao_depth_sampler, + ), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView( + &self.velocity_rt_view, + ), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::Sampler( + &self.composite_sampler, + ), + }, + ], + })); + } + self.taa_bind_group_cache[taa_src_idx] + .as_ref() + .expect("ordinary TAA bind group was initialized") + .clone() + }; + let taa_ts = profiler.pass_timestamp_writes("taa_pass"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("taa_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.taa_views[taa_dst_idx], + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: taa_ts, + occlusion_query_set: None, + multiview_mask: None, + }); + if self.temporal_reactive_active { + pass.set_pipeline( + self.taa_reactive_pipeline + .as_ref() + .expect("reactive TAA pipeline initialized"), + ); + } else { + pass.set_pipeline(&self.taa_pipeline); + } + pass.set_bind_group(0, &bg, &[]); + pass.draw(0..3, 0..1); + drop(pass); + #[cfg(not(target_arch = "wasm32"))] + if self.pending_quality_capture_dir.is_some() { + self.record_taa_diagnostics(encoder, &bg, self.temporal_reactive_active); + } + self.taa_history_valid = true; + self.taa_history_written = true; + } - if self.dof_enabled && self.dof_aperture > 0.0 { - let inv_proj = self.current_inv_proj_matrix; - let dp = DofParams { - params: [self.dof_focus_distance, self.dof_aperture, self.dof_max_blur, 0.0], - inv_proj, + // ============================================================ + // DoF pass: variable-radius Poisson disc blur driven by CoC + // Reads TAA output / upscale_rt / hdr_rt + depth → dof_rt + // ============================================================ + let pre_dof_source = if self.taa_enabled { + if taa_dst_idx == 0 { + PostFxSource::Taa0 + } else { + PostFxSource::Taa1 + } + } else if self.render_scale < 0.999 { + PostFxSource::Upscale + } else { + PostFxSource::Hdr }; - self.queue.write_buffer(&self.dof_uniform_buffer, 0, bytemuck::bytes_of(&dp)); - - let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("dof_bg"), - layout: &self.dof_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.dof_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(pre_dof_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.depth_view) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler) }, - ], - }); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("dof_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.dof_rt_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(&self.dof_pipeline); - pass.set_bind_group(0, &bg, &[]); - pass.draw(0..3, 0..1); - } - // ============================================================ - // Motion blur pass: 8-tap directional blur along velocity - // Reads upstream color + velocity_rt → motion_blur_rt - // ============================================================ - let pre_mblur_view = if self.dof_enabled && self.dof_aperture > 0.0 { - &self.dof_rt_view - } else if self.taa_enabled { - &self.taa_views[taa_dst_idx] - } else if self.render_scale < 0.999 { - &self.upscale_rt_view - } else { - &self.hdr_rt_view - }; + if self.dof_enabled && self.dof_aperture > 0.0 { + let inv_proj = self.current_inv_proj_matrix; + let dp = DofParams { + params: [ + self.dof_focus_distance, + self.dof_aperture, + self.dof_max_blur, + 0.0, + ], + inv_proj, + }; + self.queue + .write_buffer(&self.dof_uniform_buffer, 0, bytemuck::bytes_of(&dp)); - if self.motion_blur_enabled && self.motion_blur_strength > 0.0 { - let mbp = MotionBlurParams { - params: [self.motion_blur_strength, self.motion_blur_max_blur, 0.0, 0.0], + let cache_index = pre_dof_source as usize; + if self.dof_bind_group_cache[cache_index].is_none() { + self.frame_resource_stats + .created_bind_group(frame_resource_stats::BindGroupCreationSite::DepthOfField); + let pre_dof_view = self.postfx_source_view_for(pre_dof_source); + self.dof_bind_group_cache[cache_index] = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("dof_bg"), + layout: &self.dof_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.dof_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(pre_dof_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&self.depth_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler), + }, + ], + })); + } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("dof_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.dof_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.dof_pipeline); + pass.set_bind_group( + 0, + self.dof_bind_group_cache[cache_index] + .as_ref() + .expect("depth-of-field bind group was initialized"), + &[], + ); + pass.draw(0..3, 0..1); + } + + // ============================================================ + // Motion blur pass: 8-tap directional blur along velocity + // Reads upstream color + velocity_rt → motion_blur_rt + // ============================================================ + let pre_mblur_source = if self.dof_enabled && self.dof_aperture > 0.0 { + PostFxSource::DepthOfField + } else if self.taa_enabled { + if taa_dst_idx == 0 { + PostFxSource::Taa0 + } else { + PostFxSource::Taa1 + } + } else if self.render_scale < 0.999 { + PostFxSource::Upscale + } else { + PostFxSource::Hdr }; - self.queue.write_buffer(&self.motion_blur_uniform_buffer, 0, bytemuck::bytes_of(&mbp)); - - let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("motion_blur_bg"), - layout: &self.motion_blur_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.motion_blur_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(pre_mblur_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.velocity_rt_view) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - ], - }); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("motion_blur_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.motion_blur_rt_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(&self.motion_blur_pipeline); - pass.set_bind_group(0, &bg, &[]); - pass.draw(0..3, 0..1); - } - // ============================================================ - // SSS pass: chromatic disc blur (skin / wax / leaves) - // Reads upstream color + depth → sss_rt. - // Runs after motion blur so it applies to the fully composited - // motion state, not to individual geometry. - // ============================================================ - let pre_sss_view = if self.motion_blur_enabled && self.motion_blur_strength > 0.0 { - &self.motion_blur_rt_view - } else if self.dof_enabled && self.dof_aperture > 0.0 { - &self.dof_rt_view - } else if self.taa_enabled { - &self.taa_views[taa_dst_idx] - } else if self.render_scale < 0.999 { - &self.upscale_rt_view - } else { - &self.hdr_rt_view - }; + if self.motion_blur_enabled && self.motion_blur_strength > 0.0 { + let mbp = MotionBlurParams { + params: [ + self.motion_blur_strength, + self.motion_blur_max_blur, + 0.0, + 0.0, + ], + }; + self.queue.write_buffer( + &self.motion_blur_uniform_buffer, + 0, + bytemuck::bytes_of(&mbp), + ); - if self.sss_enabled && self.sss_strength > 0.0 { - let sp = SssParams { - params: [self.sss_strength, self.sss_width, 500.0, 0.0], + let cache_index = pre_mblur_source as usize; + if self.motion_blur_bind_group_cache[cache_index].is_none() { + self.frame_resource_stats + .created_bind_group(frame_resource_stats::BindGroupCreationSite::MotionBlur); + let pre_mblur_view = self.postfx_source_view_for(pre_mblur_source); + self.motion_blur_bind_group_cache[cache_index] = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("motion_blur_bg"), + layout: &self.motion_blur_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.motion_blur_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(pre_mblur_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView( + &self.velocity_rt_view, + ), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + ], + })); + } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("motion_blur_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.motion_blur_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.motion_blur_pipeline); + pass.set_bind_group( + 0, + self.motion_blur_bind_group_cache[cache_index] + .as_ref() + .expect("motion-blur bind group was initialized"), + &[], + ); + pass.draw(0..3, 0..1); + } + + // ============================================================ + // SSS pass: chromatic disc blur (skin / wax / leaves) + // Reads upstream color + depth → sss_rt. + // Runs after motion blur so it applies to the fully composited + // motion state, not to individual geometry. + // ============================================================ + let pre_sss_source = if self.motion_blur_enabled && self.motion_blur_strength > 0.0 { + PostFxSource::MotionBlur + } else if self.dof_enabled && self.dof_aperture > 0.0 { + PostFxSource::DepthOfField + } else if self.taa_enabled { + if taa_dst_idx == 0 { + PostFxSource::Taa0 + } else { + PostFxSource::Taa1 + } + } else if self.render_scale < 0.999 { + PostFxSource::Upscale + } else { + PostFxSource::Hdr }; - self.queue.write_buffer(&self.sss_uniform_buffer, 0, bytemuck::bytes_of(&sp)); - - let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("sss_bg"), - layout: &self.sss_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.sss_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(pre_sss_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.depth_view) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler) }, - ], - }); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("sss_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.sss_rt_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(&self.sss_pipeline); - pass.set_bind_group(0, &bg, &[]); - pass.draw(0..3, 0..1); - } - // ============================================================ - // RCAS sharpen pass: contrast-adaptive 5-tap cross. Reads the - // same texture composite would otherwise sample (sss/mb/dof/ - // taa/upscale/composed) and writes cas_rt. Off by default — - // gated on cas_strength > 0. - // ============================================================ - let cas_input_view: &wgpu::TextureView = if self.sss_enabled && self.sss_strength > 0.0 { - &self.sss_rt_view - } else if self.motion_blur_enabled && self.motion_blur_strength > 0.0 { - &self.motion_blur_rt_view - } else if self.dof_enabled && self.dof_aperture > 0.0 { - &self.dof_rt_view - } else if self.taa_enabled { - &self.taa_views[taa_dst_idx] - } else if self.render_scale < 0.999 { - &self.upscale_rt_view - } else { - // TAA off, native res: composed_rt is already full-surface - // and carries SSR / SSGI / bloom / fog / shafts. - &self.composed_rt_view - }; + if self.sss_enabled && self.sss_strength > 0.0 { + let sp = SssParams { + params: [self.sss_strength, self.sss_width, 500.0, 0.0], + }; + self.queue + .write_buffer(&self.sss_uniform_buffer, 0, bytemuck::bytes_of(&sp)); + + let cache_index = pre_sss_source as usize; + if self.sss_bind_group_cache[cache_index].is_none() { + self.frame_resource_stats.created_bind_group( + frame_resource_stats::BindGroupCreationSite::SubsurfaceScattering, + ); + let pre_sss_view = self.postfx_source_view_for(pre_sss_source); + self.sss_bind_group_cache[cache_index] = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("sss_bg"), + layout: &self.sss_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.sss_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(pre_sss_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&self.depth_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler), + }, + ], + })); + } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("sss_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.sss_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.sss_pipeline); + pass.set_bind_group( + 0, + self.sss_bind_group_cache[cache_index] + .as_ref() + .expect("subsurface-scattering bind group was initialized"), + &[], + ); + pass.draw(0..3, 0..1); + } - if self.cas_strength > 0.0 { - let cp = RcasParams { - params: [self.cas_strength, 0.0, 0.0, 0.0], + // ============================================================ + // RCAS sharpen pass: contrast-adaptive 5-tap cross. Reads the + // same texture composite would otherwise sample (sss/mb/dof/ + // taa/upscale/composed) and writes cas_rt. Off by default — + // gated on cas_strength > 0. + // ============================================================ + let cas_input_source = if self.sss_enabled && self.sss_strength > 0.0 { + PostFxSource::SubsurfaceScattering + } else if self.motion_blur_enabled && self.motion_blur_strength > 0.0 { + PostFxSource::MotionBlur + } else if self.dof_enabled && self.dof_aperture > 0.0 { + PostFxSource::DepthOfField + } else if self.taa_enabled { + if taa_dst_idx == 0 { + PostFxSource::Taa0 + } else { + PostFxSource::Taa1 + } + } else if self.render_scale < 0.999 { + PostFxSource::Upscale + } else { + // TAA off, native res: composed_rt is already full-surface + // and carries SSR / SSGI / bloom / fog / shafts. + PostFxSource::Composed }; - self.queue.write_buffer(&self.cas_uniform_buffer, 0, bytemuck::bytes_of(&cp)); - let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("cas_bg"), - layout: &self.cas_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.cas_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(cas_input_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - ], - }); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("cas_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.cas_rt_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(&self.cas_pipeline); - pass.set_bind_group(0, &bg, &[]); - pass.draw(0..3, 0..1); - } + if self.cas_strength > 0.0 { + let cp = RcasParams { + params: [self.cas_strength, 0.0, 0.0, 0.0], + }; + self.queue + .write_buffer(&self.cas_uniform_buffer, 0, bytemuck::bytes_of(&cp)); + let cache_index = cas_input_source as usize; + if self.cas_bind_group_cache[cache_index].is_none() { + self.frame_resource_stats.created_bind_group( + frame_resource_stats::BindGroupCreationSite::ContrastAdaptiveSharpen, + ); + let cas_input_view = self.postfx_source_view_for(cas_input_source); + self.cas_bind_group_cache[cache_index] = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("cas_bg"), + layout: &self.cas_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.cas_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(cas_input_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + ], + })); + } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("cas_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.cas_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.cas_pipeline); + pass.set_bind_group( + 0, + self.cas_bind_group_cache[cache_index] + .as_ref() + .expect("contrast-adaptive-sharpen bind group was initialized"), + &[], + ); + pass.draw(0..3, 0..1); + } } /// The view the composite pass reads: the output of the LAST enabled /// stage in chain order CAS > SSS > motion blur > DoF > TAA > /// upscale > raw HDR. Must mirror the `pre_*_view` cascade in /// record_postfx_tail. - pub(super) fn composite_source_view(&self) -> &wgpu::TextureView { + pub(super) fn composite_source(&self) -> CompositeSource { if self.cas_strength > 0.0 { - &self.cas_rt_view + CompositeSource::ContrastAdaptiveSharpen } else if self.sss_enabled && self.sss_strength > 0.0 { - &self.sss_rt_view + CompositeSource::SubsurfaceScattering } else if self.motion_blur_enabled && self.motion_blur_strength > 0.0 { - &self.motion_blur_rt_view + CompositeSource::MotionBlur } else if self.dof_enabled && self.dof_aperture > 0.0 { - &self.dof_rt_view + CompositeSource::DepthOfField } else if self.taa_enabled { - &self.taa_views[self.taa_current_idx] + if self.taa_current_idx == 0 { + CompositeSource::Taa0 + } else { + CompositeSource::Taa1 + } } else if self.render_scale < 0.999 { - &self.upscale_rt_view + CompositeSource::Upscale } else { - &self.hdr_rt_view + CompositeSource::Hdr + } + } + + pub(super) fn composite_source_view_for(&self, source: CompositeSource) -> &wgpu::TextureView { + match source { + CompositeSource::Hdr => &self.hdr_rt_view, + CompositeSource::Upscale => &self.upscale_rt_view, + CompositeSource::Taa0 => &self.taa_views[0], + CompositeSource::Taa1 => &self.taa_views[1], + CompositeSource::DepthOfField => &self.dof_rt_view, + CompositeSource::MotionBlur => &self.motion_blur_rt_view, + CompositeSource::SubsurfaceScattering => &self.sss_rt_view, + CompositeSource::ContrastAdaptiveSharpen => &self.cas_rt_view, + } + } + + fn postfx_source_view_for(&self, source: PostFxSource) -> &wgpu::TextureView { + match source { + PostFxSource::Hdr => &self.hdr_rt_view, + PostFxSource::Composed => &self.composed_rt_view, + PostFxSource::Upscale => &self.upscale_rt_view, + PostFxSource::Taa0 => &self.taa_views[0], + PostFxSource::Taa1 => &self.taa_views[1], + PostFxSource::DepthOfField => &self.dof_rt_view, + PostFxSource::MotionBlur => &self.motion_blur_rt_view, + PostFxSource::SubsurfaceScattering => &self.sss_rt_view, } } } impl Renderer { + /// Manual exposure multiplier used while auto-exposure is disabled. + pub fn set_manual_exposure(&mut self, value: f32) { + self.manual_exposure = value.max(0.0); + } + + /// Toggle auto-exposure. The first enabled frame measures and seeds the + /// current scene instead of adapting from a value frozen while disabled. + pub fn set_auto_exposure(&mut self, enabled: bool) { + if self.auto_exposure != enabled { + self.auto_exposure = enabled; + self.exposure_current_idx = 0; + self.exposure_history_valid = false; + self.exposure_history_written = false; + } + } + + /// Auto-exposure target scene key (average luminance to drive toward). + pub fn set_auto_exposure_key(&mut self, key: f32) { + self.auto_exposure_key = key.clamp(0.01, 1.0); + } + + /// Auto-exposure smoothing rate per frame. Invalid history always seeds + /// once; this authored rate applies to all subsequent adaptation. + pub fn set_auto_exposure_rate(&mut self, rate: f32) { + self.auto_exposure_rate = rate.clamp(0.0, 1.0); + } + /// Auto-exposure measure + adapt pass into the dst slot of the /// ping-pong exposure texture. No-op when auto_exposure is off (the /// composite keeps reading the stale texture, which manual_exposure @@ -676,53 +1439,87 @@ impl Renderer { exposure_dst_idx: usize, ) { // The luminance source is whatever the composite will read. - let composite_src_view = self.composite_source_view(); - if self.auto_exposure { - let ep = ExposureParams { - params: [ - self.auto_exposure_key, - self.auto_exposure_rate, - // Wide clamp — without SSGI, Sponza's shadowed - // corridors have ~7× less average luma than its - // sunlit courtyard, so exposure needs to span - // the same range to keep perceived brightness - // stable across rotations. - 0.1, - 10.0, - ], - }; - self.queue.write_buffer(&self.exposure_uniform_buffer, 0, bytemuck::bytes_of(&ep)); - - let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("exposure_bg"), - layout: &self.exposure_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.exposure_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(composite_src_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.exposure_views[exposure_src_idx]) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - ], - }); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("exposure_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.exposure_views[exposure_dst_idx], - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(&self.exposure_pipeline); - pass.set_bind_group(0, &bg, &[]); - pass.draw(0..3, 0..1); - } + if self.auto_exposure { + let composite_source = self.composite_source(); + let cache_index = composite_source.bind_group_cache_index(exposure_src_idx); + let ep = ExposureParams { + params: [ + self.auto_exposure_key, + exposure_update_rate(self.exposure_history_valid, self.auto_exposure_rate), + // Wide clamp — without SSGI, Sponza's shadowed + // corridors have ~7× less average luma than its + // sunlit courtyard, so exposure needs to span + // the same range to keep perceived brightness + // stable across rotations. + 0.1, + 10.0, + ], + }; + self.queue + .write_buffer(&self.exposure_uniform_buffer, 0, bytemuck::bytes_of(&ep)); + + if self.exposure_bind_group_cache[cache_index].is_none() { + self.frame_resource_stats + .created_bind_group(frame_resource_stats::BindGroupCreationSite::AutoExposure); + let composite_src_view = self.composite_source_view_for(composite_source); + self.exposure_bind_group_cache[cache_index] = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("exposure_bg"), + layout: &self.exposure_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.exposure_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(composite_src_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView( + &self.exposure_views[exposure_src_idx], + ), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + ], + })); + } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("exposure_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.exposure_views[exposure_dst_idx], + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.exposure_pipeline); + pass.set_bind_group( + 0, + self.exposure_bind_group_cache[cache_index] + .as_ref() + .expect("auto-exposure bind group was initialized"), + &[], + ); + pass.draw(0..3, 0..1); + drop(pass); + self.exposure_history_valid = true; + self.exposure_history_written = true; + } } } diff --git a/native/shared/src/renderer/pt_geometry.rs b/native/shared/src/renderer/pt_geometry.rs new file mode 100644 index 00000000..f3702d8f --- /dev/null +++ b/native/shared/src/renderer/pt_geometry.rs @@ -0,0 +1,140 @@ +//! Path-tracing geometry megabuffer uploads. +//! +//! UV1 is a separate, independently lazy stream so `Vertex3D`, BLAS input, +//! and base-only PT keep their established ABI and bandwidth. + +use super::*; + +pub(super) fn append_pt_secondary_uvs( + output: &mut Option>, + vertex_base: usize, + vertex_count: usize, + secondary_uvs: Option<&[[f32; 2]]>, + uses_uv1: bool, +) { + debug_assert!(output.as_ref().is_none_or(|uvs| uvs.len() == vertex_base)); + debug_assert!(!uses_uv1 || secondary_uvs.is_some_and(|uvs| uvs.len() == vertex_count)); + if output.is_none() && uses_uv1 { + *output = Some(vec![[0.0; 2]; vertex_base]); + } + if let Some(output) = output { + if uses_uv1 { + output.extend_from_slice(secondary_uvs.unwrap()); + } else { + output.resize(output.len() + vertex_count, [0.0; 2]); + } + } +} + +impl Renderer { + pub(super) fn upload_pt_geometry( + &mut self, + vertices: &[f32], + indices: &[u32], + secondary_uvs: Option<&[[f32; 2]]>, + ) { + let vertex_bytes = (std::mem::size_of_val(vertices)).max(16) as u64; + let index_bytes = (std::mem::size_of_val(indices)).max(16) as u64; + let vertex_recreate = self + .pt_geo_vertex_buffer + .as_ref() + .is_none_or(|buffer| buffer.size() < vertex_bytes); + let index_recreate = self + .pt_geo_index_buffer + .as_ref() + .is_none_or(|buffer| buffer.size() < index_bytes); + // Dynamic PT windows also serve as BLAS input after pre-skinning. + let geometry_usage = if self.hw_rt_enabled { + wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_DST + | wgpu::BufferUsages::BLAS_INPUT + } else { + wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST + }; + if vertex_recreate { + self.pt_geo_vertex_buffer = Some(self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pt_geo_vertices"), + size: vertex_bytes.next_power_of_two(), + usage: geometry_usage, + mapped_at_creation: false, + })); + } + if index_recreate { + self.pt_geo_index_buffer = Some(self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pt_geo_indices"), + size: index_bytes.next_power_of_two(), + usage: geometry_usage, + mapped_at_creation: false, + })); + } + if vertex_recreate || index_recreate { + self.pt_bg = [None, None]; + } + if !vertices.is_empty() { + self.queue.write_buffer( + self.pt_geo_vertex_buffer.as_ref().unwrap(), + 0, + bytemuck::cast_slice(vertices), + ); + } + if !indices.is_empty() { + self.queue.write_buffer( + self.pt_geo_index_buffer.as_ref().unwrap(), + 0, + bytemuck::cast_slice(indices), + ); + } + + let Some(secondary_uvs) = secondary_uvs else { + return; + }; + let secondary_bytes = std::mem::size_of_val(secondary_uvs).max(8) as u64; + let secondary_recreate = self + .pt_layered + .uv1_buffer + .as_ref() + .is_none_or(|buffer| buffer.size() < secondary_bytes); + if secondary_recreate { + self.pt_layered.uv1_buffer = Some(self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pt_layered_uv1_vertices"), + size: secondary_bytes.next_power_of_two(), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + })); + self.pt_layered + .bind_groups + .iter_mut() + .for_each(|group| *group = None); + } + self.queue.write_buffer( + self.pt_layered.uv1_buffer.as_ref().unwrap(), + 0, + bytemuck::cast_slice(secondary_uvs), + ); + } +} + +#[cfg(test)] +mod tests { + use super::append_pt_secondary_uvs; + + #[test] + fn secondary_uv_stream_backfills_and_stays_vertex_aligned() { + let mut output = None; + append_pt_secondary_uvs(&mut output, 0, 3, None, false); + assert!(output.is_none()); + append_pt_secondary_uvs(&mut output, 3, 2, Some(&[[0.25, 0.5], [0.75, 1.0]]), true); + append_pt_secondary_uvs(&mut output, 5, 1, None, false); + assert_eq!( + output.unwrap(), + vec![ + [0.0, 0.0], + [0.0, 0.0], + [0.0, 0.0], + [0.25, 0.5], + [0.75, 1.0], + [0.0, 0.0], + ] + ); + } +} diff --git a/native/shared/src/renderer/pt_pass.rs b/native/shared/src/renderer/pt_pass.rs index 2d0b0df7..fb7c551b 100644 --- a/native/shared/src/renderer/pt_pass.rs +++ b/native/shared/src/renderer/pt_pass.rs @@ -7,6 +7,55 @@ use super::*; impl Renderer { + /// Path-tracing mode request (0 off / 1 progressive / 2 realtime). + /// Ownership transitions invalidate raster SSR and SSGI history. + pub fn set_path_tracing(&mut self, mode: u32) { + let mode = mode.min(2); + if self.pt_mode != mode { + self.pt_mode = mode; + self.reset_path_tracing_history(0); + self.ssr_history_idx = 0; + self.ssr_history_valid = false; + self.probe_history_idx = 0; + self.probe_history_valid = false; + } + } + + /// Reset path-tracing history and restart its deterministic sample stream. + /// Useful after a camera cut and by correctness captures that must not + /// inherit temporal state. Existing GPU allocations are retained. + pub fn reset_path_tracing_history(&mut self, sequence_start: u32) { + self.pt_accum_idx = 0; + self.pt_accum_count = 0; + self.pt_frame_index = sequence_start; + self.pt_last_tlas_version = self.tlas_built_version; + self.pt_wrote_frame = false; + self.pt_dump_written = false; + } + + /// Number of samples accumulated at the current progressive view. + pub fn path_tracing_sample_count(&self) -> u32 { + self.pt_accum_count + } + + /// Set the global scramble for deterministic per-pixel PT sampling. + /// Changing it invalidates history so samples from different sequences + /// are never blended accidentally. + pub fn set_path_tracing_seed(&mut self, seed: u32) { + if self.pt_rng_seed != seed { + self.pt_rng_seed = seed; + self.reset_path_tracing_history(0); + } + } + + /// Select one of the diagnostic views documented in `shaders/pt.rs`. + /// Hidden from generated API docs because this is a renderer-validation + /// hook, not a game-facing quality setting. + #[doc(hidden)] + pub fn set_path_tracing_debug_view(&mut self, view: u32) { + self.pt_debug = view as f32; + self.reset_path_tracing_history(0); + } pub(super) fn record_pt_pass( &mut self, encoder: &mut wgpu::CommandEncoder, @@ -33,6 +82,58 @@ impl Renderer { self.pt_wrote_frame = false; return; } + // A sidecar record can reserve a future lobe without changing the + // current renderer. Select the extra pipeline only for lobes whose + // transport is qualified, preserving the exact base path otherwise. + let layered_active = self.pt_layered_transport_active(); + let layered_sheen = self.pt_layered_sheen_active(); + let layered_anisotropy = layered_active && self.pt_layered_anisotropy_active(); + let layered_iridescence = layered_active && self.pt_layered_iridescence_active(); + let layered_textures = + layered_active && self.pt_texture_arrays_enabled && self.pt_layered_texture_active(); + let layered_clearcoat_textures = layered_active + && self.pt_texture_arrays_enabled + && self.pt_layered_clearcoat_texture_active(); + let layered_clearcoat_normals = layered_active + && self.pt_texture_arrays_enabled + && self.pt_layered_clearcoat_normal_active(); + let layered_sheen_textures = layered_active + && self.pt_texture_arrays_enabled + && self.pt_layered_sheen_texture_active(); + let layered_iridescence_textures = layered_active + && self.pt_texture_arrays_enabled + && self.pt_layered_iridescence_texture_active(); + let layered_anisotropy_textures = layered_active + && self.pt_texture_arrays_enabled + && self.pt_layered_anisotropy_texture_active(); + let layered_uv1 = (layered_textures + || layered_clearcoat_textures + || layered_clearcoat_normals + || layered_sheen_textures + || layered_iridescence_textures + || layered_anisotropy_textures) + && self.pt_layered_uv1_active(); + let layered_pipeline_variant = layered_sheen as usize + | ((layered_anisotropy as usize) << 1) + | ((layered_iridescence as usize) << 2) + | ((layered_textures as usize) << 3) + | ((layered_uv1 as usize) << 4) + | ((layered_clearcoat_textures as usize) << 5) + | ((layered_sheen_textures as usize) << 6) + | ((layered_iridescence_textures as usize) << 7) + | ((layered_anisotropy_textures as usize) << 8) + | ((layered_clearcoat_normals as usize) << 9); + let layered_resource_variant = layered_sheen as usize + | ((layered_textures as usize) << 1) + | ((layered_uv1 as usize) << 2) + | ((layered_clearcoat_textures as usize) << 3) + | ((layered_sheen_textures as usize) << 4) + | ((layered_iridescence_textures as usize) << 5) + | ((layered_anisotropy_textures as usize) << 6) + | ((layered_clearcoat_normals as usize) << 7); + if layered_active { + self.ensure_pt_layered_resources(); + } // PT-2 — a grown texture store means the baked view array is // stale; rebuild so new textures become visible to hit shading. if self.pt_texture_arrays_enabled && self.pt_bg_texture_count != self.textures.len() { @@ -148,7 +249,11 @@ impl Renderer { // and pt_accum_count = 0 marks the whole history invalid. for (i, slot) in self.pt_moments_buffers.iter_mut().enumerate() { *slot = Some(self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some(if i == 0 { "pt_moments_a" } else { "pt_moments_b" }), + label: Some(if i == 0 { + "pt_moments_a" + } else { + "pt_moments_b" + }), size: needed, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, @@ -180,16 +285,23 @@ impl Renderer { mapped_at_creation: false, })); self.pt_bg = [None, None]; - self.pt_atrous_bgs = [[None, None, None, None, None, None], [None, None, None, None, None, None]]; + self.pt_atrous_bgs = [ + [None, None, None, None, None, None], + [None, None, None, None, None, None], + ]; self.pt_accum_count = 0; self.pt_accum_idx = 0; } // ---- uniforms ---- - // Sun / sky derivation matches record_ssgi_passes exactly so PT - // brightness lines up with the raster + GI frame it replaces. + // The scene shader stores and consumes light_dir as the vector from + // the shaded point toward the sun. Preserve that convention here so + // raster and PT NdotL, shadow rays, and CPU-reference lighting agree. let ld = self.lighting_uniforms.light_dir; - let sun_inv_len = 1.0 / (ld[0] * ld[0] + ld[1] * ld[1] + ld[2] * ld[2]).sqrt().max(1e-4); + let sun_inv_len = 1.0 + / (ld[0] * ld[0] + ld[1] * ld[1] + ld[2] * ld[2]) + .sqrt() + .max(1e-4); let sun_intensity = ld[3].max(0.0); let lc = self.lighting_uniforms.light_color; let amb = self.lighting_uniforms.ambient; @@ -234,9 +346,9 @@ impl Renderer { prev_vp: prev_vp_t, cam_pos: [cam[0], cam[1], cam[2], 0.0], sun_dir: [ - -ld[0] * sun_inv_len, - -ld[1] * sun_inv_len, - -ld[2] * sun_inv_len, + ld[0] * sun_inv_len, + ld[1] * sun_inv_len, + ld[2] * sun_inv_len, 0.0, ], sun_color: [ @@ -256,7 +368,12 @@ impl Renderer { // headless tests), which froze the sample sequence and // silently stopped progressive accumulation from ever // converging (found by the pt_progressive golden). - size: [trace_w, trace_h, self.pt_frame_index, self.pt_accum_count], + size: [ + trace_w, + trace_h, + self.pt_frame_index ^ self.pt_rng_seed, + self.pt_accum_count, + ], cfg: [ self.pt_mode as f32, max_bounces, @@ -270,9 +387,17 @@ impl Renderer { ext: [ surf_w, surf_h, - if self.pt_mode >= 2 && self.shadow_map.enabled { 1 } else { 0 }, + if self.pt_mode >= 2 && self.shadow_map.enabled { + 1 + } else { + 0 + }, // PT-4 experimental flag (BLOOM_PT_RESTIR=1), realtime only. - if self.pt_restir && self.pt_mode >= 2 { 1 } else { 0 }, + if self.pt_restir && self.pt_mode >= 2 { + 1 + } else { + 0 + }, ], // RAW upload, unlike inv_vp: the shadow VPs are consumed as // M*v by every existing WGSL user (scene shader, WSRC @@ -282,7 +407,8 @@ impl Renderer { shadow_vps: self.shadow_map.light_vps, lights, }; - self.queue.write_buffer(&self.pt_uniform_buffer, 0, bytemuck::bytes_of(¶ms)); + self.queue + .write_buffer(&self.pt_uniform_buffer, 0, bytemuck::bytes_of(¶ms)); // ---- bind groups (lazy; nulled on resize / TLAS or instance // buffer recreation). Two ping-pong variants: bg[i] reads accum @@ -293,36 +419,132 @@ impl Renderer { } let tlas = self.tlas.as_ref().unwrap(); let entries = vec![ - wgpu::BindGroupEntry { binding: 0, resource: self.pt_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: tlas.as_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: self.tlas_instance_data_buffer.as_ref().unwrap().as_entire_binding() }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.depth_view) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self.albedo_rt_view) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&self.material_rt_view) }, - // Raw albedo atlas, NOT the pre-lit radiance atlas the - // GI probe trace uses — PT computes its own lighting at - // hits; radiance would double-count. - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self.mesh_card_atlas_view) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::Sampler(&self.mesh_card_atlas_sampler) }, - wgpu::BindGroupEntry { binding: 8, resource: self.pt_accum_buffers[i].as_ref().unwrap().as_entire_binding() }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::TextureView(&self.hdr_rt_view) }, - wgpu::BindGroupEntry { binding: 10, resource: self.pt_geo_vertex_buffer.as_ref().unwrap().as_entire_binding() }, - wgpu::BindGroupEntry { binding: 11, resource: self.pt_geo_index_buffer.as_ref().unwrap().as_entire_binding() }, - wgpu::BindGroupEntry { binding: 13, resource: self.pt_accum_buffers[1 - i].as_ref().unwrap().as_entire_binding() }, - wgpu::BindGroupEntry { binding: 14, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[0]) }, - wgpu::BindGroupEntry { binding: 15, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[1]) }, - wgpu::BindGroupEntry { binding: 16, resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[2]) }, - wgpu::BindGroupEntry { binding: 17, resource: wgpu::BindingResource::Sampler(&self.shadow_map.sampler) }, - // SVGF moments: read prev (paired with accum read - // side), write out (paired with the write side). - wgpu::BindGroupEntry { binding: 18, resource: self.pt_moments_buffers[i].as_ref().unwrap().as_entire_binding() }, - wgpu::BindGroupEntry { binding: 19, resource: self.pt_moments_buffers[1 - i].as_ref().unwrap().as_entire_binding() }, - // PT-4 ReSTIR reservoirs, same ping-pong pairing. - wgpu::BindGroupEntry { binding: 20, resource: self.pt_resv_buffers[i].as_ref().unwrap().as_entire_binding() }, - wgpu::BindGroupEntry { binding: 21, resource: self.pt_resv_buffers[1 - i].as_ref().unwrap().as_entire_binding() }, - // PT-7 — velocity MRT (written by hdr_scene, which - // runs before the PT node every frame). - wgpu::BindGroupEntry { binding: 22, resource: wgpu::BindingResource::TextureView(&self.velocity_rt_view) }, + wgpu::BindGroupEntry { + binding: 0, + resource: self.pt_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: tlas.as_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: self + .tlas_instance_data_buffer + .as_ref() + .unwrap() + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&self.depth_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&self.albedo_rt_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&self.material_rt_view), + }, + // Raw albedo atlas, NOT the pre-lit radiance atlas the + // GI probe trace uses — PT computes its own lighting at + // hits; radiance would double-count. + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&self.mesh_card_atlas_view), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::Sampler(&self.mesh_card_atlas_sampler), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: self.pt_accum_buffers[i] + .as_ref() + .unwrap() + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::TextureView(&self.hdr_rt_view), + }, + wgpu::BindGroupEntry { + binding: 10, + resource: self + .pt_geo_vertex_buffer + .as_ref() + .unwrap() + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: self + .pt_geo_index_buffer + .as_ref() + .unwrap() + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 13, + resource: self.pt_accum_buffers[1 - i] + .as_ref() + .unwrap() + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 14, + resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[0]), + }, + wgpu::BindGroupEntry { + binding: 15, + resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[1]), + }, + wgpu::BindGroupEntry { + binding: 16, + resource: wgpu::BindingResource::TextureView(&self.shadow_map.depth_views[2]), + }, + wgpu::BindGroupEntry { + binding: 17, + resource: wgpu::BindingResource::Sampler(&self.shadow_map.sampler), + }, + // SVGF moments: read prev (paired with accum read + // side), write out (paired with the write side). + wgpu::BindGroupEntry { + binding: 18, + resource: self.pt_moments_buffers[i] + .as_ref() + .unwrap() + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 19, + resource: self.pt_moments_buffers[1 - i] + .as_ref() + .unwrap() + .as_entire_binding(), + }, + // PT-4 ReSTIR reservoirs, same ping-pong pairing. + wgpu::BindGroupEntry { + binding: 20, + resource: self.pt_resv_buffers[i] + .as_ref() + .unwrap() + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 21, + resource: self.pt_resv_buffers[1 - i] + .as_ref() + .unwrap() + .as_entire_binding(), + }, + // PT-7 — velocity MRT (written by hdr_scene, which + // runs before the PT node every frame). + wgpu::BindGroupEntry { + binding: 22, + resource: wgpu::BindingResource::TextureView(&self.velocity_rt_view), + }, ]; self.pt_bg[i] = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pt_bg"), @@ -336,8 +558,10 @@ impl Renderer { if self.pt_texture_arrays_enabled && self.pt_tex_bg.is_none() { let n = self.textures.len().min(PT_MAX_TEXTURES); let tex_views: Vec = (0..n.max(1)) - .map(|i| self.textures[i.min(self.textures.len() - 1)] - .create_view(&wgpu::TextureViewDescriptor::default())) + .map(|i| { + self.textures[i.min(self.textures.len() - 1)] + .create_view(&wgpu::TextureViewDescriptor::default()) + }) .collect(); let tex_view_refs: Vec<&wgpu::TextureView> = (0..PT_MAX_TEXTURES) .map(|i| &tex_views[if i < n { i } else { 0 }]) @@ -360,17 +584,36 @@ impl Renderer { label: Some("pt_pass"), timestamp_writes: ts, }); - pass.set_pipeline(self.pt_pipeline.as_ref().unwrap()); + pass.set_pipeline(if layered_active { + self.pt_layered.pipelines[layered_pipeline_variant] + .as_ref() + .unwrap() + } else { + self.pt_pipeline.as_ref().unwrap() + }); pass.set_bind_group(0, self.pt_bg[self.pt_accum_idx].as_ref().unwrap(), &[]); if self.pt_texture_arrays_enabled { pass.set_bind_group(1, self.pt_tex_bg.as_ref().unwrap(), &[]); } + if layered_active { + pass.set_bind_group( + 2, + self.pt_layered.bind_groups[layered_resource_variant] + .as_ref() + .unwrap(), + &[], + ); + } pass.dispatch_workgroups((trace_w + 7) / 8, (trace_h + 7) / 8, 1); } // This frame wrote into buffers[1 - idx]; it becomes next // frame's read side. let written_idx = 1 - self.pt_accum_idx; self.pt_accum_idx = written_idx; + #[cfg(not(target_arch = "wasm32"))] + if self.pt_mode >= 2 && self.pending_quality_capture_dir.is_some() { + self.record_pt_temporal_diagnostics(encoder, written_idx, trace_w, trace_h); + } // ---- PT-3b: SVGF wavelet filter (realtime mode only) ---- // Six variance-guided à-trous iterations on the trace grid @@ -395,7 +638,8 @@ impl Renderer { [*step, first, trace_w as f32, trace_h as f32], [surf_w as f32, surf_h as f32, 0.0, 0.0], ]; - self.queue.write_buffer(&self.pt_atrous_params_bufs[i], 0, bytemuck::bytes_of(&p)); + self.queue + .write_buffer(&self.pt_atrous_params_bufs[i], 0, bytemuck::bytes_of(&p)); } if self.pt_atrous_bgs[written_idx][0].is_none() { @@ -417,19 +661,43 @@ impl Renderer { (scratch, scratch2), ]; for (i, (src, dst)) in chain.iter().enumerate() { - self.pt_atrous_bgs[written_idx][i] = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("pt_atrous_bg"), - layout: self.pt_atrous_layout.as_ref().unwrap(), - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.pt_atrous_params_bufs[i].as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: src.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: dst.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.hdr_rt_view) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self.depth_view) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&self.albedo_rt_view) }, - wgpu::BindGroupEntry { binding: 6, resource: moments_w.as_entire_binding() }, - ], - })); + self.pt_atrous_bgs[written_idx][i] = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pt_atrous_bg"), + layout: self.pt_atrous_layout.as_ref().unwrap(), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.pt_atrous_params_bufs[i].as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: src.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: dst.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&self.hdr_rt_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&self.depth_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView( + &self.albedo_rt_view, + ), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: moments_w.as_entire_binding(), + }, + ], + })); } } @@ -460,7 +728,11 @@ impl Renderer { }); pass.set_pipeline(self.pt_atrous_mid_pipeline.as_ref().unwrap()); for i in 1..5 { - pass.set_bind_group(0, self.pt_atrous_bgs[written_idx][i].as_ref().unwrap(), &[]); + pass.set_bind_group( + 0, + self.pt_atrous_bgs[written_idx][i].as_ref().unwrap(), + &[], + ); pass.dispatch_workgroups((trace_w + 7) / 8, (trace_h + 7) / 8, 1); } pass.set_pipeline(self.pt_atrous_final_pipeline.as_ref().unwrap()); @@ -472,7 +744,7 @@ impl Renderer { // frame on screen until 8 samples exist (u.size.w carried the // pre-increment count), so SSGI/SSR must keep running for those // frames — the gates downstream check pt_owns_frame(). - self.pt_wrote_frame = self.pt_mode >= 2 || self.pt_accum_count >= 8; + self.pt_wrote_frame = self.pt_debug != 0.0 || self.pt_mode >= 2 || self.pt_accum_count >= 8; self.pt_accum_count = self.pt_accum_count.saturating_add(1); self.pt_frame_index = self.pt_frame_index.wrapping_add(1); @@ -494,12 +766,13 @@ impl Renderer { let row = (trace_h / 2) as u64; let offset = row * trace_w as u64 * 16; if self.pt_readback_buffer.is_none() { - self.pt_readback_buffer = Some(self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("pt_readback"), - size: dump_pixels * 16, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - })); + self.pt_readback_buffer = + Some(self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pt_readback"), + size: dump_pixels * 16, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + })); encoder.copy_buffer_to_buffer( // written_idx == pt_accum_idx here (already flipped): // the buffer this frame's dispatch wrote. @@ -514,7 +787,10 @@ impl Renderer { let buf = self.pt_readback_buffer.as_ref().unwrap(); let slice = buf.slice(..); slice.map_async(wgpu::MapMode::Read, |_| {}); - let _ = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + let _ = self.device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); let data = slice.get_mapped_range(); let vals: &[[f32; 4]] = bytemuck::cast_slice(&data); let mut out = String::new(); @@ -552,9 +828,17 @@ impl Renderer { unproject transposed: h={:?} p={:?}\n", self.current_camera_pos, h_col, - [h_col[0] / h_col[3], h_col[1] / h_col[3], h_col[2] / h_col[3]], + [ + h_col[0] / h_col[3], + h_col[1] / h_col[3], + h_col[2] / h_col[3] + ], h_row, - [h_row[0] / h_row[3], h_row[1] / h_row[3], h_row[2] / h_row[3]], + [ + h_row[0] / h_row[3], + h_row[1] / h_row[3], + h_row[2] / h_row[3] + ], )); // Distinct instance-id count over the whole row. let mut ids: Vec = vals diff --git a/native/shared/src/renderer/pt_temporal_diagnostics.rs b/native/shared/src/renderer/pt_temporal_diagnostics.rs new file mode 100644 index 00000000..7cb828e0 --- /dev/null +++ b/native/shared/src/renderer/pt_temporal_diagnostics.rs @@ -0,0 +1,275 @@ +//! Capture-only realtime path-tracing temporal diagnostics. +//! +//! The pass reads the production SVGF ping-pong buffers after temporal +//! accumulation and exposes their decisions without modifying denoiser state. + +use super::*; + +pub(super) const PT_TEMPORAL_DIAGNOSTIC_NAMES: [&str; 4] = [ + "pt-rejection-reason", + "pt-motion", + "pt-reprojected-uv", + "pt-temporal-confidence", +]; +const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +const SHADER: &str = include_str!("pt_temporal_diagnostics.wgsl"); + +pub(super) struct PtTemporalDiagnosticResources { + textures: Vec, + views: Vec, + pipeline: wgpu::ComputePipeline, + layout: wgpu::BindGroupLayout, + width: u32, + height: u32, +} + +impl PtTemporalDiagnosticResources { + fn new(device: &wgpu::Device, width: u32, height: u32) -> Self { + let textures = PT_TEMPORAL_DIAGNOSTIC_NAMES + .iter() + .map(|name| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some(name), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FORMAT, + usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }) + }) + .collect::>(); + let views = textures + .iter() + .map(|texture| texture.create_view(&wgpu::TextureViewDescriptor::default())) + .collect::>(); + let buffer = |binding, read_only| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }; + let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }; + let storage = |binding| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: FORMAT, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }; + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("pt_temporal_diagnostic_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + buffer(1, true), + buffer(2, true), + buffer(3, true), + texture(4, wgpu::TextureSampleType::Depth), + texture(5, wgpu::TextureSampleType::Float { filterable: false }), + storage(6), + storage(7), + storage(8), + storage(9), + ], + }); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("pt_temporal_diagnostic_shader"), + source: wgpu::ShaderSource::Wgsl(SHADER.into()), + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("pt_temporal_diagnostic_pipeline_layout"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("pt_temporal_diagnostic_pipeline"), + layout: Some(&pipeline_layout), + module: &shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }); + Self { + textures, + views, + pipeline, + layout, + width, + height, + } + } +} + +impl Renderer { + pub(super) fn record_pt_temporal_diagnostics( + &mut self, + encoder: &mut wgpu::CommandEncoder, + current_index: usize, + width: u32, + height: u32, + ) { + let resize = self + .pt_temporal_diagnostics + .as_ref() + .is_some_and(|resources| resources.width != width || resources.height != height); + if resize { + self.pt_temporal_diagnostics = None; + } + if self.pt_temporal_diagnostics.is_none() { + self.pt_temporal_diagnostics = Some(PtTemporalDiagnosticResources::new( + &self.device, + width, + height, + )); + } + let resources = self.pt_temporal_diagnostics.as_ref().unwrap(); + let previous_index = 1 - current_index; + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pt_temporal_diagnostic_bg"), + layout: &resources.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.pt_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: self.pt_accum_buffers[current_index] + .as_ref() + .unwrap() + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: self.pt_moments_buffers[current_index] + .as_ref() + .unwrap() + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: self.pt_moments_buffers[previous_index] + .as_ref() + .unwrap() + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&self.depth_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&self.velocity_rt_view), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&resources.views[0]), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(&resources.views[1]), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView(&resources.views[2]), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::TextureView(&resources.views[3]), + }, + ], + }); + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("pt_temporal_diagnostic_pass"), + timestamp_writes: None, + }); + pass.set_pipeline(&resources.pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1); + } + + pub(super) fn pt_temporal_diagnostic_textures(&self) -> Option<&[wgpu::Texture]> { + self.pt_temporal_diagnostics + .as_ref() + .map(|resources| resources.textures.as_slice()) + } + + pub(super) fn release_pt_temporal_diagnostics(&mut self) { + self.pt_temporal_diagnostics = None; + } + + pub(super) fn append_pt_temporal_diagnostic_telemetry(&self, out: &mut String) { + let (render_width, render_height) = self.render_extent(); + let width = render_width.div_ceil(2).min(960); + let height = render_height.div_ceil(2).min(540); + let count = PT_TEMPORAL_DIAGNOSTIC_NAMES.len() as u64; + let texture_bytes = u64::from(width) * u64::from(height) * count * 4; + let row_bytes = u64::from((width * 4 + 255) & !255); + let readback_bytes = row_bytes * u64::from(height) * count; + out.push_str(",\"pt_diagnostic_persistent_bytes\":0"); + out.push_str(",\"pt_diagnostic_capture_texture_bytes\":"); + out.push_str(&texture_bytes.to_string()); + out.push_str(",\"pt_diagnostic_capture_readback_bytes\":"); + out.push_str(&readback_bytes.to_string()); + out.push_str(",\"pt_diagnostic_capture_passes\":1"); + out.push_str(",\"pt_diagnostic_resources_live\":"); + out.push_str(if self.pt_temporal_diagnostic_textures().is_some() { + "true" + } else { + "false" + }); + } +} + +#[cfg(test)] +mod tests { + use super::SHADER; + + #[test] + fn diagnostic_shader_parses_without_modifying_production_svgf() { + wgpu::naga::front::wgsl::parse_str(SHADER) + .unwrap_or_else(|error| panic!("PT temporal diagnostics WGSL failed: {error}")); + let production = super::super::shaders::PT_KERNEL_WGSL; + for contract in [ + "uv_prev = vec2(uv_cur.x - vel.x, uv_cur.y + vel.y)", + "let tol = 0.1 * rp_zl_here + 0.02", + "let wtol = 0.1 * zl_st + 0.02", + "let alpha_c = max(1.0 / n_new, 0.1)", + ] { + assert!( + production.contains(contract), + "PT temporal contract changed without updating diagnostics: {contract}" + ); + } + } +} diff --git a/native/shared/src/renderer/pt_temporal_diagnostics.wgsl b/native/shared/src/renderer/pt_temporal_diagnostics.wgsl new file mode 100644 index 00000000..39a492a4 --- /dev/null +++ b/native/shared/src/renderer/pt_temporal_diagnostics.wgsl @@ -0,0 +1,245 @@ +struct PtDiagnosticParams { + inv_vp: mat4x4, + prev_vp: mat4x4, + cam_pos: vec4, + sun_dir: vec4, + sun_color: vec4, + sky_color: vec4, + size: vec4, + cfg: vec4, + ext: vec4, +}; + +@group(0) @binding(0) var u: PtDiagnosticParams; +@group(0) @binding(1) var accum_current: array>; +@group(0) @binding(2) var moments_current: array>; +@group(0) @binding(3) var moments_previous: array>; +@group(0) @binding(4) var depth_tex: texture_depth_2d; +@group(0) @binding(5) var velocity_tex: texture_2d; +@group(0) @binding(6) var reason_out: texture_storage_2d; +@group(0) @binding(7) var motion_out: texture_storage_2d; +@group(0) @binding(8) var reprojection_out: texture_storage_2d; +@group(0) @binding(9) var confidence_out: texture_storage_2d; + +fn finite4(value: vec4) -> bool { + return all(value == value) && all(abs(value) <= vec4(1e20)); +} + +fn linear_depth(depth: f32) -> f32 { + return 0.02 / max(1.0 - depth, 1e-6); +} + +fn full_pixel(trace_pixel: vec2) -> vec2 { + if (u.ext.x <= u.size.x) { + return trace_pixel; + } + return min( + vec2( + trace_pixel.x * i32(u.ext.x) / i32(u.size.x), + trace_pixel.y * i32(u.ext.y) / i32(u.size.y), + ), + vec2(i32(u.ext.x) - 1, i32(u.ext.y) - 1), + ); +} + +fn depth_at(pixel: vec2) -> f32 { + let limit = vec2(i32(u.ext.x) - 1, i32(u.ext.y) - 1); + return textureLoad(depth_tex, clamp(pixel, vec2(0), limit), 0); +} + +fn world_at(pixel: vec2, depth: f32) -> vec3 { + let dims = vec2(f32(u.ext.x), f32(u.ext.y)); + let uv = (vec2(pixel) + 0.5) / dims; + let ndc = vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, depth, 1.0); + let world = u.inv_vp * ndc; + return world.xyz / world.w; +} + +@compute @workgroup_size(8, 8, 1) +fn cs_main(@builtin(global_invocation_id) gid: vec3) { + if (gid.x >= u.size.x || gid.y >= u.size.y) { + return; + } + let pixel = vec2(gid.xy); + let pixel_full = full_pixel(pixel); + let index = gid.y * u.size.x + gid.x; + let current_accum = accum_current[index]; + let current_moments = moments_current[index]; + let depth = current_moments.w; + let sky = depth >= 0.9999999; + let seeded = u.size.w == 0u; + let current_finite = finite4(current_accum) && finite4(current_moments); + let velocity = textureLoad(velocity_tex, pixel_full, 0).rg; + let moving = abs(velocity.x) + abs(velocity.y) > 1e-5; + + var history_in_bounds = false; + var history_finite = true; + var accepted_mass = 0.0; + var footprint_retained = false; + var previous_uv = vec2(0.0); + if (!seeded && !sky && current_finite) { + let position = world_at(pixel_full, depth); + var linear_here = 0.0; + if (moving) { + let current_uv = (vec2(pixel_full) + 0.5) + / vec2(f32(u.ext.x), f32(u.ext.y)); + previous_uv = vec2( + current_uv.x - velocity.x, + current_uv.y + velocity.y, + ); + linear_here = linear_depth(depth); + } else { + let previous_clip = u.prev_vp * vec4(position, 1.0); + if (previous_clip.w > 1e-4) { + let previous_ndc = previous_clip.xyz / previous_clip.w; + previous_uv = vec2( + previous_ndc.x * 0.5 + 0.5, + 0.5 - previous_ndc.y * 0.5, + ); + linear_here = linear_depth(previous_ndc.z); + } + } + history_in_bounds = + previous_uv.x >= 0.0 && previous_uv.x < 1.0 && + previous_uv.y >= 0.0 && previous_uv.y < 1.0 && + linear_here > 0.0; + if (history_in_bounds) { + let position_previous = + previous_uv * vec2(f32(u.size.x), f32(u.size.y)) - 0.5; + let base = vec2(floor(position_previous)); + let fraction = position_previous - floor(position_previous); + let tolerance = 0.1 * linear_here + 0.02; + for (var ty = 0; ty <= 1; ty = ty + 1) { + for (var tx = 0; tx <= 1; tx = tx + 1) { + let sample_pixel = base + vec2(tx, ty); + if (sample_pixel.x < 0 || sample_pixel.y < 0 || + sample_pixel.x >= i32(u.size.x) || + sample_pixel.y >= i32(u.size.y)) { + continue; + } + let sample_index = + u32(sample_pixel.y) * u.size.x + u32(sample_pixel.x); + let sample_moments = moments_previous[sample_index]; + if (!finite4(sample_moments)) { + history_finite = false; + continue; + } + if (sample_moments.w >= 0.9999999 || + abs(linear_depth(sample_moments.w) - linear_here) > + tolerance) { + continue; + } + let wx = mix(1.0 - fraction.x, fraction.x, f32(tx)); + let wy = mix(1.0 - fraction.y, fraction.y, f32(ty)); + accepted_mass += wx * wy + 1e-4; + } + } + + if (accepted_mass <= 1e-3) { + let nearest_pixel = vec2( + min( + u32(max(base.x + i32(round(fraction.x)), 0)), + u.size.x - 1u, + ), + min( + u32(max(base.y + i32(round(fraction.y)), 0)), + u.size.y - 1u, + ), + ); + let nearest_index = nearest_pixel.y * u.size.x + nearest_pixel.x; + let nearest_moments = moments_previous[nearest_index]; + history_finite = history_finite && finite4(nearest_moments); + var footprint_low = 1e30; + var footprint_high = 0.0; + let ratio_x = max(i32(u.ext.x) / i32(u.size.x), 1); + let ratio_y = max(i32(u.ext.y) / i32(u.size.y), 1); + for (var sy = 0; sy <= 1; sy = sy + 1) { + for (var sx = 0; sx <= 1; sx = sx + 1) { + let sample_full = min( + pixel_full + vec2( + sx * (ratio_x - 1), + sy * (ratio_y - 1), + ), + vec2(i32(u.ext.x) - 1, i32(u.ext.y) - 1), + ); + let sample_depth = depth_at(sample_full); + if (sample_depth < 0.9999999) { + let sample_linear = linear_depth(sample_depth); + footprint_low = min(footprint_low, sample_linear); + footprint_high = max(footprint_high, sample_linear); + } + } + } + if (history_finite && nearest_moments.w < 0.9999999 && + nearest_moments.z > 0.0 && footprint_high > 0.0 && + footprint_low < 1e29) { + let stored_linear = linear_depth(nearest_moments.w); + let window = 0.1 * stored_linear + 0.02; + footprint_retained = + stored_linear > footprint_low - window && + stored_linear < footprint_high + window; + } + } + } + } + + // Shared palette: gray seed/sky, red off-screen, magenta invalid or + // disoccluded, cyan retained footprint flip, blue accepted motion-vector + // reprojection, green accepted matrix/static history. + var reason = vec3(0.05, 0.65, 0.10); + if (seeded || sky) { + reason = vec3(0.25); + } else if (!history_in_bounds) { + reason = vec3(1.0, 0.05, 0.02); + } else if (!current_finite || !history_finite || accepted_mass <= 1e-3) { + reason = select( + vec3(1.0, 0.0, 0.8), + vec3(0.0, 0.9, 1.0), + footprint_retained && current_finite && history_finite, + ); + } else if (moving) { + reason = vec3(0.05, 0.25, 1.0); + } + + let motion = vec3( + clamp(0.5 + velocity.x * 32.0, 0.0, 1.0), + clamp(0.5 - velocity.y * 32.0, 0.0, 1.0), + clamp(length(velocity) * 64.0, 0.0, 1.0), + ); + let valid_reprojection = history_in_bounds && history_finite; + let variance_heat = select( + 1.0, + 1.0 - exp(-max(current_accum.w, 0.0) * 10.0), + current_finite, + ); + let history_length = select( + 0.0, + clamp(current_moments.z / 32.0, 0.0, 1.0), + current_finite && !sky, + ); + var retained_history = 0.0; + if (footprint_retained) { + retained_history = 1.0; + } else if (current_finite && current_moments.z > 1.0 && + history_in_bounds) { + retained_history = + 1.0 - max(1.0 / current_moments.z, 0.1); + } + + textureStore(reason_out, pixel, vec4(reason, 1.0)); + textureStore(motion_out, pixel, vec4(motion, 1.0)); + textureStore( + reprojection_out, + pixel, + vec4( + clamp(previous_uv, vec2(0.0), vec2(1.0)), + select(0.0, 1.0, valid_reprojection), + 1.0, + ), + ); + textureStore( + confidence_out, + pixel, + vec4(variance_heat, history_length, retained_history, 1.0), + ); +} diff --git a/native/shared/src/renderer/quality_capture.rs b/native/shared/src/renderer/quality_capture.rs new file mode 100644 index 00000000..031a4aec --- /dev/null +++ b/native/shared/src/renderer/quality_capture.rs @@ -0,0 +1,1519 @@ +//! Opt-in qualification readback and adapter evidence. +//! +//! Nothing in this module runs unless the qualification API requests a +//! capture. Copies are encoded into the same post-measurement command buffer +//! as the final screenshot, then converted to display PNGs on the CPU. + +use std::sync::mpsc; + +use super::util::encode_png_simple; +use super::weighted_transparency::WEIGHTED_TRANSPARENCY_AUTO_DRAW_THRESHOLD; +use super::Renderer; + +#[derive(Clone, Copy)] +enum ReadbackKind { + Hdr, + Depth, + Rgba8, +} + +pub(super) struct QualityReadback { + name: &'static str, + kind: ReadbackKind, + width: u32, + height: u32, + padded_bytes_per_row: u32, + buffer: wgpu::Buffer, +} + +pub(super) struct FrameReadback { + staging: wgpu::Buffer, + width: u32, + height: u32, + padded_bytes_per_row: u32, + quality_capture_dir: Option, + quality_readbacks: Vec, +} + +fn json_string(out: &mut String, value: &str) { + out.push('"'); + for character in value.chars() { + match character { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c <= '\u{1f}' => { + use std::fmt::Write as _; + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); +} + +fn linear_to_srgb(value: f32) -> u8 { + let value = value.max(0.0); + let aces = (value * (2.51 * value + 0.03)) / (value * (2.43 * value + 0.59) + 0.14); + let clamped = aces.clamp(0.0, 1.0); + let srgb = if clamped <= 0.003_130_8 { + 12.92 * clamped + } else { + 1.055 * clamped.powf(1.0 / 2.4) - 0.055 + }; + (srgb * 255.0 + 0.5) as u8 +} + +fn hdr_rgb(data: &[u8], width: u32, height: u32, padded_bytes_per_row: u32) -> Vec { + let mut rgb = Vec::with_capacity((width * height * 3) as usize); + for row in 0..height { + let row_start = (row * padded_bytes_per_row) as usize; + for column in 0..width { + let base = row_start + (column * 8) as usize; + for channel in 0..3 { + let offset = base + channel * 2; + let bits = u16::from_le_bytes([data[offset], data[offset + 1]]); + rgb.push(linear_to_srgb(half::f16::from_bits(bits).to_f32())); + } + } + } + rgb +} + +fn hdr_luminance_at(data: &[u8], x: u32, y: u32, padded_bytes_per_row: u32) -> Option { + let base = (y * padded_bytes_per_row + x * 8) as usize; + let channel = |index: usize| { + let offset = base + index * 2; + half::f16::from_bits(u16::from_le_bytes([data[offset], data[offset + 1]])).to_f32() + }; + let luma = 0.2126 * channel(0) + 0.7152 * channel(1) + 0.0722 * channel(2); + luma.is_finite().then_some(luma.max(0.0)) +} + +fn hdr_metrics_json(data: &[u8], width: u32, height: u32, padded_bytes_per_row: u32) -> String { + let mut luminances = Vec::with_capacity((width * height) as usize); + let mut non_finite = 0usize; + let mut non_finite_alpha = 0usize; + let mut max_alpha = 0.0f32; + let mut hit_alpha_pixels = 0usize; + for y in 0..height { + for x in 0..width { + match hdr_luminance_at(data, x, y, padded_bytes_per_row) { + Some(luma) => luminances.push(luma), + None => non_finite += 1, + } + let alpha_offset = (y * padded_bytes_per_row + x * 8 + 6) as usize; + let alpha = half::f16::from_bits(u16::from_le_bytes([ + data[alpha_offset], + data[alpha_offset + 1], + ])) + .to_f32(); + if alpha.is_finite() { + max_alpha = max_alpha.max(alpha); + hit_alpha_pixels += usize::from(alpha > 0.1); + } else { + non_finite_alpha += 1; + } + } + } + luminances.sort_by(f32::total_cmp); + let percentile = |fraction: f32| { + let index = ((luminances.len().saturating_sub(1)) as f32 * fraction).round() as usize; + luminances.get(index).copied().unwrap_or(0.0) + }; + let mean = luminances + .iter() + .map(|value| f64::from(*value)) + .sum::() + / luminances.len().max(1) as f64; + let mut isolated_local_outliers = 0usize; + for y in 1..height.saturating_sub(1) { + for x in 1..width.saturating_sub(1) { + let Some(center) = hdr_luminance_at(data, x, y, padded_bytes_per_row) else { + continue; + }; + if center <= 4.0 { + continue; + } + let mut neighbor_max = 0.0f32; + for oy in -1..=1 { + for ox in -1..=1 { + if ox == 0 && oy == 0 { + continue; + } + neighbor_max = neighbor_max.max( + hdr_luminance_at( + data, + x.wrapping_add_signed(ox), + y.wrapping_add_signed(oy), + padded_bytes_per_row, + ) + .unwrap_or(0.0), + ); + } + } + isolated_local_outliers += usize::from(center > neighbor_max.max(1.0) * 4.0); + } + } + format!( + "{{\n \"width\": {width},\n \"height\": {height},\n \"finite_pixels\": {},\n \ + \"non_finite_pixels\": {non_finite},\n \ + \"non_finite_alpha\": {non_finite_alpha},\n \"mean_luminance\": {mean:.9},\n \ + \"max_luminance\": {:.9},\n \"p99_luminance\": {:.9},\n \ + \"p999_luminance\": {:.9},\n \"max_alpha\": {max_alpha:.9},\n \ + \"hit_alpha_pixels\": {hit_alpha_pixels},\n \ + \"isolated_local_outliers\": {isolated_local_outliers},\n \ + \"outlier_rule\": \"luma > 4 and > 4x every 3x3 neighbor\"\n}}\n", + luminances.len(), + luminances.last().copied().unwrap_or(0.0), + percentile(0.99), + percentile(0.999), + ) +} + +fn depth_rgb(data: &[u8], width: u32, height: u32, padded_bytes_per_row: u32) -> Vec { + let mut min_proximity = f32::INFINITY; + let mut max_proximity = 0.0f32; + for row in 0..height { + let row_start = (row * padded_bytes_per_row) as usize; + for column in 0..width { + let offset = row_start + (column * 4) as usize; + let depth = f32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]); + let proximity = 1.0 - depth.clamp(0.0, 1.0); + if proximity > 1.0e-7 { + min_proximity = min_proximity.min(proximity); + max_proximity = max_proximity.max(proximity); + } + } + } + let range = (max_proximity - min_proximity).max(1.0e-7); + let mut rgb = Vec::with_capacity((width * height * 3) as usize); + for row in 0..height { + let row_start = (row * padded_bytes_per_row) as usize; + for column in 0..width { + let offset = row_start + (column * 4) as usize; + let depth = f32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]); + // Normalize the non-clear range per capture. This is a diagnostic + // display transform only; it makes perspective depth and all + // three cascade ranges legible without touching render state. + let proximity = 1.0 - depth.clamp(0.0, 1.0); + let normalized = if proximity <= 1.0e-7 { + 0.0 + } else { + ((proximity - min_proximity) / range).clamp(0.0, 1.0).sqrt() + }; + let gray = (normalized * 255.0 + 0.5) as u8; + rgb.extend_from_slice(&[gray, gray, gray]); + } + } + rgb +} + +fn rgba8_rgb(data: &[u8], width: u32, height: u32, padded_bytes_per_row: u32) -> Vec { + let mut rgb = Vec::with_capacity((width * height * 3) as usize); + for row in 0..height { + let row_start = (row * padded_bytes_per_row) as usize; + for column in 0..width { + let offset = row_start + (column * 4) as usize; + rgb.extend_from_slice(&data[offset..offset + 3]); + } + } + rgb +} + +impl Renderer { + fn record_quality_texture( + &self, + encoder: &mut wgpu::CommandEncoder, + texture: &wgpu::Texture, + name: &'static str, + kind: ReadbackKind, + aspect: wgpu::TextureAspect, + bytes_per_pixel: u32, + ) -> QualityReadback { + let size = texture.size(); + let unpadded_bytes_per_row = size.width * bytes_per_pixel; + let padded_bytes_per_row = (unpadded_bytes_per_row + 255) & !255; + let buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("bloom_quality_intermediate_readback"), + size: (padded_bytes_per_row * size.height) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_bytes_per_row), + rows_per_image: Some(size.height), + }, + }, + wgpu::Extent3d { + width: size.width, + height: size.height, + depth_or_array_layers: 1, + }, + ); + QualityReadback { + name, + kind, + width: size.width, + height: size.height, + padded_bytes_per_row, + buffer, + } + } + + /// Resolve logical graph resource names to the renderer-owned imports and + /// encode their copies. Unknown names fail loudly instead of silently + /// capturing the wrong attachment after a topology refactor. + pub(super) fn record_quality_resources_by_name( + &self, + encoder: &mut wgpu::CommandEncoder, + names: &[&'static str], + ) -> Result, String> { + let mut readbacks = Vec::with_capacity(names.len()); + for &name in names { + let readback = match name { + "hdr-scene" => self.record_quality_texture( + encoder, + &self.hdr_rt_texture, + name, + ReadbackKind::Hdr, + wgpu::TextureAspect::All, + 8, + ), + "scene-depth" => self.record_quality_texture( + encoder, + &self.depth_texture, + name, + ReadbackKind::Depth, + wgpu::TextureAspect::DepthOnly, + 4, + ), + "ssr" => self.record_quality_texture( + encoder, + &self.ssr_history_textures[self.ssr_history_idx], + name, + ReadbackKind::Hdr, + wgpu::TextureAspect::All, + 8, + ), + "ssgi" => self.record_quality_texture( + encoder, + &self.ssgi_rt_texture, + name, + ReadbackKind::Hdr, + wgpu::TextureAspect::All, + 8, + ), + "shadow-cascade-0" | "shadow-cascade-1" | "shadow-cascade-2" => { + let cascade = name + .as_bytes() + .last() + .copied() + .and_then(|value| value.checked_sub(b'0')) + .map(usize::from) + .filter(|&index| index < self.shadow_map.depth_textures.len()) + .ok_or_else(|| format!("invalid shadow capture resource '{name}'"))?; + self.record_quality_texture( + encoder, + &self.shadow_map.depth_textures[cascade], + name, + ReadbackKind::Depth, + wgpu::TextureAspect::DepthOnly, + 4, + ) + } + _ => return Err(format!("unknown render-graph capture resource '{name}'")), + }; + readbacks.push(readback); + } + Ok(readbacks) + } + + pub(super) fn record_frame_readback( + &self, + encoder: &mut wgpu::CommandEncoder, + output: &wgpu::Texture, + ) -> FrameReadback { + let size = output.size(); + let width = size.width; + let height = size.height; + let unpadded_bytes_per_row = width * 4; + let padded_bytes_per_row = (unpadded_bytes_per_row + 255) & !255; + let staging = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("screenshot_staging"), + size: u64::from(padded_bytes_per_row) * u64::from(height), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: output, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &staging, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_bytes_per_row), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + + let quality_capture_dir = self.pending_quality_capture_dir.clone(); + let mut quality_readbacks = if quality_capture_dir.is_some() { + match self.record_quality_resources_by_name( + encoder, + &super::graph::QUALITY_CAPTURE_RESOURCE_NAMES, + ) { + Ok(readbacks) => readbacks, + Err(error) => { + eprintln!("bloom: graph quality capture request failed: {error}"); + Vec::new() + } + } + } else { + Vec::new() + }; + if quality_capture_dir.is_some() { + // The graph's `ssr` name resolves to filtered history. Keep the + // noisy march as an explicitly physical companion diagnostic. + quality_readbacks.push(self.record_quality_texture( + encoder, + &self.ssr_rt_texture, + "ssr-raw", + ReadbackKind::Hdr, + wgpu::TextureAspect::All, + 8, + )); + if let Some(textures) = self.taa_diagnostic_textures() { + for (&name, texture) in super::temporal_diagnostics::TAA_DIAGNOSTIC_NAMES + .iter() + .zip(textures) + { + quality_readbacks.push(self.record_quality_texture( + encoder, + texture, + name, + ReadbackKind::Rgba8, + wgpu::TextureAspect::All, + 4, + )); + } + } + if let Some(textures) = self.ssr_temporal_diagnostic_textures() { + for (&name, texture) in + super::ssr_temporal_diagnostics::SSR_TEMPORAL_DIAGNOSTIC_NAMES + .iter() + .zip(textures) + { + quality_readbacks.push(self.record_quality_texture( + encoder, + texture, + name, + ReadbackKind::Rgba8, + wgpu::TextureAspect::All, + 4, + )); + } + } + if let Some(textures) = self.ssgi_temporal_diagnostic_textures() { + for (&name, texture) in + super::ssgi_temporal_diagnostics::SSGI_TEMPORAL_DIAGNOSTIC_NAMES + .iter() + .zip(textures) + { + quality_readbacks.push(self.record_quality_texture( + encoder, + texture, + name, + ReadbackKind::Rgba8, + wgpu::TextureAspect::All, + 4, + )); + } + } + if let Some(textures) = self.pt_temporal_diagnostic_textures() { + for (&name, texture) in super::pt_temporal_diagnostics::PT_TEMPORAL_DIAGNOSTIC_NAMES + .iter() + .zip(textures) + { + quality_readbacks.push(self.record_quality_texture( + encoder, + texture, + name, + ReadbackKind::Rgba8, + wgpu::TextureAspect::All, + 4, + )); + } + } + } + FrameReadback { + staging, + width, + height, + padded_bytes_per_row, + quality_capture_dir, + quality_readbacks, + } + } + + pub(super) fn finish_frame_readback(&mut self, readback: FrameReadback) { + let slice = readback.staging.slice(..); + let (tx, rx) = mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + let _ = tx.send(result); + }); + let quality_receivers = Self::begin_quality_intermediate_maps(&readback.quality_readbacks); + let _ = self.device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); + if self.surface.is_none() { + self.headless_in_flight.clear(); + } + + if let Ok(Ok(())) = rx.recv() { + let data = slice.get_mapped_range(); + let mut rgba = Vec::with_capacity((readback.width * readback.height * 4) as usize); + for row in 0..readback.height { + let start = (row * readback.padded_bytes_per_row) as usize; + let end = start + (readback.width * 4) as usize; + rgba.extend_from_slice(&data[start..end]); + } + drop(data); + if let Some(path) = self.pending_screenshot_path.take() { + let mut rgb = Vec::with_capacity((readback.width * readback.height * 3) as usize); + for chunk in rgba.chunks_exact(4) { + // Native surface captures are BGRA; the PNG contract is RGB. + rgb.extend_from_slice(&[chunk[2], chunk[1], chunk[0]]); + } + match encode_png_simple(readback.width, readback.height, &rgb) { + Some(png) => { + if let Err(error) = std::fs::write(&path, png) { + eprintln!("bloom: screenshot write '{path}' failed: {error}"); + } + } + None => eprintln!( + "bloom: screenshot PNG encode failed ({}x{})", + readback.width, readback.height + ), + } + } + self.screenshot_data = Some((readback.width, readback.height, rgba)); + } + readback.staging.unmap(); + + if let Some(directory) = readback.quality_capture_dir.as_deref() { + self.finish_quality_intermediates( + directory, + &readback.quality_readbacks, + quality_receivers, + ); + } + self.pending_quality_capture_dir.take(); + self.release_temporal_diagnostics(); + self.release_ssr_temporal_diagnostics(); + self.release_ssgi_temporal_diagnostics(); + self.release_pt_temporal_diagnostics(); + self.screenshot_requested = false; + } + + pub(super) fn begin_quality_intermediate_maps( + readbacks: &[QualityReadback], + ) -> Vec>> { + readbacks + .iter() + .map(|readback| { + let (tx, rx) = mpsc::channel(); + readback + .buffer + .slice(..) + .map_async(wgpu::MapMode::Read, move |result| { + let _ = tx.send(result); + }); + rx + }) + .collect() + } + + pub(super) fn finish_quality_intermediates( + &self, + directory: &str, + readbacks: &[QualityReadback], + receivers: Vec>>, + ) { + let directory = std::path::Path::new(directory); + if let Err(error) = std::fs::create_dir_all(directory) { + eprintln!("bloom: cannot create quality capture directory '{directory:?}': {error}"); + return; + } + for (readback, receiver) in readbacks.iter().zip(receivers) { + if !matches!(receiver.recv(), Ok(Ok(()))) { + eprintln!("bloom: intermediate '{}' readback failed", readback.name); + continue; + } + let data = readback.buffer.slice(..).get_mapped_range(); + if matches!(readback.kind, ReadbackKind::Hdr) { + let metrics = hdr_metrics_json( + &data, + readback.width, + readback.height, + readback.padded_bytes_per_row, + ); + let metrics_path = directory.join(format!("{}.metrics.json", readback.name)); + if let Err(error) = std::fs::write(&metrics_path, metrics) { + eprintln!("bloom: HDR metrics write '{metrics_path:?}' failed: {error}"); + } + } + let rgb = match readback.kind { + ReadbackKind::Hdr => hdr_rgb( + &data, + readback.width, + readback.height, + readback.padded_bytes_per_row, + ), + ReadbackKind::Depth => depth_rgb( + &data, + readback.width, + readback.height, + readback.padded_bytes_per_row, + ), + ReadbackKind::Rgba8 => rgba8_rgb( + &data, + readback.width, + readback.height, + readback.padded_bytes_per_row, + ), + }; + drop(data); + readback.buffer.unmap(); + let path = directory.join(format!("{}.png", readback.name)); + match encode_png_simple(readback.width, readback.height, &rgb) { + Some(png) => { + if let Err(error) = std::fs::write(&path, png) { + eprintln!("bloom: intermediate write '{path:?}' failed: {error}"); + } + } + None => eprintln!("bloom: intermediate '{}' PNG encode failed", readback.name), + } + } + for (name, width, height, rgb) in self.shadow_map.virtual_map.debug_images() { + let path = directory.join(format!("{name}.png")); + match encode_png_simple(width, height, &rgb) { + Some(png) => { + if let Err(error) = std::fs::write(&path, png) { + eprintln!("bloom: VSM debug write '{path:?}' failed: {error}"); + } + } + None => eprintln!("bloom: VSM debug PNG encode failed for '{name}'"), + } + } + } + + /// Capacity owned by renderer containers whose contents are rebuilt or + /// extended while recording frames. This intentionally excludes immutable + /// startup resources and user-created registries: the qualification gate + /// compares it after warm-up to detect steady-state CPU growth. + pub fn quality_frame_cpu_capacity_bytes(&self) -> usize { + fn bytes(values: &Vec) -> usize { + values.capacity().saturating_mul(std::mem::size_of::()) + } + + let mut total = 0usize; + for value in [ + self.headless_in_flight + .capacity() + .saturating_mul(std::mem::size_of::()), + bytes(&self.vertices_2d), + bytes(&self.indices_2d), + bytes(&self.draw_calls_2d), + bytes(&self.vertices_3d), + bytes(&self.indices_3d), + bytes(&self.draw_calls_3d), + bytes(&self.model_draw_commands), + bytes(&self.model_uniform_scratch), + bytes(&self.model_uniform_bind_groups), + bytes(&self.pending_skin_groups), + bytes(&self.frame_joint_data), + bytes(&self.pending_skin_groups_prev), + bytes(&self.frame_joint_data_prev), + bytes(&self.pt_dynamic_draws), + bytes(&self.pt_dyn_windows), + bytes(&self.pt_dyn_blas), + bytes(&self.pt_skin_params), + bytes(&self.sdf_cache_writes), + bytes(&self.material_system.commands), + bytes(&self.material_system.translucent_commands), + bytes(&self.material_system.per_draw_buffers), + bytes(&self.material_system.per_draw_bgs), + ] { + total = total.saturating_add(value); + } + for palettes in [&self.pending_skin_groups, &self.pending_skin_groups_prev] { + for palette in palettes { + total = total.saturating_add(bytes(palette)); + } + } + #[cfg(not(target_arch = "wasm32"))] + let (_, cached_motion_bytes) = self.cached_model_motion_stats(); + #[cfg(target_arch = "wasm32")] + let cached_motion_bytes = 0; + let (_, unkeyed_skin_motion_bytes) = self.unkeyed_skin_motion_stats(); + total + .saturating_add(cached_motion_bytes) + .saturating_add(unkeyed_skin_motion_bytes) + } + + pub fn quality_runtime_paths_json(&self) -> String { + let ssgi = self.ssgi_backend_logged.unwrap_or(if self.hw_rt_enabled { + "hw-ray-query-pending" + } else { + "software-fallback-pending" + }); + let mut out = String::from("{\"ssgi_trace_backend\":"); + json_string(&mut out, ssgi); + out.push_str(",\"path_tracing_available\":"); + out.push_str(if self.pt_pipeline.is_some() { + "true" + } else { + "false" + }); + out.push_str(",\"ray_scene_preparation\":"); + json_string( + &mut out, + match (self.ssgi_enabled, self.pt_active()) { + (true, true) => "ssgi+pt", + (true, false) => "ssgi", + (false, true) => "pt", + (false, false) => "disabled", + }, + ); + out.push_str(",\"temporal_history\":{"); + out.push_str("\"ssr_valid\":"); + out.push_str(if self.ssr_history_valid { + "true" + } else { + "false" + }); + out.push_str(",\"ssr_index\":"); + out.push_str(&self.ssr_history_idx.to_string()); + out.push_str(",\"ssgi_probe_valid\":"); + out.push_str(if self.probe_history_valid { + "true" + } else { + "false" + }); + out.push_str(",\"ssgi_probe_index\":"); + out.push_str(&self.probe_history_idx.to_string()); + out.push_str(",\"taa_valid\":"); + out.push_str(if self.taa_history_valid { + "true" + } else { + "false" + }); + out.push_str(",\"taa_index\":"); + out.push_str(&self.taa_current_idx.to_string()); + out.push_str(",\"taa_pt_owned\":"); + out.push_str(if self.taa_history_pt_owned { + "true" + } else { + "false" + }); + out.push_str(",\"exposure_valid\":"); + out.push_str(if self.exposure_history_valid { + "true" + } else { + "false" + }); + out.push_str(",\"exposure_index\":"); + out.push_str(&self.exposure_current_idx.to_string()); + out.push_str(",\"pt_samples\":"); + out.push_str(&self.pt_accum_count.to_string()); + out.push_str(",\"pt_index\":"); + out.push_str(&self.pt_accum_idx.to_string()); + out.push_str(",\"ssao_frames\":"); + out.push_str(&self.ssao_history_frame.to_string()); + out.push_str(",\"ssao_index\":"); + out.push_str(&self.ssao_history_idx.to_string()); + out.push_str(",\"camera_cut_pending\":"); + out.push_str(if self.temporal_camera_cut_pending { + "true" + } else { + "false" + }); + out.push_str(",\"camera_cut_active\":"); + out.push_str(if self.temporal_camera_cut_active { + "true" + } else { + "false" + }); + let (cached_motion_entries, cached_motion_cpu_bytes) = self.cached_model_motion_stats(); + out.push_str(",\"cached_model_motion_entries\":"); + out.push_str(&cached_motion_entries.to_string()); + out.push_str(",\"cached_model_motion_cpu_capacity_bytes\":"); + out.push_str(&cached_motion_cpu_bytes.to_string()); + out.push_str(",\"cached_model_motion_gpu_bytes\":0"); + out.push_str(",\"cached_model_motion_passes\":0"); + let (unkeyed_skin_entries, unkeyed_skin_cpu_bytes) = self.unkeyed_skin_motion_stats(); + out.push_str(",\"unkeyed_skin_motion_entries\":"); + out.push_str(&unkeyed_skin_entries.to_string()); + out.push_str(",\"unkeyed_skin_motion_cpu_capacity_bytes\":"); + out.push_str(&unkeyed_skin_cpu_bytes.to_string()); + out.push_str(",\"unkeyed_skin_motion_gpu_bytes\":0"); + out.push_str(",\"unkeyed_skin_motion_passes\":0"); + let (immediate_motion_entries, immediate_motion_cpu_bytes) = self.immediate_motion.stats(); + out.push_str(",\"immediate_motion_entries\":"); + out.push_str(&immediate_motion_entries.to_string()); + out.push_str(",\"immediate_motion_cpu_capacity_bytes\":"); + out.push_str(&immediate_motion_cpu_bytes.to_string()); + out.push_str(",\"immediate_motion_gpu_bytes\":0"); + out.push_str(",\"immediate_motion_passes\":0"); + out.push_str(",\"diagnostic_persistent_bytes\":0"); + let diagnostic_texture_bytes = u64::from(self.surface_config.width) + * u64::from(self.surface_config.height) + * super::temporal_diagnostics::TAA_DIAGNOSTIC_NAMES.len() as u64 + * 4; + let diagnostic_row_bytes = u64::from((self.surface_config.width * 4 + 255) & !255); + let diagnostic_readback_bytes = diagnostic_row_bytes + * u64::from(self.surface_config.height) + * super::temporal_diagnostics::TAA_DIAGNOSTIC_NAMES.len() as u64; + out.push_str(",\"diagnostic_capture_texture_bytes\":"); + out.push_str(&diagnostic_texture_bytes.to_string()); + out.push_str(",\"diagnostic_capture_readback_bytes\":"); + out.push_str(&diagnostic_readback_bytes.to_string()); + out.push_str(",\"diagnostic_capture_passes\":1"); + out.push_str(",\"diagnostic_resources_live\":"); + out.push_str(if self.taa_diagnostic_textures().is_some() { + "true" + } else { + "false" + }); + let ssr_size = self.ssr_rt_texture.size(); + let ssr_hdr_row_bytes = u64::from((ssr_size.width * 8 + 255) & !255); + let ssr_rgba8_row_bytes = u64::from((ssr_size.width * 4 + 255) & !255); + let ssr_capture_texture_bytes = u64::from(ssr_size.width) + * u64::from(ssr_size.height) + * super::ssr_temporal_diagnostics::SSR_TEMPORAL_DIAGNOSTIC_NAMES.len() as u64 + * 4; + let ssr_capture_bytes = ssr_hdr_row_bytes * u64::from(ssr_size.height) * 2 + + ssr_rgba8_row_bytes + * u64::from(ssr_size.height) + * super::ssr_temporal_diagnostics::SSR_TEMPORAL_DIAGNOSTIC_NAMES.len() as u64; + out.push_str(",\"ssr_diagnostic_persistent_bytes\":0"); + out.push_str(",\"ssr_diagnostic_capture_texture_bytes\":"); + out.push_str(&ssr_capture_texture_bytes.to_string()); + out.push_str(",\"ssr_diagnostic_capture_readback_bytes\":"); + out.push_str(&ssr_capture_bytes.to_string()); + out.push_str(",\"ssr_diagnostic_capture_passes\":1"); + out.push_str(",\"ssr_diagnostic_resources_live\":"); + out.push_str(if self.ssr_temporal_diagnostic_textures().is_some() { + "true" + } else { + "false" + }); + let ssgi_diagnostic_width = self.probe_grid_w * super::PROBE_OCT_SIZE; + let ssgi_diagnostic_height = self.probe_grid_h * super::PROBE_OCT_SIZE; + let ssgi_diagnostic_count = + super::ssgi_temporal_diagnostics::SSGI_TEMPORAL_DIAGNOSTIC_NAMES.len() as u64; + let ssgi_diagnostic_texture_bytes = u64::from(ssgi_diagnostic_width) + * u64::from(ssgi_diagnostic_height) + * ssgi_diagnostic_count + * 4; + let ssgi_diagnostic_row_bytes = u64::from((ssgi_diagnostic_width * 4 + 255) & !255); + let ssgi_diagnostic_readback_bytes = + ssgi_diagnostic_row_bytes * u64::from(ssgi_diagnostic_height) * ssgi_diagnostic_count; + out.push_str(",\"ssgi_diagnostic_persistent_bytes\":0"); + out.push_str(",\"ssgi_diagnostic_capture_texture_bytes\":"); + out.push_str(&ssgi_diagnostic_texture_bytes.to_string()); + out.push_str(",\"ssgi_diagnostic_capture_readback_bytes\":"); + out.push_str(&ssgi_diagnostic_readback_bytes.to_string()); + out.push_str(",\"ssgi_diagnostic_capture_passes\":1"); + out.push_str(",\"ssgi_diagnostic_resources_live\":"); + out.push_str(if self.ssgi_temporal_diagnostic_textures().is_some() { + "true" + } else { + "false" + }); + self.append_pt_temporal_diagnostic_telemetry(&mut out); + out.push('}'); + out.push_str(",\"transparent_gi\":{"); + out.push_str("\"enabled\":"); + out.push_str(if super::transparent_gi::transparent_gi_enabled() { + "true" + } else { + "false" + }); + out.push_str(",\"active\":"); + out.push_str(if self.transparent_gi_active { + "true" + } else { + "false" + }); + out.push_str(",\"representation\":\"one-layer-colored-continuation\""); + out.push_str(",\"additional_persistent_bytes\":0"); + out.push_str(",\"instance_count\":"); + out.push_str(&self.transparent_gi_instance_count.to_string()); + out.push('}'); + out.push_str(",\"refractive_reflections\":{"); + out.push_str("\"enabled\":"); + out.push_str( + if cfg!(not(fold_scene_inputs)) + && super::refractive_reflections::refractive_reflection_hierarchy_enabled() + { + "true" + } else { + "false" + }, + ); + out.push_str(",\"active\":"); + out.push_str(if self.refractive_reflections_active { + "true" + } else { + "false" + }); + out.push_str(",\"source\":"); + json_string(&mut out, self.refractive_reflection_source_name()); + out.push_str(",\"march_steps\":"); + out.push_str( + &(super::refractive_reflections::REFRACTIVE_REFLECTION_STEPS as u32).to_string(), + ); + out.push_str(",\"max_distance\":"); + out.push_str( + &super::refractive_reflections::REFRACTIVE_REFLECTION_MAX_DISTANCE.to_string(), + ); + out.push_str(",\"max_roughness\":"); + out.push_str( + &super::refractive_reflections::REFRACTIVE_REFLECTION_MAX_ROUGHNESS.to_string(), + ); + out.push_str(",\"persistent_bytes_when_initialized\":"); + out.push_str( + &super::refractive_reflections::REFRACTIVE_REFLECTION_PERSISTENT_BYTES.to_string(), + ); + out.push_str(",\"additional_graph_passes\":0"); + out.push_str(",\"additional_image_bytes\":0}"); + out.push_str(",\"physical_texture_uv\":{"); + out.push_str("\"supported_sets\":[0,1]"); + out.push_str(",\"uv1_pipeline_initialized\":"); + out.push_str(if self.scene_refractive_uv1_pipeline.is_some() { + "true" + } else { + "false" + }); + out.push_str(",\"ordinary_vertex_stride_bytes\":"); + out.push_str(&std::mem::size_of::().to_string()); + out.push_str(",\"uv1_sidecar_stride_bytes\":"); + out.push_str(&std::mem::size_of::<[f32; 2]>().to_string()); + out.push_str(",\"additional_graph_passes\":0"); + out.push_str(",\"additional_image_bytes\":0}"); + out.push_str(",\"layered_pbr\":{"); + let default_bound_material = super::material_system::MaterialFactorsUniforms::default(); + let default_global_material = super::material_indirection::GpuMaterialRecord::default(); + out.push_str("\"material_record_version\":"); + out.push_str(&super::layered_pbr::MATERIAL_RECORD_VERSION.to_string()); + out.push_str(",\"bound_material_record_version\":"); + out.push_str(&default_bound_material.layered_pbr_version().to_string()); + out.push_str(",\"global_material_record_version\":"); + out.push_str(&default_global_material.layered_pbr_version().to_string()); + out.push_str(",\"lobe_mask_bits\":"); + out.push_str(&super::layered_pbr::MATERIAL_LOBE_MASK_BITS.to_string()); + out.push_str(",\"known_lobe_mask\":"); + out.push_str( + &super::layered_pbr::MaterialLobeMask::KNOWN + .bits() + .to_string(), + ); + out.push_str(",\"default_bound_lobe_mask\":"); + out.push_str( + &default_bound_material + .layered_pbr_lobe_mask() + .bits() + .to_string(), + ); + out.push_str(",\"default_global_lobe_mask\":"); + out.push_str( + &default_global_material + .layered_pbr_lobe_mask() + .bits() + .to_string(), + ); + out.push_str(",\"active_lobe_material_count\":"); + out.push_str( + &self + .material_system + .indirection + .active_layered_material_count() + .to_string(), + ); + out.push_str(",\"bound_material_uniform_bytes\":"); + out.push_str( + &std::mem::size_of::().to_string(), + ); + out.push_str(",\"global_material_record_bytes\":"); + out.push_str( + &std::mem::size_of::().to_string(), + ); + out.push_str(",\"scene_specialization_initialized\":"); + out.push_str(if self.scene_layered_pbr_resources.is_some() { + "true" + } else { + "false" + }); + out.push_str(",\"combined_refraction_specialization_initialized\":"); + out.push_str(if self.scene_layered_refractive_resources.is_some() { + "true" + } else { + "false" + }); + out.push_str(",\"sheen_lut_initialized\":"); + out.push_str(if self.scene_sheen_albedo_lut.is_some() { + "true" + } else { + "false" + }); + out.push_str(",\"sheen_lut_bytes_when_initialized\":"); + out.push_str(&super::layered_pbr_scene::SHEEN_ALBEDO_LUT_BYTES.to_string()); + out.push_str(",\"layered_shared_sampler_count\":1"); + out.push_str(",\"path_tracing_specialization_initialized\":"); + out.push_str(if self.pt_layered.pipelines.iter().any(Option::is_some) { + "true" + } else { + "false" + }); + out.push_str(",\"path_tracing_sheen_specialization_initialized\":"); + out.push_str( + if self + .pt_layered + .pipelines + .iter() + .enumerate() + .any(|(index, pipeline)| index & 1 != 0 && pipeline.is_some()) + { + "true" + } else { + "false" + }, + ); + out.push_str(",\"path_tracing_anisotropy_specialization_initialized\":"); + out.push_str( + if self + .pt_layered + .pipelines + .iter() + .enumerate() + .any(|(index, pipeline)| index & 2 != 0 && pipeline.is_some()) + { + "true" + } else { + "false" + }, + ); + out.push_str(",\"path_tracing_iridescence_specialization_initialized\":"); + out.push_str( + if self + .pt_layered + .pipelines + .iter() + .enumerate() + .any(|(index, pipeline)| index & 4 != 0 && pipeline.is_some()) + { + "true" + } else { + "false" + }, + ); + out.push_str(",\"path_tracing_texture_specialization_initialized\":"); + out.push_str( + if self + .pt_layered + .pipelines + .iter() + .enumerate() + .any(|(index, pipeline)| index & 8 != 0 && pipeline.is_some()) + { + "true" + } else { + "false" + }, + ); + out.push_str(",\"path_tracing_uv1_specialization_initialized\":"); + out.push_str( + if self + .pt_layered + .pipelines + .iter() + .enumerate() + .any(|(index, pipeline)| index & 16 != 0 && pipeline.is_some()) + { + "true" + } else { + "false" + }, + ); + out.push_str(",\"path_tracing_clearcoat_texture_specialization_initialized\":"); + out.push_str( + if self + .pt_layered + .pipelines + .iter() + .enumerate() + .any(|(index, pipeline)| index & 32 != 0 && pipeline.is_some()) + { + "true" + } else { + "false" + }, + ); + out.push_str(",\"path_tracing_clearcoat_normal_specialization_initialized\":"); + out.push_str( + if self + .pt_layered + .pipelines + .iter() + .enumerate() + .any(|(index, pipeline)| index & 512 != 0 && pipeline.is_some()) + { + "true" + } else { + "false" + }, + ); + out.push_str(",\"path_tracing_sheen_texture_specialization_initialized\":"); + out.push_str( + if self + .pt_layered + .pipelines + .iter() + .enumerate() + .any(|(index, pipeline)| index & 64 != 0 && pipeline.is_some()) + { + "true" + } else { + "false" + }, + ); + out.push_str(",\"path_tracing_iridescence_texture_specialization_initialized\":"); + out.push_str( + if self + .pt_layered + .pipelines + .iter() + .enumerate() + .any(|(index, pipeline)| index & 128 != 0 && pipeline.is_some()) + { + "true" + } else { + "false" + }, + ); + out.push_str(",\"path_tracing_anisotropy_texture_specialization_initialized\":"); + out.push_str( + if self + .pt_layered + .pipelines + .iter() + .enumerate() + .any(|(index, pipeline)| index & 256 != 0 && pipeline.is_some()) + { + "true" + } else { + "false" + }, + ); + out.push_str(",\"path_tracing_active_instance_count\":"); + out.push_str( + &self + .pt_layered + .records + .iter() + .filter(|record| record.active()) + .count() + .to_string(), + ); + out.push_str(",\"path_tracing_sidecar_record_bytes\":"); + out.push_str( + &std::mem::size_of::().to_string(), + ); + out.push_str(",\"path_tracing_sidecar_allocated_bytes\":"); + out.push_str( + &self + .pt_layered + .instance_buffer + .as_ref() + .map_or(0, wgpu::Buffer::size) + .to_string(), + ); + out.push_str(",\"path_tracing_texture_sidecar_record_bytes\":"); + out.push_str( + &std::mem::size_of::().to_string(), + ); + out.push_str(",\"path_tracing_texture_sidecar_allocated_bytes\":"); + out.push_str( + &self + .pt_layered + .texture_buffer + .as_ref() + .map_or(0, wgpu::Buffer::size) + .to_string(), + ); + out.push_str(",\"path_tracing_clearcoat_texture_sidecar_record_bytes\":"); + out.push_str( + &std::mem::size_of::().to_string(), + ); + out.push_str(",\"path_tracing_clearcoat_texture_sidecar_allocated_bytes\":"); + out.push_str( + &self + .pt_layered + .clearcoat_texture_buffer + .as_ref() + .map_or(0, wgpu::Buffer::size) + .to_string(), + ); + out.push_str(",\"path_tracing_clearcoat_normal_sidecar_record_bytes\":"); + out.push_str( + &std::mem::size_of::().to_string(), + ); + out.push_str(",\"path_tracing_clearcoat_normal_sidecar_allocated_bytes\":"); + out.push_str( + &self + .pt_layered + .clearcoat_normal_buffer + .as_ref() + .map_or(0, wgpu::Buffer::size) + .to_string(), + ); + out.push_str(",\"path_tracing_sheen_texture_sidecar_record_bytes\":"); + out.push_str(&std::mem::size_of::().to_string()); + out.push_str(",\"path_tracing_sheen_texture_sidecar_allocated_bytes\":"); + out.push_str( + &self + .pt_layered + .sheen_texture_buffer + .as_ref() + .map_or(0, wgpu::Buffer::size) + .to_string(), + ); + out.push_str(",\"path_tracing_iridescence_texture_sidecar_record_bytes\":"); + out.push_str( + &std::mem::size_of::().to_string(), + ); + out.push_str(",\"path_tracing_iridescence_texture_sidecar_allocated_bytes\":"); + out.push_str( + &self + .pt_layered + .iridescence_texture_buffer + .as_ref() + .map_or(0, wgpu::Buffer::size) + .to_string(), + ); + out.push_str(",\"path_tracing_anisotropy_texture_sidecar_record_bytes\":"); + out.push_str( + &std::mem::size_of::().to_string(), + ); + out.push_str(",\"path_tracing_anisotropy_texture_sidecar_allocated_bytes\":"); + out.push_str( + &self + .pt_layered + .anisotropy_texture_buffer + .as_ref() + .map_or(0, wgpu::Buffer::size) + .to_string(), + ); + out.push_str(",\"path_tracing_uv1_sidecar_allocated_bytes\":"); + out.push_str( + &self + .pt_layered + .uv1_buffer + .as_ref() + .map_or(0, wgpu::Buffer::size) + .to_string(), + ); + out.push_str(",\"additional_base_material_bytes\":0"); + out.push_str(",\"additional_base_material_bindings\":0"); + out.push_str(",\"additional_base_material_branches\":0"); + out.push_str(",\"additional_graph_passes\":0"); + out.push_str(",\"additional_image_bytes\":0}"); + out.push_str(",\"transparency\":{"); + out.push_str("\"preference\":"); + json_string( + &mut out, + match self.transparency_composition_mode_code() { + 0 => "sorted", + 2 => "weighted", + _ => "auto", + }, + ); + out.push_str(",\"active\":"); + json_string( + &mut out, + if self.active_transparency_composition_mode_code() == 1 { + "weighted" + } else { + "sorted" + }, + ); + out.push_str(",\"auto_draw_threshold\":"); + out.push_str(&WEIGHTED_TRANSPARENCY_AUTO_DRAW_THRESHOLD.to_string()); + out.push_str(",\"sorted_interleaving\":\"global-depth-source-stable-id\""); + out.push_str(",\"sorted_interleaving_enabled\":"); + out.push_str( + if super::sorted_transparency::sorted_interleaving_enabled() { + "true" + } else { + "false" + }, + ); + out.push_str(",\"reactive_custom_pipeline_count\":"); + out.push_str( + &self + .material_system + .reactive_translucent_pipeline_count() + .to_string(), + ); + out.push_str(",\"sorted_interleaving_additional_draws\":0"); + out.push_str(",\"sorted_interleaving_additional_graph_passes\":0"); + out.push('}'); + out.push_str(",\"temporal_reactive\":{"); + out.push_str("\"enabled\":"); + out.push_str(if super::temporal_reactive::temporal_reactive_enabled() { + "true" + } else { + "false" + }); + out.push_str(",\"active\":"); + out.push_str(if self.temporal_reactive_active { + "true" + } else { + "false" + }); + out.push_str(",\"format\":\"r8unorm\""); + out.push_str(",\"bytes_per_render_pixel\":1"); + out.push_str(",\"sources\":\"imported-blend-transmission-and-authored-custom-coverage\"}"); + out.push_str(",\"transmitted_shadows\":{"); + out.push_str("\"enabled\":"); + out.push_str( + if super::transmitted_shadows::transmitted_shadows_enabled() { + "true" + } else { + "false" + }, + ); + out.push_str(",\"active\":"); + out.push_str(if self.transmitted_shadows_active { + "true" + } else { + "false" + }); + out.push_str(",\"representation\":\"nearest-layer-rgb-depth\""); + out.push_str(",\"resolution\":"); + out.push_str(&super::transmitted_shadows::TRANSMITTED_SHADOW_MAP_SIZE.to_string()); + out.push_str(",\"persistent_bytes_when_allocated\":"); + out.push_str(&super::transmitted_shadows::TRANSMITTED_SHADOW_PERSISTENT_BYTES.to_string()); + out.push_str(",\"caster_count\":"); + out.push_str( + &self + .transmitted_shadow_resources + .as_ref() + .map_or(0, |resources| resources.last_caster_count) + .to_string(), + ); + out.push('}'); + out.push_str(",\"masked_alpha\":{"); + out.push_str("\"coverage_mip_textures\":"); + out.push_str(&self.mask_coverage_texture_count.to_string()); + out.push_str(",\"coverage_mips_supported\":"); + out.push_str(if cfg!(target_os = "android") { + "false" + } else { + "true" + }); + out.push_str(",\"sample_count\":1"); + out.push_str(",\"alpha_to_coverage_supported\":false"); + out.push_str(",\"single_sample_fallback\":\"coverage-mips-bayer-4x4\"}"); + let lighting_uploads = self.lighting_upload_tracker.frame_stats(); + out.push_str(",\"steady_state_uploads\":{\"lighting\":{"); + out.push_str("\"write_count\":"); + out.push_str(&lighting_uploads.write_count.to_string()); + out.push_str(",\"byte_count\":"); + out.push_str(&lighting_uploads.byte_count.to_string()); + out.push_str(",\"full_buffer_bytes\":"); + out.push_str(&std::mem::size_of::().to_string()); + out.push_str("}},\"steady_state_resources\":{\"bind_group_creations\":{"); + out.push_str("\"total\":"); + out.push_str( + &self + .steady_state_frame_resource_stats + .total_bind_group_creations() + .to_string(), + ); + out.push_str(",\"sites\":{"); + for (index, (site, count)) in self + .steady_state_frame_resource_stats + .bind_group_creations() + .enumerate() + { + if index != 0 { + out.push(','); + } + json_string(&mut out, site.name()); + out.push(':'); + out.push_str(&count.to_string()); + } + out.push_str("}},\"graph_compiles\":"); + out.push_str( + &self + .steady_state_frame_resource_stats + .graph_compiles() + .to_string(), + ); + out.push_str(",\"pipeline_creations\":{\"first_use\":"); + out.push_str( + &self + .steady_state_frame_resource_stats + .pipeline_creations() + .to_string(), + ); + out.push('}'); + out.push_str(",\"command_encoder_creations\":{\"total\":"); + out.push_str( + &self + .steady_state_frame_resource_stats + .command_encoder_creations() + .to_string(), + ); + out.push_str(",\"sites\":{\"frame_submission\":"); + out.push_str( + &self + .steady_state_frame_resource_stats + .command_encoder_creations() + .to_string(), + ); + out.push_str("}},\"transient_physical_creations\":{\"textures\":"); + out.push_str( + &self + .steady_state_frame_resource_stats + .physical_texture_creations() + .to_string(), + ); + out.push_str(",\"buffers\":"); + out.push_str( + &self + .steady_state_frame_resource_stats + .physical_buffer_creations() + .to_string(), + ); + out.push_str("}}"); + let graph_stats = self.render_graph_cache_stats(); + out.push_str(",\"render_graph\":{"); + out.push_str("\"compile_count\":"); + out.push_str(&graph_stats.compile_count.to_string()); + out.push_str(",\"cache_hit_count\":"); + out.push_str(&graph_stats.hit_count.to_string()); + out.push_str(",\"cached_plan_count\":"); + out.push_str(&graph_stats.plan_count.to_string()); + if let Some(plan) = self.last_frame_plan.as_ref() { + let render_extent = self.render_extent(); + let output_extent = (self.surface_config.width, self.surface_config.height); + out.push_str(",\"plan_id\":"); + json_string(&mut out, &format!("{:016x}", plan.plan_id)); + out.push_str(",\"pass_count\":"); + out.push_str(&plan.passes.len().to_string()); + out.push_str(",\"aliasing_enabled\":"); + out.push_str(if plan.aliasing_enabled { + "true" + } else { + "false" + }); + out.push_str(",\"transient_bytes\":"); + out.push_str( + &plan + .transient_bytes(render_extent, output_extent) + .to_string(), + ); + out.push_str(",\"unaliased_transient_bytes\":"); + out.push_str( + &plan + .unaliased_transient_bytes(render_extent, output_extent) + .to_string(), + ); + out.push_str(",\"physical_transient_slots\":"); + out.push_str(&self.transient_pool.compiled_slot_count().to_string()); + } + out.push('}'); + out.push_str(",\"renderer_owned_memory\":{"); + out.push_str("\"tracked_frame_cpu_capacity_bytes\":"); + out.push_str(&self.quality_frame_cpu_capacity_bytes().to_string()); + out.push_str(",\"cached_graph_plans\":"); + out.push_str(&graph_stats.plan_count.to_string()); + out.push_str(",\"physical_transient_slots\":"); + out.push_str(&self.transient_pool.compiled_slot_count().to_string()); + out.push('}'); + out.push_str(",\"gpu_driven\":"); + out.push_str(&self.gpu_driven.report_json()); + out.push_str(",\"virtual_shadows\":"); + out.push_str(&self.shadow_map.virtual_map.report_json()); + out.push_str(",\"material_binding\":"); + out.push_str(&self.material_binding_report_json()); + out.push('}'); + out + } +} + +#[cfg(test)] +mod tests { + use super::hdr_metrics_json; + + #[test] + fn hdr_metrics_detect_a_single_local_firefly() { + let width = 3; + let height = 3; + let row_bytes = width * 8; + let mut data = vec![0u8; (row_bytes * height) as usize]; + for y in 0..height { + for x in 0..width { + let value = if (x, y) == (1, 1) { 10.0 } else { 1.0 }; + let bits = half::f16::from_f32(value).to_bits().to_le_bytes(); + let base = (y * row_bytes + x * 8) as usize; + for channel in 0..3 { + data[base + channel * 2..base + channel * 2 + 2].copy_from_slice(&bits); + } + data[base + 6..base + 8] + .copy_from_slice(&half::f16::from_f32(1.0).to_bits().to_le_bytes()); + } + } + let metrics = hdr_metrics_json(&data, width, height, row_bytes); + assert!(metrics.contains("\"non_finite_pixels\": 0")); + assert!(metrics.contains("\"max_luminance\": 10.000000000")); + assert!(metrics.contains("\"isolated_local_outliers\": 1")); + } +} diff --git a/native/shared/src/renderer/quality_preset.rs b/native/shared/src/renderer/quality_preset.rs new file mode 100644 index 00000000..38c646c5 --- /dev/null +++ b/native/shared/src/renderer/quality_preset.rs @@ -0,0 +1,168 @@ +//! Coherent resolution, reconstruction, and effect policy for quality presets. + +use super::Renderer; + +/// Balanced first-run default: 56% of native pixel shading instead of the +/// former 25%, while leaving a meaningful performance tier below it. +pub(super) const DEFAULT_RENDER_SCALE: f32 = 0.75; + +#[derive(Clone, Copy, Debug, PartialEq)] +struct QualityPresetConfig { + render_scale: f32, + taa: bool, + upscale_mode: u32, + composite_sharpen: f32, + cas_sharpen: f32, + shadows: bool, + ssao: bool, + bloom: bool, + ssr: bool, + ssgi: bool, + motion_blur: bool, + sss: bool, + chromatic_aberration: f32, +} + +fn quality_preset_config(preset: u32) -> QualityPresetConfig { + match preset { + 0 => QualityPresetConfig { + render_scale: 0.50, + taa: false, + upscale_mode: 0, + composite_sharpen: 0.0, + cas_sharpen: 0.0, + shadows: false, + ssao: false, + bloom: false, + ssr: false, + ssgi: false, + motion_blur: false, + sss: false, + chromatic_aberration: 0.0, + }, + 1 => QualityPresetConfig { + render_scale: 0.67, + taa: false, + upscale_mode: 1, + composite_sharpen: 0.25, + cas_sharpen: 0.0, + shadows: false, + ssao: false, + bloom: true, + ssr: false, + ssgi: false, + motion_blur: false, + sss: false, + chromatic_aberration: 0.0, + }, + 2 => QualityPresetConfig { + render_scale: DEFAULT_RENDER_SCALE, + taa: true, + upscale_mode: 1, + composite_sharpen: 0.40, + cas_sharpen: 0.0, + shadows: true, + ssao: true, + bloom: true, + ssr: false, + ssgi: false, + motion_blur: false, + sss: false, + chromatic_aberration: 0.0, + }, + 3 => QualityPresetConfig { + render_scale: 0.85, + taa: true, + upscale_mode: 1, + composite_sharpen: 0.45, + cas_sharpen: 0.0, + shadows: true, + ssao: true, + bloom: true, + ssr: true, + ssgi: true, + motion_blur: false, + sss: false, + chromatic_aberration: 0.002, + }, + _ => QualityPresetConfig { + render_scale: 1.0, + taa: true, + upscale_mode: 1, + composite_sharpen: 0.50, + cas_sharpen: 0.0, + shadows: true, + ssao: true, + bloom: true, + ssr: true, + ssgi: true, + motion_blur: true, + sss: true, + chromatic_aberration: 0.003, + }, + } +} + +impl Renderer { + /// Apply one coherent quality tier. Resolution, temporal reconstruction, + /// upscale filtering, sharpening, and effects change together. Individual + /// setters remain overrides and should be called after this method. + pub fn apply_quality_preset(&mut self, preset: u32) { + let config = quality_preset_config(preset); + self.set_render_scale(config.render_scale); + self.set_taa_enabled(config.taa); + self.set_upscale_mode(config.upscale_mode); + self.set_sharpen_strength(config.composite_sharpen); + self.set_cas_strength(config.cas_sharpen); + self.set_shadows_enabled(config.shadows); + self.set_ssao_enabled(config.ssao); + self.set_bloom_enabled(config.bloom); + self.set_ssr_enabled(config.ssr); + self.set_ssgi_enabled(config.ssgi); + self.set_motion_blur_enabled(config.motion_blur); + self.set_sss_enabled(config.sss); + self.set_chromatic_aberration(config.chromatic_aberration); + } +} + +#[cfg(test)] +mod tests { + use super::{quality_preset_config, DEFAULT_RENDER_SCALE}; + + #[test] + fn tiers_raise_resolution_monotonically_and_ultra_is_native() { + let configs = (0..=4).map(quality_preset_config).collect::>(); + assert_eq!(DEFAULT_RENDER_SCALE, 0.75); + assert_eq!(configs[4].render_scale, 1.0); + for pair in configs.windows(2) { + assert!(pair[0].render_scale < pair[1].render_scale); + } + } + + #[test] + fn reconstruction_and_sharpening_are_explicit_per_tier() { + let off = quality_preset_config(0); + let low = quality_preset_config(1); + let medium = quality_preset_config(2); + let high = quality_preset_config(3); + let ultra = quality_preset_config(4); + + assert!(!off.taa && !low.taa); + assert!(medium.taa && high.taa && ultra.taa); + assert_eq!(off.upscale_mode, 0); + assert_eq!(low.upscale_mode, 1); + assert_eq!(off.composite_sharpen, 0.0); + assert!(low.composite_sharpen < medium.composite_sharpen); + assert!(medium.composite_sharpen < high.composite_sharpen); + assert!(high.composite_sharpen < ultra.composite_sharpen); + assert!([off, low, medium, high, ultra] + .iter() + .all(|config| config.cas_sharpen == 0.0)); + } + + #[test] + fn out_of_range_presets_clamp_to_ultra_policy() { + assert_eq!(quality_preset_config(5), quality_preset_config(4)); + assert_eq!(quality_preset_config(u32::MAX), quality_preset_config(4)); + } +} diff --git a/native/shared/src/renderer/refractive_reflections.rs b/native/shared/src/renderer/refractive_reflections.rs new file mode 100644 index 00000000..535f06bb --- /dev/null +++ b/native/shared/src/renderer/refractive_reflections.rs @@ -0,0 +1,225 @@ +//! Bounded reflection-source routing for imported physical transmission. +//! +//! The first production tier is a fragment-local screen-space march against +//! the immutable opaque color/depth snapshots already required by native +//! refraction. It creates no graph pass or image allocation, and the shader, +//! layout, uniform, and per-frame work remain absent until a physical +//! transmission draw is actually submitted. + +#![cfg_attr(target_arch = "wasm32", allow(dead_code))] + +use super::Renderer; + +pub(super) const REFRACTIVE_REFLECTION_MAX_DISTANCE: f32 = 8.0; +pub(super) const REFRACTIVE_REFLECTION_STEPS: f32 = 8.0; +pub(super) const REFRACTIVE_REFLECTION_MAX_ROUGHNESS: f32 = 0.45; +pub(super) const REFRACTIVE_REFLECTION_PERSISTENT_BYTES: u64 = + std::mem::size_of::() as u64; + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub(super) struct RefractiveReflectionParams { + /// Current jittered world-to-view and view-to-clip transforms. Keeping the + /// two stages explicit mirrors the established opaque SSR math and avoids + /// accumulating a world-space ray over large scene coordinates. + view: [[f32; 4]; 4], + proj: [[f32; 4]; 4], + /// x = screen-space tier active, y = maximum world-space distance, + /// z = fixed march steps, w = maximum participating roughness. + params: [f32; 4], + /// xyz = explicit planar-probe normal, w = plane equation d. + /// A zero normal is the no-probe sentinel. + planar_plane: [f32; 4], +} + +fn enabled_from(value: Option<&str>) -> bool { + !matches!( + value.unwrap_or("on").trim().to_ascii_lowercase().as_str(), + "0" | "off" | "false" | "disabled" + ) +} + +/// Startup-selected exact A/B gate. When disabled, the refractive pipeline is +/// compiled with the established environment-only reflection expression and +/// does not allocate this module's layout, uniform, or bind group. +pub(super) fn refractive_reflection_hierarchy_enabled() -> bool { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *ENABLED.get_or_init(|| { + enabled_from( + std::env::var("BLOOM_REFRACTIVE_REFLECTIONS") + .ok() + .as_deref(), + ) + }) +} + +#[cfg(not(fold_scene_inputs))] +pub(super) fn create_inputs_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("scene_refractive_inputs_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: Some( + std::num::NonZeroU64::new(REFRACTIVE_REFLECTION_PERSISTENT_BYTES) + .expect("reflection params are non-empty"), + ), + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + ], + }) +} + +#[cfg(not(fold_scene_inputs))] +pub(super) fn create_params_buffer(device: &wgpu::Device) -> wgpu::Buffer { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("scene_refractive_reflection_params"), + size: REFRACTIVE_REFLECTION_PERSISTENT_BYTES, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) +} + +#[cfg(not(fold_scene_inputs))] +pub(super) struct RefractiveInputs<'a> { + pub(super) layout: &'a wgpu::BindGroupLayout, + pub(super) params_buffer: &'a wgpu::Buffer, + pub(super) scene_color: &'a wgpu::TextureView, + pub(super) scene_color_sampler: &'a wgpu::Sampler, + pub(super) scene_depth: &'a wgpu::TextureView, + pub(super) planar_reflection: &'a wgpu::TextureView, +} + +#[cfg(not(fold_scene_inputs))] +pub(super) fn materialize_inputs( + device: &wgpu::Device, + queue: &wgpu::Queue, + inputs: RefractiveInputs<'_>, + view: [[f32; 4]; 4], + proj: [[f32; 4]; 4], + screen_space_active: bool, + planar_plane: Option<([f32; 3], f32)>, +) -> wgpu::BindGroup { + let planar_plane = planar_plane.map_or([0.0; 4], |(normal, plane_y)| { + [normal[0], normal[1], normal[2], normal[1] * plane_y] + }); + let params = RefractiveReflectionParams { + view, + proj, + params: [ + if screen_space_active { 1.0 } else { 0.0 }, + REFRACTIVE_REFLECTION_MAX_DISTANCE, + REFRACTIVE_REFLECTION_STEPS, + REFRACTIVE_REFLECTION_MAX_ROUGHNESS, + ], + planar_plane, + }; + queue.write_buffer(inputs.params_buffer, 0, bytemuck::bytes_of(¶ms)); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("scene_refractive_inputs_bg"), + layout: inputs.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(inputs.scene_color), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(inputs.scene_color_sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(inputs.scene_depth), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: inputs.params_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(inputs.planar_reflection), + }, + ], + }) +} + +impl Renderer { + pub(super) fn refractive_reflection_source_name(&self) -> &'static str { + #[cfg(fold_scene_inputs)] + { + return "environment"; + } + #[cfg(not(fold_scene_inputs))] + if self.scene_refractive_inputs_layout.is_some() { + if self.planar_probes.iter().any(Option::is_some) { + "planar-then-screen-space-then-environment" + } else if self.ssr_enabled { + "screen-space-then-environment" + } else { + "environment-runtime-fallback" + } + } else { + "environment" + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn startup_gate_defaults_on_and_accepts_documented_false_values() { + assert!(enabled_from(None)); + assert!(enabled_from(Some("yes"))); + for value in ["0", "off", " false ", "DISABLED"] { + assert!(!enabled_from(Some(value)), "{value}"); + } + } + + #[test] + fn reflection_uniform_abi_is_exactly_one_hundred_sixty_bytes() { + assert_eq!(REFRACTIVE_REFLECTION_PERSISTENT_BYTES, 160); + } +} diff --git a/native/shared/src/renderer/scene_pass.rs b/native/shared/src/renderer/scene_pass.rs index 719b7e56..e5595264 100644 --- a/native/shared/src/renderer/scene_pass.rs +++ b/native/shared/src/renderer/scene_pass.rs @@ -17,116 +17,182 @@ impl Renderer { // Rebind: the immediate-mode 3D upload just before this call // checks the same predicate; vertices_3d is untouched between. let has_3d = !self.vertices_3d.is_empty(); - // ============================================================ - // HDR pass: sky + 3D + scene → linear HDR offscreen RT. - // ============================================================ - // The composite-tonemap pass downstream reads this RT and - // writes the final image to the sRGB surface. Keeping the - // intermediate radiance in HDR sets up a future bloom pass - // and means tonemap + sRGB encode happen exactly once, in - // one place. - // EN-005 Phase 2 — refresh the sky-view LUT before the HDR - // pass opens. The compute dispatch can't be nested inside a - // render pass, and `maybe_update_sky_view_lut` is a no-op - // unless the sun (or atmosphere knobs) actually changed. - // EN-005 V2 — also re-bake the aerial-perspective volume, - // which must happen every frame because the camera moves. - if self.procedural_sky_enabled { - self.maybe_update_sky_view_lut(); - self.dispatch_aerial_perspective_lut(); - } + // ============================================================ + // HDR pass: sky + 3D + scene → linear HDR offscreen RT. + // ============================================================ + // The composite-tonemap pass downstream reads this RT and + // writes the final image to the sRGB surface. Keeping the + // intermediate radiance in HDR sets up a future bloom pass + // and means tonemap + sRGB encode happen exactly once, in + // one place. + // EN-005 Phase 2 — refresh the sky-view LUT before the HDR + // pass opens. The compute dispatch can't be nested inside a + // render pass, and `maybe_update_sky_view_lut` is a no-op + // unless the sun (or atmosphere knobs) actually changed. + // EN-005 V2 — also re-bake the aerial-perspective volume, + // which must happen every frame because the camera moves. + if self.procedural_sky_enabled { + self.maybe_update_sky_view_lut(); + self.dispatch_aerial_perspective_lut(); + } + self.prepare_gpu_driven_camera(encoder, scene); - // EN-044 — DEPTH PREPASS over the cached-model draws. - // - // The scene fragment shader can `discard` (alpha-cutout foliage), and a shader - // that may discard cannot early-Z *write*: the GPU must run it in full before it - // knows whether the pixel survives. So an 88-tree forest of overlapping leaf - // cards shaded the whole 5-target MRT several layers deep and threw most of it - // away. Measured: the forest alone was 5.6 ms of a 7.4 ms main_hdr_pass, and - // dropping it took the title screen from 46.7 fps to the 60 fps vsync cap. - // - // Priming depth first turns that around. The prepass writes depth only (no MRT, - // no lighting, alpha cutout honoured so cards keep their real silhouette), and - // the main pass then early-Z *rejects* the occluded leaves before its shader - // ever runs. The main pass tests LessEqual, not Less — the visible surface - // arrives with a depth exactly equal to the one the prepass stored, and `Less` - // would throw it away. - // - // Same vertex stage, so the foliage wind displaces identically in both and the - // depths agree to the bit. - // Runs even with no cached models, because it now owns the depth CLEAR that - // main_hdr_pass used to do — skipping it would hand the main pass a depth - // buffer full of last frame's garbage. - profiler.begin("depth_prepass"); - { - let prepass_ts = profiler.pass_timestamp_writes("depth_prepass"); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("bloom_depth_prepass"), - color_attachments: &[], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: &self.depth_view, - depth_ops: Some(wgpu::Operations { - load: wgpu::LoadOp::Clear(1.0), - store: wgpu::StoreOp::Store, + // EN-044 — DEPTH PREPASS over the cached-model draws. + // + // The scene fragment shader can `discard` (alpha-cutout foliage), and a shader + // that may discard cannot early-Z *write*: the GPU must run it in full before it + // knows whether the pixel survives. So an 88-tree forest of overlapping leaf + // cards shaded the whole 5-target MRT several layers deep and threw most of it + // away. Measured: the forest alone was 5.6 ms of a 7.4 ms main_hdr_pass, and + // dropping it took the title screen from 46.7 fps to the 60 fps vsync cap. + // + // Priming depth first turns that around. The prepass writes depth only (no MRT, + // no lighting, alpha cutout honoured so cards keep their real silhouette), and + // the main pass then early-Z *rejects* the occluded leaves before its shader + // ever runs. The main pass tests LessEqual, not Less — the visible surface + // arrives with a depth exactly equal to the one the prepass stored, and `Less` + // would throw it away. + // + // Same vertex stage, so the foliage wind displaces identically in both and the + // depths agree to the bit. + // Runs even with no cached models, because it now owns the depth CLEAR that + // main_hdr_pass used to do — skipping it would hand the main pass a depth + // buffer full of last frame's garbage. + profiler.begin("depth_prepass"); + // SH-055 diag — "prepass" skips this whole pass (clear included; the main + // pass then loads undefined depth — visually wrong, timing-valid). + if !self.dbg_skip("prepass") { + let prepass_ts = profiler.pass_timestamp_writes("depth_prepass"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_depth_prepass"), + color_attachments: &[], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, }), - stencil_ops: None, - }), - timestamp_writes: prepass_ts, - occlusion_query_set: None, - multiview_mask: None, - }); - // EN-063 — on wasm the prepass DRAWS are skipped (the pass still - // owns the depth clear). The prepass/main pairing requires the two - // pipelines to produce bit-identical depths; Tint (the browser's - // WGSL compiler) does not preserve that even under @invariant once - // the foliage-wind displacement is in the chain, and the main - // pass's Equal test then discards whole leaf cards (white torn - // canopies, streaks). The main pass runs the classic Less+write - // pipeline on wasm instead — overdraw over early-Z, correctness - // over speed. - #[cfg(not(target_arch = "wasm32"))] - { - pass.set_pipeline(&self.scene_depth_pipeline); - pass.set_bind_group(1, &self.lighting_bind_group, &[]); - pass.set_bind_group(3, &self.joint_bind_group, &[]); - let cam_vp = mat4_multiply( - self.current_proj_matrix_unjittered, - self.current_view_matrix, - ); - let cam_planes = crate::scene::extract_frustum_planes(&cam_vp); - for cmd in &self.model_draw_commands { - if let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.cache_handle) { - if cmd.mesh_idx < meshes.len() { - let mesh = &meshes[cmd.mesh_idx]; - let (wmin, wmax) = cmd.bounds_override.unwrap_or_else(|| { - transform_aabb(&cmd.model, mesh.local_min, mesh.local_max) - }); - if wmin[0] <= wmax[0] - && crate::scene::aabb_outside_frustum(&cam_planes, wmin, wmax) - { - continue; + timestamp_writes: prepass_ts, + occlusion_query_set: None, + multiview_mask: None, + }); + // EN-063 — on wasm the prepass DRAWS are skipped (the pass still + // owns the depth clear). The prepass/main pairing requires the two + // pipelines to produce bit-identical depths; Tint (the browser's + // WGSL compiler) does not preserve that even under @invariant once + // the foliage-wind displacement is in the chain, and the main + // pass's Equal test then discards whole leaf cards (white torn + // canopies, streaks). The main pass runs the classic Less+write + // pipeline on wasm instead — overdraw over early-Z, correctness + // over speed. + #[cfg(not(target_arch = "wasm32"))] + if !self.dbg_skip("prepass_draws") { + // SH-055 diag — keep pass+clear, skip draws + if let Some(global_materials) = + self.material_system.indirection.global_bind_group.as_ref() + { + self.gpu_driven.draw_depth( + &mut pass, + &self.lighting_bind_group, + global_materials, + &self.joint_bind_group, + ); + } + pass.set_pipeline(&self.scene_depth_pipeline); + pass.set_bind_group(1, &self.lighting_bind_group, &[]); + pass.set_bind_group(3, &self.joint_bind_group, &[]); + let cam_vp = mat4_multiply( + self.current_proj_matrix_unjittered, + self.current_view_matrix, + ); + let cam_planes = crate::scene::extract_frustum_planes(&cam_vp); + for cmd in &self.model_draw_commands { + if let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.cache_handle) { + if cmd.mesh_idx < meshes.len() { + let mesh = &meshes[cmd.mesh_idx]; + if mesh.alpha_mode == MaterialAlphaMode::Blend + || (self.imported_refraction_enabled + && mesh.transmission.is_active()) + { + continue; + } + if self.gpu_driven.submitting() + && !cmd.skinned + && matches!(&mesh.geometry, gpu_driven::MeshGeometry::Shared(_)) + { + continue; + } + let (wmin, wmax) = cmd.bounds_override.unwrap_or_else(|| { + transform_aabb(&cmd.model, mesh.local_min, mesh.local_max) + }); + if wmin[0] <= wmax[0] + && crate::scene::aabb_outside_frustum(&cam_planes, wmin, wmax) + { + continue; + } + let draw = self.gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count); + pass.set_bind_group( + 0, + &self.model_uniform_bind_groups[cmd.uniform_slot], + &[], + ); + pass.set_bind_group(2, &mesh.material_bg, &[]); + pass.set_vertex_buffer(0, draw.vertex.slice(..)); + pass.set_index_buffer(draw.index.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(draw.index_range(), draw.base_vertex, 0..1); + } } - pass.set_bind_group(0, &self.model_uniform_bind_groups[cmd.uniform_slot], &[]); - pass.set_bind_group(2, &mesh.material_bg, &[]); - pass.set_vertex_buffer(0, mesh.vb.slice(..)); - pass.set_index_buffer(mesh.ib.slice(..), wgpu::IndexFormat::Uint32); - pass.draw_indexed(0..mesh.index_count, 0, 0..1); } } } - } - } - profiler.end("depth_prepass"); + profiler.end("depth_prepass"); - profiler.begin("main_hdr_pass"); - { - // HDR clear: the user's clear_color is in 0-1 srgb-ish - // range; treat it as the linear background for the HDR - // RT. After tonemap it ends up roughly the same shade. - let hdr_ts = profiler.pass_timestamp_writes("main_hdr_pass"); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("bloom_hdr_pass"), - color_attachments: &[ + profiler.begin("main_hdr_pass"); + // SH-055 diag — "hdr_pass" skips the whole main HDR pass (prepass untouched). + if !self.dbg_skip("hdr_pass") { + // HDR clear: the user's clear_color is in 0-1 srgb-ish + // range; treat it as the linear background for the HDR + // RT. After tonemap it ends up roughly the same shade. + let hdr_ts = profiler.pass_timestamp_writes("main_hdr_pass"); + // SH-055 — `lean_mrt` (Android): skip the material + albedo + // attachments entirely (None at the same indices the pipelines in + // mod.rs/material_pipeline.rs declare None for — wgpu-core requires + // the render pass and the bound pipeline to agree index-for-index). + // This is what actually drops main_hdr_pass's per-pixel byte + // footprint below the Adreno GMEM-overflow threshold; see build.rs. + #[cfg(lean_mrt)] + let color_attachments: &[Option>] = &[ + Some(wgpu::RenderPassColorAttachment { + view: &self.hdr_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(self.clear_color), + store: wgpu::StoreOp::Store, + }, + }), + None, + Some(wgpu::RenderPassColorAttachment { + view: &self.velocity_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + // Zero velocity = stationary pixel. + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.0, + }), + store: wgpu::StoreOp::Store, + }, + }), + None, + ]; + #[cfg(not(lean_mrt))] + let color_attachments: &[Option>] = &[ Some(wgpu::RenderPassColorAttachment { view: &self.hdr_rt_view, resolve_target: None, @@ -159,7 +225,12 @@ impl Renderer { depth_slice: None, ops: wgpu::Operations { // Zero velocity = stationary pixel. - load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }), + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.0, + }), store: wgpu::StoreOp::Store, }, }), @@ -173,248 +244,541 @@ impl Renderer { // indirect light fully. Sky then writes 0 // too so SSGI rays landing on sky don't // re-tint bounce by background radiance. - load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }), + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.0, + }), store: wgpu::StoreOp::Store, }, }), - ], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: &self.depth_view, - depth_ops: Some(wgpu::Operations { - // EN-044 — LOAD, not Clear: the depth prepass just primed this - // buffer, and clearing it here would throw that away. - load: wgpu::LoadOp::Load, - store: wgpu::StoreOp::Store, + ]; + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_hdr_pass"), + color_attachments, + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + // EN-044 — LOAD, not Clear: the depth prepass just primed this + // buffer, and clearing it here would throw that away. + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, }), - stencil_ops: None, - }), - timestamp_writes: hdr_ts, - occlusion_query_set: None, - multiview_mask: None, - }); + timestamp_writes: hdr_ts, + occlusion_query_set: None, + multiview_mask: None, + }); - // Sky uses the same env_intensity as IBL so the background - // and lighting stay in sync — otherwise bumping IBL down - // would leave the sky blown out. - if self.procedural_sky_enabled { - self.render_procedural_sky_pass(&mut pass, self.lighting_uniforms.camera_pos[3]); - } else { - self.render_sky_pass(&mut pass, self.lighting_uniforms.camera_pos[3]); - } + // Sky uses the same env_intensity as IBL so the background + // and lighting stay in sync — otherwise bumping IBL down + // would leave the sky blown out. + // + // SH-055 diag — BLOOM_SKIP_SKY=1 (Android: `adb shell setprop + // debug.bloom.skipsky 1`, propagated in JNI_OnLoad) skips the sky + // draw entirely, to bisect the unexplained per-pixel frame cost on + // Adreno. Read once; a mid-run flip needs an app restart anyway. + static SKIP_SKY: std::sync::OnceLock = std::sync::OnceLock::new(); + let skip_sky = *SKIP_SKY.get_or_init(|| { + std::env::var("BLOOM_SKIP_SKY") + .map(|v| v == "1") + .unwrap_or(false) + }); + if !skip_sky { + if self.procedural_sky_enabled { + self.render_procedural_sky_pass( + &mut pass, + self.lighting_uniforms.camera_pos[3], + ); + } else { + self.render_sky_pass(&mut pass, self.lighting_uniforms.camera_pos[3]); + } + } - if has_3d { - pass.set_pipeline(&self.pipeline_3d); - pass.set_bind_group(0, &self.uniform_bind_group_3d, &[]); - pass.set_bind_group(1, &self.lighting_bind_group, &[]); - pass.set_bind_group(3, &self.joint_bind_group, &[]); - pass.set_vertex_buffer(0, self.persistent_vb_3d.slice(..)); - pass.set_index_buffer(self.persistent_ib_3d.slice(..), wgpu::IndexFormat::Uint32); + if has_3d && !self.dbg_skip("imm3d") { + // SH-055 diag + pass.set_pipeline(&self.pipeline_3d); + pass.set_bind_group(0, &self.uniform_bind_group_3d, &[]); + pass.set_bind_group(1, &self.lighting_bind_group, &[]); + pass.set_bind_group(3, &self.joint_bind_group, &[]); + pass.set_vertex_buffer(0, self.persistent_vb_3d.slice(..)); + pass.set_index_buffer(self.persistent_ib_3d.slice(..), wgpu::IndexFormat::Uint32); - if self.draw_calls_3d.is_empty() { - pass.set_bind_group(2, &self.texture_bind_groups[0], &[]); - pass.draw_indexed(0..self.indices_3d.len() as u32, 0, 0..1); - } else { - let num_calls = self.draw_calls_3d.len(); - for i in 0..num_calls { - let call = &self.draw_calls_3d[i]; - let next_start = if i + 1 < num_calls { - self.draw_calls_3d[i + 1].index_start - } else { - self.indices_3d.len() as u32 - }; - let count = next_start - call.index_start; - if count == 0 { continue; } - let tex_idx = call.texture_idx as usize; - if tex_idx < self.texture_bind_groups.len() { - pass.set_bind_group(2, &self.texture_bind_groups[tex_idx], &[]); - } else { - pass.set_bind_group(2, &self.texture_bind_groups[0], &[]); + if self.draw_calls_3d.is_empty() { + pass.set_bind_group(2, &self.texture_bind_groups[0], &[]); + pass.draw_indexed(0..self.indices_3d.len() as u32, 0, 0..1); + } else { + let num_calls = self.draw_calls_3d.len(); + for i in 0..num_calls { + let call = &self.draw_calls_3d[i]; + let next_start = if i + 1 < num_calls { + self.draw_calls_3d[i + 1].index_start + } else { + self.indices_3d.len() as u32 + }; + let count = next_start - call.index_start; + if count == 0 { + continue; + } + let tex_idx = call.texture_idx as usize; + if tex_idx < self.texture_bind_groups.len() { + pass.set_bind_group(2, &self.texture_bind_groups[tex_idx], &[]); + } else { + pass.set_bind_group(2, &self.texture_bind_groups[0], &[]); + } + pass.draw_indexed(call.index_start..next_start, 0, 0..1); } - pass.draw_indexed(call.index_start..next_start, 0, 0..1); } } - } - // Cached models + retained scene graph — both via scene_pipeline. - let has_cached_models = !self.model_draw_commands.is_empty(); - if has_cached_models || scene.node_count() > 0 { - // EN-044 — cached models go through the PREPASSED pipeline (no depth - // write, Equal test), because the depth prepass above already stored - // their exact depth. That is what lets the hardware early-Z reject the - // occluded leaf cards instead of shading every one of them. - #[cfg(not(target_arch = "wasm32"))] - pass.set_pipeline(&self.scene_pipeline_prepassed); - // wasm: no prepass priming (see above) — classic Less + write. - #[cfg(target_arch = "wasm32")] - pass.set_pipeline(&self.scene_pipeline); - pass.set_bind_group(1, &self.lighting_bind_group, &[]); - pass.set_bind_group(3, &self.joint_bind_group, &[]); + // Cached models + retained scene graph — both via scene_pipeline. + let has_cached_models = !self.model_draw_commands.is_empty(); + if has_cached_models || scene.node_count() > 0 { + if let Some(global_materials) = + self.material_system.indirection.global_bind_group.as_ref() + { + self.gpu_driven.draw_main( + &mut pass, + &self.lighting_bind_group, + global_materials, + &self.joint_bind_group, + cfg!(not(target_arch = "wasm32")), + ); + } + // EN-044 — cached models go through the PREPASSED pipeline (no depth + // write, Equal test), because the depth prepass above already stored + // their exact depth. That is what lets the hardware early-Z reject the + // occluded leaf cards instead of shading every one of them. + #[cfg(not(target_arch = "wasm32"))] + pass.set_pipeline(&self.scene_pipeline_prepassed); + // wasm: no prepass priming (see above) — classic Less + write. + #[cfg(target_arch = "wasm32")] + pass.set_pipeline(&self.scene_pipeline); + #[cfg(not(target_arch = "wasm32"))] + let base_cached_pipeline = &self.scene_pipeline_prepassed; + #[cfg(target_arch = "wasm32")] + let base_cached_pipeline = &self.scene_pipeline; + let cached_prepassed = cfg!(not(target_arch = "wasm32")); + let mut current_cached_pipeline = Some((false, false)); + pass.set_bind_group(1, &self.lighting_bind_group, &[]); + pass.set_bind_group(3, &self.joint_bind_group, &[]); - if has_cached_models { - // Frustum-cull cached-model draws against the camera. These - // commands (the forest: ~400 draws/frame) previously had no - // culling in any pass — everything behind the camera still - // paid bind-group switches + a full VS pass. AABBs are - // conservative (cache-time local bounds × model matrix), and - // the unjittered projection is used so TAA's sub-pixel - // jitter can't flicker a borderline caster (the AABB slop - // exceeds jitter by orders of magnitude). - let cam_vp = mat4_multiply( - self.current_proj_matrix_unjittered, - self.current_view_matrix, - ); - let cam_planes = crate::scene::extract_frustum_planes(&cam_vp); - for cmd in &self.model_draw_commands { - if let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.cache_handle) { - if cmd.mesh_idx < meshes.len() { - let mesh = &meshes[cmd.mesh_idx]; - // Skinned draws carry a pre-computed joint-union - // AABB (their rest AABB × model matrix would be - // wrong once posed); static draws derive theirs. - let (wmin, wmax) = cmd.bounds_override.unwrap_or_else(|| { - transform_aabb(&cmd.model, mesh.local_min, mesh.local_max) - }); - if wmin[0] <= wmax[0] - && crate::scene::aabb_outside_frustum(&cam_planes, wmin, wmax) - { - continue; + if has_cached_models { + // Frustum-cull cached-model draws against the camera. These + // commands (the forest: ~400 draws/frame) previously had no + // culling in any pass — everything behind the camera still + // paid bind-group switches + a full VS pass. AABBs are + // conservative (cache-time local bounds × model matrix), and + // the unjittered projection is used so TAA's sub-pixel + // jitter can't flicker a borderline caster (the AABB slop + // exceeds jitter by orders of magnitude). + let cam_vp = mat4_multiply( + self.current_proj_matrix_unjittered, + self.current_view_matrix, + ); + let cam_planes = crate::scene::extract_frustum_planes(&cam_vp); + // SH-055 diag — "cached_models" skips the cached-model draws; + // BLOOM_MAX_MODELS (Android: setprop debug.bloom.maxmodels N) + // caps the count so the pathological draw can be found by + // binary search. One-time log lists every command. + { + static LOGGED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); + LOGGED.get_or_init(|| { + log::warn!("[MODELS] {} cached draw commands", self.model_draw_commands.len()); + for (i, cmd) in self.model_draw_commands.iter().enumerate() { + if let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.cache_handle) { + if cmd.mesh_idx < meshes.len() { + let m = &meshes[cmd.mesh_idx]; + log::warn!( + "[MODELS] #{i} handle={:x} mesh_idx={} indices={} skinned={}", + cmd.cache_handle, cmd.mesh_idx, m.index_count, + cmd.bounds_override.is_some() + ); + } + } + } + }); + } + static MAX_MODELS: std::sync::OnceLock = std::sync::OnceLock::new(); + let max_models = *MAX_MODELS.get_or_init(|| { + std::env::var("BLOOM_MAX_MODELS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(usize::MAX) + }); + let cached_take = if self.dbg_skip("cached_models") { + 0 + } else { + max_models + }; + for cmd in self.model_draw_commands.iter().take(cached_take) { + if let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.cache_handle) { + if cmd.mesh_idx < meshes.len() { + let mesh = &meshes[cmd.mesh_idx]; + if mesh.alpha_mode == MaterialAlphaMode::Blend + || (self.imported_refraction_enabled + && mesh.transmission.is_active()) + { + continue; + } + if self.gpu_driven.submitting() + && !cmd.skinned + && matches!(&mesh.geometry, gpu_driven::MeshGeometry::Shared(_)) + { + continue; + } + // Skinned draws carry a pre-computed joint-union + // AABB (their rest AABB × model matrix would be + // wrong once posed); static draws derive theirs. + let (wmin, wmax) = cmd.bounds_override.unwrap_or_else(|| { + transform_aabb(&cmd.model, mesh.local_min, mesh.local_max) + }); + if wmin[0] <= wmax[0] + && crate::scene::aabb_outside_frustum(&cam_planes, wmin, wmax) + { + continue; + } + let layered_material = mesh.layered_material_bg.as_ref(); + let layered_uv1 = + layered_material.is_some() && mesh.layered_uses_uv1; + if current_cached_pipeline + != Some((layered_material.is_some(), layered_uv1)) + { + if layered_material.is_some() { + let resources = self + .scene_layered_pbr_resources + .as_ref() + .expect("layered material initialized its pipelines"); + pass.set_pipeline( + resources + .opaque_pipeline(layered_uv1, cached_prepassed), + ); + } else { + pass.set_pipeline(base_cached_pipeline); + } + current_cached_pipeline = + Some((layered_material.is_some(), layered_uv1)); + } + let (draw, vertex_offset, index_offset) = if layered_uv1 { + let Some(secondary_uv) = mesh.layered_uv1_buffer.as_ref() + else { + continue; + }; + pass.set_vertex_buffer(1, secondary_uv.slice(..)); + let (draw, vertex_offset, index_offset) = self + .gpu_driven + .mesh_draw_localized(&mesh.geometry, mesh.index_count); + (draw, vertex_offset, index_offset) + } else { + ( + self.gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count), + 0, + 0, + ) + }; + pass.set_bind_group( + 0, + &self.model_uniform_bind_groups[cmd.uniform_slot], + &[], + ); + pass.set_bind_group( + 2, + layered_material.unwrap_or(&mesh.material_bg), + &[], + ); + pass.set_vertex_buffer(0, draw.vertex.slice(vertex_offset..)); + pass.set_index_buffer( + draw.index.slice(index_offset..), + wgpu::IndexFormat::Uint32, + ); + pass.draw_indexed(draw.index_range(), draw.base_vertex, 0..1); } - pass.set_bind_group(0, &self.model_uniform_bind_groups[cmd.uniform_slot], &[]); - pass.set_bind_group(2, &mesh.material_bg, &[]); - pass.set_vertex_buffer(0, mesh.vb.slice(..)); - pass.set_index_buffer(mesh.ib.slice(..), wgpu::IndexFormat::Uint32); - pass.draw_indexed(0..mesh.index_count, 0, 0..1); } } } - } - // Retained scene-graph nodes are not in the prepass, so they still need - // the depth-writing pipeline. - pass.set_pipeline(&self.scene_pipeline); - scene.render(&mut pass); + // Retained scene-graph nodes are not in the prepass, so they still need + // the depth-writing pipeline. + if !self.dbg_skip("scene_graph") { + // SH-055 diag + pass.set_pipeline(&self.scene_pipeline); + scene.render_with_material_specializations( + &mut pass, + self.gpu_driven.submitting(), + self.imported_refraction_enabled, + &self.scene_pipeline, + self.scene_layered_pbr_resources.as_ref(), + ); + } + } } - } - profiler.end("main_hdr_pass"); + profiler.end("main_hdr_pass"); - // EN-011 — render every registered planar reflection probe - // BEFORE the main material pass so the probe RTs are - // sampleable when materials run. No-op when no probes are - // registered or no opaque material draws are queued. - profiler.begin("planar_reflections"); - self.dispatch_planar_reflections(&mut *encoder, scene, profiler); - profiler.end("planar_reflections"); + // EN-011 — render every registered planar reflection probe + // BEFORE the main material pass so the probe RTs are + // sampleable when materials run. No-op when no probes are + // registered or no opaque material draws are queued. + profiler.begin("planar_reflections"); + self.dispatch_planar_reflections(&mut *encoder, scene, profiler); + profiler.end("planar_reflections"); - // Phase 2c — schedule the material pass through the render - // graph. First real consumer of `renderer::graph` from #35. - // For now a one-node graph; later phases add more nodes - // (main_hdr, ssao, bloom, translucent, composite) and the - // graph's topological sort picks the order from read/write - // declarations. - // - // All per-frame borrows that the pass body needs are captured - // here from `&self` before we build the context that wraps - // `&mut *encoder` + `&mut profiler`. Rust's borrow checker is - // happy because the immutable and mutable borrows are - // disjoint fields of the same struct. - if !self.material_system.commands.is_empty() { - use graph::{Graph, PassNode, PassOutput}; + // User-material draws are part of the compiled `hdr_scene` node. The old + // one-node graph rebuilt and topologically sorted this closure every frame. + self.record_opaque_material_pass(encoder, profiler); + // The legacy G-buffer cannot losslessly carry thin-film Fresnel. Replay + // only visible opaque iridescent meshes into a lazy metadata target for + // the later SSR pass; base-only frames return before allocating or + // recording anything. + self.record_layered_iridescence_ssr_metadata(encoder, profiler, scene); + } +} - let hdr_rt_view = &self.hdr_rt_view; - let material_rt_view = &self.material_rt_view; - let velocity_rt_view = &self.velocity_rt_view; - let albedo_rt_view = &self.albedo_rt_view; - let depth_view = &self.depth_view; - let material_system = &self.material_system; - let model_gpu_cache = &self.model_gpu_cache; - // Camera frustum for instance-tile culling (same unjittered - // matrices as the cached-model culling above). - let cam_planes = crate::scene::extract_frustum_planes(&mat4_multiply( +impl Renderer { + fn draw_imported_refraction<'a>( + &'a self, + pass: &mut wgpu::RenderPass<'a>, + scene: &'a crate::scene::SceneGraph, + reactive: bool, + ) { + let camera_vp = mat4_multiply( self.current_proj_matrix_unjittered, self.current_view_matrix, - )); - - struct FrameCtx<'a> { - encoder: &'a mut wgpu::CommandEncoder, - profiler: &'a mut crate::profiler::Profiler, + ); + let camera_planes = crate::scene::extract_frustum_planes(&camera_vp); + let mut draws: Vec> = Vec::new(); + for (stable_id, command) in self.model_draw_commands.iter().enumerate() { + let Some(Some(meshes)) = self.model_gpu_cache.get(&command.cache_handle) else { + continue; + }; + let Some(mesh) = meshes.get(command.mesh_idx) else { + continue; + }; + if !mesh.transmission.is_active() { + continue; + } + let Some(material) = mesh.refractive_material_bg.as_ref() else { + continue; + }; + let (world_min, world_max) = command + .bounds_override + .unwrap_or_else(|| transform_aabb(&command.model, mesh.local_min, mesh.local_max)); + if world_min[0] <= world_max[0] + && crate::scene::aabb_outside_frustum(&camera_planes, world_min, world_max) + { + continue; + } + let center = if world_min[0] <= world_max[0] { + [ + (world_min[0] + world_max[0]) * 0.5, + (world_min[1] + world_max[1]) * 0.5, + (world_min[2] + world_max[2]) * 0.5, + ] + } else { + [ + command.model[3][0], + command.model[3][1], + command.model[3][2], + ] + }; + let pivot = mat4_mul_vec4( + &self.current_vp_matrix, + &[center[0], center[1], center[2], 1.0], + ); + let secondary_uv = if mesh.refractive_uses_uv1 { + let Some(buffer) = mesh.refractive_uv1_buffer.as_ref() else { + continue; + }; + Some(buffer) + } else { + None + }; + let (mesh_draw, vertex_byte_offset, index_byte_offset) = if secondary_uv.is_some() { + self.gpu_driven + .mesh_draw_localized(&mesh.geometry, mesh.index_count) + } else { + ( + self.gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count), + 0, + 0, + ) + }; + draws.push(ImportedRefractiveDrawRef { + view_depth: pivot[3], + stable_id, + double_sided: mesh.double_sided, + layered: mesh.refractive_layered, + uniforms: &self.model_uniform_bind_groups[command.uniform_slot], + material, + mesh: mesh_draw, + secondary_uv, + vertex_byte_offset, + index_byte_offset, + }); } - - let mut graph: Graph> = Graph::new(); - graph.push( - PassNode::new("material_pass", Box::new(move |ctx: &mut FrameCtx| { - ctx.profiler.begin("material_pass"); - { - let mat_ts = ctx.profiler.pass_timestamp_writes("material_pass"); - let mut pass = ctx.encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("bloom_material_pass"), - color_attachments: &[ - Some(wgpu::RenderPassColorAttachment { - view: hdr_rt_view, - resolve_target: None, depth_slice: None, - ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }, - }), - Some(wgpu::RenderPassColorAttachment { - view: material_rt_view, - resolve_target: None, depth_slice: None, - ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }, - }), - Some(wgpu::RenderPassColorAttachment { - view: velocity_rt_view, - resolve_target: None, depth_slice: None, - ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }, - }), - Some(wgpu::RenderPassColorAttachment { - view: albedo_rt_view, - resolve_target: None, depth_slice: None, - ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }, - }), - ], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: depth_view, - depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }), - stencil_ops: None, - }), - timestamp_writes: mat_ts, - occlusion_query_set: None, - multiview_mask: None, - }); - material_system.dispatch(&mut pass, Some(&cam_planes), |handle, idx| { - if let Some(Some(meshes)) = model_gpu_cache.get(&handle) { - if idx < meshes.len() { - let mesh = &meshes[idx]; - return Some(( - &mesh.vb, &mesh.ib, mesh.index_count, - mesh.local_min, mesh.local_max, - )); - } - } - None - }); - } - ctx.profiler.end("material_pass"); - })) - // Writes HdrColor + the G-buffer so Phase 2d's scheduler - // can order downstream passes (SSAO, bloom, translucent) - // correctly once they're nodes too. - .with_writes(&[ - PassOutput::HdrColor, - PassOutput::MaterialRt, - PassOutput::VelocityRt, - PassOutput::AlbedoRt, - PassOutput::Depth, - ]), + scene.append_refractive_draws( + &mut draws, + &self.current_vp_matrix, + self.model_draw_commands.len(), ); + draws.sort_by(|left, right| { + right + .view_depth + .total_cmp(&left.view_depth) + .then_with(|| left.stable_id.cmp(&right.stable_id)) + }); - let mut ctx = FrameCtx { encoder: &mut *encoder, profiler: &mut *profiler }; - if let Err(e) = graph.execute(&mut ctx) { - eprintln!("[graph] material_pass failed: {:?}", e); + pass.set_bind_group(1, &self.lighting_bind_group, &[]); + pass.set_bind_group(3, &self.joint_bind_group, &[]); + #[cfg(not(fold_scene_inputs))] + pass.set_bind_group( + 4, + self.scene_refractive_inputs_bg.as_ref().unwrap_or_else(|| { + self.material_system + .scene_inputs_bg + .as_ref() + .expect("native imported refraction has scene snapshots") + }), + &[], + ); + let mut current_pipeline_key = None; + for draw in draws { + let uses_uv1 = draw.secondary_uv.is_some(); + let pipeline_key = (draw.layered, draw.double_sided, uses_uv1); + if current_pipeline_key != Some(pipeline_key) { + let pipeline = if draw.layered { + self.scene_layered_refractive_resources + .as_ref() + .expect("layered refractive material initialized its pipelines") + .pipeline(uses_uv1, draw.double_sided, reactive) + } else { + match (reactive, uses_uv1, draw.double_sided) { + (false, false, false) => self.scene_refractive_pipeline.as_ref(), + (false, false, true) => { + self.scene_refractive_double_sided_pipeline.as_ref() + } + (false, true, false) => self.scene_refractive_uv1_pipeline.as_ref(), + (false, true, true) => { + self.scene_refractive_uv1_double_sided_pipeline.as_ref() + } + (true, false, false) => self.scene_refractive_reactive_pipeline.as_ref(), + (true, false, true) => self + .scene_refractive_reactive_double_sided_pipeline + .as_ref(), + (true, true, false) => self.scene_refractive_reactive_uv1_pipeline.as_ref(), + (true, true, true) => self + .scene_refractive_reactive_uv1_double_sided_pipeline + .as_ref(), + } + .expect("selected imported-refraction pipeline must be initialized") + }; + pass.set_pipeline(pipeline); + current_pipeline_key = Some(pipeline_key); + } + pass.set_bind_group(0, draw.uniforms, &[]); + pass.set_bind_group(2, draw.material, &[]); + pass.set_vertex_buffer(0, draw.mesh.vertex.slice(draw.vertex_byte_offset..)); + if let Some(secondary_uv) = draw.secondary_uv { + pass.set_vertex_buffer(1, secondary_uv.slice(..)); + } + pass.set_index_buffer( + draw.mesh.index.slice(draw.index_byte_offset..), + wgpu::IndexFormat::Uint32, + ); + pass.draw_indexed(draw.mesh.index_range(), draw.mesh.base_vertex, 0..1); } } - - } } impl Renderer { + fn prepare_gpu_driven_camera( + &mut self, + encoder: &mut wgpu::CommandEncoder, + scene: &crate::scene::SceneGraph, + ) { + self.gpu_driven.draw_scratch.clear(); + if !self.gpu_driven.enabled() { + self.gpu_driven.stats.compatibility = self.model_draw_commands.len() as u32; + return; + } + let mut compatibility = 0u32; + for cmd in &self.model_draw_commands { + let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.cache_handle) else { + compatibility += 1; + continue; + }; + let Some(mesh) = meshes.get(cmd.mesh_idx) else { + compatibility += 1; + continue; + }; + if mesh.alpha_mode == MaterialAlphaMode::Blend + || (self.imported_refraction_enabled && mesh.transmission.is_active()) + || mesh.layered_pbr.is_active() + { + compatibility += 1; + continue; + } + let gpu_driven::MeshGeometry::Shared(slice) = &mesh.geometry else { + compatibility += 1; + continue; + }; + if cmd.skinned { + compatibility += 1; + continue; + } + let uniform_offset = cmd.uniform_slot * MODEL_UNIFORM_STRIDE; + let uniform_end = uniform_offset + std::mem::size_of::(); + let Some(bytes) = self.model_uniform_scratch.get(uniform_offset..uniform_end) else { + compatibility += 1; + continue; + }; + let uniforms = bytemuck::pod_read_unaligned::(bytes); + let (wmin, wmax) = transform_aabb(&cmd.model, mesh.local_min, mesh.local_max); + self.gpu_driven + .draw_scratch + .push(gpu_driven::GpuDrawRecord { + uniforms, + // Cached-model prepass semantics are two-sided (foliage + // and cutout cards rely on it). Bit 0 rides in the unused + // bounds lane and is consumed only by the depth shader. + bounds_min: [wmin[0], wmin[1], wmin[2], f32::from_bits(1)], + bounds_max: [wmax[0], wmax[1], wmax[2], 0.0], + draw: [ + mesh.index_count, + slice.first_index, + slice.base_vertex as u32, + mesh.material_id.raw(), + ], + }); + } + let [scene_compatibility, frustum_visible, frustum_culled] = + scene.append_gpu_driven_draws(&mut self.gpu_driven.draw_scratch); + compatibility += scene_compatibility; + let (frustum_visible, frustum_culled) = + if self.gpu_driven.draw_scratch.len() < gpu_driven::GPU_DRIVEN_MIN_DRAWS { + compatibility += self.gpu_driven.draw_scratch.len() as u32; + self.gpu_driven.draw_scratch.clear(); + (0, 0) + } else { + (frustum_visible, frustum_culled) + }; + let camera_vp = mat4_multiply( + self.current_proj_matrix_unjittered, + self.current_view_matrix, + ); + let planes = crate::scene::extract_frustum_planes(&camera_vp); + self.gpu_driven.prepare( + &self.device, + &self.queue, + encoder, + planes, + compatibility, + frustum_visible, + frustum_culled, + ); + } + /// Translucent / refractive / additive material pass: after opaque, /// before post-FX; loads hdr_rt, depth read-only, back-to-front /// sorted; snapshots scene color for reads_scene materials. Split @@ -423,185 +787,762 @@ impl Renderer { &mut self, encoder: &mut wgpu::CommandEncoder, profiler: &mut crate::profiler::Profiler, + scene: &crate::scene::SceneGraph, ) { - // ============================================================ - // Phase 4b — translucent / refractive / additive material pass - // ============================================================ - // - // Runs after opaque materials, before post-FX. Loads hdr_rt so - // opaque output survives; alpha-blends into it. Depth is - // bound as read-only so translucent draws participate in the - // depth test without writing. - // - // If any submitted translucent material declared - // `reads_scene = true`, we first snapshot hdr_rt into a - // swapchain-sized transient and bind that as group 4 - // scene_color_tex for the dispatch. Free after the pass so - // the transient pool reuses on the next frame. - if !self.material_system.translucent_commands.is_empty() { - // Back-to-front by view depth — required for correct alpha - // compositing; submission order is only kept between - // equal-depth draws (stable sort). - self.material_system.sort_translucent(); - profiler.begin("translucent_pass"); - let swap_w = self.surface_config.width; - let swap_h = self.surface_config.height; - self.transient_pool.begin_frame(swap_w, swap_h); + self.refractive_reflections_active = false; + // ============================================================ + // Phase 4b — translucent / refractive / additive material pass + // ============================================================ + // + // Runs after opaque materials, before post-FX. Loads hdr_rt so + // opaque output survives; alpha-blends into it. Depth is + // bound as read-only so translucent draws participate in the + // depth test without writing. + // + // If any submitted translucent material declared + // `reads_scene = true`, we first snapshot hdr_rt into a + // swapchain-sized transient and bind that as group 4 + // scene_color_tex for the dispatch. Free after the pass so + // the transient pool reuses on the next frame. + let has_imported_refraction = self.imported_refraction_enabled + && (self.has_refractive_model_draws || scene.has_refractive_nodes()); + let has_layered_imported_transparency = + self.has_layered_blend_model_draws || scene.has_layered_transparent_nodes(); + if !self.material_system.translucent_commands.is_empty() + || self.has_blend_model_draws + || scene.has_transparent_nodes() + || has_imported_refraction + { + if has_imported_refraction { + self.ensure_scene_refraction_resources(); + } + if self.weighted_transparency_active { + self.ensure_weighted_transparency_resources(); + if has_layered_imported_transparency { + self.ensure_scene_layered_pbr_weighted_resources(); + } + } + if self.temporal_reactive_active { + if has_imported_refraction { + self.ensure_scene_refraction_reactive_resources(); + self.ensure_scene_layered_refraction_reactive_resources(); + } + if self.weighted_transparency_active { + self.ensure_weighted_transparency_reactive_resources(); + } else if self.has_blend_model_draws || scene.has_transparent_nodes() { + self.ensure_scene_transparent_reactive_resources(); + if has_layered_imported_transparency { + self.ensure_scene_layered_pbr_reactive_resources(); + } + } + } + let has_sorted_imported_transparency = !self.weighted_transparency_active + && (self.has_blend_model_draws || scene.has_transparent_nodes()); + let globally_interleave_sorted = has_sorted_imported_transparency + && !self.material_system.translucent_commands.is_empty() + && super::sorted_transparency::sorted_interleaving_enabled(); + // Custom commands retain their established stable in-place sort. + // The mixed dispatcher merge-walks this list with the independently + // sorted imported list, avoiding a second combined allocation. + self.material_system.sort_translucent(); + if self.temporal_reactive_active + && !self.material_system.translucent_commands.is_empty() + { + self.material_system + .ensure_translucent_reactive_pipelines(&self.device); + } + profiler.begin("translucent_pass"); + let swap_w = self.surface_config.width; + let swap_h = self.surface_config.height; + self.transient_pool.begin_frame(swap_w, swap_h); - // Phase 7 — run the impulse decay + splat compute BEFORE - // we build scene_inputs so the front view reflects this - // frame's submissions. - self.impulse_field.update(&self.device, &self.queue, &mut *encoder); + // Phase 7 — run the impulse decay + splat compute BEFORE + // we build scene_inputs so the front view reflects this + // frame's submissions. + self.impulse_field + .update(&self.device, &self.queue, &mut *encoder); - // Does any queued translucent material need the scene - // colour snapshot? - let needs_scene = self.material_system.translucent_commands - .iter() - .any(|c| self.material_system.pipelines - .get(c.material as usize - 1) - .and_then(|p| p.as_ref()) - .map(|p| p.reads_scene) - .unwrap_or(false)); + // Does any queued translucent material need the scene + // colour snapshot? + let custom_reads_scene = self.material_system.translucent_commands.iter().any(|c| { + self.material_system + .pipelines + .get(c.material as usize - 1) + .and_then(|p| p.as_ref()) + .map(|p| p.reads_scene) + .unwrap_or(false) + }); + let needs_scene = + custom_reads_scene || (cfg!(not(fold_scene_inputs)) && has_imported_refraction); - // The snapshots mirror hdr_rt/depth, which live at RENDER - // resolution (surface × render_scale) — not swapchain size. - // Sizing them from the swapchain overruns the source copy - // whenever render_scale < 1 (TSR upscaling). - let (render_w, render_h) = self.render_extent(); - let scene_color_tid = if needs_scene { - let desc = transient::TransientDesc::new( - formats::HDR_FORMAT, - wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING, - transient::SizePolicy::Fixed(render_w, render_h), - ); - Some(self.transient_pool.acquire(&self.device, desc)) - } else { - None - }; + // The snapshots mirror hdr_rt/depth, which live at RENDER + // resolution (surface × render_scale) — not swapchain size. + // Sizing them from the swapchain overruns the source copy + // whenever render_scale < 1 (TSR upscaling). + let (render_w, render_h) = self.render_extent(); + if needs_scene || self.weighted_transparency_active || self.temporal_reactive_active { + let plan = self + .last_frame_plan + .as_ref() + .expect("active frame plan is installed before translucent execution"); + let creations = self + .transient_pool + .prepare_compiled_plan( + &self.device, + plan, + (render_w, render_h), + (swap_w, swap_h), + ) + .unwrap_or_else(|error| { + panic!("compiled transient allocation failed: {error}") + }); + self.frame_resource_stats + .created_physical_textures(creations.textures); + self.frame_resource_stats + .created_physical_buffers(creations.buffers); + } + let compiled_snapshots = needs_scene.then(|| { + let plan = self + .last_frame_plan + .as_ref() + .expect("active frame plan is installed before translucent execution"); + let color = plan + .resource("translucent-scene-color") + .expect("scene-reading topology declares its color snapshot") + .id; + let depth = plan + .resource("translucent-scene-depth") + .expect("scene-reading topology declares its depth snapshot") + .id; + (plan.plan_id, color, depth) + }); + let compiled_reactive = self.temporal_reactive_active.then(|| { + let plan = self + .last_frame_plan + .as_ref() + .expect("reactive transparency has an active frame plan"); + let reactive = plan + .resource("transparency-reactive") + .expect("reactive topology declares its coverage target") + .id; + (plan.plan_id, reactive) + }); - // Phase 4c — depth snapshot. wgpu forbids sampling a - // texture that is also a depth-stencil attachment of the - // same pass, so we copy the opaque depth buffer into a - // transient before beginning the translucent pass and - // bind the transient at group 4 binding 2. Acquired - // whenever any translucent material reads_scene (same - // gate as colour) — cheap enough that it's not worth a - // separate `reads_depth` flag yet. - let scene_depth_tid = if needs_scene { - let desc = transient::TransientDesc::new( - formats::DEPTH_FORMAT, - wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING, - transient::SizePolicy::Fixed(render_w, render_h), - ); - Some(self.transient_pool.acquire(&self.device, desc)) - } else { - None - }; + // Phase 4c — depth snapshot. wgpu forbids sampling a + // texture that is also a depth-stencil attachment of the + // same pass, so we copy the opaque depth buffer into a + // transient before beginning the translucent pass and + // bind the transient at group 4 binding 2. Acquired + // whenever any translucent material reads_scene (same + // gate as colour) — cheap enough that it's not worth a + // separate `reads_depth` flag yet. + // Snapshot hdr_rt + live depth -> transients. + if needs_scene { + let (plan_id, color, depth) = + compiled_snapshots.expect("scene-reading topology has compiled snapshots"); + let color_tex = self + .transient_pool + .compiled_texture(plan_id, color) + .expect("compiled color snapshot"); + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: &self.hdr_rt_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: color_tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width: render_w, + height: render_h, + depth_or_array_layers: 1, + }, + ); + let depth_tex = self + .transient_pool + .compiled_texture(plan_id, depth) + .expect("compiled depth snapshot"); + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: &self.depth_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::DepthOnly, + }, + wgpu::TexelCopyTextureInfo { + texture: depth_tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::DepthOnly, + }, + wgpu::Extent3d { + width: render_w, + height: render_h, + depth_or_array_layers: 1, + }, + ); + let color_view = self + .transient_pool + .compiled_view(plan_id, color) + .expect("compiled color snapshot view"); + let depth_view = self + .transient_pool + .compiled_view(plan_id, depth) + .expect("compiled depth snapshot view"); + let imp_view = self.impulse_field.front_view(); + let imp_samp = self.impulse_field.sampler(); + #[cfg(not(fold_scene_inputs))] + let dedicated_refractive_inputs = + has_imported_refraction && self.scene_refractive_inputs_layout.is_some(); + #[cfg(fold_scene_inputs)] + let dedicated_refractive_inputs = false; + // The hierarchy owns a smaller dedicated group 4. Do not also + // rebuild the legacy seven-binding group unless a custom + // scene-reading material consumes it. The startup kill switch + // has no dedicated layout and therefore preserves the exact + // established bind-group route. + if custom_reads_scene || !dedicated_refractive_inputs { + self.material_system.update_scene_inputs( + &self.device, + color_view, + Some(depth_view), + Some((imp_view, imp_samp)), + ); + } + #[cfg(not(fold_scene_inputs))] + if has_imported_refraction { + if let (Some(layout), Some(params_buffer)) = ( + self.scene_refractive_inputs_layout.as_ref(), + self.scene_refractive_reflection_params_buffer.as_ref(), + ) { + let planar_probe = self.planar_probes.iter().flatten().next(); + let planar_view = planar_probe + .map(|probe| &probe.color_view) + .unwrap_or(&self.scene_env_default_view); + let planar_plane = planar_probe.map(|probe| (probe.normal, probe.plane_y)); + self.scene_refractive_inputs_bg = + Some(refractive_reflections::materialize_inputs( + &self.device, + &self.queue, + refractive_reflections::RefractiveInputs { + layout, + params_buffer, + scene_color: color_view, + scene_color_sampler: &self.composite_sampler, + scene_depth: depth_view, + planar_reflection: planar_view, + }, + self.current_view_matrix, + self.current_proj_matrix, + self.ssr_enabled, + planar_plane, + )); + self.refractive_reflections_active = + self.ssr_enabled || planar_probe.is_some(); + } + } + } else { + // No refractive/depth-reading materials this frame — + // still need a valid bind group. None → internal stubs. + self.material_system.update_scene_inputs( + &self.device, + &self.hdr_rt_view, + None, + None, + ); + } - // Snapshot hdr_rt + live depth -> transients. - if let (Some(ctid), Some(dtid)) = (scene_color_tid, scene_depth_tid) { - let color_tex = self.transient_pool.texture(ctid).expect("fresh color transient"); - encoder.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: &self.hdr_rt_texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyTextureInfo { - texture: color_tex, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::Extent3d { width: render_w, height: render_h, depth_or_array_layers: 1 }, - ); - let depth_tex = self.transient_pool.texture(dtid).expect("fresh depth transient"); - encoder.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: &self.depth_texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::DepthOnly, - }, - wgpu::TexelCopyTextureInfo { - texture: depth_tex, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::DepthOnly, - }, - wgpu::Extent3d { width: render_w, height: render_h, depth_or_array_layers: 1 }, - ); - let color_view = self.transient_pool.view(ctid).unwrap(); - let depth_view = self.transient_pool.view(dtid).unwrap(); - let imp_view = self.impulse_field.front_view(); - let imp_samp = self.impulse_field.sampler(); - self.material_system.update_scene_inputs( - &self.device, color_view, Some(depth_view), - Some((imp_view, imp_samp)), - ); - } else { - // No refractive/depth-reading materials this frame — - // still need a valid bind group. None → internal stubs. - self.material_system.update_scene_inputs( - &self.device, &self.hdr_rt_view, None, None, - ); - } + if self.weighted_transparency_active { + let plan = self + .last_frame_plan + .as_ref() + .expect("weighted transparency has an active frame plan"); + let accumulation = plan + .resource("transparency-accumulation") + .expect("weighted topology declares accumulation") + .id; + let revealage = plan + .resource("transparency-revealage") + .expect("weighted topology declares revealage") + .id; + let key = (plan.plan_id, self.transient_pool.rebuild_epoch); + if self.weighted_transparency_resolve_bind_group_key != Some(key) { + let accumulation_view = self + .transient_pool + .compiled_view(plan.plan_id, accumulation) + .expect("compiled weighted accumulation view"); + let revealage_view = self + .transient_pool + .compiled_view(plan.plan_id, revealage) + .expect("compiled weighted revealage view"); + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("weighted_transparency_resolve_bind_group"), + layout: self + .weighted_transparency_resolve_layout + .as_ref() + .expect("weighted resolve layout initialized"), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(accumulation_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(revealage_view), + }, + ], + }); + self.weighted_transparency_resolve_bind_group = Some(bind_group); + self.weighted_transparency_resolve_bind_group_key = Some(key); + } + } - { - let t_ts = profiler.pass_timestamp_writes("translucent_pass"); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("bloom_translucent_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.hdr_rt_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Load, - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: &self.depth_view, - depth_ops: Some(wgpu::Operations { - load: wgpu::LoadOp::Load, - // Translucents don't write depth — keep - // the opaque pass's depth pristine so - // downstream post-FX (SSR/SSGI) still - // sees the opaque geometry. - store: wgpu::StoreOp::Store, - }), - stencil_ops: None, - }), - timestamp_writes: t_ts, - occlusion_query_set: None, - multiview_mask: None, + let reactive_view = compiled_reactive.map(|(plan_id, reactive)| { + self.transient_pool + .compiled_view(plan_id, reactive) + .expect("compiled temporal reactive view") }); - let cache = &self.model_gpu_cache; - self.material_system.dispatch_translucent(&mut pass, |handle, idx| { - if let Some(Some(meshes)) = cache.get(&handle) { - if idx < meshes.len() { - let mesh = &meshes[idx]; - return Some((&mesh.vb, &mesh.ib, mesh.index_count)); + let mut reactive_initialized = false; + + if has_imported_refraction { + profiler.begin("refractive_pass"); + if let Some(reactive_view) = reactive_view { + let refractive_ts = profiler.pass_timestamp_writes("refractive_pass"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_imported_refractive_reactive_pass"), + color_attachments: &[ + Some(wgpu::RenderPassColorAttachment { + view: &self.hdr_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: &self.velocity_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: reactive_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + }), + ], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: refractive_ts, + occlusion_query_set: None, + multiview_mask: None, + }); + self.draw_imported_refraction(&mut pass, scene, true); + reactive_initialized = true; + } else { + let refractive_ts = profiler.pass_timestamp_writes("refractive_pass"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_imported_refractive_pass"), + color_attachments: &[ + Some(wgpu::RenderPassColorAttachment { + view: &self.hdr_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: &self.velocity_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + ], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: refractive_ts, + occlusion_query_set: None, + multiview_mask: None, + }); + self.draw_imported_refraction(&mut pass, scene, false); + } + profiler.end("refractive_pass"); + } + + if self.weighted_transparency_active { + profiler.begin("weighted_transparency_pass"); + let plan = self + .last_frame_plan + .as_ref() + .expect("weighted transparency has an active frame plan"); + let accumulation = plan + .resource("transparency-accumulation") + .expect("weighted topology declares accumulation") + .id; + let revealage = plan + .resource("transparency-revealage") + .expect("weighted topology declares revealage") + .id; + let accumulation_view = self + .transient_pool + .compiled_view(plan.plan_id, accumulation) + .expect("compiled weighted accumulation view"); + let revealage_view = self + .transient_pool + .compiled_view(plan.plan_id, revealage) + .expect("compiled weighted revealage view"); + { + let weighted_ts = profiler.pass_timestamp_writes("weighted_transparency_pass"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_weighted_transparency_accumulation_pass"), + color_attachments: &[ + Some(wgpu::RenderPassColorAttachment { + view: accumulation_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: revealage_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), + store: wgpu::StoreOp::Store, + }, + }), + ], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: weighted_ts, + occlusion_query_set: None, + multiview_mask: None, + }); + self.draw_imported_transparency(&mut pass, scene, true, false); + } + if let Some(reactive_view) = reactive_view { + let reactive_load = if reactive_initialized { + wgpu::LoadOp::Load + } else { + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT) + }; + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_weighted_transparency_reactive_resolve_pass"), + color_attachments: &[ + Some(wgpu::RenderPassColorAttachment { + view: &self.hdr_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: reactive_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: reactive_load, + store: wgpu::StoreOp::Store, + }, + }), + ], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline( + self.weighted_transparency_reactive_resolve_pipeline + .as_ref() + .expect("weighted reactive resolve pipeline initialized"), + ); + pass.set_bind_group( + 0, + self.weighted_transparency_resolve_bind_group + .as_ref() + .expect("weighted resolve bind group initialized"), + &[], + ); + pass.draw(0..3, 0..1); + reactive_initialized = true; + } else { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_weighted_transparency_resolve_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.hdr_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline( + self.weighted_transparency_resolve_pipeline + .as_ref() + .expect("weighted resolve pipeline initialized"), + ); + pass.set_bind_group( + 0, + self.weighted_transparency_resolve_bind_group + .as_ref() + .expect("weighted resolve bind group initialized"), + &[], + ); + pass.draw(0..3, 0..1); + } + profiler.end("weighted_transparency_pass"); + } + + let has_conventional_translucency = + !self.material_system.translucent_commands.is_empty() + || has_sorted_imported_transparency; + if has_conventional_translucency { + if self.temporal_reactive_active { + if has_sorted_imported_transparency { + let reactive_view = + reactive_view.expect("active reactive topology has a view"); + let reactive_load = if reactive_initialized { + wgpu::LoadOp::Load + } else { + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT) + }; + let t_ts = profiler.pass_timestamp_writes("translucent_pass"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_imported_transparency_reactive_pass"), + color_attachments: &[ + Some(wgpu::RenderPassColorAttachment { + view: &self.hdr_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: reactive_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: reactive_load, + store: wgpu::StoreOp::Store, + }, + }), + ], + depth_stencil_attachment: Some( + wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }, + ), + timestamp_writes: t_ts, + occlusion_query_set: None, + multiview_mask: None, + }); + if globally_interleave_sorted { + self.draw_sorted_transparency(&mut pass, scene, true); + } else { + self.draw_imported_transparency(&mut pass, scene, false, true); + } + reactive_initialized = true; + } + + if !globally_interleave_sorted + && !self.material_system.translucent_commands.is_empty() + { + let reactive_view = + reactive_view.expect("active reactive topology has a view"); + let reactive_load = if reactive_initialized { + wgpu::LoadOp::Load + } else { + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT) + }; + let t_ts = profiler.pass_timestamp_writes("translucent_pass"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_custom_translucent_reactive_pass"), + color_attachments: &[ + Some(wgpu::RenderPassColorAttachment { + view: &self.hdr_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: reactive_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: reactive_load, + store: wgpu::StoreOp::Store, + }, + }), + ], + depth_stencil_attachment: Some( + wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }, + ), + timestamp_writes: t_ts, + occlusion_query_set: None, + multiview_mask: None, + }); + let cache = &self.model_gpu_cache; + let gpu_driven = &self.gpu_driven; + self.material_system.dispatch_translucent_reactive( + &mut pass, + |handle, idx| { + if let Some(Some(meshes)) = cache.get(&handle) { + if idx < meshes.len() { + let mesh = &meshes[idx]; + return Some( + gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count), + ); + } + } + None + }, + ); + reactive_initialized = true; + } + } else { + let t_ts = profiler.pass_timestamp_writes("translucent_pass"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom_translucent_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.hdr_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + // Translucents don't write depth — keep + // the opaque pass's depth pristine so + // downstream post-FX (SSR/SSGI) still + // sees the opaque geometry. + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: t_ts, + occlusion_query_set: None, + multiview_mask: None, + }); + if has_sorted_imported_transparency { + if globally_interleave_sorted { + self.draw_sorted_transparency(&mut pass, scene, false); + } else { + self.draw_imported_transparency(&mut pass, scene, false, false); + if !self.material_system.translucent_commands.is_empty() { + let cache = &self.model_gpu_cache; + let gpu_driven = &self.gpu_driven; + self.material_system.dispatch_translucent( + &mut pass, + |handle, idx| { + if let Some(Some(meshes)) = cache.get(&handle) { + if idx < meshes.len() { + let mesh = &meshes[idx]; + return Some( + gpu_driven.mesh_draw( + &mesh.geometry, + mesh.index_count, + ), + ); + } + } + None + }, + ); + } + } + } else { + let cache = &self.model_gpu_cache; + let gpu_driven = &self.gpu_driven; + self.material_system + .dispatch_translucent(&mut pass, |handle, idx| { + if let Some(Some(meshes)) = cache.get(&handle) { + if idx < meshes.len() { + let mesh = &meshes[idx]; + return Some( + gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count), + ); + } + } + None + }); } } - None - }); - } + } - if let Some(tid) = scene_color_tid { - self.transient_pool.release(tid); - } - // Round-2 audit: the depth snapshot was acquired every frame but - // NEVER released — the pool (which has no auto-reclaim by design) - // allocated a brand-new render-res Depth32Float every frame with - // refractive water on screen. ~8 MB/frame of texture creation plus - // an ever-growing slot scan; measured as translucent_pass CPU - // climbing 0.4 ms → 6.7 ms over ~50 s of play, in every session. - if let Some(tid) = scene_depth_tid { - self.transient_pool.release(tid); + debug_assert!( + !self.temporal_reactive_active || reactive_initialized, + "active temporal coverage must be initialized by a contributing imported pass" + ); + profiler.end("translucent_pass"); } - profiler.end("translucent_pass"); - } } } diff --git a/native/shared/src/renderer/shader_include.rs b/native/shared/src/renderer/shader_include.rs index 43292c51..24ed50b3 100644 --- a/native/shared/src/renderer/shader_include.rs +++ b/native/shared/src/renderer/shader_include.rs @@ -28,7 +28,10 @@ pub struct BakedSource<'a> { } impl<'a> ShaderSource for BakedSource<'a> { fn fetch(&self, path: &str) -> Option<&str> { - self.entries.iter().find(|(p, _)| *p == path).map(|(_, s)| *s) + self.entries + .iter() + .find(|(p, _)| *p == path) + .map(|(_, s)| *s) } } @@ -62,12 +65,17 @@ pub fn abi_version_of(source: &str) -> u32 { /// Recursively resolve `#include "..."` directives in `entry_path`'s /// source, returning the fully-expanded WGSL. Each included file is /// emitted exactly once even if referenced multiple times. -pub fn process( - source: &dyn ShaderSource, entry_path: &str, -) -> Result { +pub fn process(source: &dyn ShaderSource, entry_path: &str) -> Result { let mut out = String::new(); let mut seen = HashSet::new(); - expand(source, entry_path, entry_path, &mut out, &mut seen, &mut Vec::new())?; + expand( + source, + entry_path, + entry_path, + &mut out, + &mut seen, + &mut Vec::new(), + )?; Ok(out) } @@ -92,20 +100,21 @@ fn expand( seen.insert(current_path.to_string()); stack.push(current_path.to_string()); - let body = source.fetch(current_path).ok_or_else(|| IncludeError::Missing { - referrer: referrer.to_string(), - included: current_path.to_string(), - })?; + let body = source + .fetch(current_path) + .ok_or_else(|| IncludeError::Missing { + referrer: referrer.to_string(), + included: current_path.to_string(), + })?; for (line_idx, line) in body.lines().enumerate() { let trimmed = line.trim_start(); if let Some(rest) = trimmed.strip_prefix("#include") { - let include_path = parse_include_arg(rest).ok_or_else(|| { - IncludeError::MalformedDirective { + let include_path = + parse_include_arg(rest).ok_or_else(|| IncludeError::MalformedDirective { referrer: current_path.to_string(), line: format!("line {}: {}", line_idx + 1, line), - } - })?; + })?; // We emit a banner so WGSL error messages carry enough // context back to which file broke. out.push_str(&format!("// --- begin include: {} ---\n", include_path)); diff --git a/native/shared/src/renderer/shader_library.rs b/native/shared/src/renderer/shader_library.rs index 7c8f3204..bf442b66 100644 --- a/native/shared/src/renderer/shader_library.rs +++ b/native/shared/src/renderer/shader_library.rs @@ -13,16 +13,50 @@ use super::shader_include::{BakedSource, ShaderSource}; const ENTRIES: &[(&str, &str)] = &[ - ("material_abi.wgsl", include_str!("../../../shared/shaders/material_abi.wgsl")), - ("common/pbr.wgsl", include_str!("../../../shared/shaders/common/pbr.wgsl")), - ("common/shadows.wgsl", include_str!("../../../shared/shaders/common/shadows.wgsl")), - ("common/imposter.wgsl", include_str!("../../../shared/shaders/common/imposter.wgsl")), - ("common/fog.wgsl", include_str!("../../../shared/shaders/common/fog.wgsl")), - ("common/tonemap.wgsl", include_str!("../../../shared/shaders/common/tonemap.wgsl")), - ("common/sky.wgsl", include_str!("../../../shared/shaders/common/sky.wgsl")), - ("common/clouds.wgsl", include_str!("../../../shared/shaders/common/clouds.wgsl")), - ("materials/test_minimal.wgsl", include_str!("../../../shared/shaders/materials/test_minimal.wgsl")), - ("impulse_field.wgsl", include_str!("../../../shared/shaders/impulse_field.wgsl")), + ( + "material_abi.wgsl", + include_str!("../../../shared/shaders/material_abi.wgsl"), + ), + ( + "common/pbr.wgsl", + include_str!("../../../shared/shaders/common/pbr.wgsl"), + ), + ( + "common/shadows.wgsl", + include_str!("../../../shared/shaders/common/shadows.wgsl"), + ), + ( + "common/imposter.wgsl", + include_str!("../../../shared/shaders/common/imposter.wgsl"), + ), + ( + "common/fog.wgsl", + include_str!("../../../shared/shaders/common/fog.wgsl"), + ), + ( + "common/tonemap.wgsl", + include_str!("../../../shared/shaders/common/tonemap.wgsl"), + ), + ( + "common/sky.wgsl", + include_str!("../../../shared/shaders/common/sky.wgsl"), + ), + ( + "common/clouds.wgsl", + include_str!("../../../shared/shaders/common/clouds.wgsl"), + ), + ( + "materials/test_minimal.wgsl", + include_str!("../../../shared/shaders/materials/test_minimal.wgsl"), + ), + ( + "impulse_field.wgsl", + include_str!("../../../shared/shaders/impulse_field.wgsl"), + ), + ( + "material_indirection.wgsl", + include_str!("../../../shared/shaders/material_indirection.wgsl"), + ), ]; /// The single shared source resolver for built-in shaders. Phase 1 @@ -36,7 +70,8 @@ pub fn library() -> impl ShaderSource { /// draw call. pub fn verify_abi_version(expected: u32) -> Result<(), String> { let src = library(); - let body = src.fetch("material_abi.wgsl") + let body = src + .fetch("material_abi.wgsl") .ok_or_else(|| "material_abi.wgsl missing from shader library".to_string())?; let actual = super::shader_include::abi_version_of(body); if actual != expected { @@ -88,6 +123,9 @@ mod tests { let out = process(&synthetic, "test.wgsl").unwrap(); assert!(out.contains("struct PerFrame")); assert!(out.contains("fn d_ggx")); + assert!(out.contains("BLOOM_BOUND_MATERIAL_RECORD_VERSION")); + assert!(out.contains("fn bloom_bound_material_lobe_mask")); + assert!(out.contains("bitcast(material.foliage_params.w)")); assert!(out.contains("synthesised")); } @@ -98,13 +136,27 @@ mod tests { } } + #[test] + fn material_indirection_header_is_baked_for_gpu_driven_passes() { + let src = library(); + let body = src + .fetch("material_indirection.wgsl") + .expect("global material indirection header must be embedded"); + assert!(body.contains("fn bloom_material_record")); + assert!(body.contains("global_resource_generations")); + assert!(body.contains("BLOOM_GLOBAL_MATERIAL_RECORD_VERSION")); + assert!(body.contains("fn bloom_global_material_record_version")); + assert!(body.contains("fn bloom_global_material_lobe_mask")); + } + // EN-015 V1 — imposter helper library is registered and includes // cleanly through the preprocessor. #[test] fn imposter_helper_in_library() { let src = library(); - let body = src.fetch("common/imposter.wgsl") + let body = src + .fetch("common/imposter.wgsl") .expect("common/imposter.wgsl must be registered in ENTRIES"); assert!(body.contains("fn octahedral_encode")); assert!(body.contains("fn imposter_atlas_uv")); diff --git a/native/shared/src/renderer/shaders/ao.rs b/native/shared/src/renderer/shaders/ao.rs index 8a974e2e..b74772bc 100644 --- a/native/shared/src/renderer/shaders/ao.rs +++ b/native/shared/src/renderer/shaders/ao.rs @@ -567,4 +567,3 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { return vec4(ao_blurred, center.g, 0.0, 1.0); } "; - diff --git a/native/shared/src/renderer/shaders/core.rs b/native/shared/src/renderer/shaders/core.rs index f9dfd85e..79a181db 100644 --- a/native/shared/src/renderer/shaders/core.rs +++ b/native/shared/src/renderer/shaders/core.rs @@ -1,13 +1,6 @@ //! Core pipeline shaders: batched 2D, legacy 3D, and the main scene shader (forward MRT). //! Split from renderer/shaders.rs. - -//! WGSL shader strings used by the renderer. -//! -//! Pure data — no behavior, no struct definitions. Each `const` -//! is `pub(super)` so the surrounding `renderer` module (and only -//! that module) can see it, via `use super::shaders::*;` in -//! `mod.rs`. Split out so the ~11 500-line renderer file shrinks -//! to the Rust logic it actually contains. +//! Pure WGSL data, private to the surrounding renderer module. pub(in crate::renderer) const SHADER_2D: &str = " struct Uniforms { @@ -94,6 +87,9 @@ struct VertexInput3D { @location(3) uv: vec2, @location(4) joints: vec4, @location(5) weights: vec4, + // Immediate primitives use their otherwise-unused tangent lane for + // previous world position. w=2 distinguishes it from model tangents. + @location(6) previous_position: vec4, }; struct VertexOutput3D { @@ -110,13 +106,13 @@ struct VertexOutput3D { @group(1) @binding(0) var lighting: Lighting; @group(2) @binding(0) var tex3d: texture_2d; @group(2) @binding(1) var tex3d_sampler: sampler; -@group(3) @binding(0) var joints: JointMatrices; +@group(3) @binding(0) var joints: JointMatrices; @group(3) @binding(1) var joints_prev: JointMatrices; @vertex fn vs_main_3d(in: VertexInput3D) -> VertexOutput3D { var out: VertexOutput3D; let total_weight = in.weights.x + in.weights.y + in.weights.z + in.weights.w; - var pos = vec4(in.position, 1.0); + var pos = vec4(in.position, 1.0); var prev_pos = pos; var norm = vec4(in.normal, 0.0); if (total_weight > 0.01) { let j0 = u32(in.joints.x); let j1 = u32(in.joints.y); @@ -129,13 +125,14 @@ fn vs_main_3d(in: VertexInput3D) -> VertexOutput3D { + joints.matrices[j1] * norm * in.weights.y + joints.matrices[j2] * norm * in.weights.z + joints.matrices[j3] * norm * in.weights.w; + prev_pos = joints_prev.matrices[j0] * pos * in.weights.x + joints_prev.matrices[j1] * pos * in.weights.y + joints_prev.matrices[j2] * pos * in.weights.z + joints_prev.matrices[j3] * pos * in.weights.w; pos = skinned_pos; norm = skinned_norm; - } + } else if (in.previous_position.w > 1.5) { prev_pos = vec4(in.previous_position.xyz, 1.0); } // immediate primitive history let curr = u.mvp * pos; out.clip_position = curr; out.curr_clip = curr; - out.prev_clip = u.prev_mvp * pos; + out.prev_clip = u.prev_mvp * prev_pos; out.normal = normalize((u.model * norm).xyz); out.world_pos = (u.model * pos).xyz; out.color = in.color * u.model_tint; @@ -358,6 +355,15 @@ fn env_sample(dir: vec3) -> vec3 { * lighting.camera_pos.w; } +fn safe_scene_tangent(tangent: vec3) -> vec3 { + let length_squared = dot(tangent, tangent); + return select( + vec3(0.0), + tangent * inverseSqrt(max(length_squared, 1e-20)), + length_squared > 1e-8, + ); +} + @vertex fn vs_main_scene(in: VertexInputScene) -> VertexOutputScene { if (u.misc.y > 0.5) { @@ -412,7 +418,7 @@ fn vs_main_scene(in: VertexInputScene) -> VertexOutputScene { o.normal = normalize(nrm4.xyz); o.color = in.color * u.model_tint; o.uv = in.uv; - o.tangent = vec4(normalize(tan4.xyz), in.tangent.w); + o.tangent = vec4(safe_scene_tangent(tan4.xyz), in.tangent.w); return o; } var out: VertexOutputScene; @@ -447,7 +453,10 @@ fn vs_main_scene(in: VertexInputScene) -> VertexOutputScene { out.normal = normalize((u.model * vec4(in.normal, 0.0)).xyz); out.color = in.color * u.model_tint; out.uv = in.uv; - out.tangent = vec4(normalize((u.model * vec4(in.tangent.xyz, 0.0)).xyz), in.tangent.w); + out.tangent = vec4( + safe_scene_tangent((u.model * vec4(in.tangent.xyz, 0.0)).xyz), + in.tangent.w, + ); return out; } @@ -472,6 +481,49 @@ fn compute_tbn(dp1: vec3, dp2: vec3, duv1: vec2, duv2: vec2, return mat3x3(t * invmax, b * invmax, n); } +// Stable 4x4 Bayer threshold. Bloom's scene targets are single-sample, so +// hardware alpha-to-coverage is unavailable; this is its deterministic +// spatial equivalent for subpixel MASK coverage. It is used only after the +// sampler footprint reaches the coverage-encoded lower mip chain. +fn mask_coverage_threshold(pixel: vec2) -> f32 { + let bayer = array( + 0.5 / 16.0, 8.5 / 16.0, 2.5 / 16.0, 10.5 / 16.0, + 12.5 / 16.0, 4.5 / 16.0, 14.5 / 16.0, 6.5 / 16.0, + 3.5 / 16.0, 11.5 / 16.0, 1.5 / 16.0, 9.5 / 16.0, + 15.5 / 16.0, 7.5 / 16.0, 13.5 / 16.0, 5.5 / 16.0, + ); + let x = u32(floor(pixel.x)) & 3u; + let y = u32(floor(pixel.y)) & 3u; + return bayer[y * 4u + x]; +} + +fn mask_texture_lod( + uv: vec2, + dimensions: vec2, + lod_bias: f32, +) -> f32 { + let extent = vec2(dimensions); + let dx = dpdx(uv) * extent; + let dy = dpdy(uv) * extent; + let footprint2 = max(dot(dx, dx), dot(dy, dy)); + return max(0.5 * log2(max(footprint2, 1.0)) + lod_bias, 0.0); +} + +fn mask_coverage_survives( + authored_alpha: f32, + lower_mip_coverage: f32, + cutoff: f32, + lod: f32, + pixel: vec2, +) -> bool { + let hard_coverage = select(0.0, 1.0, authored_alpha >= cutoff); + // Transition before LOD 1 avoids a pop while trilinear sampling moves + // from authored level-zero alpha to lower levels whose alpha is coverage. + let blend = smoothstep(0.5, 1.0, lod); + let probability = mix(hard_coverage, lower_mip_coverage, blend); + return probability >= mask_coverage_threshold(pixel); +} + // Exact piecewise sRGB → linear, matching bloom-reference's // `srgb_u8_to_linear`. The 2.2-gamma approximation we used before // drifts by ~0.005 in mid-tones, which adds up across base color + @@ -762,13 +814,48 @@ struct SceneOut { fn fs_depth_prepass(in: VertexOutputScene) { let alpha_cutoff = material.metal_rough.w; if (alpha_cutoff > 0.0) { - let a = textureSample(base_color_tex, base_color_samp, in.uv).a * in.color.a; - if (a < alpha_cutoff) { discard; } + let lod_bias = lighting.shadow_cascade_splits.w; + var survives = true; + if (material.emissive.w > 0.5) { + let mask_lod = mask_texture_lod( + in.uv, + textureDimensions(base_color_tex), + lod_bias, + ); + if (mask_lod <= 0.5) { + let authored_alpha = + textureSampleLevel(base_color_tex, base_color_samp, in.uv, 0.0).a * + in.color.a; + survives = authored_alpha >= alpha_cutoff; + } else if (mask_lod >= 1.0) { + let coverage = + textureSampleLevel(base_color_tex, base_color_samp, in.uv, mask_lod).a; + survives = coverage >= mask_coverage_threshold(in.clip_position.xy); + } else { + let authored_alpha = + textureSampleLevel(base_color_tex, base_color_samp, in.uv, 0.0).a * + in.color.a; + let coverage = + textureSampleLevel(base_color_tex, base_color_samp, in.uv, 1.0).a; + survives = mask_coverage_survives( + authored_alpha, + coverage, + alpha_cutoff, + mask_lod, + in.clip_position.xy, + ); + } + } else { + let raw_alpha = + textureSampleBias(base_color_tex, base_color_samp, in.uv, lod_bias).a * + in.color.a; + survives = raw_alpha >= alpha_cutoff; + } + if (!survives) { discard; } } } -@fragment -fn fs_main_scene(in: VertexOutputScene) -> SceneOut { +fn shade_main_scene(in: VertexOutputScene, front_facing: bool) -> SceneOut { var n = normalize(in.normal); // --- Normal mapping (tangent-space) --- @@ -830,16 +917,51 @@ fn fs_main_scene(in: VertexOutputScene) -> SceneOut { let base_color = srgb_to_linear_v(base_tex.rgb) * in.color.rgb; let base_alpha = base_tex.a * in.color.a; - // glTF MASK / BLEND alpha mode — discard fragments below the - // authored cutoff so alpha-cutout foliage, fences, chains, and - // fabric render as their actual shape instead of opaque billboards. - // OPAQUE materials carry cutoff = 0 so the branch collapses. - // BLEND is treated as MASK @ 0.5 (via the loader) pending a real - // sorted transparent pipeline. + // glTF alpha mode tag: MASK carries its positive authored cutoff, + // OPAQUE is zero, and BLEND is negative. Only MASK discards here; + // BLEND is routed through the sorted forward translucent pipeline. let alpha_cutoff = material.metal_rough.w; - if (alpha_cutoff > 0.0 && base_alpha < alpha_cutoff) { - discard; + //PREPASS_STRIP_BEGIN — SH-055: removed in the prepassed-pipeline variant. + // The prepassed main pass Equal-tests against prepass-exact depth, so a + // would-be-discarded pixel fails the depth test anyway; keeping `discard` + // in the shader disables Adreno LRZ/early-Z for the whole draw and made + // the canopy overdraw shade the full lighting shader per layer (~250 ms + // per frame on an Adreno 618). See mod.rs scene_shader_prepassed. + if (alpha_cutoff > 0.0) { + var survives = base_alpha >= alpha_cutoff; + if (material.emissive.w > 0.5) { + let mask_lod = mask_texture_lod( + in.uv, + textureDimensions(base_color_tex), + lod_bias, + ); + if (mask_lod <= 0.5) { + let authored_alpha = + textureSampleLevel(base_color_tex, base_color_samp, in.uv, 0.0).a * + in.color.a; + survives = authored_alpha >= alpha_cutoff; + } else if (mask_lod >= 1.0) { + let coverage = + textureSampleLevel(base_color_tex, base_color_samp, in.uv, mask_lod).a; + survives = coverage >= mask_coverage_threshold(in.clip_position.xy); + } else { + let authored_alpha = + textureSampleLevel(base_color_tex, base_color_samp, in.uv, 0.0).a * + in.color.a; + let coverage = + textureSampleLevel(base_color_tex, base_color_samp, in.uv, 1.0).a; + survives = mask_coverage_survives( + authored_alpha, + coverage, + alpha_cutoff, + mask_lod, + in.clip_position.xy, + ); + } + } + if (!survives) { discard; } } + //PREPASS_STRIP_END // Two-sided foliage normal. Alpha-cutout cards (leaves, grass blades) // are seen from both sides, but the geometric normal only faces one @@ -850,6 +972,9 @@ fn fs_main_scene(in: VertexOutputScene) -> SceneOut { if (alpha_cutoff > 0.0 && dot(n, lighting.camera_pos.xyz - in.world_pos) < 0.0) { n = -n; } + if (alpha_cutoff < 0.0 && !front_facing) { + n = -n; + } // glTF metallicRoughnessTexture: G=roughness, B=metallic (linear). // When the material has no MR texture (metal_rough.z == 0), the @@ -1029,6 +1154,10 @@ fn fs_main_scene(in: VertexOutputScene) -> SceneOut { // convolution — that's the next refinement — but together with // the BRDF LUT it captures the bulk of correct PBR appearance. + //IBL_STRIP_BEGIN — SH-055 probe: replaced by a flat-ambient fallback when + // BLOOM_SKIP contains "ibl" (see mod.rs), to measure the split-sum IBL + // chain's per-fragment cost on mobile GPUs. Only ibl_diffuse/ibl_spec + // escape this block. let n_dot_v_ibl = max(dot(n, v), 0.0); let f0 = mix(vec3(0.04), base_color, metallic); @@ -1162,6 +1291,7 @@ fn fs_main_scene(in: VertexOutputScene) -> SceneOut { 0.0, 1.0); let ibl_spec = ibl_spec_raw * dielectric_scale * spec_occ * roughness_amp * cap2 * (1.0 - ssr_own); + //IBL_STRIP_END // Indirect-shadow attenuation. 0.15 — deep enough that windows // Shadow darkening floor. Prior 0.15 matched Cycles path- @@ -1218,9 +1348,9 @@ fn fs_main_scene(in: VertexOutputScene) -> SceneOut { // glTF OPAQUE materials (alpha_cutoff == 0) ignore texture alpha by // spec — armor/gloss masks stored in .a must not make the mesh - // translucent. MASK materials keep the sampled alpha (post-discard) - // for soft edges. Tint alpha survives so games can fade models. - let out_alpha = select(in.color.a, base_alpha, alpha_cutoff > 0.0); + // translucent. MASK keeps sampled alpha after its cutoff; BLEND + // keeps fractional alpha for forward compositing. + let out_alpha = select(in.color.a, base_alpha, alpha_cutoff != 0.0); return SceneOut( vec4(hdr, out_alpha), @@ -1239,5 +1369,632 @@ fn fs_main_scene(in: VertexOutputScene) -> SceneOut { vec4(base_color, 1.0 - shadow_factor), ); } -"#); +@fragment +fn fs_main_scene( + in: VertexOutputScene, + @builtin(front_facing) front_facing: bool, +) -> SceneOut { + return shade_main_scene(in, front_facing); +} + +@fragment +fn fs_transparent_scene( + in: VertexOutputScene, + @builtin(front_facing) front_facing: bool, +) -> @location(0) vec4 { + return shade_main_scene(in, front_facing).color; +} +"# +); + +/// Build the dedicated imported-transmission scene shader without changing +/// the shader compiled for ordinary scene materials. +/// +/// Desktop/native backends add group 4 and sample the render-graph-owned +/// pre-translucency color/depth snapshots. Four-bind-group targets +/// (`fold_scene_inputs`: WebGPU/Android) compile the same physical lobe but +/// source transmitted radiance from the environment map instead. +pub(in crate::renderer) fn scene_refractive_shader_source( + base_scene_shader: &str, + folded_scene_inputs: bool, + screen_space_reflections: bool, + secondary_uv: bool, +) -> String { + assert!( + !folded_scene_inputs || !screen_space_reflections, + "folded four-bind-group targets cannot add native reflection inputs" + ); + const JOINT_DECLARATION: &str = + "@group(3) @binding(1) var joints_prev: JointMatrices;"; + let scene_inputs = if folded_scene_inputs { + "" + } else if screen_space_reflections { + r#" +struct RefractiveReflectionParams { + view: mat4x4, + proj: mat4x4, + params: vec4, + planar_plane: vec4, +}; + +@group(4) @binding(0) var refractive_scene_color_tex: texture_2d; +@group(4) @binding(1) var refractive_scene_color_samp: sampler; +@group(4) @binding(2) var refractive_scene_depth_tex: texture_depth_2d; +@group(4) @binding(3) var refractive_reflection: RefractiveReflectionParams; +@group(4) @binding(4) var refractive_planar_tex: texture_2d; +"# + } else { + r#" +@group(4) @binding(0) var refractive_scene_color_tex: texture_2d; +@group(4) @binding(1) var refractive_scene_color_samp: sampler; +@group(4) @binding(2) var refractive_scene_depth_tex: texture_depth_2d; +"# + }; + let physical_declarations = format!( + r#"{JOINT_DECLARATION} + +struct TransmissionFactors {{ + transmission: vec4, + attenuation: vec4, + transmission_uv: vec4, + transmission_rotation: vec4, + thickness_uv: vec4, + thickness_rotation: vec4, +}}; + +@group(2) @binding(11) var transmission_tex: texture_2d; +@group(2) @binding(12) var transmission_samp: sampler; +@group(2) @binding(13) var thickness_tex: texture_2d; +@group(2) @binding(14) var thickness_samp: sampler; +@group(2) @binding(15) var transmission_material: TransmissionFactors; +{scene_inputs}"# + ); + let mut source = base_scene_shader.replacen(JOINT_DECLARATION, &physical_declarations, 1); + assert_ne!( + source, base_scene_shader, + "scene shader joint declaration changed; refractive ABI injection must be updated" + ); + let layered_secondary_uv = + secondary_uv && base_scene_shader.contains("fn layered_secondary_uv(in:"); + let model_scale_location = if layered_secondary_uv { 8 } else { 7 }; + // The ordinary per-draw group is vertex-visible only. Carry model scale as + // a refractive-variant-only interpolant instead of widening that established + // layout to the fragment stage for every scene material. + source = source.replacen( + " @location(6) prev_clip: vec4,", + &format!( + " @location(6) prev_clip: vec4,\n @location({model_scale_location}) model_scale: f32," + ), + 1, + ); + source = source.replacen( + " o.prev_clip = u.prev_mvp * prev_world4;", + " o.prev_clip = u.prev_mvp * prev_world4;\n\ + o.model_scale = (length(u.model[0].xyz) + length(u.model[1].xyz) \ + + length(u.model[2].xyz)) / 3.0;", + 1, + ); + source = source.replacen( + " out.prev_clip = u.prev_mvp * vec4(prev_local, 1.0);", + " out.prev_clip = u.prev_mvp * vec4(prev_local, 1.0);\n\ + out.model_scale = (length(u.model[0].xyz) + length(u.model[1].xyz) \ + + length(u.model[2].xyz)) / 3.0;", + 1, + ); + if secondary_uv && !layered_secondary_uv { + source = source.replacen( + " @location(6) tangent: vec4,\n};", + " @location(6) tangent: vec4,\n\ + @location(7) secondary_uv: vec2,\n\ + };", + 1, + ); + source = source.replacen( + " @location(7) model_scale: f32,", + " @location(7) model_scale: f32,\n\ + @location(8) secondary_uv: vec2,", + 1, + ); + source = source.replacen( + " o.uv = in.uv;", + " o.uv = in.uv;\n o.secondary_uv = in.secondary_uv;", + 1, + ); + source = source.replacen( + " out.uv = in.uv;", + " out.uv = in.uv;\n out.secondary_uv = in.secondary_uv;", + 1, + ); + assert!( + source.contains("@location(8) secondary_uv"), + "scene vertex ABI changed; refractive UV1 injection must be updated" + ); + } else if layered_secondary_uv { + assert!( + source.contains("@location(7) secondary_uv") + && source.contains("@location(8) model_scale"), + "layered refractive vertex ABI changed; specialization must be updated" + ); + } + + let transmitted_radiance = if folded_scene_inputs { + r#" + // Constrained four-bind-group fallback: preserve the refractive material + // type and its Fresnel/absorption response, but source off-screen + // transmitted radiance from the prefiltered environment. + let max_transmission_mip = max(f32(textureNumLevels(env_tex)) - 1.0, 0.0); + let transmitted_radiance = env_sample_lod( + refracted_direction, + roughness * max_transmission_mip, + ); + let undistorted_radiance = env_sample_lod(-v, roughness * max_transmission_mip); +"# + } else { + r#" + let scene_dimensions_u = textureDimensions(refractive_scene_color_tex, 0); + let scene_dimensions = vec2(scene_dimensions_u); + let current_ndc = in.curr_clip.xy / max(abs(in.curr_clip.w), 0.000001); + let current_uv = clamp( + vec2(current_ndc.x * 0.5 + 0.5, 0.5 - current_ndc.y * 0.5), + vec2(0.0001), + vec2(0.9999), + ); + + // Convert the refracted world-space direction into a stable screen-space + // travel distance using the fragment's world-position derivatives. The + // authored thickness remains in model units and is promoted to world units + // with the mean model scale. A 64-pixel cap prevents pathological assets + // from sampling unrelated parts of the frame. + let world_dx = dpdx(in.world_pos); + let world_dy = dpdy(in.world_pos); + let world_dx_len = max(length(world_dx), 0.000001); + let world_dy_len = max(length(world_dy), 0.000001); + let screen_tangent_x = world_dx / world_dx_len; + let screen_tangent_y = world_dy / world_dy_len; + let ray_distance = thickness_world + / max(abs(dot(refracted_direction, n)), 0.15); + var offset_pixels = vec2( + dot(refracted_direction, screen_tangent_x) * ray_distance / world_dx_len, + dot(refracted_direction, screen_tangent_y) * ray_distance / world_dy_len, + ); + offset_pixels = clamp(offset_pixels, vec2(-64.0), vec2(64.0)); + var refracted_uv = clamp( + current_uv + offset_pixels / scene_dimensions, + vec2(0.0001), + vec2(0.9999), + ); + + // Reject offsets that cross in front of this glass surface. This keeps a + // nearby opaque silhouette from being pulled through the refractor. + let candidate_pixel = clamp( + vec2(refracted_uv * scene_dimensions), + vec2(0), + vec2(scene_dimensions_u) - vec2(1), + ); + let candidate_depth = textureLoad( + refractive_scene_depth_tex, + candidate_pixel, + 0, + ); + if (candidate_depth + 0.0005 < in.clip_position.z) { + refracted_uv = current_uv; + } + + var transmitted_radiance = textureSampleLevel( + refractive_scene_color_tex, + refractive_scene_color_samp, + refracted_uv, + 0.0, + ).rgb; + // Deterministic five-tap rough transmission. Smooth glass stays at one + // fetch; rough glass integrates a bounded footprint without temporal noise. + if (roughness > 0.08) { + let blur_uv = vec2(8.0 * roughness * roughness) / scene_dimensions; + transmitted_radiance = ( + transmitted_radiance * 4.0 + + textureSampleLevel( + refractive_scene_color_tex, + refractive_scene_color_samp, + refracted_uv + vec2(blur_uv.x, 0.0), + 0.0, + ).rgb + + textureSampleLevel( + refractive_scene_color_tex, + refractive_scene_color_samp, + refracted_uv - vec2(blur_uv.x, 0.0), + 0.0, + ).rgb + + textureSampleLevel( + refractive_scene_color_tex, + refractive_scene_color_samp, + refracted_uv + vec2(0.0, blur_uv.y), + 0.0, + ).rgb + + textureSampleLevel( + refractive_scene_color_tex, + refractive_scene_color_samp, + refracted_uv - vec2(0.0, blur_uv.y), + 0.0, + ).rgb + ) * 0.125; + } + let undistorted_radiance = textureSampleLevel( + refractive_scene_color_tex, + refractive_scene_color_samp, + current_uv, + 0.0, + ).rgb; +"# + }; + + let reflection_helpers = if screen_space_reflections { + r#" +fn refractive_screen_reflection( + in: VertexOutputScene, + reflected_direction: vec3, + roughness: f32, + environment_fallback: vec3, +) -> vec3 { + // The ordinary SSR target cannot be reused here: it was traced from the + // opaque surface behind this fragment and therefore owns a different + // normal/reflection ray. Launch one bounded ray from the glass fragment + // against the immutable opaque snapshots instead. + if (refractive_reflection.params.x < 0.5 + || roughness >= refractive_reflection.params.w) { + return environment_fallback; + } + + let dimensions_u = textureDimensions(refractive_scene_color_tex, 0); + let dimensions = vec2(dimensions_u); + let step_count = u32(refractive_reflection.params.z); + let max_distance = refractive_reflection.params.y; + let start_view = ( + refractive_reflection.view * vec4(in.world_pos, 1.0) + ).xyz; + let reflected_view = normalize(( + refractive_reflection.view * vec4(reflected_direction, 0.0) + ).xyz); + let start_clip = refractive_reflection.proj * vec4(start_view, 1.0); + var previous_ray_depth = start_clip.z / max(abs(start_clip.w), 0.000001); + var hit_uv = vec2(-1.0); + var hit_confidence = 0.0; + + // Quadratic spacing keeps the nearest samples dense enough for window + // frames and props while still reaching the same architectural range as + // the established opaque SSR pass. The loop bound and every texture read + // are fixed by the lazy uniform (currently eight). + for (var step = 0u; step < step_count; step = step + 1u) { + let fraction = f32(step + 1u) / f32(step_count); + let distance = max_distance * fraction * fraction; + let ray_view = start_view + reflected_view * distance; + let ray_clip = refractive_reflection.proj * vec4(ray_view, 1.0); + if (ray_clip.w <= 0.000001) { + break; + } + let ray_ndc = ray_clip.xyz / ray_clip.w; + if (ray_ndc.x <= -1.0 || ray_ndc.x >= 1.0 + || ray_ndc.y <= -1.0 || ray_ndc.y >= 1.0 + || ray_ndc.z <= 0.0 || ray_ndc.z >= 1.0) { + break; + } + let ray_uv = vec2( + ray_ndc.x * 0.5 + 0.5, + 0.5 - ray_ndc.y * 0.5, + ); + let pixel = clamp( + vec2(ray_uv * dimensions), + vec2(0), + vec2(dimensions_u) - vec2(1), + ); + let scene_depth = textureLoad(refractive_scene_depth_tex, pixel, 0); + let depth_delta = ray_ndc.z - scene_depth; + let depth_stride = abs(ray_ndc.z - previous_ray_depth); + let thickness = max(depth_stride * 2.0, 0.00075); + if (scene_depth < 0.9999 + && depth_delta >= 0.0 + && depth_delta <= thickness) { + hit_uv = ray_uv; + hit_confidence = 1.0 - smoothstep( + thickness * 0.25, + thickness, + depth_delta, + ); + break; + } + previous_ray_depth = ray_ndc.z; + } + + if (hit_uv.x < 0.0) { + return environment_fallback; + } + // Suppress the screen boundary before it can pop. Rough glass fades to + // the prefiltered environment rather than returning an incorrectly sharp + // scene-color tap (the snapshot deliberately has no mip chain). + let edge_pixels = min( + min(hit_uv.x, 1.0 - hit_uv.x) * dimensions.x, + min(hit_uv.y, 1.0 - hit_uv.y) * dimensions.y, + ); + let edge_weight = smoothstep(0.0, 8.0, edge_pixels); + let roughness_weight = 1.0 - smoothstep( + refractive_reflection.params.w * 0.45, + refractive_reflection.params.w, + roughness, + ); + let raw = textureSampleLevel( + refractive_scene_color_tex, + refractive_scene_color_samp, + hit_uv, + 0.0, + ).rgb; + let screen_radiance = select(vec3(0.0), raw, raw == raw); + let source_weight = clamp( + edge_weight * roughness_weight * hit_confidence, + 0.0, + 1.0, + ); + return mix(environment_fallback, screen_radiance, source_weight); +} + +fn refractive_planar_sample( + in: VertexOutputScene, + roughness: f32, +) -> vec4 { + let plane = refractive_reflection.planar_plane; + if (dot(plane.xyz, plane.xyz) < 0.5) { + return vec4(0.0, 0.0, 0.0, -1.0); + } + // A plane crossing unrelated vertical glass must not make the global + // first-probe choice leak onto that surface. Use the unperturbed vertex + // normal so authored water waves can still perturb the sampled reflection. + if (abs(dot(normalize(plane.xyz), normalize(in.normal))) < 0.8) { + return vec4(0.0, 0.0, 0.0, -1.0); + } + let plane_distance = abs(dot(plane.xyz, in.world_pos) - plane.w); + if (plane_distance > 0.075) { + return vec4(0.0, 0.0, 0.0, -1.0); + } + let ndc = in.curr_clip.xy / max(abs(in.curr_clip.w), 0.000001); + let uv = clamp( + vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5), + vec2(0.0001), + vec2(0.9999), + ); + let planar = textureSampleLevel( + refractive_planar_tex, + refractive_scene_color_samp, + uv, + 0.0, + ); + let planar_safe = select(vec3(0.0), planar.rgb, planar.rgb == planar.rgb); + // The existing planar capture has no mip chain. Fade its exact reflection + // into the lower tiers as roughness grows instead of returning an + // incorrectly sharp image. Alpha zero is the probe's explicit miss value. + let roughness_weight = 1.0 - smoothstep(0.18, 0.45, roughness); + let source_weight = clamp(planar.a * roughness_weight, 0.0, 1.0); + return vec4(planar_safe, source_weight); +} +"# + } else { + "" + }; + let reflected_radiance = if screen_space_reflections { + r#" + let reflected_environment = env_sample_lod( + reflected_direction, + roughness * max(f32(textureNumLevels(env_tex)) - 1.0, 0.0), + ); + let planar_reflected = refractive_planar_sample(in, roughness); + var reflected = mix( + reflected_environment, + planar_reflected.rgb, + max(planar_reflected.a, 0.0), + ); + // A matching explicit probe is authoritative for its plane: alpha zero is + // its documented geometry miss and therefore reveals the environment sky. + // Only glass without an applicable probe pays for the bounded screen march. + if (planar_reflected.a < 0.0) { + reflected = refractive_screen_reflection( + in, + reflected_direction, + roughness, + reflected_environment, + ); + } +"# + } else { + r#" + let reflected = env_sample_lod( + reflected_direction, + roughness * max(f32(textureNumLevels(env_tex)) - 1.0, 0.0), + ); +"# + }; + + let transmission_source_uv = if secondary_uv { + "select(\n in.uv,\n in.secondary_uv,\n transmission_material.transmission_rotation.w > 0.5,\n )" + } else { + "in.uv" + }; + let thickness_source_uv = if secondary_uv { + "select(\n in.uv,\n in.secondary_uv,\n transmission_material.thickness_rotation.z > 0.5,\n )" + } else { + "in.uv" + }; + source.push_str(&format!( + r#" + +{reflection_helpers} + +fn physical_texture_uv( + uv: vec2, + offset_scale: vec4, + rotation: vec2, +) -> vec2 {{ + let scaled = uv * offset_scale.zw; + let rotated = vec2( + rotation.x * scaled.x - rotation.y * scaled.y, + rotation.y * scaled.x + rotation.x * scaled.y, + ); + return offset_scale.xy + rotated; +}} + +fn refractive_scene_normal(in: VertexOutputScene, front_facing: bool) -> vec3 {{ + var n = normalize(in.normal); + let normal_sample = textureSampleBias( + normal_tex, + normal_samp, + in.uv, + 1.0 + lighting.shadow_cascade_splits.w, + ).xyz * 2.0 - 1.0; + let mapped = normal_sample / max(length(normal_sample), 0.000001); + let tangent_len2 = dot(in.tangent.xyz, in.tangent.xyz); + if (tangent_len2 > 0.0001) {{ + let tangent = normalize(in.tangent.xyz); + let tangent_ortho = normalize(tangent - n * dot(n, tangent)); + let bitangent = cross(n, tangent_ortho) * in.tangent.w; + n = normalize( + tangent_ortho * mapped.x + bitangent * mapped.y + n * mapped.z, + ); + }} else {{ + let tbn = compute_tbn( + dpdx(in.world_pos), + dpdy(in.world_pos), + dpdx(in.uv), + dpdy(in.uv), + n, + ); + n = normalize(tbn * mapped); + }} + if (!front_facing) {{ + n = -n; + }} + return n; +}} + +struct RefractiveSceneOut {{ + @location(0) color: vec4, + @location(1) velocity: vec2, +}}; + +@fragment +fn fs_refractive_scene( + in: VertexOutputScene, + @builtin(front_facing) front_facing: bool, +) -> RefractiveSceneOut {{ + // Reuse the established direct/IBL PBR evaluation for the non-transmitted + // energy, then split the dielectric lobe below. This also preserves MASK + // discard semantics for the unusual but legal MASK+transmission case. + let surface = shade_main_scene(in, front_facing); + let n = refractive_scene_normal(in, front_facing); + let v = normalize(lighting.camera_pos.xyz - in.world_pos); + + let base_texel = textureSample(base_color_tex, base_color_samp, in.uv); + let base_color = srgb_to_linear_v(base_texel.rgb) * in.color.rgb; + let base_alpha = base_texel.a * in.color.a; + let mr_texel = textureSample(mr_tex, mr_samp, in.uv); + let has_mr = material.metal_rough.z > 0.5; + let metallic = select( + clamp(material.metal_rough.x, 0.0, 1.0), + clamp(mr_texel.b * material.metal_rough.x, 0.0, 1.0), + has_mr, + ); + let roughness = select( + clamp(material.metal_rough.y, 0.045, 1.0), + clamp(mr_texel.g * material.metal_rough.y, 0.045, 1.0), + has_mr, + ); + + let transmission_uv = physical_texture_uv( + {transmission_source_uv}, + transmission_material.transmission_uv, + transmission_material.transmission_rotation.xy, + ); + let texture_transmission = select( + 1.0, + textureSample( + transmission_tex, + transmission_samp, + transmission_uv, + ).r, + transmission_material.transmission.w > 0.5, + ); + let dielectric_weight = 1.0 - metallic; + let transmission_weight = clamp( + transmission_material.transmission.x + * texture_transmission + * dielectric_weight, + 0.0, + 1.0, + ); + + let thickness_uv = physical_texture_uv( + {thickness_source_uv}, + transmission_material.thickness_uv, + transmission_material.thickness_rotation.xy, + ); + let texture_thickness = select( + 1.0, + textureSample( + thickness_tex, + thickness_samp, + thickness_uv, + ).g, + transmission_material.transmission_rotation.z > 0.5, + ); + let mean_model_scale = max(in.model_scale, 0.0); + let thickness_world = max( + transmission_material.transmission.z + * texture_thickness + * mean_model_scale, + 0.0, + ); + + let ior = max(transmission_material.transmission.y, 1.0); + let eta = 1.0 / ior; + var refracted_direction = refract(-v, n, eta); + if (dot(refracted_direction, refracted_direction) < 0.000001) {{ + refracted_direction = reflect(-v, n); + }} + refracted_direction = normalize(refracted_direction); + +{transmitted_radiance} + + var absorption = vec3(1.0); + if (transmission_material.attenuation.w > 0.0 && thickness_world > 0.0) {{ + let optical_distance = thickness_world + / transmission_material.attenuation.w; + absorption = pow( + max(transmission_material.attenuation.rgb, vec3(0.000001)), + vec3(optical_distance), + ); + }} + let transmitted = transmitted_radiance * base_color * absorption; + + let f0_scalar = pow((ior - 1.0) / (ior + 1.0), 2.0); + let n_dot_v = clamp(dot(n, v), 0.0, 1.0); + let fresnel = f0_scalar + + (1.0 - f0_scalar) * pow(1.0 - n_dot_v, 5.0); + let reflected_direction = reflect(-v, n); +{reflected_radiance} + + // Energy partition: the ordinary PBR surface owns the opaque fraction; + // the transmission fraction is split exactly between Fresnel reflection + // and absorbed transmitted radiance. + let dielectric_transmission = mix(transmitted, reflected, fresnel); + var hdr = surface.color.rgb * (1.0 - transmission_weight) + + dielectric_transmission * transmission_weight; + + // glTF BLEND+transmission additionally applies base-color alpha. Because + // this fragment already composites against the snapshot, write alpha=1 + // to avoid applying the background a second time in fixed-function blend. + if (material.metal_rough.w < 0.0) {{ + hdr = mix(undistorted_radiance, hdr, clamp(base_alpha, 0.0, 1.0)); + }} + hdr = select(vec3(0.0), hdr, hdr == hdr); + return RefractiveSceneOut(vec4(hdr, 1.0), surface.velocity); +}} +"# + )); + source +} diff --git a/native/shared/src/renderer/shaders/core_tests.rs b/native/shared/src/renderer/shaders/core_tests.rs new file mode 100644 index 00000000..d8e3f0a2 --- /dev/null +++ b/native/shared/src/renderer/shaders/core_tests.rs @@ -0,0 +1,69 @@ +use super::core::{scene_refractive_shader_source, SCENE_SHADER}; + +#[test] +fn scene_vertex_shader_preserves_the_missing_tangent_sentinel() { + assert!(SCENE_SHADER.contains("fn safe_scene_tangent(")); + assert_eq!(SCENE_SHADER.matches("safe_scene_tangent(").count(), 3); + assert!(SCENE_SHADER.contains("length_squared > 1e-8")); + assert!(!SCENE_SHADER.contains("normalize(tan4.xyz)")); + assert!(!SCENE_SHADER.contains("normalize((u.model * vec4(in.tangent.xyz, 0.0)).xyz)")); +} + +#[test] +fn native_and_folded_refractive_variants_parse_without_touching_ordinary_shader() { + assert!(!SCENE_SHADER.contains("fs_refractive_scene")); + for (folded, screen_space_reflections) in [(false, false), (false, true), (true, false)] { + for secondary_uv in [false, true] { + let source = scene_refractive_shader_source( + SCENE_SHADER, + folded, + screen_space_reflections, + secondary_uv, + ); + wgpu::naga::front::wgsl::parse_str(&source).unwrap_or_else(|error| { + panic!( + "{}{} refractive WGSL failed: {error}", + if folded { + "folded" + } else if screen_space_reflections { + "native reflection hierarchy" + } else { + "native environment-only" + }, + if secondary_uv { " UV1" } else { "" }, + ) + }); + assert!(source.contains("fn fs_refractive_scene")); + assert_eq!( + source.contains("@group(4) @binding(0) var refractive_scene_color_tex"), + !folded + ); + assert_eq!( + source.contains("fn refractive_screen_reflection"), + screen_space_reflections + ); + assert_eq!( + source.contains("@group(4) @binding(3) var refractive_reflection"), + screen_space_reflections + ); + assert_eq!( + source.contains("@group(4) @binding(4) var refractive_planar_tex"), + screen_space_reflections + ); + assert_eq!( + source.contains("fn refractive_planar_sample"), + screen_space_reflections + ); + assert_eq!( + source.contains("if (planar_reflected.a < 0.0)"), + screen_space_reflections, + "only non-planar glass may pay for the bounded screen march" + ); + assert_eq!( + source.contains("@location(8) secondary_uv"), + secondary_uv, + "UV1 must exist only in the lazy refractive variant" + ); + } + } +} diff --git a/native/shared/src/renderer/shaders/env.rs b/native/shared/src/renderer/shaders/env.rs index d084d008..1171456e 100644 --- a/native/shared/src/renderer/shaders/env.rs +++ b/native/shared/src/renderer/shaders/env.rs @@ -41,6 +41,13 @@ fn importance_sample_ggx(xi: vec2, n: vec3, roughness: f32) -> vec3 f32 { + let a = roughness * roughness; + let a2 = a * a; + let denominator = n_dot_h * n_dot_h * (a2 - 1.0) + 1.0; + return a2 / max(PI * denominator * denominator, 1e-7); +} + fn dir_to_uv(dir: vec3) -> vec2 { let d = normalize(dir); let theta = acos(clamp(d.y, -1.0, 1.0)); @@ -82,7 +89,28 @@ fn fs_main(@builtin(position) frag_pos: vec4) -> @location(0) vec4 { let l = normalize(2.0 * dot(v, h) * h - v); let n_dot_l = max(dot(n, l), 0.0); if (n_dot_l > 0.0) { - color += textureSampleLevel(src_tex, src_samp, dir_to_uv(l), 0.0).rgb * n_dot_l; + let n_dot_h = max(dot(n, h), 1e-5); + let h_dot_v = max(dot(h, v), 1e-5); + let pdf = max(distribution_ggx(n_dot_h, roughness) * n_dot_h / (4.0 * h_dot_v), 1e-6); + let sample_solid_angle = 1.0 / (f32(n_samples) * pdf); + + // One equirectangular texel spans d_phi times the exact spherical + // area between its two latitude edges. Selecting the source mip + // whose footprint matches the importance sample integrates tiny + // HDR emitters instead of randomly hitting them as isolated + // fireflies. A one-level source view naturally clamps this to mip + // zero for procedural-sky bakes. + let sample_uv = dir_to_uv(l); + let source_size = vec2(textureDimensions(src_tex, 0)); + let half_theta = 0.5 * PI / source_size.y; + let theta = sample_uv.y * PI; + let theta0 = max(theta - half_theta, 0.0); + let theta1 = min(theta + half_theta, PI); + let texel_solid_angle = + (2.0 * PI / source_size.x) * max(cos(theta0) - cos(theta1), 1e-8); + let source_lod = + 0.5 * log2(max(sample_solid_angle / texel_solid_angle, 1.0)); + color += textureSampleLevel(src_tex, src_samp, sample_uv, source_lod).rgb * n_dot_l; weight += n_dot_l; } } @@ -141,6 +169,20 @@ fn fs_diffuse(@builtin(position) frag_pos: vec4) -> @location(0) vec4 } "; +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn environment_prefilter_shader_parses_with_pdf_lod_selection() { + wgpu::naga::front::wgsl::parse_str(PREFILTER_SHADER_WGSL) + .expect("environment prefilter WGSL must parse"); + assert!(PREFILTER_SHADER_WGSL.contains("sample_solid_angle")); + assert!(PREFILTER_SHADER_WGSL.contains("texel_solid_angle")); + assert!(PREFILTER_SHADER_WGSL.contains("source_lod")); + } +} + pub(in crate::renderer) const SKY_SHADER_WGSL: &str = " struct SkyUniforms { // Camera world basis (right, up, forward) and screen scale. @@ -796,7 +838,8 @@ fn sky_fs(in: VsOut) -> SkyOut { out.albedo = vec4(0.0, 0.0, 0.0, 0.0); return out; } -"#); +"# +); /// EN-011 — single-target reflection shader for rendering cached models /// (trees, house, etc.) into a planar-reflection probe with a mirrored @@ -904,4 +947,3 @@ fn fs_reflect(in: VsOut) -> @location(0) vec4 { return vec4(lit, 1.0); // alpha 1 = 'real reflection here' for the water blend } "; - diff --git a/native/shared/src/renderer/shaders/gi.rs b/native/shared/src/renderer/shaders/gi.rs index 8f81dae2..d7208381 100644 --- a/native/shared/src/renderer/shaders/gi.rs +++ b/native/shared/src/renderer/shaders/gi.rs @@ -483,11 +483,10 @@ fn wsrc_sample_cascade(cascade: i32, pos_ws: vec3, bias: f32) -> f32 { } } -// V10 — workgroup writes the 10×10 padded slab per probe. Thread +// V10/V11/#23 — workgroup writes the 10×10 padded slab per probe. Thread // (lid.x, lid.y) writes texel (wg.*10 + lid) in the atlas; border -// threads (lid on 0 or 9) shade for the nearest INSIDE octel -// direction so the sampler's edge-extend behaviour is baked into -// the data. The 1-texel border is what lets the hardware bilinear +// threads (lid on 0 or 9) shade the octahedrally wrapped interior +// direction. The 1-texel border is what lets the hardware bilinear // sampler do octel smoothing without leaking into adjacent probes. const WSRC_OCT_PADDED_SIZE: u32 = 10u; @@ -507,37 +506,10 @@ fn cs_main( - vec3(extent * 0.5) + (vec3(f32(wg.x), f32(wg.y), f32(wg.z)) + vec3(0.5)) * cell; - // V11 — map padded octel → real octel with true octahedral - // silhouette wrap on the 4 edges. Beyond v<0 or v>1 in octel uv - // space the octahedron folds onto itself with u ↔ 1-u; likewise - // u<0 or u>1 folds with v ↔ 1-v. Corners (both axes out) keep - // the V10 edge-extend fill since the double-fold has two valid - // representations and the exact corner only matters when the - // sampler bilinear-weights it near zero anyway. - let px = i32(lid.x); - let py = i32(lid.y); - let is_edge_x = px == 0 || px == 9; - let is_edge_y = py == 0 || py == 9; - var real_ox: i32; - var real_oy: i32; - if (is_edge_x && is_edge_y) { - // Corner — nearest-inside (edge-extend). - real_ox = clamp(px - 1, 0, 7); - real_oy = clamp(py - 1, 0, 7); - } else if (is_edge_y) { - // Top/bottom border: mirror x across the edge, same row. - real_ox = 8 - px; - real_oy = clamp(py - 1, 0, 7); - } else if (is_edge_x) { - // Left/right border: same column, mirror y across the edge. - real_ox = clamp(px - 1, 0, 7); - real_oy = 8 - py; - } else { - // Interior — direct mapping. - real_ox = px - 1; - real_oy = py - 1; - } - let dir = octel_direction(vec2(u32(real_ox), u32(real_oy))); + // V11/#23 — shared edge and double-fold corner wrap. Both software + // and hardware bakes must write identical padded octahedral slabs. + let real_octel = wsrc_real_octel(vec2(lid.xy)); + let dir = octel_direction(real_octel); // Shadow at the probe position (cascade 2 — widest, covers the // full 120 m cube without per-probe cascade selection). @@ -628,6 +600,7 @@ struct HwBakeInstanceGiData { const HW_BAKE_CARD_SLOTS_PER_ROW: f32 = 64.0; const HW_BAKE_OCT_PADDED: u32 = 10u; +const BLOOM_TRANSPARENT_GI: bool = false; @group(0) @binding(0) var u: WsrcBakeParams; @group(0) @binding(1) var shadow_atlas_0: texture_depth_2d; @@ -656,6 +629,109 @@ fn hw_bake_sample_cascade(cascade: i32, pos_ws: vec3, bias: f32) -> f32 { else { return textureSampleCompareLevel(shadow_atlas_2, shadow_samp, shadow_uv, ref_depth); } } +fn hw_bake_card_uv( + inst: HwBakeInstanceGiData, + hit_os: vec3, + dir: vec3, +) -> vec2 { + let abs_d = abs(dir); + var axis_idx: u32 = 0u; + if (abs_d.y >= abs_d.x && abs_d.y >= abs_d.z) { + axis_idx = 2u; + } else if (abs_d.z >= abs_d.x) { + axis_idx = 4u; + } + var signed_axis: u32 = axis_idx; + if (axis_idx == 0u && dir.x > 0.0) { signed_axis = 1u; } + else if (axis_idx == 2u && dir.y > 0.0) { signed_axis = 3u; } + else if (axis_idx == 4u && dir.z > 0.0) { signed_axis = 5u; } + + let slot = u32(inst.card_slot.x) + signed_axis; + let slot_x = slot % 64u; + let slot_y = slot / 64u; + let bmin = inst.card_aabb_min.xyz; + let bmax = inst.card_aabb_max.xyz; + var u_os: f32; + var v_os: f32; + var u_lo: f32; var u_hi: f32; + var v_lo: f32; var v_hi: f32; + var u_flip: f32 = 1.0; + if (signed_axis == 0u || signed_axis == 1u) { + u_os = hit_os.y; v_os = hit_os.z; + u_lo = bmin.y; u_hi = bmax.y; v_lo = bmin.z; v_hi = bmax.z; + if (signed_axis == 1u) { u_flip = -1.0; } + } else if (signed_axis == 2u || signed_axis == 3u) { + u_os = hit_os.x; v_os = hit_os.z; + u_lo = bmin.x; u_hi = bmax.x; v_lo = bmin.z; v_hi = bmax.z; + if (signed_axis == 3u) { u_flip = -1.0; } + } else { + u_os = hit_os.x; v_os = hit_os.y; + u_lo = bmin.x; u_hi = bmax.x; v_lo = bmin.y; v_hi = bmax.y; + if (signed_axis == 5u) { u_flip = -1.0; } + } + var u_norm = clamp((u_os - u_lo) / max(u_hi - u_lo, 1e-4), 0.0, 1.0); + let v_norm = clamp((v_os - v_lo) / max(v_hi - v_lo, 1e-4), 0.0, 1.0); + if (u_flip < 0.0) { u_norm = 1.0 - u_norm; } + + let slot_size_uv = 1.0 / HW_BAKE_CARD_SLOTS_PER_ROW; + let texel_in_slot = slot_size_uv / f32(64); + let slot_u0 = f32(slot_x) * slot_size_uv + texel_in_slot; + let slot_v0 = f32(slot_y) * slot_size_uv + texel_in_slot; + let slot_span = slot_size_uv - 2.0 * texel_in_slot; + return vec2( + slot_u0 + u_norm * slot_span, + slot_v0 + v_norm * slot_span, + ); +} + +fn hw_bake_shade_hit( + inst: HwBakeInstanceGiData, + hit_os: vec3, + dir: vec3, +) -> vec3 { + if (inst.card_slot.w > 0.5) { + return textureSampleLevel( + card_atlas, + card_samp, + hw_bake_card_uv(inst, hit_os, dir), + 0.0, + ).rgb; + } + let hit_n = inst.normal_ws; + let ndotl = max(dot(hit_n, u.sun_dir.xyz), 0.0); + let direct = u.sun_color.xyz * ndotl; + let ndotup = max(dot(hit_n, vec3(0.0, 1.0, 0.0)), 0.0); + let sky = u.sky_color.xyz * ndotup; + return inst.albedo * (direct + sky) + + inst.albedo * inst.emissive_luma; +} + +fn hw_bake_miss(probe_pos: vec3, dir: vec3) -> vec3 { + var shadow: f32 = 1.0; + if (u.flags.y > 0.5) { + shadow = hw_bake_sample_cascade(2, probe_pos, u.flags.x); + } + let ndotl = max(dot(dir, u.sun_dir.xyz), 0.0); + let sun = u.sun_color.xyz * ndotl * shadow; + let up = clamp(dir.y * 0.5 + 0.5, 0.0, 1.0); + let sky = u.sky_color.xyz * up * up; + return sun + sky; +} + +fn hw_bake_transmittance(inst: HwBakeInstanceGiData) -> vec3 { + let absorption = vec3( + inst.card_aabb_min.w, + inst.card_aabb_max.w, + inst.world_aabb_min.w, + ); + let physical = clamp( + inst.albedo * absorption * inst.mat_params.z * inst.mat_params.w, + vec3(0.0), + vec3(1.0), + ); + return mix(vec3(1.0), physical, clamp(inst.world_aabb_max.w, 0.0, 1.0)); +} + @compute @workgroup_size(10, 10, 1) fn cs_main( @builtin(workgroup_id) wg: vec3, @@ -671,27 +747,9 @@ fn cs_main( - vec3(extent * 0.5) + (vec3(f32(wg.x), f32(wg.y), f32(wg.z)) + vec3(0.5)) * cell; - // V11 octahedral wrap for the padded borders. - let px = i32(lid.x); - let py = i32(lid.y); - let is_edge_x = px == 0 || px == 9; - let is_edge_y = py == 0 || py == 9; - var real_ox: i32; - var real_oy: i32; - if (is_edge_x && is_edge_y) { - real_ox = clamp(px - 1, 0, 7); - real_oy = clamp(py - 1, 0, 7); - } else if (is_edge_y) { - real_ox = 8 - px; - real_oy = clamp(py - 1, 0, 7); - } else if (is_edge_x) { - real_ox = clamp(px - 1, 0, 7); - real_oy = 8 - py; - } else { - real_ox = px - 1; - real_oy = py - 1; - } - let dir = octel_direction(vec2(u32(real_ox), u32(real_oy))); + // V11/#23 — shared edge and double-fold corner wrap. + let real_octel = wsrc_real_octel(vec2(lid.xy)); + let dir = octel_direction(real_octel); // V14 — fire a short ray from the probe centre. Ray length // scales with the cascade extent so each cascade's rays stay @@ -707,96 +765,57 @@ fn cs_main( probe_pos, dir, )); - loop { - if (!rayQueryProceed(&rq)) { break; } + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&rq)) { break; } + } } let hit = rayQueryGetCommittedIntersection(&rq); var radiance = vec3(0.0); if (hit.kind != RAY_QUERY_INTERSECTION_NONE) { let inst = instance_data[hit.instance_custom_data]; - if (inst.card_slot.w > 0.5) { - // Sample Mesh Cards pre-lit radiance at hit. Same - // projection math as SSGI_PROBE_TRACE_HW_WGSL's hit - // branch. - let hit_world = probe_pos + dir * hit.t; - let hit_os = (hit.world_to_object * vec4(hit_world, 1.0)).xyz; - let abs_d = abs(dir); - var axis_idx: u32 = 0u; - if (abs_d.y >= abs_d.x && abs_d.y >= abs_d.z) { - axis_idx = 2u; - } else if (abs_d.z >= abs_d.x) { - axis_idx = 4u; + let hit_world = probe_pos + dir * hit.t; + let hit_os = (hit.world_to_object * vec4(hit_world, 1.0)).xyz; + let front = hw_bake_shade_hit(inst, hit_os, dir); + radiance = front; + + if (BLOOM_TRANSPARENT_GI && inst.mat_params.z > 0.0) { + var opaque_rq: ray_query; + rayQueryInitialize(&opaque_rq, accel, RayDesc( + 0u, + 0x01u, + 0.01, + ray_length, + probe_pos, + dir, + )); + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&opaque_rq)) { break; } + } } - var signed_axis: u32 = axis_idx; - if (axis_idx == 0u && dir.x > 0.0) { signed_axis = 1u; } - else if (axis_idx == 2u && dir.y > 0.0) { signed_axis = 3u; } - else if (axis_idx == 4u && dir.z > 0.0) { signed_axis = 5u; } - - let first_slot = u32(inst.card_slot.x); - let slot = first_slot + signed_axis; - let slot_x = slot % 64u; - let slot_y = slot / 64u; - - let bmin = inst.card_aabb_min.xyz; - let bmax = inst.card_aabb_max.xyz; - var u_os: f32; - var v_os: f32; - var u_lo: f32; var u_hi: f32; - var v_lo: f32; var v_hi: f32; - var u_flip: f32 = 1.0; - if (signed_axis == 0u || signed_axis == 1u) { - u_os = hit_os.y; v_os = hit_os.z; - u_lo = bmin.y; u_hi = bmax.y; v_lo = bmin.z; v_hi = bmax.z; - if (signed_axis == 1u) { u_flip = -1.0; } - } else if (signed_axis == 2u || signed_axis == 3u) { - u_os = hit_os.x; v_os = hit_os.z; - u_lo = bmin.x; u_hi = bmax.x; v_lo = bmin.z; v_hi = bmax.z; - if (signed_axis == 3u) { u_flip = -1.0; } - } else { - u_os = hit_os.x; v_os = hit_os.y; - u_lo = bmin.x; u_hi = bmax.x; v_lo = bmin.y; v_hi = bmax.y; - if (signed_axis == 5u) { u_flip = -1.0; } + let opaque_hit = rayQueryGetCommittedIntersection(&opaque_rq); + var behind = hw_bake_miss(probe_pos, dir); + if (opaque_hit.kind != RAY_QUERY_INTERSECTION_NONE) { + let opaque_inst = instance_data[opaque_hit.instance_custom_data]; + let opaque_world = probe_pos + dir * opaque_hit.t; + let opaque_os = ( + opaque_hit.world_to_object * vec4(opaque_world, 1.0) + ).xyz; + behind = hw_bake_shade_hit(opaque_inst, opaque_os, dir); } - var u_norm = clamp((u_os - u_lo) / max(u_hi - u_lo, 1e-4), 0.0, 1.0); - let v_norm = clamp((v_os - v_lo) / max(v_hi - v_lo, 1e-4), 0.0, 1.0); - if (u_flip < 0.0) { u_norm = 1.0 - u_norm; } - - let slot_size_uv = 1.0 / HW_BAKE_CARD_SLOTS_PER_ROW; - let texel_in_slot = slot_size_uv / f32(64); - let slot_u0 = f32(slot_x) * slot_size_uv + texel_in_slot; - let slot_v0 = f32(slot_y) * slot_size_uv + texel_in_slot; - let slot_span = slot_size_uv - 2.0 * texel_in_slot; - let atlas_uv = vec2( - slot_u0 + u_norm * slot_span, - slot_v0 + v_norm * slot_span, - ); - radiance = textureSampleLevel(card_atlas, card_samp, atlas_uv, 0.0).rgb; - } else { - // Instance without a card — shade analytically using - // its flat normal + albedo. - let hit_n = inst.normal_ws; - let ndotl = max(dot(hit_n, u.sun_dir.xyz), 0.0); - let direct = u.sun_color.xyz * ndotl; - let ndotup = max(dot(hit_n, vec3(0.0, 1.0, 0.0)), 0.0); - let sky = u.sky_color.xyz * ndotup; - radiance = inst.albedo * (direct + sky) - + inst.albedo * inst.emissive_luma; + let surface_weight = clamp(inst.world_aabb_max.w, 0.0, 1.0) + * (1.0 - clamp(inst.mat_params.z, 0.0, 1.0)); + radiance = front * surface_weight + + behind * hw_bake_transmittance(inst); } } else { // Miss — V13 analytic fallback (shadow-sampled sun + // hemisphere sky). The ray went past `extent * 0.5` without // hitting anything, so this is the open-sky direction at // probe scale. - var shadow: f32 = 1.0; - if (u.flags.y > 0.5) { - shadow = hw_bake_sample_cascade(2, probe_pos, u.flags.x); - } - let ndotl = max(dot(dir, u.sun_dir.xyz), 0.0); - let sun = u.sun_color.xyz * ndotl * shadow; - let up = clamp(dir.y * 0.5 + 0.5, 0.0, 1.0); - let sky = u.sky_color.xyz * up * up; - radiance = sun + sky; + radiance = hw_bake_miss(probe_pos, dir); } let cascade_idx = u32(u.flags.z); diff --git a/native/shared/src/renderer/shaders/mod.rs b/native/shared/src/renderer/shaders/mod.rs index 9a096be7..652e633c 100644 --- a/native/shared/src/renderer/shaders/mod.rs +++ b/native/shared/src/renderer/shaders/mod.rs @@ -1,19 +1,107 @@ - - // Shader sources are grouped by pass cluster (2000-line file policy); // everything re-exports from here so call sites keep `shaders::NAME`. mod core; -pub(super) use core::{SHADER_2D, SHADER_3D, SCENE_SHADER}; +pub(super) use core::{scene_refractive_shader_source, SCENE_SHADER, SHADER_2D, SHADER_3D}; +#[cfg(test)] +mod core_tests; +mod weighted; +pub(super) use weighted::{ + scene_weighted_transparency_shader_source, WEIGHTED_TRANSPARENCY_RESOLVE_SHADER, +}; mod env; -pub(super) use env::{PREFILTER_SHADER_WGSL, SKY_SHADER_WGSL, AERIAL_PERSPECTIVE_SHADER_WGSL, SKY_VIEW_LUT_SHADER_WGSL, EQUIRECT_FROM_SKY_VIEW_SHADER_WGSL, PROCEDURAL_SKY_SHADER_WGSL, REFLECT_SCENE_WGSL}; +pub(super) use env::{ + AERIAL_PERSPECTIVE_SHADER_WGSL, EQUIRECT_FROM_SKY_VIEW_SHADER_WGSL, PREFILTER_SHADER_WGSL, + PROCEDURAL_SKY_SHADER_WGSL, REFLECT_SCENE_WGSL, SKY_SHADER_WGSL, SKY_VIEW_LUT_SHADER_WGSL, +}; mod ao; -pub(super) use ao::{HIZ_LINEARIZE_SHADER_WGSL, HIZ_DOWNSAMPLE_SHADER_WGSL, SSAO_SHADER_WGSL, SSAO_BLUR_SHADER_WGSL}; +pub(super) use ao::{ + HIZ_DOWNSAMPLE_SHADER_WGSL, HIZ_LINEARIZE_SHADER_WGSL, SSAO_BLUR_SHADER_WGSL, SSAO_SHADER_WGSL, +}; mod gi; -pub(super) use gi::{CARD_CAPTURE_WGSL, SDF_BAKE_WGSL, SDF_CLIPMAP_BAKE_WGSL, CARD_LIGHT_WGSL, WSRC_BAKE_WGSL, WSRC_BAKE_HW_WGSL}; +pub(super) use gi::{ + CARD_CAPTURE_WGSL, CARD_LIGHT_WGSL, SDF_BAKE_WGSL, SDF_CLIPMAP_BAKE_WGSL, WSRC_BAKE_HW_WGSL, + WSRC_BAKE_WGSL, +}; mod ssgi; -pub(super) use ssgi::{PROBE_HELPERS_WGSL, SSGI_PROBE_PLACE_WGSL, SSGI_PROBE_TRACE_SW_WGSL, SSGI_PROBE_TRACE_HW_WGSL, SSGI_PROBE_TRACE_SDF_WGSL, SSGI_PROBE_TEMPORAL_WGSL, SSGI_PROBE_RESOLVE_WGSL, SSR_TEMPORAL_SHADER_WGSL, SSR_SHADER_WGSL}; +pub(super) use ssgi::{ + PROBE_HELPERS_WGSL, SSGI_PROBE_PLACE_WGSL, SSGI_PROBE_RESOLVE_WGSL, SSGI_PROBE_TEMPORAL_WGSL, + SSGI_PROBE_TRACE_HW_WGSL, SSGI_PROBE_TRACE_SDF_WGSL, SSGI_PROBE_TRACE_SW_WGSL, SSR_SHADER_WGSL, + SSR_TEMPORAL_SHADER_WGSL, +}; mod pt; -pub(super) use pt::{PT_KERNEL_WGSL, PT_ATROUS_WGSL, PT_SKIN_WGSL}; +#[cfg(test)] +pub(super) use pt::PT_KERNEL_WGSL; +pub(super) use pt::{pt_fault_constants, pt_kernel_variant, PT_ATROUS_WGSL, PT_SKIN_WGSL}; + +/// Naga's Metal lowering completes a query in `rayQueryInitialize`; its +/// non-modern `rayQueryProceed` only reads a `ready` flag that never clears. +/// Metal must skip that loop, while DX12/Vulkan retain canonical WGSL flow. +pub(super) fn ray_query_backend_variant(device: &wgpu::Device) -> &'static str { + ray_query_backend_variant_for(device.adapter_info().backend) +} + +fn ray_query_backend_variant_for(backend: wgpu::Backend) -> &'static str { + if backend == wgpu::Backend::Metal { + "const BLOOM_RAY_QUERY_NEEDS_PROCEED: bool = false;\n" + } else { + "const BLOOM_RAY_QUERY_NEEDS_PROCEED: bool = true;\n" + } +} + +#[cfg(test)] +mod ray_query_variant_tests { + use super::*; + + #[test] + fn metal_skips_proceed_and_explicit_query_backends_keep_it() { + assert!(ray_query_backend_variant_for(wgpu::Backend::Metal).contains("false")); + assert!(ray_query_backend_variant_for(wgpu::Backend::Dx12).contains("true")); + assert!(ray_query_backend_variant_for(wgpu::Backend::Vulkan).contains("true")); + } + + #[test] + fn every_hardware_query_loop_has_the_backend_guard() { + let production_pt = pt_kernel_variant(false); + for source in [ + production_pt.as_ref(), + SSGI_PROBE_TRACE_HW_WGSL, + WSRC_BAKE_HW_WGSL, + ] { + assert_eq!( + source.matches("rayQueryProceed").count(), + source.matches("if (BLOOM_RAY_QUERY_NEEDS_PROCEED)").count(), + ); + } + } + + #[test] + fn wsrc_corner_wrap_is_shared_by_software_and_hardware_bakes() { + assert!(PROBE_HELPERS_WGSL.contains("select(oct_size - 1, 0, padded.x == padded_max)")); + assert!(PROBE_HELPERS_WGSL.contains("select(oct_size - 1, 0, padded.y == padded_max)")); + for source in [WSRC_BAKE_WGSL, WSRC_BAKE_HW_WGSL] { + assert_eq!( + source.matches("wsrc_real_octel(vec2(lid.xy))").count(), + 1 + ); + assert!(!source.contains("Corner — nearest-inside")); + } + + let software = format!("{PROBE_HELPERS_WGSL}{WSRC_BAKE_WGSL}"); + wgpu::naga::front::wgsl::parse_str(&software) + .unwrap_or_else(|error| panic!("software WSRC WGSL failed: {error:?}")); + let hardware = format!( + "enable wgpu_ray_query;\n\ + const BLOOM_RAY_QUERY_NEEDS_PROCEED: bool = false;\n\ + {PROBE_HELPERS_WGSL}{WSRC_BAKE_HW_WGSL}" + ); + wgpu::naga::front::wgsl::parse_str(&hardware) + .unwrap_or_else(|error| panic!("hardware WSRC WGSL failed: {error:?}")); + } +} mod post; -pub(super) use post::{BLOOM_SHADER_WGSL, DOF_SHADER_WGSL, MOTION_BLUR_SHADER_WGSL, SSS_SHADER_WGSL, SCENE_COMPOSE_SHADER_WGSL, TAA_SHADER_WGSL, EXPOSURE_SHADER_WGSL, COMPOSITE_SHADER_WGSL, UPSCALE_SHADER_WGSL, RCAS_SHADER_WGSL}; +pub(super) use post::{ + BLOOM_SHADER_WGSL, COMPOSITE_SHADER_WGSL, DOF_SHADER_WGSL, EXPOSURE_SHADER_WGSL, + MOTION_BLUR_SHADER_WGSL, RCAS_SHADER_WGSL, SCENE_COMPOSE_SHADER_WGSL, SSS_SHADER_WGSL, + TAA_SHADER_WGSL, UPSCALE_SHADER_WGSL, +}; diff --git a/native/shared/src/renderer/shaders/post.rs b/native/shared/src/renderer/shaders/post.rs index 6911bc9b..729c1a73 100644 --- a/native/shared/src/renderer/shaders/post.rs +++ b/native/shared/src/renderer/shaders/post.rs @@ -945,7 +945,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { pub(in crate::renderer) const EXPOSURE_SHADER_WGSL: &str = " struct ExposureParams { /// x = target key value (0.18 = photography 18%-gray). - /// y = smoothing rate (0 = no adapt, 1 = instant). + /// y = smoothing rate (0 = no adapt, 1 = instant, negative = seed). /// z = min exposure clamp (prevents pitch-black scenes from /// exploding to max brightness). /// w = max exposure clamp (prevents sun scenes from crushing @@ -1029,7 +1029,8 @@ fn fs_main() -> @location(0) vec4 { let median_luma = exp2(median_log); let key = u.params.x; - let rate = u.params.y; + let force_refresh = u.params.y < 0.0; + let rate = abs(u.params.y); let min_e = u.params.z; let max_e = u.params.w; @@ -1042,7 +1043,9 @@ fn fs_main() -> @location(0) vec4 { // byte-stable exposure while real lighting changes re-anchor at // once and adapt at the normal rate. var anchor = prev.g; - if (anchor < min_e * 0.5 || abs(raw_target - anchor) > anchor * 0.02) { + if (force_refresh) { + anchor = raw_target; + } else if (anchor < min_e * 0.5 || abs(raw_target - anchor) > anchor * 0.02) { anchor = raw_target; } // Proportional adaptation speed (flicker fix): the measurement is @@ -1056,7 +1059,7 @@ fn fs_main() -> @location(0) vec4 { let rate_scale = smoothstep(0.0, 0.25, gap); // First frame: prev is 0; snap to target instead of crawling up. var smoothed = mix(prev.r, anchor, rate * rate_scale); - if (prev.r < min_e * 0.5) { + if (force_refresh || prev.r < min_e * 0.5) { smoothed = anchor; } return vec4(smoothed, anchor, 0.0, 1.0); @@ -1555,4 +1558,3 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { return vec4(max(sharpened, vec3(0.0)), center.a); } "; - diff --git a/native/shared/src/renderer/shaders/pt.rs b/native/shared/src/renderer/shaders/pt.rs index 6483b063..82202ca9 100644 --- a/native/shared/src/renderer/shaders/pt.rs +++ b/native/shared/src/renderer/shaders/pt.rs @@ -8,11 +8,10 @@ //! own lighting at every vertex of the path — sampling pre-lit cards would //! bake Lumen's direct light into ours twice). //! -//! Radiometric convention: light intensities are treated as π-premultiplied, -//! i.e. diffuse contribution is `albedo * L * NdotL` with no 1/π — matching -//! the raster shader (core.rs point-light loop has no 1/π either), so -//! toggling PT on/off does not jump scene brightness. bloom-reference -//! comparisons account for this in scene config (see the PT-1 ticket). +//! Radiometric convention: analytic lights carry linear radiance, matching +//! the raster shader. BRDF evaluation therefore retains its physical 1/π; +//! cosine-sampled bounce throughput cancels that normalization against the +//! sampling PDF in the usual way. //! //! Sky pixels are never written: the raster sky/cloud passes already drew //! them, and PT replacing a procedural cloud deck with an analytic gradient @@ -38,6 +37,8 @@ //! (pt_trace_dump.txt): 16 = t/instance/prim/kind, 17 = p0 + //! raw depth. These found the transposed inv_vp: when every //! probe looks "constant", dump numbers before theorizing. +//! 20 = temporal history length 21 = temporal variance +//! 24 = raw, unaccumulated radiance 25 = raster motion vectors pub(in crate::renderer) const PT_KERNEL_WGSL: &str = r#" struct PtLight { @@ -103,14 +104,14 @@ struct InstanceGiData { // moments = (mu1, mu2, history length, raw depth). Progressive mode // keeps its original (radiance sum, sample count) layout in accum and // leaves the moments buffers untouched. -@group(0) @binding(8) var accum: array>; +@group(0) @binding(8) var accum: array>; @group(0) @binding(9) var out_hdr: texture_storage_2d; @group(0) @binding(13) var accum_out: array>; -@group(0) @binding(18) var moments: array>; +@group(0) @binding(18) var moments: array>; @group(0) @binding(19) var moments_out: array>; // PT-4 (EXPERIMENTAL, ext.w == 1) — ReSTIR DI reservoirs, ping-pong // with the accum pair: (light index, W, M, target pdf) per trace texel. -@group(0) @binding(20) var resv: array>; +@group(0) @binding(20) var resv: array>; @group(0) @binding(21) var resv_out: array>; // PT-7 — the raster velocity MRT (uv-space delta, current − previous, // no Y flip at write; see core.rs). Non-zero where a surface MOVED — @@ -156,6 +157,10 @@ fn compute_reproj(p0: vec3, px_full: vec2, depth_cur: f32) { if (uv_prev.x < 0.0 || uv_prev.x >= 1.0 || uv_prev.y < 0.0 || uv_prev.y >= 1.0) { return; } + // Negative-control hook for the hardware oracle. The production variant + // injects zero; scheduled validation can deliberately shift history and + // prove that the motion golden rejects the known reprojection fault. + uv_prev.x += PT_TEST_REPROJECTION_OFFSET; let pos = uv_prev * vec2(f32(u.size.x), f32(u.size.y)) - 0.5; rp_base = vec2(floor(pos)); rp_fr = pos - floor(pos); @@ -416,8 +421,8 @@ fn smith_g1(n_dot_x: f32, alpha: f32) -> f32 { fn v_smith(n_dot_v: f32, n_dot_l: f32, alpha: f32) -> f32 { let a2 = alpha * alpha; - let ggx_v = n_dot_l * sqrt((n_dot_v * (1.0 - a2) + a2) * n_dot_v); - let ggx_l = n_dot_v * sqrt((n_dot_l * (1.0 - a2) + a2) * n_dot_l); + let ggx_v = n_dot_l * sqrt(n_dot_v * n_dot_v * (1.0 - a2) + a2); + let ggx_l = n_dot_v * sqrt(n_dot_l * n_dot_l * (1.0 - a2) + a2); return 0.5 / (ggx_v + ggx_l + 1e-6); } @@ -425,7 +430,10 @@ fn burley_diffuse(n_dot_l: f32, n_dot_v: f32, l_dot_h: f32, roughness: f32) -> f let fd90 = 0.5 + 2.0 * l_dot_h * l_dot_h * roughness; let ml = pow(1.0 - n_dot_l, 5.0); let mv = pow(1.0 - n_dot_v, 5.0); - return (1.0 + (fd90 - 1.0) * ml) * (1.0 + (fd90 - 1.0) * mv) / 3.14159265; + let energy_factor = mix(1.0, 1.0 / 1.51, roughness); + return (1.0 + (fd90 - 1.0) * ml) + * (1.0 + (fd90 - 1.0) * mv) + * energy_factor / 3.14159265; } // Heitz 2018 VNDF sampler — visible-normal distribution, tangent frame. @@ -450,8 +458,7 @@ fn sample_ggx_vndf(v_t: vec3, alpha: f32, r2: vec2) -> vec3 { struct BrdfSample { dir: vec3, // BRDF * cos / pdf, physical convention. For the pure-diffuse case - // this reduces to plain albedo, so the game's pi-premultiplied - // light intensities are unaffected. + // this reduces to plain albedo. weight: vec3, valid: bool, }; @@ -523,7 +530,10 @@ fn sample_brdf( if (dot(h_un, h_un) > 1e-8) { l_dot_h = max(dot(l_t, normalize(h_un)), 0.0); } - let diffuse_albedo = base_color * (1.0 - metallic) * (vec3(1.0) - f0); + let view_transmission = vec3(1.0) - fresnel_schlick3(n_dot_v, f0); + let light_transmission = vec3(1.0) - fresnel_schlick3(n_dot_l, f0); + let diffuse_albedo = + base_color * (1.0 - metallic) * view_transmission * light_transmission; let fd = burley_diffuse(n_dot_l, n_dot_v, l_dot_h, roughness); out.dir = m * l_t; out.weight = diffuse_albedo * fd * 3.14159265 / (1.0 - p_spec); @@ -539,8 +549,10 @@ fn sample_brdf( fn occluded(origin: vec3, dir: vec3, max_t: f32) -> bool { var rq: ray_query; rayQueryInitialize(&rq, accel, RayDesc(0u, 0xFFu, 0.001, max_t, origin, dir)); - loop { - if (!rayQueryProceed(&rq)) { break; } + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&rq)) { break; } + } } let hit = rayQueryGetCommittedIntersection(&rq); return hit.kind != RAY_QUERY_INTERSECTION_NONE; @@ -610,8 +622,8 @@ fn albedo_at_hit( // ---- Next-event estimation --------------------------------------------------------- // Direct light at a surface point: sun through the solar cone + one point -// light chosen uniformly (contribution / pdf). Game-radiometry convention: -// no 1/pi (see file header). +// light chosen uniformly (contribution / pdf). The BRDF retains 1/pi here; +// unlike cosine-sampled bounce transport, NEE has no cosine PDF to cancel it. // Sun visibility at a surface point: shadow cascades in hybrid mode // (deterministic, matches the raster shadows exactly), a traced cone // ray otherwise (reference quality, soft penumbra). @@ -649,17 +661,36 @@ fn nee_spec(n: vec3, view: vec3, ldir: vec3, ndl: f32, return fresnel_schlick3(vdh, f0s) * dterm * v_smith(ndv, ndl, alpha0) * ndl; } -fn direct_light(p: vec3, n: vec3, alb: vec3, sun_r2: vec2, +fn nee_diffuse(n: vec3, view: vec3, ldir: vec3, ndl: f32, + full_alb: vec3, rough: f32, metal: f32) -> vec3 { + let half_raw = view + ldir; + if (dot(half_raw, half_raw) <= 1e-8) { + return vec3(0.0); + } + let half = normalize(half_raw); + let ndv = max(dot(n, view), 1e-4); + let ldh = max(dot(ldir, half), 0.0); + let f0 = mix(vec3(0.04), full_alb, metal); + let view_transmission = vec3(1.0) - fresnel_schlick3(ndv, f0); + let light_transmission = vec3(1.0) - fresnel_schlick3(ndl, f0); + let diffuse_albedo = + full_alb * (1.0 - metal) * view_transmission * light_transmission; + return diffuse_albedo * burley_diffuse(ndl, ndv, ldh, rough) * ndl; +} + +fn direct_light(p: vec3, n: vec3, sun_r2: vec2, view: vec3, full_alb: vec3, rough: f32, metal: f32, with_points: bool) -> vec3 { - var lit = vec3(0.0); + var diffuse = vec3(0.0); var spec = vec3(0.0); let ndl = max(dot(n, u.sun_dir.xyz), 0.0); if (ndl > 0.0) { let vis = sun_visibility(p, n, sun_r2); - lit += u.sun_color.rgb * ndl * vis; if (vis > 0.0) { + diffuse += nee_diffuse( + n, view, u.sun_dir.xyz, ndl, full_alb, rough, metal, + ) * u.sun_color.rgb * vis; spec += nee_spec(n, view, u.sun_dir.xyz, ndl, full_alb, rough, metal) * u.sun_color.rgb * vis; } @@ -679,12 +710,14 @@ fn direct_light(p: vec3, n: vec3, alb: vec3, sun_r2: vec2, // Raster-parity falloff: (1 - d/range)^2, core.rs. let att = 1.0 - d / range; let li = l.color_int.rgb * l.color_int.w * att * att * f32(count); - lit += li * ndl2; + diffuse += nee_diffuse( + n, view, dir, ndl2, full_alb, rough, metal, + ) * li; spec += nee_spec(n, view, dir, ndl2, full_alb, rough, metal) * li; } } } - return alb * lit + spec; + return diffuse + spec; } // ---- PT-4 (EXPERIMENTAL) — ReSTIR DI over the analytic point lights ------- @@ -700,8 +733,7 @@ fn direct_light(p: vec3, n: vec3, alb: vec3, sun_r2: vec2, // Unshadowed contribution of light `li` at the shading point. fn restir_contrib(li: u32, p: vec3, n: vec3, view: vec3, - alb_diff: vec3, full_alb: vec3, - rough: f32, metal: f32) -> vec3 { + full_alb: vec3, rough: f32, metal: f32) -> vec3 { let l = u.lights[li]; let to_l = l.pos_range.xyz - p; let d = length(to_l); @@ -712,24 +744,22 @@ fn restir_contrib(li: u32, p: vec3, n: vec3, view: vec3, if (ndl <= 0.0) { return vec3(0.0); } let att = 1.0 - d / range; let li_rgb = l.color_int.rgb * l.color_int.w * att * att; - return alb_diff * li_rgb * ndl + return nee_diffuse(n, view, dir, ndl, full_alb, rough, metal) * li_rgb + nee_spec(n, view, dir, ndl, full_alb, rough, metal) * li_rgb; } // Scalar target density: luminance of the unshadowed contribution. // Correctness never depends on the target — only variance does. fn restir_target(li: u32, p: vec3, n: vec3, view: vec3, - alb_diff: vec3, full_alb: vec3, - rough: f32, metal: f32) -> f32 { - return dot(restir_contrib(li, p, n, view, alb_diff, full_alb, rough, metal), + full_alb: vec3, rough: f32, metal: f32) -> f32 { + return dot(restir_contrib(li, p, n, view, full_alb, rough, metal), vec3(0.2126, 0.7152, 0.0722)); } // Runs at the primary vertex when ext.w == 1; writes this frame's // reservoir and returns the winner's shadow-tested contribution. fn restir_point_light(idx: u32, p: vec3, n: vec3, view: vec3, - alb_diff: vec3, full_alb: vec3, - rough: f32, metal: f32) -> vec3 { + full_alb: vec3, rough: f32, metal: f32) -> vec3 { let count = u32(u.cfg.z); if (count == 0u) { resv_out[idx] = vec4(-1.0, 0.0, 0.0, 0.0); @@ -742,7 +772,7 @@ fn restir_point_light(idx: u32, p: vec3, n: vec3, view: vec3, // RIS: 8 uniform candidates (pdf = 1/count => w = phat * count). for (var c = 0u; c < 8u; c = c + 1u) { let cand = min(u32(rand_f() * f32(count)), count - 1u); - let ph = restir_target(cand, p, n, view, alb_diff, full_alb, rough, metal); + let ph = restir_target(cand, p, n, view, full_alb, rough, metal); let w = ph * f32(count); r_wsum += w; r_m += 1.0; @@ -759,7 +789,7 @@ fn restir_point_light(idx: u32, p: vec3, n: vec3, view: vec3, let pm = min(pr.z, 160.0); if (pm > 0.0 && pr.x >= 0.0 && u32(pr.x) < count) { let py = u32(pr.x); - let ph = restir_target(py, p, n, view, alb_diff, full_alb, rough, metal); + let ph = restir_target(py, p, n, view, full_alb, rough, metal); let w = ph * pr.y * pm; if (w > 0.0) { r_wsum += w; @@ -784,7 +814,7 @@ fn restir_point_light(idx: u32, p: vec3, n: vec3, view: vec3, if (d <= 1e-3) { return vec3(0.0); } let dir = to_l / d; if (occluded(p, dir, d - 0.02)) { return vec3(0.0); } - return restir_contrib(r_y, p, n, view, alb_diff, full_alb, rough, metal) * r_w; + return restir_contrib(r_y, p, n, view, full_alb, rough, metal) * r_w; } // ---- Main ----------------------------------------------------------------------------- @@ -843,6 +873,17 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { return; } + if (debug == 25.0) { + let vel = textureLoad(velocity_tex, px_full, 0).rg; + let motion = vec3( + clamp(0.5 + vel.x * 16.0, 0.0, 1.0), + clamp(0.5 + vel.y * 16.0, 0.0, 1.0), + clamp(length(vel) * 64.0, 0.0, 1.0), + ); + textureStore(out_hdr, px_full, vec4(motion, 1.0)); + return; + } + let p0 = world_at(px_full, depth); let n0 = normal_from_depth(px_full, p0); let albedo0 = textureLoad(albedo_tex, px_full, 0).rgb; @@ -861,6 +902,12 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { textureStore(out_hdr, px_full, vec4(vec3(vis), 1.0)); return; } + // The DX12 bring-up probes below declare ten additional ray-query + // objects. Metal reserves their full intersection/transform state per + // thread even when the runtime debug selector is zero, which can spill or + // exhaust local state for an otherwise healthy production path. Compile + // the block out unless a query-heavy diagnostic was explicitly requested. + // PT_QUERY_DIAGNOSTICS_BEGIN if (debug == 8.0) { // Binary probe: white = traced hit has a geometry window, // black = geo.z reads 0, red = TLAS miss. HDR-large values so @@ -868,8 +915,10 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { let dir0 = normalize(p0 - u.cam_pos.xyz); var rq8: ray_query; rayQueryInitialize(&rq8, accel, RayDesc(0u, 0xFFu, 0.001, 1000.0, u.cam_pos.xyz, dir0)); - loop { - if (!rayQueryProceed(&rq8)) { break; } + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&rq8)) { break; } + } } let h8 = rayQueryGetCommittedIntersection(&rq8); var c8 = vec3(100.0, 0.0, 0.0); @@ -888,8 +937,10 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { let dir0 = normalize(p0 - u.cam_pos.xyz); var rq9: ray_query; rayQueryInitialize(&rq9, accel, RayDesc(0u, 0xFFu, 0.001, 1000.0, u.cam_pos.xyz, dir0)); - loop { - if (!rayQueryProceed(&rq9)) { break; } + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&rq9)) { break; } + } } let h9 = rayQueryGetCommittedIntersection(&rq9); var c9 = vec3(5.0, 5.0, 5.0); @@ -922,8 +973,10 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { let dir0 = normalize(p0 - u.cam_pos.xyz); var rq10: ray_query; rayQueryInitialize(&rq10, accel, RayDesc(0u, 0xFFu, 0.001, 1000.0, u.cam_pos.xyz, dir0)); - loop { - if (!rayQueryProceed(&rq10)) { break; } + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&rq10)) { break; } + } } let h10 = rayQueryGetCommittedIntersection(&rq10); var c10 = vec3(0.0); @@ -941,8 +994,10 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { let dir0 = normalize(p0 - u.cam_pos.xyz); var rq11: ray_query; rayQueryInitialize(&rq11, accel, RayDesc(0u, 0xFFu, 0.001, 1000.0, u.cam_pos.xyz, dir0)); - loop { - if (!rayQueryProceed(&rq11)) { break; } + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&rq11)) { break; } + } } let h11 = rayQueryGetCommittedIntersection(&rq11); var c11 = vec3(0.0); @@ -973,8 +1028,10 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { let dir0 = to_p / max(gdist, 1e-4); var rq13: ray_query; rayQueryInitialize(&rq13, accel, RayDesc(0u, 0xFFu, 0.001, 1000.0, u.cam_pos.xyz, dir0)); - loop { - if (!rayQueryProceed(&rq13)) { break; } + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&rq13)) { break; } + } } let h13 = rayQueryGetCommittedIntersection(&rq13); var c13 = vec3(0.0, 0.0, 50.0); @@ -995,8 +1052,10 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { let dir0 = normalize(p0 - u.cam_pos.xyz); var rq14: ray_query; rayQueryInitialize(&rq14, accel, RayDesc(0u, 0xFFu, 0.001, 1000.0, u.cam_pos.xyz, dir0)); - loop { - if (!rayQueryProceed(&rq14)) { break; } + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&rq14)) { break; } + } } let h14 = rayQueryGetCommittedIntersection(&rq14); var c14 = vec3(0.0, 0.0, 30.0); @@ -1018,13 +1077,17 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { let dirA = normalize(p0 - u.cam_pos.xyz); var rqA: ray_query; rayQueryInitialize(&rqA, accel, RayDesc(0u, 0xFFu, 0.001, 1000.0, u.cam_pos.xyz, dirA)); - loop { - if (!rayQueryProceed(&rqA)) { break; } + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&rqA)) { break; } + } } var rqB: ray_query; rayQueryInitialize(&rqB, accel, RayDesc(0u, 0xFFu, 0.001, 1000.0, u.cam_pos.xyz, vec3(0.0, -1.0, 0.0))); - loop { - if (!rayQueryProceed(&rqB)) { break; } + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&rqB)) { break; } + } } let hA = rayQueryGetCommittedIntersection(&rqA); let hB = rayQueryGetCommittedIntersection(&rqB); @@ -1044,8 +1107,10 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { let dir0 = normalize(p0 - u.cam_pos.xyz); var rq16: ray_query; rayQueryInitialize(&rq16, accel, RayDesc(0u, 0xFFu, 0.001, 1000.0, u.cam_pos.xyz, dir0)); - loop { - if (!rayQueryProceed(&rq16)) { break; } + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&rq16)) { break; } + } } let h16 = rayQueryGetCommittedIntersection(&rq16); let idx16 = gid.y * u.size.x + gid.x; @@ -1104,8 +1169,10 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { let dir0 = normalize(p0 - u.cam_pos.xyz); var rq0: ray_query; rayQueryInitialize(&rq0, accel, RayDesc(0u, 0xFFu, 0.001, 1000.0, u.cam_pos.xyz, dir0)); - loop { - if (!rayQueryProceed(&rq0)) { break; } + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&rq0)) { break; } + } } let h = rayQueryGetCommittedIntersection(&rq0); var col = vec3(1.0, 0.0, 1.0); // magenta: TLAS miss @@ -1127,14 +1194,13 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { textureStore(out_hdr, px_full, vec4(col, 1.0)); return; } + // PT_QUERY_DIAGNOSTICS_END // ---- one path sample -------------------------------------------------- // Primary surface material from the G-buffer (R = metallic, - // G = roughness). NEE stays diffuse-only, so scale it by - // (1 - metallic) — metals have no diffuse lobe. Specular NEE is a - // known gap (see the PT-2 ticket); specular reflection of sky and - // scene comes from the GGX bounce below. + // G = roughness). Direct NEE and bounce transport share the same + // energy-conserving base diffuse and GGX specular contract. let mr0 = textureLoad(material_tex, px_full, 0).rg; var metal_cur = mr0.r; var rough_cur = mr0.g; @@ -1162,13 +1228,13 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { // Sun + point lights, diffuse AND specular (nee_spec inside) — the // GGX highlight rides the same visibility as the diffuse term. var radiance = direct_light( - p0 + n0 * 0.02, n0, albedo0 * (1.0 - metal_cur), sun_r2, - view_cur, albedo0, rough_cur, metal_cur, !use_restir, + p0 + n0 * 0.02, n0, sun_r2, view_cur, + albedo0, rough_cur, metal_cur, !use_restir, ); if (use_restir) { radiance += restir_point_light( gid.y * u.size.x + gid.x, p0 + n0 * 0.02, n0, view_cur, - albedo0 * (1.0 - metal_cur), albedo0, rough_cur, metal_cur, + albedo0, rough_cur, metal_cur, ); } var throughput = vec3(1.0); @@ -1187,8 +1253,10 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { var rq: ray_query; rayQueryInitialize(&rq, accel, RayDesc(0u, 0xFFu, 0.001, 500.0, origin, dir)); - loop { - if (!rayQueryProceed(&rq)) { break; } + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&rq)) { break; } + } } let hit = rayQueryGetCommittedIntersection(&rq); @@ -1234,8 +1302,8 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { // Bounce vertices always use plain NEE (reservoirs are per // PRIMARY texel; reusing them off-surface would be biased). radiance += throughput * direct_light( - hit_p, n_hit, alb_hit * (1.0 - inst.mat_params.y), rand_2f(), - -dir, alb_hit, inst.mat_params.x, inst.mat_params.y, true, + hit_p, n_hit, rand_2f(), -dir, + alb_hit, inst.mat_params.x, inst.mat_params.y, true, ); origin = hit_p; @@ -1258,6 +1326,14 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { radiance = vec3(0.0); } + // Negative-control hook paired with the progressive transport golden. + // Production injects 1.0, so the compiler removes the multiplication. + radiance *= PT_TEST_BRDF_ENERGY_SCALE; + if (debug == 24.0) { + textureStore(out_hdr, px_full, vec4(radiance, 1.0)); + return; + } + // ---- accumulate --------------------------------------------------------- let idx = gid.y * u.size.x + gid.x; @@ -1346,11 +1422,12 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { let m = moments[qidx]; // Sky texels and depth-inconsistent taps carry // another surface's lighting — skip them. - if (m.w >= 0.9999999) { + if (m.w >= 0.9999999 && !PT_TEST_REPROJECTION_BYPASS_VALIDATION) { continue; } let zl_hist = lin_depth(m.w); - if (abs(zl_hist - rp_zl_here) > tol) { + if (abs(zl_hist - rp_zl_here) > tol + && !PT_TEST_REPROJECTION_BYPASS_VALIDATION) { continue; } let wx = mix(1.0 - rp_fr.x, rp_fr.x, f32(tx)); @@ -1431,26 +1508,31 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { var seed_m2 = 0.0; var seed_n = 0.0; var seed_w = 0.0; - // 7x7: under TRANSLATION a whole COLUMN of texels streams in per - // frame (rotation only trickles a few px), so a 5x5 often found - // nothing but fellow newborns and the band stayed raw. - for (var by = -3; by <= 3; by = by + 1) { - for (var bx = -3; bx <= 3; bx = bx + 1) { - if (bx == 0 && by == 0) { continue; } - let q = vec2(i32(gid.x) + bx, i32(gid.y) + by); - if (q.x < 0 || q.y < 0 || q.x >= i32(u.size.x) || q.y >= i32(u.size.y)) { - continue; + // A global reset leaves old bytes allocated, but size.w = 0 + // makes them invalid. Do not let neighbour seeding resurrect + // those moments after a mode toggle, camera cut, or seed change. + if (u.size.w > 0u) { + // 7x7: under TRANSLATION a whole COLUMN of texels streams in + // per frame (rotation only trickles a few px), so a 5x5 often + // found nothing but fellow newborns and the band stayed raw. + for (var by = -3; by <= 3; by = by + 1) { + for (var bx = -3; bx <= 3; bx = bx + 1) { + if (bx == 0 && by == 0) { continue; } + let q = vec2(i32(gid.x) + bx, i32(gid.y) + by); + if (q.x < 0 || q.y < 0 || q.x >= i32(u.size.x) || q.y >= i32(u.size.y)) { + continue; + } + let qidx = u32(q.y) * u.size.x + u32(q.x); + let m = moments[qidx]; + if (m.w >= 0.9999999 || m.z < 4.0) { continue; } + if (abs(lin_depth(m.w) - zl_here) > btol) { continue; } + let wt = m.z; + seed_rgb += accum[qidx].rgb * wt; + seed_m1 += m.x * wt; + seed_m2 += m.y * wt; + seed_n += m.z * wt; + seed_w += wt; } - let qidx = u32(q.y) * u.size.x + u32(q.x); - let m = moments[qidx]; - if (m.w >= 0.9999999 || m.z < 4.0) { continue; } - if (abs(lin_depth(m.w) - zl_here) > btol) { continue; } - let wt = m.z; - seed_rgb += accum[qidx].rgb * wt; - seed_m1 += m.x * wt; - seed_m2 += m.y * wt; - seed_n += m.z * wt; - seed_w += wt; } } if (seed_w > 0.0) { @@ -1470,7 +1552,12 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { // only delays indirect/ambient changes (~10 frames). let n_new = min(n_hist + 1.0, 32.0); let alpha_c = max(1.0 / n_new, 0.1); - let out_irr = mix(hist_rgb, irr, alpha_c); + var out_irr = mix(hist_rgb, irr, alpha_c); + // Negative control: trust misaddressed history so fresh radiance + // cannot repair the ghost. Production compiles this branch away. + if (PT_TEST_REPROJECTION_BYPASS_VALIDATION && rp_valid) { + out_irr = hist_rgb; + } let m1 = mix(hist_m1, l_new, alpha_c); let m2 = mix(hist_m2, l_new * l_new, alpha_c); // Temporal luminance variance — the signal that drives the @@ -1532,6 +1619,48 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { } "#; +pub(in crate::renderer) fn pt_kernel_variant( + query_diagnostics: bool, +) -> std::borrow::Cow<'static, str> { + if query_diagnostics { + return PT_KERNEL_WGSL.into(); + } + const BEGIN: &str = "// PT_QUERY_DIAGNOSTICS_BEGIN"; + const END: &str = "// PT_QUERY_DIAGNOSTICS_END"; + let begin = PT_KERNEL_WGSL + .find(BEGIN) + .expect("PT query diagnostics begin marker"); + let end = PT_KERNEL_WGSL + .find(END) + .map(|offset| offset + END.len()) + .expect("PT query diagnostics end marker"); + format!("{}{}", &PT_KERNEL_WGSL[..begin], &PT_KERNEL_WGSL[end..]).into() +} + +pub(in crate::renderer) fn pt_fault_constants(fault: Option<&str>) -> &'static str { + match fault { + Some("brdf-energy") => { + "const PT_TEST_BRDF_ENERGY_SCALE: f32 = 1.25;\n\ + const PT_TEST_REPROJECTION_OFFSET: f32 = 0.0;\n\ + const PT_TEST_REPROJECTION_BYPASS_VALIDATION: bool = false;\n" + } + Some("reprojection") => { + "const PT_TEST_BRDF_ENERGY_SCALE: f32 = 1.0;\n\ + const PT_TEST_REPROJECTION_OFFSET: f32 = 0.05;\n\ + const PT_TEST_REPROJECTION_BYPASS_VALIDATION: bool = true;\n" + } + _ => { + "const PT_TEST_BRDF_ENERGY_SCALE: f32 = 1.0;\n\ + const PT_TEST_REPROJECTION_OFFSET: f32 = 0.0;\n\ + const PT_TEST_REPROJECTION_BYPASS_VALIDATION: bool = false;\n" + } + } +} + +#[cfg(test)] +#[path = "pt_tests.rs"] +mod pt_kernel_variant_tests; + /// PT-3b — SVGF wavelet filter (Schied et al. 2017) for the realtime /// mode. Four `cs_mid` à-trous iterations (steps 1/2/4/8) run on the /// trace grid over the temporally-accumulated irradiance; `cs_final` diff --git a/native/shared/src/renderer/shaders/pt_tests.rs b/native/shared/src/renderer/shaders/pt_tests.rs new file mode 100644 index 00000000..2d8565bd --- /dev/null +++ b/native/shared/src/renderer/shaders/pt_tests.rs @@ -0,0 +1,68 @@ +use super::{pt_fault_constants, pt_kernel_variant}; + +#[test] +fn production_variant_removes_query_heavy_debug_locals() { + let production = pt_kernel_variant(false); + assert!(!production.contains("var rq8: ray_query")); + assert!(!production.contains("PT_QUERY_DIAGNOSTICS")); + assert!(production.contains("var rq: ray_query")); + assert_eq!(production.matches(": ray_query").count(), 2); + assert!(production.contains("debug == 24.0")); + assert!(production.contains("debug == 25.0")); + assert!(production.contains("@binding(8) var accum")); + assert!(production.contains("@binding(18) var moments")); + assert!(production.contains("@binding(20) var resv")); + + let diagnostics = pt_kernel_variant(true); + assert!(diagnostics.contains("var rq8: ray_query")); + assert_eq!(diagnostics.matches(": ray_query").count(), 12); +} + +#[test] +fn negative_control_constants_change_only_the_targeted_input() { + let production = pt_fault_constants(None); + assert!(production.contains("ENERGY_SCALE: f32 = 1.0")); + assert!(production.contains("REPROJECTION_OFFSET: f32 = 0.0")); + assert!(production.contains("BYPASS_VALIDATION: bool = false")); + + let energy = pt_fault_constants(Some("brdf-energy")); + assert!(energy.contains("ENERGY_SCALE: f32 = 1.25")); + assert!(energy.contains("REPROJECTION_OFFSET: f32 = 0.0")); + assert!(energy.contains("BYPASS_VALIDATION: bool = false")); + + let reprojection = pt_fault_constants(Some("reprojection")); + assert!(reprojection.contains("ENERGY_SCALE: f32 = 1.0")); + assert!(reprojection.contains("REPROJECTION_OFFSET: f32 = 0.05")); + assert!(reprojection.contains("BYPASS_VALIDATION: bool = true")); +} + +#[test] +fn base_transport_uses_bounded_reciprocal_layered_contract() { + let production = pt_kernel_variant(false); + assert!(production.contains("sqrt(n_dot_v * n_dot_v * (1.0 - a2) + a2)")); + assert!(production.contains("sqrt(n_dot_l * n_dot_l * (1.0 - a2) + a2)")); + assert!(!production.contains("sqrt((n_dot_v * (1.0 - a2) + a2) * n_dot_v)")); + assert!(production.contains("let energy_factor = mix(1.0, 1.0 / 1.51, roughness)")); + assert!(production + .contains("base_color * (1.0 - metallic) * view_transmission * light_transmission")); + assert!( + production.contains("full_alb * (1.0 - metal) * view_transmission * light_transmission") + ); + assert!(production.contains("return diffuse + spec;")); + assert!(!production.contains("return alb * lit + spec;")); +} + +#[test] +fn zero_sample_count_cannot_seed_from_retained_moments() { + let production = pt_kernel_variant(false); + let gate = production + .find("if (u.size.w > 0u) {") + .expect("neighbor seeding must have a global-history gate"); + let neighbor_read = production[gate..] + .find("let m = moments[qidx];") + .expect("gate must enclose the disocclusion neighbor read"); + let gate_end = production[gate..] + .find("if (seed_w > 0.0) {") + .expect("seed reduction follows the gated search"); + assert!(neighbor_read < gate_end); +} diff --git a/native/shared/src/renderer/shaders/ssgi.rs b/native/shared/src/renderer/shaders/ssgi.rs index 2e6b6d0c..76ba1ef1 100644 --- a/native/shared/src/renderer/shaders/ssgi.rs +++ b/native/shared/src/renderer/shaders/ssgi.rs @@ -1,7 +1,6 @@ //! Screen-space GI probes and SSR (placement, trace SW/HW/SDF, //! temporal, resolve). Split from renderer/shaders.rs. - // ============================================================================ // Ticket 007a — Lumen-style screen-probe SSGI (software Hi-Z trace) // @@ -27,6 +26,8 @@ struct ProbeHeader { world_pos: vec4, // xyz = world-space normal at the probe surface; w = linear |view-z| normal: vec4, + // xyz = cosine-convolved diffuse radiance, w = reserved. + diffuse: vec4, }; fn oct_wrap(v: vec2) -> vec2 { @@ -57,6 +58,34 @@ fn octel_direction(octel: vec2) -> vec3 { return oct_decode(uv); } +// Map a 10x10 WSRC slab texel to its wrapped 8x8 octahedral texel. The +// double fold at each padded corner crosses both silhouette edges: +// (0,0)->(7,7), (0,9)->(7,0), (9,0)->(0,7), (9,9)->(0,0). +fn wsrc_real_octel(padded: vec2) -> vec2 { + let oct_size = i32(PROBE_OCT_SIZE); + let padded_max = oct_size + 1; + let is_edge_x = padded.x == 0 || padded.x == padded_max; + let is_edge_y = padded.y == 0 || padded.y == padded_max; + var real = padded - vec2(1); + if (is_edge_x && is_edge_y) { + real = vec2( + select(oct_size - 1, 0, padded.x == padded_max), + select(oct_size - 1, 0, padded.y == padded_max), + ); + } else if (is_edge_y) { + real = vec2( + oct_size - padded.x, + clamp(padded.y - 1, 0, oct_size - 1), + ); + } else if (is_edge_x) { + real = vec2( + clamp(padded.x - 1, 0, oct_size - 1), + oct_size - padded.y, + ); + } + return vec2(real); +} + // Ticket 016 V1/V2 — temporal octahedral direction jitter with // per-probe decorrelation (V2). V1 indexed a 2D R2 low-discrepancy // sequence by frame, giving every probe the same per-frame sample @@ -102,6 +131,25 @@ fn view_pos_from_linear(uv: vec2, linear_z: f32, fn ign(p: vec2) -> f32 { return fract(52.9829189 * fract(0.06711056 * p.x + 0.00583715 * p.y)); } + +fn bounded_probe_history(value: vec3) -> vec3 { + // Rgba16Float can retain undefined Inf/NaN bytes across allocation reuse. + // Componentwise comparison rejects both without changing finite radiance. + return select( + vec3(0.0), + value, + abs(value) <= vec3(65504.0), + ); +} + +fn safe_probe_direction(value: vec3, fallback: vec3) -> vec3 { + let clean = bounded_probe_history(value); + let len2 = dot(clean, clean); + if (len2 <= 0.000001) { + return fallback; + } + return clean * inverseSqrt(len2); +} "; /// Probe placement. One workgroup invocation per probe tile writes a @@ -174,10 +222,16 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { let zu = textureSampleLevel(hiz0, hiz_samp, uv_u, 0.0).r; let P_r = view_pos_from_linear(uv_r, zr, p00, p11, p20, p21); let P_u = view_pos_from_linear(uv_u, zu, p00, p11, p20, p21); - let N_vs = normalize(cross(P_r - P, P_u - P)); + let N_vs = safe_probe_direction( + cross(P_r - P, P_u - P), + vec3(0.0, 0.0, 1.0), + ); let P_world = (u.inv_view * vec4(P, 1.0)).xyz; - let N_world = normalize((u.inv_view * vec4(N_vs, 0.0)).xyz); + let N_world = safe_probe_direction( + (u.inv_view * vec4(N_vs, 0.0)).xyz, + vec3(0.0, 1.0, 0.0), + ); probes[probe_idx].world_pos = vec4(P_world, 1.0); probes[probe_idx].normal = vec4(N_world, linear_z); @@ -268,7 +322,9 @@ fn cs_main( // light). Luma is read from the prev-frame temporal-filtered // history texture; `dst_coord` indexes the probe × octel slab // identically between trace output and history. - let prev_slice = textureLoad(prev_history, dst_coord, 0).rgb; + let prev_slice = bounded_probe_history( + textureLoad(prev_history, dst_coord, 0).rgb, + ); let prev_luma = dot(prev_slice, vec3(0.2126, 0.7152, 0.0722)); let jitter_scale = mix(1.0, 0.3, clamp(prev_luma, 0.0, 1.0)); let jitter = octel_jitter(u.params.x, probe_idx) * jitter_scale; @@ -329,20 +385,24 @@ fn cs_main( // lets coarse steps accept wider thickness, which matches the // existing SSGI behaviour closely enough for V1. if (ray_abs_z >= scene_z && scene_z < HIZ_SKY_Z * 0.5) { - // Refine against mip 0 to reject far-off misses. + // Refine against mip 0 to reject coarse-footprint false + // positives. Exponential stepping can cross a real surface by + // much more than the old thickness window, so a confirmed + // front-depth crossing is the hit instead of being dropped. let refined_z = hiz_sample(ray_uv, 0); - let thickness = abs(ray_abs_z - refined_z); - if (thickness < step_size * 2.0 + 0.1) { + if (ray_abs_z >= refined_z && refined_z < HIZ_SKY_Z * 0.5) { let tn = t / max_t; let falloff = 1.0 - tn * tn; - var raw = textureSampleLevel(hdr_tex, hdr_samp, ray_uv, 0.0).rgb * max(falloff, 0.0); + var raw = bounded_probe_history( + textureSampleLevel(hdr_tex, hdr_samp, ray_uv, 0.0).rgb, + ) * max(falloff, 0.0); // Firefly clamp (cap per-sample luma). let luma = dot(raw, vec3(0.2126, 0.7152, 0.0722)); let cap = u.params.w; if (luma > cap) { raw = raw * (cap / luma); } hit_color = raw; + break; } - break; } prev_t = t; @@ -350,7 +410,8 @@ fn cs_main( } let intensity = u.params.y; - textureStore(radiance_out, dst_coord, vec4(hit_color * intensity * ndotd, 1.0)); + let output = bounded_probe_history(hit_color * intensity * ndotd); + textureStore(radiance_out, dst_coord, vec4(output, 1.0)); } "; @@ -413,6 +474,7 @@ struct InstanceGiData { const CARD_SLOTS_PER_ROW: f32 = 64.0; const HW_WSRC_GRID_RES: i32 = 16; +const BLOOM_TRANSPARENT_GI: bool = false; @group(0) @binding(0) var u: TraceParams; @group(0) @binding(1) var probes: array; @@ -488,6 +550,116 @@ fn hw_wsrc_sample(pos_ws: vec3, dir_ws: vec3) -> vec3 { + c01 * (ix * fy) + c11 * (fx * fy); } +fn hw_gi_cap(raw_in: vec3) -> vec3 { + var raw = raw_in; + let luma = dot(raw, vec3(0.2126, 0.7152, 0.0722)); + let cap = u.params.w; + if (luma > cap) { raw = raw * (cap / luma); } + return raw; +} + +fn hw_gi_miss(origin_ws: vec3, dir_ws: vec3, max_t: f32) -> vec3 { + return hw_gi_cap(hw_wsrc_sample(origin_ws + dir_ws * max_t, dir_ws)); +} + +fn hw_gi_card_uv( + inst: InstanceGiData, + hit_os: vec3, + dir_ws: vec3, +) -> vec2 { + let abs_d = abs(dir_ws); + var axis_idx: u32 = 0u; + if (abs_d.y >= abs_d.x && abs_d.y >= abs_d.z) { + axis_idx = 2u; + } else if (abs_d.z >= abs_d.x) { + axis_idx = 4u; + } + var signed_axis: u32 = axis_idx; + if (axis_idx == 0u && dir_ws.x > 0.0) { signed_axis = 1u; } + else if (axis_idx == 2u && dir_ws.y > 0.0) { signed_axis = 3u; } + else if (axis_idx == 4u && dir_ws.z > 0.0) { signed_axis = 5u; } + + let slot = u32(inst.card_slot.x) + signed_axis; + let slot_x = slot % 64u; + let slot_y = slot / 64u; + let bmin = inst.card_aabb_min.xyz; + let bmax = inst.card_aabb_max.xyz; + var u_os: f32; + var v_os: f32; + var u_lo: f32; + var u_hi: f32; + var v_lo: f32; + var v_hi: f32; + var u_flip: f32 = 1.0; + if (signed_axis == 0u || signed_axis == 1u) { + u_os = hit_os.y; v_os = hit_os.z; + u_lo = bmin.y; u_hi = bmax.y; v_lo = bmin.z; v_hi = bmax.z; + if (signed_axis == 1u) { u_flip = -1.0; } + } else if (signed_axis == 2u || signed_axis == 3u) { + u_os = hit_os.x; v_os = hit_os.z; + u_lo = bmin.x; u_hi = bmax.x; v_lo = bmin.z; v_hi = bmax.z; + if (signed_axis == 3u) { u_flip = -1.0; } + } else { + u_os = hit_os.x; v_os = hit_os.y; + u_lo = bmin.x; u_hi = bmax.x; v_lo = bmin.y; v_hi = bmax.y; + if (signed_axis == 5u) { u_flip = -1.0; } + } + var u_norm = clamp((u_os - u_lo) / max(u_hi - u_lo, 1e-4), 0.0, 1.0); + let v_norm = clamp((v_os - v_lo) / max(v_hi - v_lo, 1e-4), 0.0, 1.0); + if (u_flip < 0.0) { u_norm = 1.0 - u_norm; } + + let slot_size_uv = 1.0 / CARD_SLOTS_PER_ROW; + let texel_in_slot = slot_size_uv / f32(64); + let slot_u0 = f32(slot_x) * slot_size_uv + texel_in_slot; + let slot_v0 = f32(slot_y) * slot_size_uv + texel_in_slot; + let slot_span = slot_size_uv - 2.0 * texel_in_slot; + return vec2( + slot_u0 + u_norm * slot_span, + slot_v0 + v_norm * slot_span, + ); +} + +fn hw_gi_shade_hit( + inst: InstanceGiData, + hit_world: vec3, + hit_os: vec3, + dir_ws: vec3, + hit_t: f32, + max_t: f32, +) -> vec3 { + let tn = hit_t / max_t; + let falloff = max(1.0 - tn * tn, 0.0); + if (inst.card_slot.w > 0.5) { + let atlas_uv = hw_gi_card_uv(inst, hit_os, dir_ws); + let pre_lit = textureSampleLevel(card_atlas, card_samp, atlas_uv, 0.0).rgb; + return hw_gi_cap(pre_lit * falloff); + } + + let hit_n = inst.normal_ws; + let ndotl = max(dot(hit_n, u.sun_dir.xyz), 0.0); + let direct = u.sun_color.xyz * ndotl; + let ndotup = max(dot(hit_n, vec3(0.0, 1.0, 0.0)), 0.0); + let sky = u.sky_color.xyz * ndotup; + return hw_gi_cap( + inst.albedo * (direct + sky) * falloff + + inst.albedo * inst.emissive_luma + ); +} + +fn hw_gi_transmittance(inst: InstanceGiData) -> vec3 { + let absorption = vec3( + inst.card_aabb_min.w, + inst.card_aabb_max.w, + inst.world_aabb_min.w, + ); + let physical = clamp( + inst.albedo * absorption * inst.mat_params.z * inst.mat_params.w, + vec3(0.0), + vec3(1.0), + ); + return mix(vec3(1.0), physical, clamp(inst.world_aabb_max.w, 0.0, 1.0)); +} + @compute @workgroup_size(8, 8, 1) fn cs_main( @builtin(workgroup_id) wg: vec3, @@ -518,7 +690,9 @@ fn cs_main( // light). Luma is read from the prev-frame temporal-filtered // history texture; `dst_coord` indexes the probe × octel slab // identically between trace output and history. - let prev_slice = textureLoad(prev_history, dst_coord, 0).rgb; + let prev_slice = bounded_probe_history( + textureLoad(prev_history, dst_coord, 0).rgb, + ); let prev_luma = dot(prev_slice, vec3(0.2126, 0.7152, 0.0722)); let jitter_scale = mix(1.0, 0.3, clamp(prev_luma, 0.0, 1.0)); let jitter = octel_jitter(u.params.x, probe_idx) * jitter_scale; @@ -544,113 +718,60 @@ fn cs_main( origin_ws, dir_ws, )); - loop { - if (!rayQueryProceed(&rq)) { break; } + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&rq)) { break; } + } } let hit = rayQueryGetCommittedIntersection(&rq); var radiance = vec3(0.0); if (hit.kind != RAY_QUERY_INTERSECTION_NONE) { let inst = instance_data[hit.instance_custom_data]; - - // Ticket 013 V2 — pick the axis facing the incoming ray and - // sample the pre-lit radiance atlas. Each mesh has 6 - // consecutive slots laid out by signed axis (see host-side - // capture loop). The card's world-space normal was baked at - // capture into `card_slot_meta`; lighting was applied once - // per frame by `card_light_pass`, so the sample IS the - // bounce contribution — no hit-time shading math. - if (inst.card_slot.w > 0.5) { - let hit_world = origin_ws + dir_ws * hit.t; - let hit_os = (hit.world_to_object * vec4(hit_world, 1.0)).xyz; - // Dominant component of the world-space ray direction - // picks the major axis; its sign selects the front/back - // face of that axis. - let abs_d = abs(dir_ws); - var axis_idx: u32 = 0u; - if (abs_d.y >= abs_d.x && abs_d.y >= abs_d.z) { - axis_idx = 2u; - } else if (abs_d.z >= abs_d.x) { - axis_idx = 4u; + let hit_world = origin_ws + dir_ws * hit.t; + let hit_os = (hit.world_to_object * vec4(hit_world, 1.0)).xyz; + let front = hw_gi_shade_hit(inst, hit_world, hit_os, dir_ws, hit.t, max_t); + radiance = front; + + if (BLOOM_TRANSPARENT_GI && inst.mat_params.z > 0.0) { + // Transmission instances use TLAS mask bit 1 only. A second + // query against bit 0 therefore skips the entire glass volume, + // including its back face, and returns the nearest opaque + // receiver. This is one bounded continuation, never a layer loop. + var opaque_rq: ray_query; + rayQueryInitialize(&opaque_rq, accel, RayDesc( + 0u, + 0x01u, + 0.001, + max_t, + origin_ws, + dir_ws, + )); + if (BLOOM_RAY_QUERY_NEEDS_PROCEED) { + loop { + if (!rayQueryProceed(&opaque_rq)) { break; } + } } - // Sign: ray going -X (dir.x < 0) lands on +X face → axis + 0. - // ray going +X lands on -X face → axis + 1. - var signed_axis: u32 = axis_idx; - if (axis_idx == 0u && dir_ws.x > 0.0) { signed_axis = 1u; } - else if (axis_idx == 2u && dir_ws.y > 0.0) { signed_axis = 3u; } - else if (axis_idx == 4u && dir_ws.z > 0.0) { signed_axis = 5u; } - - let first_slot = u32(inst.card_slot.x); - let slot = first_slot + signed_axis; - let slot_x = slot % 64u; - let slot_y = slot / 64u; - - // Project hit_os onto the card plane — same math as V1 but - // with signed-axis-aware u sign flips so the ±pair cards - // pick up opposite views of the mesh. - let bmin = inst.card_aabb_min.xyz; - let bmax = inst.card_aabb_max.xyz; - var u_os: f32; - var v_os: f32; - var u_lo: f32; - var u_hi: f32; - var v_lo: f32; - var v_hi: f32; - var u_flip: f32 = 1.0; - if (signed_axis == 0u || signed_axis == 1u) { - u_os = hit_os.y; v_os = hit_os.z; - u_lo = bmin.y; u_hi = bmax.y; v_lo = bmin.z; v_hi = bmax.z; - if (signed_axis == 1u) { u_flip = -1.0; } - } else if (signed_axis == 2u || signed_axis == 3u) { - u_os = hit_os.x; v_os = hit_os.z; - u_lo = bmin.x; u_hi = bmax.x; v_lo = bmin.z; v_hi = bmax.z; - if (signed_axis == 3u) { u_flip = -1.0; } - } else { - u_os = hit_os.x; v_os = hit_os.y; - u_lo = bmin.x; u_hi = bmax.x; v_lo = bmin.y; v_hi = bmax.y; - if (signed_axis == 5u) { u_flip = -1.0; } + let opaque_hit = rayQueryGetCommittedIntersection(&opaque_rq); + var behind = hw_gi_miss(origin_ws, dir_ws, max_t); + if (opaque_hit.kind != RAY_QUERY_INTERSECTION_NONE) { + let opaque_inst = instance_data[opaque_hit.instance_custom_data]; + let opaque_world = origin_ws + dir_ws * opaque_hit.t; + let opaque_os = ( + opaque_hit.world_to_object * vec4(opaque_world, 1.0) + ).xyz; + behind = hw_gi_shade_hit( + opaque_inst, + opaque_world, + opaque_os, + dir_ws, + opaque_hit.t, + max_t, + ); } - var u_norm = clamp((u_os - u_lo) / max(u_hi - u_lo, 1e-4), 0.0, 1.0); - let v_norm = clamp((v_os - v_lo) / max(v_hi - v_lo, 1e-4), 0.0, 1.0); - if (u_flip < 0.0) { u_norm = 1.0 - u_norm; } - - let slot_size_uv = 1.0 / CARD_SLOTS_PER_ROW; - let texel_in_slot = slot_size_uv / f32(64); // 64×64 card - let slot_u0 = f32(slot_x) * slot_size_uv + texel_in_slot; - let slot_v0 = f32(slot_y) * slot_size_uv + texel_in_slot; - let slot_span = slot_size_uv - 2.0 * texel_in_slot; - let atlas_uv = vec2( - slot_u0 + u_norm * slot_span, - slot_v0 + v_norm * slot_span, - ); - let pre_lit = textureSampleLevel(card_atlas, card_samp, atlas_uv, 0.0).rgb; - - // V3 — emissive is pre-added into the radiance atlas by the - // card-lighting pass, so the hit simply picks up the full - // pre-lit texel and applies distance falloff + firefly cap. - let tn = hit.t / max_t; - let falloff = max(1.0 - tn * tn, 0.0); - var raw = pre_lit * falloff; - let luma = dot(raw, vec3(0.2126, 0.7152, 0.0722)); - let cap = u.params.w; - if (luma > cap) { raw = raw * (cap / luma); } - radiance = raw; - } else { - // Fallback path — no card captured, use the flat - // instance albedo × hit-time lighting same as 007b. - let hit_n = inst.normal_ws; - let ndotl = max(dot(hit_n, u.sun_dir.xyz), 0.0); - let direct = u.sun_color.xyz * ndotl; - let ndotup = max(dot(hit_n, vec3(0.0, 1.0, 0.0)), 0.0); - let sky = u.sky_color.xyz * ndotup; - let tn = hit.t / max_t; - let falloff = max(1.0 - tn * tn, 0.0); - var raw = inst.albedo * (direct + sky) * falloff - + inst.albedo * inst.emissive_luma; - let luma = dot(raw, vec3(0.2126, 0.7152, 0.0722)); - let cap = u.params.w; - if (luma > cap) { raw = raw * (cap / luma); } - radiance = raw; + let surface_weight = clamp(inst.world_aabb_max.w, 0.0, 1.0) + * (1.0 - clamp(inst.mat_params.z, 0.0, 1.0)); + radiance = front * surface_weight + behind * hw_gi_transmittance(inst); } } else { // Ticket 014 V7 — miss path samples the WSRC envelope so HW @@ -658,16 +779,12 @@ fn cs_main( // sun-visibility signal. Terminal position is the ray's full // march distance; direction picks the octel on the nearest // probe. - let terminal = origin_ws + dir_ws * max_t; - var raw = hw_wsrc_sample(terminal, dir_ws); - let luma = dot(raw, vec3(0.2126, 0.7152, 0.0722)); - let cap = u.params.w; - if (luma > cap) { raw = raw * (cap / luma); } - radiance = raw; + radiance = hw_gi_miss(origin_ws, dir_ws, max_t); } let intensity = u.params.y; - textureStore(radiance_out, dst_coord, vec4(radiance * intensity * ndotd, 1.0)); + let output = bounded_probe_history(radiance * intensity * ndotd); + textureStore(radiance_out, dst_coord, vec4(output, 1.0)); } "; @@ -728,11 +845,17 @@ struct SdfInstanceGiData { // transform assets (Sponza). world_aabb_min: vec4, world_aabb_max: vec4, + // Layout mirror with the CPU/HW/PT record. The SDF path ignores + // geometry windows but consumes mat_params.zw in the lazy + // physical-transmission specialization. + geo: vec4, + mat_params: vec4, }; const SDF_CARD_SLOTS_PER_ROW: f32 = 64.0; const SDF_CARD_SLOT_PX: u32 = 64u; const WSRC_GRID_RES: i32 = 16; +const BLOOM_TRANSPARENT_GI: bool = false; @group(0) @binding(0) var u: TraceParams; @group(0) @binding(1) var probes: array; @@ -761,6 +884,46 @@ fn clipmap_sample(pos_ws: vec3) -> f32 { return textureSampleLevel(clipmap_tex, clipmap_samp, uv, 0.0).r; } +fn sdf_ray_aabb( + origin: vec3, + dir: vec3, + bmin: vec3, + bmax: vec3, +) -> vec2 { + let tiny_dir = select( + vec3(-0.000001), + vec3(0.000001), + dir >= vec3(0.0), + ); + let safe_dir = select( + tiny_dir, + dir, + abs(dir) > vec3(0.000001), + ); + let t0 = (bmin - origin) / safe_dir; + let t1 = (bmax - origin) / safe_dir; + let near3 = min(t0, t1); + let far3 = max(t0, t1); + return vec2( + max(near3.x, max(near3.y, near3.z)), + min(far3.x, min(far3.y, far3.z)), + ); +} + +fn sdf_gi_transmittance(inst: SdfInstanceGiData) -> vec3 { + let absorption = vec3( + inst.card_aabb_min.w, + inst.card_aabb_max.w, + inst.world_aabb_min.w, + ); + let physical = clamp( + inst.albedo * absorption * inst.mat_params.z * inst.mat_params.w, + vec3(0.0), + vec3(1.0), + ); + return mix(vec3(1.0), physical, clamp(inst.world_aabb_max.w, 0.0, 1.0)); +} + // Ticket 014 V10/V13 — WSRC lookup via the hardware linear-filtering // sampler, now multi-cascade. Each cascade occupies 16 z-slices of // the atlas at depth offset `cascade_idx * 16`. The miss path picks @@ -865,7 +1028,9 @@ fn cs_main( // light). Luma is read from the prev-frame temporal-filtered // history texture; `dst_coord` indexes the probe × octel slab // identically between trace output and history. - let prev_slice = textureLoad(prev_history, dst_coord, 0).rgb; + let prev_slice = bounded_probe_history( + textureLoad(prev_history, dst_coord, 0).rgb, + ); let prev_luma = dot(prev_slice, vec3(0.2126, 0.7152, 0.0722)); let jitter_scale = mix(1.0, 0.3, clamp(prev_luma, 0.0, 1.0)); let jitter = octel_jitter(u.params.x, probe_idx) * jitter_scale; @@ -882,6 +1047,31 @@ fn cs_main( let origin_ws = header.world_pos.xyz + n_ws * 0.02; let max_t = u.params.z; + // The transparent specialization bakes these instances out of the scene + // SDF. Find one nearest conservative world-AABB crossing up front, then + // combine it with the unchanged opaque SDF result below. The loop is + // compile-time absent from the ordinary shader variant. + var transparent_idx: i32 = -1; + var transparent_t: f32 = max_t + 1.0; + if (BLOOM_TRANSPARENT_GI) { + let instance_count = arrayLength(&instance_data); + for (var i: u32 = 0u; i < instance_count; i = i + 1u) { + let candidate = instance_data[i]; + if (candidate.mat_params.z <= 0.0) { continue; } + let interval = sdf_ray_aabb( + origin_ws, + dir_ws, + candidate.world_aabb_min.xyz, + candidate.world_aabb_max.xyz, + ); + let entry = max(interval.x, 0.001); + if (interval.y >= entry && entry < transparent_t && entry < max_t) { + transparent_idx = i32(i); + transparent_t = entry; + } + } + } + // Sphere-trace. Step is the UDF value; convergence when within a // voxel's worth of the surface or when we exhaust the budget. let voxel_size = u.clipmap.w / 64.0; // 64³ clipmap resolution @@ -930,6 +1120,7 @@ fn cs_main( for (var i: u32 = 0u; i < count; i = i + 1u) { let ad = instance_data[i]; if (ad.card_slot.w < 0.5) { continue; } + if (BLOOM_TRANSPARENT_GI && ad.mat_params.z > 0.0) { continue; } // EN-023 — compare the WORLD hit against the WORLD AABB. // The old object-space comparison only matched assets whose // vertices were already in world space; every transformed @@ -1048,8 +1239,35 @@ fn cs_main( radiance = raw; } + if (BLOOM_TRANSPARENT_GI && transparent_idx >= 0) { + let opaque_t = select(max_t, t, hit); + if (transparent_t < opaque_t) { + let glass = instance_data[u32(transparent_idx)]; + // The SW representation knows the conservative AABB crossing but + // not an exact triangle normal. Reuse the instance's established + // flat hit-lighting fallback for the non-transmitted fraction. + let hit_n = glass.normal_ws; + let ndotl = max(dot(hit_n, u.sun_dir.xyz), 0.0); + let direct = u.sun_color.xyz * ndotl; + let ndotup = max(dot(hit_n, vec3(0.0, 1.0, 0.0)), 0.0); + let sky = u.sky_color.xyz * ndotup; + let tn = transparent_t / max_t; + let falloff = max(1.0 - tn * tn, 0.0); + var front = glass.albedo * (direct + sky) * falloff + + glass.albedo * glass.emissive_luma; + let front_luma = dot(front, vec3(0.2126, 0.7152, 0.0722)); + let cap = u.params.w; + if (front_luma > cap) { front = front * (cap / front_luma); } + let surface_weight = clamp(glass.world_aabb_max.w, 0.0, 1.0) + * (1.0 - clamp(glass.mat_params.z, 0.0, 1.0)); + radiance = front * surface_weight + + radiance * sdf_gi_transmittance(glass); + } + } + let intensity = u.params.y; - textureStore(radiance_out, dst_coord, vec4(radiance * intensity * ndotd, 1.0)); + let output = bounded_probe_history(radiance * intensity * ndotd); + textureStore(radiance_out, dst_coord, vec4(output, 1.0)); } "; @@ -1073,6 +1291,13 @@ struct TemporalParams { @group(0) @binding(1) var radiance_in: texture_3d; @group(0) @binding(2) var history_in: texture_3d; @group(0) @binding(3) var history_out: texture_storage_3d; +@group(0) @binding(4) var probes: array; + +// The trace stores cosine-weighted incident radiance in 64 directional +// octels. Reserve octel zero in the filtered history for the diffuse +// convolution that resolve actually needs. Keeping the reduction in this +// existing workgroup avoids another texture, pass, or per-pixel 64-tap loop. +var diffuse_radiance: array, 64>; @compute @workgroup_size(8, 8, 1) fn cs_main( @@ -1084,11 +1309,18 @@ fn cs_main( if (wg.x >= grid_w || wg.y >= grid_h) { return; } let coord = vec3(i32(wg.x), i32(wg.y), i32(lid.y * PROBE_OCT_SIZE + lid.x)); - let curr = textureLoad(radiance_in, coord, 0).rgb; - let hist = textureLoad(history_in, coord, 0).rgb; + let curr = bounded_probe_history(textureLoad(radiance_in, coord, 0).rgb); + var hist = bounded_probe_history(textureLoad(history_in, coord, 0).rgb); + let lane = lid.y * PROBE_OCT_SIZE + lid.x; + // Octel zero held the previous frame's integrated irradiance rather than + // directional history. Seed that one directional sample from current. + if (lane == 0u) { + hist = curr; + } var alpha = u.params.x; - if (u.params.y > 0.5) { + let force_refresh = u.params.y > 0.5; + if (force_refresh) { alpha = 1.0; } else { // Ticket 016 V4 — variance-adaptive alpha. Scale the base @@ -1108,7 +1340,42 @@ fn cs_main( let delta = abs(curr_luma - hist_luma); alpha = min(1.0, alpha + delta * 0.6); } - let blended = mix(hist, curr, alpha); + var blended = mix(hist, curr, alpha); + if (force_refresh) { + // `mix(undefined, current, 1)` may still evaluate undefined * zero. + // Direct assignment guarantees invalid history is never observed. + blended = curr; + } + + diffuse_radiance[lane] = blended; + workgroupBarrier(); + if (lane < 32u) { + diffuse_radiance[lane] = diffuse_radiance[lane] + diffuse_radiance[lane + 32u]; + } + workgroupBarrier(); + if (lane < 16u) { + diffuse_radiance[lane] = diffuse_radiance[lane] + diffuse_radiance[lane + 16u]; + } + workgroupBarrier(); + if (lane < 8u) { + diffuse_radiance[lane] = diffuse_radiance[lane] + diffuse_radiance[lane + 8u]; + } + workgroupBarrier(); + if (lane < 4u) { + diffuse_radiance[lane] = diffuse_radiance[lane] + diffuse_radiance[lane + 4u]; + } + workgroupBarrier(); + if (lane < 2u) { + diffuse_radiance[lane] = diffuse_radiance[lane] + diffuse_radiance[lane + 2u]; + } + workgroupBarrier(); + if (lane == 0u) { + diffuse_radiance[0] = diffuse_radiance[0] + diffuse_radiance[1]; + // Uniform sphere samples need 4x their mean cosine-weighted + // radiance to reproduce constant diffuse incident radiance. + blended = bounded_probe_history(diffuse_radiance[0] * (4.0 / 64.0)); + probes[wg.y * grid_w + wg.x].diffuse = vec4(blended, 1.0); + } textureStore(history_out, coord, vec4(blended, 1.0)); } @@ -1156,20 +1423,11 @@ fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut { return out; } -// Sample a probe's octahedral atlas in a given world-space direction. -// Uses trilinear sampling on the 3D texture so neighbouring octels -// softly blend — some visible smear on the 8×8 atlas, at the cost of -// a cheap reconstruction. -fn sample_probe(probe_xy: vec2, dir_ws: vec3) -> vec3 { - let oct_uv = oct_encode(dir_ws); - let u_tex = (f32(probe_xy.x) + 0.5) / f32(u.size.z); - let v_tex = (f32(probe_xy.y) + 0.5) / f32(u.size.w); - // z coordinate: octahedral texel index in [0, 64) normalized to [0, 1) - let oct_x = clamp(oct_uv.x * f32(PROBE_OCT_SIZE), 0.0, f32(PROBE_OCT_SIZE) - 1.0); - let oct_y = clamp(oct_uv.y * f32(PROBE_OCT_SIZE), 0.0, f32(PROBE_OCT_SIZE) - 1.0); - let z_idx = floor(oct_y) * f32(PROBE_OCT_SIZE) + floor(oct_x); - let z = (z_idx + 0.5) / f32(PROBE_OCT_TEXELS); - return textureSampleLevel(radiance_tex, radiance_samp, vec3(u_tex, v_tex, z), 0.0).rgb; +// Temporal stores the cosine-convolved diffuse result alongside the probe +// header. Resolve already loads that header for its bilateral weights, so +// this adds no texture lookup. +fn sample_probe(probe: ProbeHeader) -> vec3 { + return probe.diffuse.rgb; } @fragment @@ -1197,8 +1455,14 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let zu = textureSampleLevel(hiz0, hiz_samp, in.uv + vec2(0.0, -texel.y), 0.0).r; let Pr = view_pos_from_linear(in.uv + vec2(texel.x, 0.0), zr, p00, p11, p20, p21); let Pu = view_pos_from_linear(in.uv + vec2(0.0, -texel.y), zu, p00, p11, p20, p21); - let N_vs = normalize(cross(Pr - P_vs, Pu - P_vs)); - let N_ws = normalize((u.inv_view * vec4(N_vs, 0.0)).xyz); + let N_vs = safe_probe_direction( + cross(Pr - P_vs, Pu - P_vs), + vec3(0.0, 0.0, 1.0), + ); + let N_ws = safe_probe_direction( + (u.inv_view * vec4(N_vs, 0.0)).xyz, + vec3(0.0, 1.0, 0.0), + ); // Pixel's grid-space fractional position (which probes surround it?). let px_x = in.uv.x * half_w; @@ -1236,7 +1500,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let w = w_corner * w_depth * w_normal; if (w <= 0.0001) { continue; } - let radiance = sample_probe(vec2(gx, gy), N_ws); + let radiance = sample_probe(probe); accum = accum + radiance * w; wsum = wsum + w; } @@ -1578,9 +1842,20 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let raw = textureSampleLevel(hdr_tex, hdr_samp, hit_uv, 0.0).rgb; let reflected = select(vec3(0.0), raw, raw == raw); let out = reflected * fresnel * roughness_fade * u.params.x * fade; - let out_safe = select(vec3(0.0), out, out == out); + // EN-061: bound a rare bright hit before it can poison the temporal + // history and become a quarter-resolution block after TSR. Eight linear + // luminance units remain far above display white and preserve bloom from + // valid polished-metal reflections; ordinary hits are byte-for-byte + // unchanged. + let out_luma = dot(out, vec3(0.2126, 0.7152, 0.0722)); + let firefly_cap = 8.0; + let firefly_scale = select( + 1.0, + firefly_cap / max(out_luma, 0.0001), + out_luma > firefly_cap, + ); + let out_bounded = out * firefly_scale; + let out_safe = select(vec3(0.0), out_bounded, out_bounded == out_bounded); return vec4(out_safe, fade); } "; - - diff --git a/native/shared/src/renderer/shaders/weighted.rs b/native/shared/src/renderer/shaders/weighted.rs new file mode 100644 index 00000000..c5a2b8f0 --- /dev/null +++ b/native/shared/src/renderer/shaders/weighted.rs @@ -0,0 +1,94 @@ +//! Lazy weighted-blended transparency shader variants. + +/// Add a dedicated weighted-blended transparency entry point to the +/// specialized scene shader. The ordinary scene module/pipelines remain +/// unchanged; this source is compiled lazily only after the weighted route +/// becomes active. +pub(in crate::renderer) fn scene_weighted_transparency_shader_source( + base_scene_shader: &str, +) -> String { + let mut source = String::with_capacity(base_scene_shader.len() + 1_400); + source.push_str(base_scene_shader); + source.push_str( + r#" + +struct WeightedTransparencyOut { + @location(0) accumulation: vec4, + @location(1) revealage: f32, +}; + +@fragment +fn fs_weighted_transparent_scene( + in: VertexOutputScene, + @builtin(front_facing) front_facing: bool, +) -> WeightedTransparencyOut { + let shaded = shade_main_scene(in, front_facing).color; + let alpha = clamp(shaded.a, 0.0, 1.0); + + // Bounded McGuire/Bavoil-style weighted OIT. Nearer fragments receive + // more influence without the unbounded exponential weights that can + // overflow rgba16float on dense particle/glass layers. With one layer + // the resolve is algebraically identical to conventional alpha blend. + let depth = clamp(in.clip_position.z, 0.0, 1.0); + let depth_weight = 0.1 + 0.9 * pow(1.0 - depth, 3.0); + let weighted_alpha = alpha * depth_weight; + let finite_color = select(vec3(0.0), shaded.rgb, shaded.rgb == shaded.rgb); + return WeightedTransparencyOut( + vec4(finite_color * weighted_alpha, weighted_alpha), + alpha, + ); +} +"#, + ); + source +} + +/// Full-screen resolve for weighted-blended transparency. Revealage is the +/// multiplicative product of `(1 - alpha)`; accumulation stores +/// `(radiance * alpha * weight, alpha * weight)`. +pub(in crate::renderer) const WEIGHTED_TRANSPARENCY_RESOLVE_SHADER: &str = r#" +@group(0) @binding(0) var accumulation_tex: texture_2d; +@group(0) @binding(1) var revealage_tex: texture_2d; + +@vertex +fn vs_weighted_transparency_resolve( + @builtin(vertex_index) vertex_index: u32, +) -> @builtin(position) vec4 { + var positions = array, 3>( + vec2(-1.0, -1.0), + vec2(3.0, -1.0), + vec2(-1.0, 3.0), + ); + return vec4(positions[vertex_index], 0.0, 1.0); +} + +@fragment +fn fs_weighted_transparency_resolve( + @builtin(position) position: vec4, +) -> @location(0) vec4 { + let pixel = vec2(position.xy); + let accumulation = textureLoad(accumulation_tex, pixel, 0); + let revealage = clamp(textureLoad(revealage_tex, pixel, 0).r, 0.0, 1.0); + let opacity = 1.0 - revealage; + let color = accumulation.rgb / max(accumulation.a, 0.00001); + let finite_color = select(vec3(0.0), color, color == color); + return vec4(finite_color, opacity); +} +"#; + +#[cfg(test)] +mod tests { + use super::*; + use crate::renderer::shaders::core::SCENE_SHADER; + + #[test] + fn weighted_transparency_variants_parse_without_touching_ordinary_shader() { + assert!(!SCENE_SHADER.contains("fs_weighted_transparent_scene")); + let source = scene_weighted_transparency_shader_source(SCENE_SHADER); + wgpu::naga::front::wgsl::parse_str(&source) + .unwrap_or_else(|error| panic!("weighted transparency WGSL failed: {error}")); + wgpu::naga::front::wgsl::parse_str(WEIGHTED_TRANSPARENCY_RESOLVE_SHADER) + .unwrap_or_else(|error| panic!("weighted transparency resolve WGSL failed: {error}")); + assert!(source.contains("fn fs_weighted_transparent_scene")); + } +} diff --git a/native/shared/src/renderer/shadow_pass.rs b/native/shared/src/renderer/shadow_pass.rs index e149dd30..08894a62 100644 --- a/native/shared/src/renderer/shadow_pass.rs +++ b/native/shared/src/renderer/shadow_pass.rs @@ -12,720 +12,1128 @@ impl Renderer { profiler: &mut crate::profiler::Profiler, scene: &mut crate::scene::SceneGraph, ) { - // Shadow pass: render scene nodes from light's perspective into - // cascaded shadow maps (3 cascades). - // - // Cache hit path (ticket 004): if no caster moved, the light - // didn't move, and the freshly-computed cascade VPs match the - // ones the cached depth textures were rendered with, we skip - // the whole pass. The depth textures retain their content and - // the main pass samples from them as if we had redrawn. - profiler.begin("shadow_pass"); - if self.shadow_map.enabled { - // EN-043 — take last frame's caster transforms out of `self` up front: the - // caster lists below hold immutable borrows of self.model_gpu_cache for the - // rest of the function, so this map cannot be touched through `self` again - // until they are dead. - let prev_caster_ids = std::mem::take(&mut self.shadow_caster_tf); - let mut caster_ids_now: std::collections::HashSet = - std::collections::HashSet::with_capacity(prev_caster_ids.len() + 64); - // Compute cascade VPs from the primary directional light and camera. - let light_dir = [ - self.lighting_uniforms.light_dir[0], - self.lighting_uniforms.light_dir[1], - self.lighting_uniforms.light_dir[2], - ]; - // Auto-fit: compute world-space AABB across every visible, - // cast-shadow node so the ortho volume always covers the - // scene regardless of what's loaded. No per-scene magic - // numbers. - let scene_bounds = scene.compute_shadow_bounds(); - self.shadow_map.compute_cascade_vps( - light_dir, - self.current_camera_pos, - self.current_view_matrix, - // Use the pre-jitter projection so the cascade VPs - // stay byte-stable when the camera is actually - // stationary (the shadow cache compares them exactly). - self.current_proj_matrix_unjittered, - 0.5, // near — start cascades slightly past the camera - 80.0, // far — shadow coverage range - scene_bounds, - ); + // Shadow pass: render scene nodes from light's perspective into + // cascaded shadow maps (3 cascades). + // + // Cache hit path (ticket 004): if no caster moved, the light + // didn't move, and the freshly-computed cascade VPs match the + // ones the cached depth textures were rendered with, we skip + // the whole pass. The depth textures retain their content and + // the main pass samples from them as if we had redrawn. + profiler.begin("shadow_pass"); + if self.shadow_map.enabled { + // EN-043 — take last frame's caster transforms out of `self` up front: the + // caster lists below hold immutable borrows of self.model_gpu_cache for the + // rest of the function, so this map cannot be touched through `self` again + // until they are dead. + let prev_caster_ids = std::mem::take(&mut self.shadow_caster_tf); + let mut caster_ids_now: std::collections::HashSet = + std::collections::HashSet::with_capacity(prev_caster_ids.len() + 64); + // Compute cascade VPs from the primary directional light and camera. + let light_dir = [ + self.lighting_uniforms.light_dir[0], + self.lighting_uniforms.light_dir[1], + self.lighting_uniforms.light_dir[2], + ]; + // Auto-fit: compute world-space AABB across every visible, + // cast-shadow node so the ortho volume always covers the + // scene regardless of what's loaded. No per-scene magic + // numbers. + let scene_bounds = + scene.compute_shadow_bounds_with_refraction(self.imported_refraction_enabled); + self.shadow_map.compute_cascade_vps( + light_dir, + self.current_camera_pos, + self.current_view_matrix, + // Use the pre-jitter projection so the cascade VPs + // stay byte-stable when the camera is actually + // stationary (the shadow cache compares them exactly). + self.current_proj_matrix_unjittered, + 0.5, // near — start cascades slightly past the camera + 80.0, // far — shadow coverage range + scene_bounds, + ); - // Re-upload lighting uniforms with cascade VPs and splits. - // Always write these — even on a cache hit the cascade - // split distances and view matrix track camera movement - // (they drive per-pixel cascade selection in the main - // shader), which is independent of shadow texture content. - self.lighting_uniforms.shadow_cascade_vps = self.shadow_map.light_vps; - self.lighting_uniforms.shadow_cascade_splits = [ - self.shadow_map.cascade_splits[0], - self.shadow_map.cascade_splits[1], - self.shadow_map.cascade_splits[2], - // .w = mip-LOD bias for material textures. Bias finer - // by log2(render_scale) — recovers -1.0 at 0.5 (one - // mip finer to offset hardware's coarser selection - // at half-res), ~-0.42 at 0.75, 0 at native. - if self.render_scale < 0.999 { self.render_scale.log2() } else { 0.0 }, - ]; - self.lighting_uniforms.shadow_view_matrix = self.current_view_matrix; - self.queue.write_buffer( - &self.lighting_buffer, - 0, - bytemuck::bytes_of(&self.lighting_uniforms), - ); - // Shadow-flicker fix: the material system's PerView buffer was - // uploaded before this fit ran and still carries LAST frame's - // cascade VPs. Patch its shadow fields so material-path - // receivers sample the depth maps with the same matrices the - // maps are rendered with this frame. Keep .w at 0.0 — that slot - // is the TSR mip-LOD bias, which the material path historically - // never received (begin_mode_3d resets it before the material - // PerView upload); delivering -1.0 here makes hardware mip - // selection flip per-panel under TAA jitter (visible texture - // detail popping). - let mut splits = self.lighting_uniforms.shadow_cascade_splits; - splits[3] = 0.0; - self.material_system.refresh_shadow_uniforms( - &self.queue, - splits, - self.lighting_uniforms.shadow_view_matrix, - self.shadow_map.light_vps, - ); + // Re-upload lighting uniforms with cascade VPs and splits. + // Always write these — even on a cache hit the cascade + // split distances and view matrix track camera movement + // (they drive per-pixel cascade selection in the main + // shader), which is independent of shadow texture content. + self.lighting_uniforms.shadow_cascade_vps = self.shadow_map.light_vps; + self.lighting_uniforms.shadow_cascade_splits = [ + self.shadow_map.cascade_splits[0], + self.shadow_map.cascade_splits[1], + self.shadow_map.cascade_splits[2], + // .w = mip-LOD bias for material textures. Bias finer + // by log2(render_scale) — recovers -1.0 at 0.5 (one + // mip finer to offset hardware's coarser selection + // at half-res), ~-0.42 at 0.75, 0 at native. + if self.render_scale < 0.999 { + self.render_scale.log2() + } else { + 0.0 + }, + ]; + self.lighting_uniforms.shadow_view_matrix = self.current_view_matrix; + // Shadow-flicker fix: the material system's PerView buffer was + // uploaded before this fit ran and still carries LAST frame's + // cascade VPs. Patch its shadow fields so material-path + // receivers sample the depth maps with the same matrices the + // maps are rendered with this frame. Keep .w at 0.0 — that slot + // is the TSR mip-LOD bias, which the material path historically + // never received (begin_mode_3d resets it before the material + // PerView upload); delivering -1.0 here makes hardware mip + // selection flip per-panel under TAA jitter (visible texture + // detail popping). + let mut splits = self.lighting_uniforms.shadow_cascade_splits; + splits[3] = 0.0; + self.material_system.refresh_shadow_uniforms( + &self.queue, + splits, + self.lighting_uniforms.shadow_view_matrix, + self.shadow_map.light_vps, + ); - // Cache gate, stage 1 — whole-pass invalidators. Per-cascade - // staleness (VP compare + caster-content signature) is decided - // after the caster lists are built. Texel-snap + radius - // quantization + re-fit slack in `compute_cascade_vps` make the - // per-cascade VP compare exact, so a kept VP + unchanged content - // means the cascade's cached depth texture is still valid. - let scene_ver = scene.shadow_version; - let light_changed = self.shadow_map.rendered_light_dir - .map(|cached| cached != light_dir) - .unwrap_or(true); - let force_all = self.shadow_map.always_fresh - || self.shadow_map.dirty - || light_changed - || self.shadow_map.rendered_light_vps.is_none() - || self.shadow_map.rendered_scene_version != scene_ver; + // Cache gate, stage 1 — whole-pass invalidators. Per-cascade + // staleness (VP compare + caster-content signature) is decided + // after the caster lists are built. Texel-snap + radius + // quantization + re-fit slack in `compute_cascade_vps` make the + // per-cascade VP compare exact, so a kept VP + unchanged content + // means the cascade's cached depth texture is still valid. + let scene_ver = scene.shadow_version; + let light_changed = self + .shadow_map + .rendered_light_dir + .map(|cached| cached != light_dir) + .unwrap_or(true); + let force_all = self.shadow_map.always_fresh + || self.shadow_map.dirty + || light_changed + || self.shadow_map.rendered_light_vps.is_none() + || self.shadow_map.rendered_scene_version != scene_ver; - // Build a shared caster list + buffer-ref vectors, then - // filter per cascade against that cascade's ortho frustum. - // A caster outside cascade N's frustum can't write pixels - // into cascade N; near/far pancaking already covers - // behind-camera casters via the cascade's own far plane. - struct ShadowDrawEntry { - vb_idx: usize, - ib_idx: usize, - index_start: u32, - index_count: u32, - transform: [[f32; 4]; 4], - wmin: [f32; 3], - wmax: [f32; 3], - // Index into `cutout_bgs` for an alpha-tested caster (cutout - // foliage), or -1 for an opaque caster (plain depth pipeline). - cutout_idx: i32, - // Immediate-mode segment containing skinned characters — - // rendered with the skinning-aware shadow pipeline so animated - // player/enemies cast a posed shadow instead of a rest pose at - // the origin. (Mixed segments: non-skinned verts still - // transform by the model matrix via the shader's weight branch.) - skinned: bool, - // Content identity for the per-cascade cache: stable across - // frames for static casters, salted with `frame_nonce` for - // animated ones so their cascades re-render every frame. - sig: u64, - // Immediate-batch content (animated characters, per-frame - // primitives). Dynamic casters render into the live cascade - // texture every frame on top of the cached static depth; - // they never invalidate the static cache. - dynamic: bool, - // Base slot of a skinned cached draw's pose in the shared - // joint buffer (the cached VB keeps RAW joint indices, so - // vs_shadow_skinned adds this via ShadowUniforms.misc.x). - // 0.0 for everything else — including immediate-batch - // skinned segments, whose vertex joints are pre-offset. - joint_offset: f32, - // Foliage wind amount for this caster (0 = rigid). Non-zero makes the - // caster MOVE, which is why it also forces `dynamic` — a swaying tree - // cannot reuse its cached static shadow depth. - foliage: f32, - // EN-043 — stable identity (NOT including the transform), so a caster - // that moved can be told apart from a caster that appeared. - key: u64, - } - fn entry_sig(kind: u8, id: u64, idx: u64, transform: &[[f32; 4]; 4]) -> u64 { - let mut h = FNV_OFFSET; - h = fnv1a_bytes(h, &[kind]); - h = fnv1a_bytes(h, &id.to_le_bytes()); - h = fnv1a_bytes(h, &idx.to_le_bytes()); - fnv1a_bytes(h, bytemuck::bytes_of(transform)) - } - // EN-043 — "was this exact caster, at this exact transform, here last frame?" - // - // One combined hash of identity AND transform, looked up in a SET. If it is - // in last frame's set, the caster has not moved and stays static. If it is - // not, it either moved or is new — either way it goes in the dynamic set, - // where it draws on top of the cached static depth instead of invalidating it. - // - // ORDER-INDEPENDENT, and that is the whole point of the rewrite. The first - // version keyed on "the Nth draw of this model handle", which was fine until - // the game started drawing its forest FRONT-TO-BACK: the sort order changes - // as the camera moves, so occurrence N became a different tree every frame, - // dozens of perfectly stationary trees were misread as movers, and the - // dynamic set blew past 32 casters in combat. A set membership test does not - // care what order the draws arrive in. - fn caster_id(kind: u8, id: u64, idx: u64, transform: &[[f32; 4]; 4]) -> u64 { - let mut h = FNV_OFFSET; - h = fnv1a_bytes(h, &[kind]); - h = fnv1a_bytes(h, &id.to_le_bytes()); - h = fnv1a_bytes(h, &idx.to_le_bytes()); - fnv1a_bytes(h, bytemuck::bytes_of(transform)) - } - // Per-frame nonce for animated casters' signatures. Bumped - // whenever shadows render — skinned CACHED model draws need it - // even when the immediate batch is empty, which is the norm now - // that skinned models draw through the cache. - self.shadow_map.frame_nonce = self.shadow_map.frame_nonce.wrapping_add(1); - let nonce = self.shadow_map.frame_nonce; + // Receiver-driven VSM marking is opt-in with VSM itself. The + // default CSM path keeps an empty Option, so it performs neither + // heap allocation nor receiver transforms/frustum tests. + let vsm_requested = self.shadow_map.virtual_map.requested(); + let camera_planes = vsm_requested.then(|| { + crate::scene::extract_frustum_planes(&mat4_multiply( + self.current_proj_matrix_unjittered, + self.current_view_matrix, + )) + }); + let mut vsm_receiver_bounds: Option> = + vsm_requested.then(Vec::new); + fn push_vsm_receiver( + receivers: &mut Vec<([f32; 3], [f32; 3])>, + camera_planes: &[[f32; 4]; 6], + wmin: [f32; 3], + wmax: [f32; 3], + ) { + if wmin[0] > wmax[0] + || wmin + .iter() + .chain(wmax.iter()) + .any(|value| !value.is_finite()) + || crate::scene::aabb_outside_frustum(camera_planes, wmin, wmax) + { + return; + } + receivers.push((wmin, wmax)); + } - let mut shadow_nodes: Vec = Vec::new(); - let mut shadow_vbs: Vec<&wgpu::Buffer> = Vec::new(); - let mut shadow_ibs: Vec<&wgpu::Buffer> = Vec::new(); - let mut cutout_bgs: Vec<&wgpu::BindGroup> = Vec::new(); - for (i, (_handle, node)) in scene.nodes.iter().enumerate() { - // gi_only proxies duplicate geometry that already casts through - // the material-command path below — including them would - // double-render every caster. - if !node.visible || node.gi_only || !node.cast_shadow || node.indices.is_empty() { - continue; + // Build a shared caster list + buffer-ref vectors, then + // filter per cascade against that cascade's ortho frustum. + // A caster outside cascade N's frustum can't write pixels + // into cascade N; near/far pancaking already covers + // behind-camera casters via the cascade's own far plane. + struct ShadowDrawEntry { + vb_idx: usize, + ib_idx: usize, + index_start: u32, + index_count: u32, + base_vertex: i32, + transform: [[f32; 4]; 4], + wmin: [f32; 3], + wmax: [f32; 3], + // Index into `cutout_bgs` for an alpha-tested caster (cutout + // foliage), or -1 for an opaque caster (plain depth pipeline). + cutout_idx: i32, + // Immediate-mode segment containing skinned characters — + // rendered with the skinning-aware shadow pipeline so animated + // player/enemies cast a posed shadow instead of a rest pose at + // the origin. (Mixed segments: non-skinned verts still + // transform by the model matrix via the shader's weight branch.) + skinned: bool, + // Content identity for the per-cascade cache: stable across + // frames for static casters, salted with `frame_nonce` for + // animated ones so their cascades re-render every frame. + sig: u64, + // Immediate-batch content (animated characters, per-frame + // primitives). Dynamic casters render into the live cascade + // texture every frame on top of the cached static depth; + // they never invalidate the static cache. + dynamic: bool, + // Base slot of a skinned cached draw's pose in the shared + // joint buffer (the cached VB keeps RAW joint indices, so + // vs_shadow_skinned adds this via ShadowUniforms.misc.x). + // 0.0 for everything else — including immediate-batch + // skinned segments, whose vertex joints are pre-offset. + joint_offset: f32, + // Foliage wind amount for this caster (0 = rigid). Non-zero makes the + // caster MOVE, which is why it also forces `dynamic` — a swaying tree + // cannot reuse its cached static shadow depth. + foliage: f32, + // EN-043 — stable identity (NOT including the transform), so a caster + // that moved can be told apart from a caster that appeared. + key: u64, } - let Some(vb) = &node.gpu_vb else { continue }; - let Some(ib) = &node.gpu_ib else { continue }; - let vb_idx = shadow_vbs.len(); - shadow_vbs.push(vb); - shadow_ibs.push(ib); - // MASK-material nodes carry an alpha-test shadow bind group so - // foliage casts dappled shadows (same as the cached-model path). - let cutout_idx = match &node.gpu_shadow_cutout_bg { - Some(bg) => { let i = cutout_bgs.len(); cutout_bgs.push(bg); i as i32 } - None => -1, - }; - shadow_nodes.push(ShadowDrawEntry { - vb_idx, - ib_idx: vb_idx, - index_start: 0, - index_count: node.gpu_index_count, - transform: node.transform, - wmin: node.world_bounds_min, - wmax: node.world_bounds_max, - cutout_idx, - skinned: false, - sig: entry_sig(0, i as u64, node.gpu_index_count as u64, &node.transform), - dynamic: false, - joint_offset: 0.0, - foliage: 0.0, - key: caster_id(0, i as u64, node.gpu_index_count as u64, &node.transform), - }); - } + fn entry_sig(kind: u8, id: u64, idx: u64, transform: &[[f32; 4]; 4]) -> u64 { + let mut h = FNV_OFFSET; + h = fnv1a_bytes(h, &[kind]); + h = fnv1a_bytes(h, &id.to_le_bytes()); + h = fnv1a_bytes(h, &idx.to_le_bytes()); + fnv1a_bytes(h, bytemuck::bytes_of(transform)) + } + // EN-043 — "was this exact caster, at this exact transform, here last frame?" + // + // One combined hash of identity AND transform, looked up in a SET. If it is + // in last frame's set, the caster has not moved and stays static. If it is + // not, it either moved or is new — either way it goes in the dynamic set, + // where it draws on top of the cached static depth instead of invalidating it. + // + // ORDER-INDEPENDENT, and that is the whole point of the rewrite. The first + // version keyed on "the Nth draw of this model handle", which was fine until + // the game started drawing its forest FRONT-TO-BACK: the sort order changes + // as the camera moves, so occurrence N became a different tree every frame, + // dozens of perfectly stationary trees were misread as movers, and the + // dynamic set blew past 32 casters in combat. A set membership test does not + // care what order the draws arrive in. + fn caster_id(kind: u8, id: u64, idx: u64, transform: &[[f32; 4]; 4]) -> u64 { + let mut h = FNV_OFFSET; + h = fnv1a_bytes(h, &[kind]); + h = fnv1a_bytes(h, &id.to_le_bytes()); + h = fnv1a_bytes(h, &idx.to_le_bytes()); + fnv1a_bytes(h, bytemuck::bytes_of(transform)) + } + // Per-frame nonce for animated casters' signatures. Bumped + // whenever shadows render — skinned CACHED model draws need it + // even when the immediate batch is empty, which is the norm now + // that skinned models draw through the cache. + self.shadow_map.frame_nonce = self.shadow_map.frame_nonce.wrapping_add(1); + let nonce = self.shadow_map.frame_nonce; - // Immediate-mode 3D batch (drawCube/drawSphere/non-cached models), - // one entry per segment. These verts are already in WORLD space, so - // the model matrix is identity. Model draws maintain per-segment - // bounds inline (skinned via joint-transformed rest AABBs); - // primitive-only segments are scanned here. Segments with skinned - // content take a per-frame nonce as their signature (animation - // means their rendered output changes every frame); static - // segments hash their vertex positions, so e.g. pickups - // re-submitted identically each frame don't dirty their cascades. - if !self.indices_3d.is_empty() { - self.scan_unbounded_segments_3d(); - let vb_idx = shadow_vbs.len(); - shadow_vbs.push(&self.persistent_vb_3d); - shadow_ibs.push(&self.persistent_ib_3d); - if self.draw_calls_3d.is_empty() { - // Fallback: vertices without segment tracking — one - // unbounded, always-dirty entry (pre-segmentation shape). + let mut shadow_nodes: Vec = Vec::new(); + let mut shadow_vbs: Vec<&wgpu::Buffer> = Vec::new(); + let mut shadow_ibs: Vec<&wgpu::Buffer> = Vec::new(); + let mut cutout_bgs: Vec<&wgpu::BindGroup> = Vec::new(); + for (i, (_handle, node)) in scene.nodes.iter().enumerate() { + // gi_only proxies duplicate geometry that already casts through + // the material-command path below — including them would + // double-render every caster. + if !node.visible + || node.gi_only + || !node.cast_shadow + || node.material.alpha_mode == MaterialAlphaMode::Blend + || (self.imported_refraction_enabled && node.material.transmission.is_active()) + || node.indices.is_empty() + { + continue; + } + let Some(vb) = &node.gpu_vb else { continue }; + let Some(ib) = &node.gpu_ib else { continue }; + let vb_idx = shadow_vbs.len(); + shadow_vbs.push(vb); + shadow_ibs.push(ib); + // MASK-material nodes carry an alpha-test shadow bind group so + // foliage casts dappled shadows (same as the cached-model path). + let cutout_idx = match &node.gpu_shadow_cutout_bg { + Some(bg) => { + let i = cutout_bgs.len(); + cutout_bgs.push(bg); + i as i32 + } + None => -1, + }; shadow_nodes.push(ShadowDrawEntry { vb_idx, ib_idx: vb_idx, index_start: 0, - index_count: self.indices_3d.len() as u32, - transform: IDENTITY_MAT4, - wmin: [1.0, 1.0, 1.0], - wmax: [-1.0, -1.0, -1.0], - cutout_idx: -1, - skinned: true, - sig: nonce, - dynamic: true, + index_count: node.gpu_index_count, + base_vertex: 0, + transform: node.transform, + wmin: node.world_bounds_min, + wmax: node.world_bounds_max, + cutout_idx, + skinned: false, + sig: entry_sig(0, i as u64, node.gpu_index_count as u64, &node.transform), + dynamic: false, joint_offset: 0.0, foliage: 0.0, - key: 0, + key: caster_id(0, i as u64, node.gpu_index_count as u64, &node.transform), }); - } else { - let num_calls = self.draw_calls_3d.len(); - for ci in 0..num_calls { - let call = &self.draw_calls_3d[ci]; - let next_start = if ci + 1 < num_calls { - self.draw_calls_3d[ci + 1].index_start - } else { - self.indices_3d.len() as u32 - }; - let count = next_start - call.index_start; - if count == 0 { continue; } + } + + // Immediate-mode 3D batch (drawCube/drawSphere/non-cached models), + // one entry per segment. These verts are already in WORLD space, so + // the model matrix is identity. Model draws maintain per-segment + // bounds inline (skinned via joint-transformed rest AABBs); + // primitive-only segments are scanned here. Segments with skinned + // content take a per-frame nonce as their signature (animation + // means their rendered output changes every frame); static + // segments hash their vertex positions, so e.g. pickups + // re-submitted identically each frame don't dirty their cascades. + if !self.indices_3d.is_empty() { + self.scan_unbounded_segments_3d(); + let vb_idx = shadow_vbs.len(); + shadow_vbs.push(&self.persistent_vb_3d); + shadow_ibs.push(&self.persistent_ib_3d); + if self.draw_calls_3d.is_empty() { + // Fallback: vertices without segment tracking — one + // unbounded, always-dirty entry (pre-segmentation shape). shadow_nodes.push(ShadowDrawEntry { vb_idx, ib_idx: vb_idx, - index_start: call.index_start, - index_count: count, + index_start: 0, + index_count: self.indices_3d.len() as u32, + base_vertex: 0, transform: IDENTITY_MAT4, - wmin: call.wmin, - wmax: call.wmax, + wmin: [1.0, 1.0, 1.0], + wmax: [-1.0, -1.0, -1.0], cutout_idx: -1, - skinned: call.has_skinned, - sig: if call.has_skinned { nonce } else { call.content_hash }, + skinned: true, + sig: nonce, dynamic: true, joint_offset: 0.0, foliage: 0.0, key: 0, }); - } - } - } - - // Foliage promoted to the dynamic set this frame. Capped well below - // SHADOW_MAX_DYNAMIC so the characters — whose shadows are the ones a - // player actually looks at — always keep their slots. - const MAX_FOLIAGE_DYNAMIC: u32 = 24; - let mut foliage_dynamic: u32 = 0; - - // Cached models (drawModel: trees, characters, etc.) — each is a - // GpuMesh plus its object→world matrix. World AABB from the - // cache-time local AABB so per-cascade culling rejects casters - // outside a cascade's ortho frustum (the forest was previously - // re-drawn into every cascade every frame). Skinned cached draws - // render through the skinning pipeline as dynamic casters (pose - // changes every frame → nonce signature) with the joint-union - // AABB computed at submit time. - for cmd in self.model_draw_commands.iter() { - if let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.cache_handle) { - if cmd.mesh_idx < meshes.len() { - let mesh = &meshes[cmd.mesh_idx]; - let vb_idx = shadow_vbs.len(); - shadow_vbs.push(&mesh.vb); - shadow_ibs.push(&mesh.ib); - // Cutout foliage → alpha-tested shadow pipeline. - let cutout_idx = match &mesh.shadow_cutout_bg { - Some(bg) => { let i = cutout_bgs.len(); cutout_bgs.push(bg); i as i32 } - None => -1, - }; - if cmd.skinned { - // Sentinel bounds (min > max) when the submit-time - // AABB was empty → uncullable, never lost. - let (wmin, wmax) = cmd.bounds_override - .unwrap_or(([1.0, 1.0, 1.0], [-1.0, -1.0, -1.0])); + } else { + let num_calls = self.draw_calls_3d.len(); + for ci in 0..num_calls { + let call = &self.draw_calls_3d[ci]; + let next_start = if ci + 1 < num_calls { + self.draw_calls_3d[ci + 1].index_start + } else { + self.indices_3d.len() as u32 + }; + let count = next_start - call.index_start; + if count == 0 { + continue; + } shadow_nodes.push(ShadowDrawEntry { vb_idx, ib_idx: vb_idx, - index_start: 0, - index_count: mesh.index_count, - transform: cmd.model, - wmin, - wmax, - cutout_idx, - skinned: true, - sig: nonce, + index_start: call.index_start, + index_count: count, + base_vertex: 0, + transform: IDENTITY_MAT4, + wmin: call.wmin, + wmax: call.wmax, + cutout_idx: -1, + skinned: call.has_skinned, + sig: if call.has_skinned { + nonce + } else { + call.content_hash + }, dynamic: true, - joint_offset: cmd.joint_offset, + joint_offset: 0.0, foliage: 0.0, key: 0, }); - } else { - // Only sway the shadow if the game asked for it AND there is - // room in the dynamic-caster budget. - // - // That second condition is not paranoia. A swaying caster - // cannot reuse the cached static depth, so it must move to - // the DYNAMIC set — and that set holds SHADOW_MAX_DYNAMIC - // (64) entries. The shooter's forest alone is 88 trees x 4 - // primitives = 352. Marking them all dynamic overflows the - // budget, and the overflow is dropped — which does not merely - // cost frames, it silently DELETES shadows. Measured: turning - // this on removed every tree shadow AND the player's own - // shadow from under their feet, while reporting a higher fps. - // - // So: sway as many as fit, leave the rest rigid. A slightly - // stale canopy shadow is invisible; a missing one is not. - let fol = if self.foliage_shadow_motion - && foliage_dynamic < MAX_FOLIAGE_DYNAMIC + } + } + } + + // Foliage promoted to the dynamic set this frame. Capped well below + // SHADOW_MAX_DYNAMIC so the characters — whose shadows are the ones a + // player actually looks at — always keep their slots. + const MAX_FOLIAGE_DYNAMIC: u32 = 24; + let mut foliage_dynamic: u32 = 0; + + // Cached models (drawModel: trees, characters, etc.) — each is a + // GpuMesh plus its object→world matrix. World AABB from the + // cache-time local AABB so per-cascade culling rejects casters + // outside a cascade's ortho frustum (the forest was previously + // re-drawn into every cascade every frame). Skinned cached draws + // render through the skinning pipeline as dynamic casters (pose + // changes every frame → nonce signature) with the joint-union + // AABB computed at submit time. + for cmd in self.model_draw_commands.iter() { + if let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.cache_handle) { + if cmd.mesh_idx < meshes.len() { + let mesh = &meshes[cmd.mesh_idx]; + if mesh.alpha_mode == MaterialAlphaMode::Blend + || (self.imported_refraction_enabled && mesh.transmission.is_active()) { - let f = self.foliage_wind.get(&cmd.cache_handle).copied().unwrap_or(0.0); - if f > 0.0 { foliage_dynamic += 1; } - f - } else { 0.0 }; + continue; + } + let draw = self.gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count); + let vb_idx = shadow_vbs.len(); + shadow_vbs.push(draw.vertex); + shadow_ibs.push(draw.index); + // Cutout foliage → alpha-tested shadow pipeline. + let cutout_idx = match &mesh.shadow_cutout_bg { + Some(bg) => { + let i = cutout_bgs.len(); + cutout_bgs.push(bg); + i as i32 + } + None => -1, + }; + if cmd.skinned { + // Sentinel bounds (min > max) when the submit-time + // AABB was empty → uncullable, never lost. + let (wmin, wmax) = cmd + .bounds_override + .unwrap_or(([1.0, 1.0, 1.0], [-1.0, -1.0, -1.0])); + shadow_nodes.push(ShadowDrawEntry { + vb_idx, + ib_idx: vb_idx, + index_start: draw.first_index, + index_count: draw.index_count, + base_vertex: draw.base_vertex, + transform: cmd.model, + wmin, + wmax, + cutout_idx, + skinned: true, + sig: nonce, + dynamic: true, + joint_offset: cmd.joint_offset, + foliage: 0.0, + key: 0, + }); + } else { + // Only sway the shadow if the game asked for it AND there is + // room in the dynamic-caster budget. + // + // That second condition is not paranoia. A swaying caster + // cannot reuse the cached static depth, so it must move to + // the DYNAMIC set — and that set holds SHADOW_MAX_DYNAMIC + // (64) entries. The shooter's forest alone is 88 trees x 4 + // primitives = 352. Marking them all dynamic overflows the + // budget, and the overflow is dropped — which does not merely + // cost frames, it silently DELETES shadows. Measured: turning + // this on removed every tree shadow AND the player's own + // shadow from under their feet, while reporting a higher fps. + // + // So: sway as many as fit, leave the rest rigid. A slightly + // stale canopy shadow is invisible; a missing one is not. + let fol = if self.foliage_shadow_motion + && foliage_dynamic < MAX_FOLIAGE_DYNAMIC + { + let f = self + .foliage_wind + .get(&cmd.cache_handle) + .copied() + .unwrap_or(0.0); + if f > 0.0 { + foliage_dynamic += 1; + } + f + } else { + 0.0 + }; + let (wmin, wmax) = + transform_aabb(&cmd.model, mesh.local_min, mesh.local_max); + shadow_nodes.push(ShadowDrawEntry { + vb_idx, + ib_idx: vb_idx, + index_start: draw.first_index, + index_count: draw.index_count, + base_vertex: draw.base_vertex, + transform: cmd.model, + wmin, + wmax, + cutout_idx, + skinned: false, + // A swaying caster changes shape every frame, so it + // cannot share the cached static depth: signature goes + // to the per-frame nonce and it renders as dynamic. + // That is exactly the cost `foliage_shadow_motion` + // gates, which is why it defaults off. + sig: if fol > 0.0 { + nonce + } else { + entry_sig(1, cmd.cache_handle, cmd.mesh_idx as u64, &cmd.model) + }, + dynamic: fol > 0.0, + joint_offset: 0.0, + foliage: fol, + key: caster_id( + 1, + cmd.cache_handle, + cmd.mesh_idx as u64, + &cmd.model, + ), + }); + } + } + } + } + + // Material-system draws (terrain / building / trees rendered through + // compiled materials). Same GpuMesh cache as drawModel — the command + // carries a CPU-side copy of its model matrix precisely for this + // pass. `commands` holds only the opaque + cutout buckets, so water / + // glass / additive effects never cast. Instanced draws (the 20k-blade + // grass field) are skipped deliberately: vs_shadow has no instance + // stream, and per-blade grass shadows are sub-texel noise at these + // cascade resolutions anyway. + for cmd in self.material_system.commands.iter() { + if cmd.instance.is_some() { + continue; + } + if let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.mesh_handle) { + if cmd.mesh_idx < meshes.len() { + let mesh = &meshes[cmd.mesh_idx]; + let draw = self.gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count); + let vb_idx = shadow_vbs.len(); + shadow_vbs.push(draw.vertex); + shadow_ibs.push(draw.index); + // MASK-material meshes (leaf cards) keep their dappled + // alpha-tested shadows, same as the other two paths. + let cutout_idx = match &mesh.shadow_cutout_bg { + Some(bg) => { + let i = cutout_bgs.len(); + cutout_bgs.push(bg); + i as i32 + } + None => -1, + }; let (wmin, wmax) = transform_aabb(&cmd.model, mesh.local_min, mesh.local_max); shadow_nodes.push(ShadowDrawEntry { vb_idx, ib_idx: vb_idx, - index_start: 0, - index_count: mesh.index_count, + index_start: draw.first_index, + index_count: draw.index_count, + base_vertex: draw.base_vertex, transform: cmd.model, wmin, wmax, cutout_idx, skinned: false, - // A swaying caster changes shape every frame, so it - // cannot share the cached static depth: signature goes - // to the per-frame nonce and it renders as dynamic. - // That is exactly the cost `foliage_shadow_motion` - // gates, which is why it defaults off. - sig: if fol > 0.0 { nonce } - else { entry_sig(1, cmd.cache_handle, cmd.mesh_idx as u64, &cmd.model) }, - dynamic: fol > 0.0, + sig: entry_sig(2, cmd.mesh_handle, cmd.mesh_idx as u64, &cmd.model), + dynamic: false, joint_offset: 0.0, - foliage: fol, - key: caster_id(1, cmd.cache_handle, cmd.mesh_idx as u64, &cmd.model), + foliage: 0.0, + key: caster_id(2, cmd.mesh_handle, cmd.mesh_idx as u64, &cmd.model), }); } } } - } - // Material-system draws (terrain / building / trees rendered through - // compiled materials). Same GpuMesh cache as drawModel — the command - // carries a CPU-side copy of its model matrix precisely for this - // pass. `commands` holds only the opaque + cutout buckets, so water / - // glass / additive effects never cast. Instanced draws (the 20k-blade - // grass field) are skipped deliberately: vs_shadow has no instance - // stream, and per-blade grass shadows are sub-texel noise at these - // cascade resolutions anyway. - for cmd in self.material_system.commands.iter() { - if cmd.instance.is_some() { continue; } - if let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.mesh_handle) { - if cmd.mesh_idx < meshes.len() { - let mesh = &meshes[cmd.mesh_idx]; - let vb_idx = shadow_vbs.len(); - shadow_vbs.push(&mesh.vb); - shadow_ibs.push(&mesh.ib); - // MASK-material meshes (leaf cards) keep their dappled - // alpha-tested shadows, same as the other two paths. - let cutout_idx = match &mesh.shadow_cutout_bg { - Some(bg) => { let i = cutout_bgs.len(); cutout_bgs.push(bg); i as i32 } - None => -1, - }; + if let (Some(receivers), Some(camera_planes)) = + (vsm_receiver_bounds.as_mut(), camera_planes.as_ref()) + { + // Scene nodes can receive without casting, so gather them + // independently from the caster list. + for (i, (_handle, node)) in scene.nodes.iter().enumerate() { + if !node.visible + || node.gi_only + || !node.receive_shadow + || node.indices.is_empty() + || node.gpu_vb.is_none() + || node.gpu_ib.is_none() + { + continue; + } + if node.cast_shadow + && !prev_caster_ids.is_empty() + && !prev_caster_ids.contains(&caster_id( + 0, + i as u64, + node.gpu_index_count as u64, + &node.transform, + )) + { + continue; + } let (wmin, wmax) = - transform_aabb(&cmd.model, mesh.local_min, mesh.local_max); - shadow_nodes.push(ShadowDrawEntry { - vb_idx, - ib_idx: vb_idx, - index_start: 0, - index_count: mesh.index_count, - transform: cmd.model, - wmin, - wmax, - cutout_idx, - skinned: false, - sig: entry_sig(2, cmd.mesh_handle, cmd.mesh_idx as u64, &cmd.model), - dynamic: false, - joint_offset: 0.0, - foliage: 0.0, - key: caster_id(2, cmd.mesh_handle, cmd.mesh_idx as u64, &cmd.model), + transform_aabb(&node.transform, node.bounds_min, node.bounds_max); + push_vsm_receiver(receivers, camera_planes, wmin, wmax); + } + // Immediate geometry has world-space bounds already. + for call in self.draw_calls_3d.iter() { + if call.has_skinned { + continue; + } + push_vsm_receiver(receivers, camera_planes, call.wmin, call.wmax); + } + // Cached-model and material commands are visible main-pass + // draws and therefore shadow receivers. Recomputing their + // bounds is confined to the explicitly enabled VSM path. + for cmd in self.model_draw_commands.iter() { + if cmd.skinned + || (!prev_caster_ids.is_empty() + && !prev_caster_ids.contains(&caster_id( + 1, + cmd.cache_handle, + cmd.mesh_idx as u64, + &cmd.model, + ))) + { + continue; + } + let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.cache_handle) else { + continue; + }; + let Some(mesh) = meshes.get(cmd.mesh_idx) else { + continue; + }; + let (wmin, wmax) = cmd.bounds_override.unwrap_or_else(|| { + transform_aabb(&cmd.model, mesh.local_min, mesh.local_max) }); + push_vsm_receiver(receivers, camera_planes, wmin, wmax); + } + for cmd in self.material_system.commands.iter() { + if cmd.instance.is_some() { + continue; + } + if !prev_caster_ids.is_empty() + && !prev_caster_ids.contains(&caster_id( + 2, + cmd.mesh_handle, + cmd.mesh_idx as u64, + &cmd.model, + )) + { + continue; + } + let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.mesh_handle) else { + continue; + }; + let Some(mesh) = meshes.get(cmd.mesh_idx) else { + continue; + }; + let (wmin, wmax) = transform_aabb(&cmd.model, mesh.local_min, mesh.local_max); + push_vsm_receiver(receivers, camera_planes, wmin, wmax); } } - } - // EN-043 — promote MOVERS to the dynamic set. - // - // A non-skinned cached caster whose transform changed since last frame used - // to stay in the STATIC set with a different content signature. That - // invalidated the cascade's cached depth, so every tree, wall and terrain - // tile in the world re-rendered into all three cascades — every frame — - // because one pickup was bobbing. Measured on the shooter's title screen: - // shadow_pass GPU 6.0-7.0 ms against the 0.1-1.7 ms the cache was built to - // deliver. - // - // A caster that moves is DYNAMIC, by definition. Dynamic casters draw on - // top of the cached static depth every frame and never invalidate it, which - // is exactly what a moving object needs and costs one draw instead of a - // thousand. - for e in shadow_nodes.iter_mut() { - if e.dynamic { continue; } - caster_ids_now.insert(e.key); - // Not here last frame at this exact transform => it moved (or is new). - // Either way: dynamic. A first-frame caster costs one extra dynamic draw - // and settles into the static set next frame. - if !prev_caster_ids.is_empty() && !prev_caster_ids.contains(&e.key) { - e.dynamic = true; - e.sig = nonce; + // EN-043 — promote MOVERS to the dynamic set. + // + // A non-skinned cached caster whose transform changed since last frame used + // to stay in the STATIC set with a different content signature. That + // invalidated the cascade's cached depth, so every tree, wall and terrain + // tile in the world re-rendered into all three cascades — every frame — + // because one pickup was bobbing. Measured on the shooter's title screen: + // shadow_pass GPU 6.0-7.0 ms against the 0.1-1.7 ms the cache was built to + // deliver. + // + // A caster that moves is DYNAMIC, by definition. Dynamic casters draw on + // top of the cached static depth every frame and never invalidate it, which + // is exactly what a moving object needs and costs one draw instead of a + // thousand. + for e in shadow_nodes.iter_mut() { + if e.dynamic { + continue; + } + caster_ids_now.insert(e.key); + // Not here last frame at this exact transform => it moved (or is new). + // Either way: dynamic. A first-frame caster costs one extra dynamic draw + // and settles into the static set next frame. + if !prev_caster_ids.is_empty() && !prev_caster_ids.contains(&e.key) { + e.dynamic = true; + e.sig = nonce; + } } - } - let cascade_planes: [[[f32; 4]; 6]; crate::shadows::NUM_CASCADES] = - std::array::from_fn(|c| { - crate::scene::extract_frustum_planes(&self.shadow_map.light_vps[c]) - }); - let mut cascade_indices: [Vec; crate::shadows::NUM_CASCADES] = - std::array::from_fn(|_| Vec::with_capacity(shadow_nodes.len())); - for (i, entry) in shadow_nodes.iter().enumerate() { - let has_bounds = entry.wmin[0] <= entry.wmax[0]; - for c in 0..crate::shadows::NUM_CASCADES { - if has_bounds - && crate::scene::aabb_outside_frustum(&cascade_planes[c], entry.wmin, entry.wmax) - { - continue; + let cascade_planes: [[[f32; 4]; 6]; crate::shadows::NUM_CASCADES] = + std::array::from_fn(|c| { + crate::scene::extract_frustum_planes(&self.shadow_map.light_vps[c]) + }); + let mut cascade_indices: [Vec; crate::shadows::NUM_CASCADES] = + std::array::from_fn(|_| Vec::with_capacity(shadow_nodes.len())); + for (i, entry) in shadow_nodes.iter().enumerate() { + let has_bounds = entry.wmin[0] <= entry.wmax[0]; + for c in 0..crate::shadows::NUM_CASCADES { + if has_bounds + && crate::scene::aabb_outside_frustum( + &cascade_planes[c], + entry.wmin, + entry.wmax, + ) + { + continue; + } + cascade_indices[c].push(i); } - cascade_indices[c].push(i); } - } - // Per-cascade STATIC content signature: fold every surviving - // non-dynamic caster's identity, in draw order. The static depth - // cache re-renders only when its cascade's VP changed, this - // signature changed, or a whole-pass invalidator fired. Dynamic - // casters are excluded — they draw on top of the cached static - // depth every frame and never invalidate it. - let mut cascade_sigs = [0u64; crate::shadows::NUM_CASCADES]; - for c in 0..crate::shadows::NUM_CASCADES { - let mut h = FNV_OFFSET; - for &ei in cascade_indices[c].iter() { - if shadow_nodes[ei].dynamic { continue; } - h = fnv1a_bytes(h, &shadow_nodes[ei].sig.to_le_bytes()); + // Per-cascade STATIC content signature: fold every surviving + // non-dynamic caster's identity, in draw order. The static depth + // cache re-renders only when its cascade's VP changed, this + // signature changed, or a whole-pass invalidator fired. Dynamic + // casters are excluded — they draw on top of the cached static + // depth every frame and never invalidate it. + let mut cascade_sigs = [0u64; crate::shadows::NUM_CASCADES]; + for c in 0..crate::shadows::NUM_CASCADES { + let mut h = FNV_OFFSET; + for &ei in cascade_indices[c].iter() { + if shadow_nodes[ei].dynamic { + continue; + } + h = fnv1a_bytes(h, &shadow_nodes[ei].sig.to_le_bytes()); + } + cascade_sigs[c] = h; } - cascade_sigs[c] = h; - } - - // Render each cascade. Static casters live in a cached depth - // texture ("cached whole-scene shadows") re-rendered only when - // the cascade's VP or static content changes; every frame the - // live texture is refreshed by copy and the few dynamic casters - // draw on top with Load. A cascade with no change is skipped - // entirely. Uniform slots: static casters use the head of the - // cascade's region, dynamic casters the reserved tail — the - // ranges are disjoint because every write_buffer lands at - // submit, before any encoded pass executes. - for cascade in 0..crate::shadows::NUM_CASCADES { - let stride = crate::shadows::SHADOW_UNIFORM_STRIDE as usize; - let max = crate::shadows::SHADOW_MAX_NODES as usize; - let max_dynamic = crate::shadows::SHADOW_MAX_DYNAMIC as usize; - let max_static = max - max_dynamic; - let cascade_base = cascade * stride * max; - let cascade_vp = self.shadow_map.light_vps[cascade]; - let entries = &cascade_indices[cascade]; + self.shadow_map.virtual_map.prepare( + &self.queue, + self.shadow_map.light_vps, + cascade_sigs, + vsm_receiver_bounds.as_deref(), + ); - let vp_changed = self.shadow_map.rendered_light_vps - .map(|vps| vps[cascade] != self.shadow_map.light_vps[cascade]) - .unwrap_or(true); - let static_stale = force_all || vp_changed - || self.shadow_map.rendered_cascade_sig[cascade] != cascade_sigs[cascade]; - let dyn_now = entries.iter().any(|&ei| shadow_nodes[ei].dynamic); - if !static_stale && !dyn_now && !self.shadow_map.had_dynamic[cascade] { - // Live texture already holds exactly this content. - continue; - } + // Render each cascade. Static casters live in a cached depth + // texture ("cached whole-scene shadows") re-rendered only when + // the cascade's VP or static content changes; every frame the + // live texture is refreshed by copy and the few dynamic casters + // draw on top with Load. A cascade with no change is skipped + // entirely. Uniform slots: static casters use the head of the + // cascade's region, dynamic casters the reserved tail — the + // ranges are disjoint because every write_buffer lands at + // submit, before any encoded pass executes. + for cascade in 0..crate::shadows::NUM_CASCADES { + let stride = crate::shadows::SHADOW_UNIFORM_STRIDE as usize; + let max = crate::shadows::SHADOW_MAX_NODES as usize; + let max_dynamic = crate::shadows::SHADOW_MAX_DYNAMIC as usize; + let max_static = max - max_dynamic; + let cascade_base = cascade * stride * max; + let cascade_vp = self.shadow_map.light_vps[cascade]; + let entries = &cascade_indices[cascade]; - if static_stale { - let static_entries: Vec = entries.iter().copied() - .filter(|&ei| !shadow_nodes[ei].dynamic) - .take(max_static) - .collect(); - let mut uniform_data: Vec = - vec![0u8; stride * static_entries.len().max(1)]; - for (slot, &ei) in static_entries.iter().enumerate() { - let uniforms = crate::shadows::ShadowUniforms { - light_vp: cascade_vp, - model: shadow_nodes[ei].transform, - misc: [shadow_nodes[ei].joint_offset, 0.0, shadow_nodes[ei].foliage, 0.0], - wind: self.lighting_uniforms.wind, - }; - let off = slot * stride; - uniform_data[off..off + std::mem::size_of::()] - .copy_from_slice(bytemuck::bytes_of(&uniforms)); - } - // Each cascade owns its own slice of the uniform buffer — - // all write_buffer calls execute at submit, BEFORE any of - // the encoded passes run, so sharing one region would - // leave every cascade rendering with the last cascade's - // matrices (the no-near-shadows bug). - if !static_entries.is_empty() { - self.queue.write_buffer( - &self.shadow_map.uniform_buffer, - cascade_base as u64, - &uniform_data[..static_entries.len() * stride], - ); + let vp_changed = self + .shadow_map + .rendered_light_vps + .map(|vps| vps[cascade] != self.shadow_map.light_vps[cascade]) + .unwrap_or(true); + let static_stale = force_all + || vp_changed + || self.shadow_map.rendered_cascade_sig[cascade] != cascade_sigs[cascade]; + let dyn_now = entries.iter().any(|&ei| shadow_nodes[ei].dynamic); + if !static_stale && !dyn_now && !self.shadow_map.had_dynamic[cascade] { + // Live texture already holds exactly this content. + continue; } - { - let shadow_ts = profiler.pass_timestamp_writes("shadow_pass"); - let mut shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("shadow_pass_static"), - color_attachments: &[], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: &self.shadow_map.static_depth_views[cascade], - depth_ops: Some(wgpu::Operations { - load: wgpu::LoadOp::Clear(1.0), - store: wgpu::StoreOp::Store, - }), - stencil_ops: None, - }), - timestamp_writes: shadow_ts, - occlusion_query_set: None, - multiview_mask: None, - }); - // Pipeline kind per caster: 0 opaque, 1 cutout, 2 skinned. - // Only switch when the kind changes. - let mut cur_kind: u8 = 0; - shadow_pass.set_pipeline(&self.shadow_map.pipeline); + if static_stale { + let static_entries: Vec = entries + .iter() + .copied() + .filter(|&ei| !shadow_nodes[ei].dynamic) + .take(max_static) + .collect(); + let mut uniform_data: Vec = vec![0u8; stride * static_entries.len().max(1)]; for (slot, &ei) in static_entries.iter().enumerate() { - let entry = &shadow_nodes[ei]; - let offset = (cascade_base + slot * stride) as u32; - let kind: u8 = if entry.skinned { 2 } - else if entry.cutout_idx >= 0 { 1 } - else { 0 }; - if kind != cur_kind { - shadow_pass.set_pipeline(match kind { - 1 => &self.shadow_map.pipeline_cutout, - 2 => &self.shadow_map.pipeline_skinned, - _ => &self.shadow_map.pipeline, + let uniforms = crate::shadows::ShadowUniforms { + light_vp: cascade_vp, + model: shadow_nodes[ei].transform, + misc: [ + shadow_nodes[ei].joint_offset, + 0.0, + shadow_nodes[ei].foliage, + 0.0, + ], + wind: self.lighting_uniforms.wind, + }; + let off = slot * stride; + uniform_data + [off..off + std::mem::size_of::()] + .copy_from_slice(bytemuck::bytes_of(&uniforms)); + } + // Each cascade owns its own slice of the uniform buffer — + // all write_buffer calls execute at submit, BEFORE any of + // the encoded passes run, so sharing one region would + // leave every cascade rendering with the last cascade's + // matrices (the no-near-shadows bug). + if !static_entries.is_empty() { + self.queue.write_buffer( + &self.shadow_map.uniform_buffer, + cascade_base as u64, + &uniform_data[..static_entries.len() * stride], + ); + } + { + let shadow_ts = profiler.pass_timestamp_writes("shadow_pass"); + let mut shadow_pass = + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("shadow_pass_static"), + color_attachments: &[], + depth_stencil_attachment: Some( + wgpu::RenderPassDepthStencilAttachment { + view: &self.shadow_map.static_depth_views[cascade], + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }, + ), + timestamp_writes: shadow_ts, + occlusion_query_set: None, + multiview_mask: None, }); - cur_kind = kind; - } - shadow_pass.set_bind_group(0, &self.shadow_map.uniform_bind_group, &[offset]); - if kind == 1 { - shadow_pass.set_bind_group(1, cutout_bgs[entry.cutout_idx as usize], &[]); - } else if kind == 2 { - shadow_pass.set_bind_group(1, &self.joint_bind_group, &[]); + + // Pipeline kind per caster: 0 opaque, 1 cutout, 2 skinned. + // Only switch when the kind changes. + let mut cur_kind: u8 = 0; + shadow_pass.set_pipeline(&self.shadow_map.pipeline); + for (slot, &ei) in static_entries.iter().enumerate() { + let entry = &shadow_nodes[ei]; + let offset = (cascade_base + slot * stride) as u32; + let kind: u8 = if entry.skinned { + 2 + } else if entry.cutout_idx >= 0 { + 1 + } else { + 0 + }; + if kind != cur_kind { + shadow_pass.set_pipeline(match kind { + 1 => &self.shadow_map.pipeline_cutout, + 2 => &self.shadow_map.pipeline_skinned, + _ => &self.shadow_map.pipeline, + }); + cur_kind = kind; + } + shadow_pass.set_bind_group( + 0, + &self.shadow_map.uniform_bind_group, + &[offset], + ); + if kind == 1 { + shadow_pass.set_bind_group( + 1, + cutout_bgs[entry.cutout_idx as usize], + &[], + ); + } else if kind == 2 { + shadow_pass.set_bind_group(1, &self.joint_bind_group, &[]); + } + shadow_pass.set_vertex_buffer(0, shadow_vbs[entry.vb_idx].slice(..)); + shadow_pass.set_index_buffer( + shadow_ibs[entry.ib_idx].slice(..), + wgpu::IndexFormat::Uint32, + ); + shadow_pass.draw_indexed( + entry.index_start..entry.index_start + entry.index_count, + entry.base_vertex, + 0..1, + ); } - shadow_pass.set_vertex_buffer(0, shadow_vbs[entry.vb_idx].slice(..)); - shadow_pass.set_index_buffer(shadow_ibs[entry.ib_idx].slice(..), wgpu::IndexFormat::Uint32); - shadow_pass.draw_indexed( - entry.index_start..entry.index_start + entry.index_count, - 0, - 0..1, - ); } + self.shadow_map.rendered_cascade_sig[cascade] = cascade_sigs[cascade]; } - self.shadow_map.rendered_cascade_sig[cascade] = cascade_sigs[cascade]; - } - // Refresh the live texture from the static cache, then draw - // dynamic casters on top. - encoder.copy_texture_to_texture( - self.shadow_map.static_depth_textures[cascade].as_image_copy(), - self.shadow_map.depth_textures[cascade].as_image_copy(), - wgpu::Extent3d { - width: crate::shadows::CASCADE_MAP_SIZE, - height: crate::shadows::CASCADE_MAP_SIZE, - depth_or_array_layers: 1, - }, - ); - if dyn_now { - let dyn_base = cascade_base + stride * max_static; - // EN-042 — the dynamic budget can overflow, and the overflow IS - // dropped. Which caster gets dropped must not be an accident of - // queue order. It was, and it cost this project twice: both times - // the thing that silently vanished was the player's own shadow, and - // both times the frame rate went UP and looked like a win. - // - // Rank them, so if we must lose a shadow we lose one nobody misses: - // characters first (the shadow a player actually looks at), then - // other movers, then foliage — a swaying canopy shadow is soft and - // dappled and the most forgiving thing in the frame. - let mut dyn_entries: Vec = entries.iter().copied() - .filter(|&ei| shadow_nodes[ei].dynamic) - .collect(); - if dyn_entries.len() > max_dynamic { - dyn_entries.sort_by_key(|&ei| { - let e = &shadow_nodes[ei]; - if e.skinned { 0u8 } - else if e.foliage > 0.0 { 2u8 } - else { 1u8 } - }); - dyn_entries.truncate(max_dynamic); - } - let mut uniform_data: Vec = - vec![0u8; stride * dyn_entries.len().max(1)]; - for (slot, &ei) in dyn_entries.iter().enumerate() { - let uniforms = crate::shadows::ShadowUniforms { - light_vp: cascade_vp, - model: shadow_nodes[ei].transform, - misc: [shadow_nodes[ei].joint_offset, 0.0, shadow_nodes[ei].foliage, 0.0], - wind: self.lighting_uniforms.wind, - }; - let off = slot * stride; - uniform_data[off..off + std::mem::size_of::()] - .copy_from_slice(bytemuck::bytes_of(&uniforms)); - } - self.queue.write_buffer( - &self.shadow_map.uniform_buffer, - dyn_base as u64, - &uniform_data[..dyn_entries.len() * stride], + // Refresh the live texture from the static cache, then draw + // dynamic casters on top. + encoder.copy_texture_to_texture( + self.shadow_map.static_depth_textures[cascade].as_image_copy(), + self.shadow_map.depth_textures[cascade].as_image_copy(), + wgpu::Extent3d { + width: crate::shadows::CASCADE_MAP_SIZE, + height: crate::shadows::CASCADE_MAP_SIZE, + depth_or_array_layers: 1, + }, ); - { - let shadow_ts = profiler.pass_timestamp_writes("shadow_pass"); - let mut shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("shadow_pass_dynamic"), - color_attachments: &[], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: &self.shadow_map.depth_views[cascade], - depth_ops: Some(wgpu::Operations { - // Refreshed static depth is the base. - load: wgpu::LoadOp::Load, - store: wgpu::StoreOp::Store, - }), - stencil_ops: None, - }), - timestamp_writes: shadow_ts, - occlusion_query_set: None, - multiview_mask: None, - }); - let mut cur_kind: u8 = 0; - shadow_pass.set_pipeline(&self.shadow_map.pipeline); + if dyn_now { + let dyn_base = cascade_base + stride * max_static; + // EN-042 — the dynamic budget can overflow, and the overflow IS + // dropped. Which caster gets dropped must not be an accident of + // queue order. It was, and it cost this project twice: both times + // the thing that silently vanished was the player's own shadow, and + // both times the frame rate went UP and looked like a win. + // + // Rank them, so if we must lose a shadow we lose one nobody misses: + // characters first (the shadow a player actually looks at), then + // other movers, then foliage — a swaying canopy shadow is soft and + // dappled and the most forgiving thing in the frame. + let mut dyn_entries: Vec = entries + .iter() + .copied() + .filter(|&ei| shadow_nodes[ei].dynamic) + .collect(); + if dyn_entries.len() > max_dynamic { + dyn_entries.sort_by_key(|&ei| { + let e = &shadow_nodes[ei]; + if e.skinned { + 0u8 + } else if e.foliage > 0.0 { + 2u8 + } else { + 1u8 + } + }); + dyn_entries.truncate(max_dynamic); + } + let mut uniform_data: Vec = vec![0u8; stride * dyn_entries.len().max(1)]; for (slot, &ei) in dyn_entries.iter().enumerate() { - let entry = &shadow_nodes[ei]; - let offset = (dyn_base + slot * stride) as u32; - let kind: u8 = if entry.skinned { 2 } - else if entry.cutout_idx >= 0 { 1 } - else { 0 }; - if kind != cur_kind { - shadow_pass.set_pipeline(match kind { - 1 => &self.shadow_map.pipeline_cutout, - 2 => &self.shadow_map.pipeline_skinned, - _ => &self.shadow_map.pipeline, + let uniforms = crate::shadows::ShadowUniforms { + light_vp: cascade_vp, + model: shadow_nodes[ei].transform, + misc: [ + shadow_nodes[ei].joint_offset, + 0.0, + shadow_nodes[ei].foliage, + 0.0, + ], + wind: self.lighting_uniforms.wind, + }; + let off = slot * stride; + uniform_data + [off..off + std::mem::size_of::()] + .copy_from_slice(bytemuck::bytes_of(&uniforms)); + } + self.queue.write_buffer( + &self.shadow_map.uniform_buffer, + dyn_base as u64, + &uniform_data[..dyn_entries.len() * stride], + ); + { + let shadow_ts = profiler.pass_timestamp_writes("shadow_pass"); + let mut shadow_pass = + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("shadow_pass_dynamic"), + color_attachments: &[], + depth_stencil_attachment: Some( + wgpu::RenderPassDepthStencilAttachment { + view: &self.shadow_map.depth_views[cascade], + depth_ops: Some(wgpu::Operations { + // Refreshed static depth is the base. + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }, + ), + timestamp_writes: shadow_ts, + occlusion_query_set: None, + multiview_mask: None, }); - cur_kind = kind; + let mut cur_kind: u8 = 0; + shadow_pass.set_pipeline(&self.shadow_map.pipeline); + for (slot, &ei) in dyn_entries.iter().enumerate() { + let entry = &shadow_nodes[ei]; + let offset = (dyn_base + slot * stride) as u32; + let kind: u8 = if entry.skinned { + 2 + } else if entry.cutout_idx >= 0 { + 1 + } else { + 0 + }; + if kind != cur_kind { + shadow_pass.set_pipeline(match kind { + 1 => &self.shadow_map.pipeline_cutout, + 2 => &self.shadow_map.pipeline_skinned, + _ => &self.shadow_map.pipeline, + }); + cur_kind = kind; + } + shadow_pass.set_bind_group( + 0, + &self.shadow_map.uniform_bind_group, + &[offset], + ); + if kind == 1 { + shadow_pass.set_bind_group( + 1, + cutout_bgs[entry.cutout_idx as usize], + &[], + ); + } else if kind == 2 { + // Joint matrices for skinning the animated + // characters in the immediate-mode batch. + shadow_pass.set_bind_group(1, &self.joint_bind_group, &[]); + } + shadow_pass.set_vertex_buffer(0, shadow_vbs[entry.vb_idx].slice(..)); + shadow_pass.set_index_buffer( + shadow_ibs[entry.ib_idx].slice(..), + wgpu::IndexFormat::Uint32, + ); + shadow_pass.draw_indexed( + entry.index_start..entry.index_start + entry.index_count, + entry.base_vertex, + 0..1, + ); } - shadow_pass.set_bind_group(0, &self.shadow_map.uniform_bind_group, &[offset]); - if kind == 1 { - shadow_pass.set_bind_group(1, cutout_bgs[entry.cutout_idx as usize], &[]); - } else if kind == 2 { - // Joint matrices for skinning the animated - // characters in the immediate-mode batch. - shadow_pass.set_bind_group(1, &self.joint_bind_group, &[]); + } + } + self.shadow_map.had_dynamic[cascade] = dyn_now; + self.shadow_map.live_cascade_generation[cascade] = + self.shadow_map.live_cascade_generation[cascade].wrapping_add(1); + } + + // Issue #132 — render a bounded number of dirty directional virtual + // pages. This is opt-in (`BLOOM_VSM=1`) and does not yet replace CSM + // sampling. Only static casters enter this cache; dynamic casters + // continue to use the always-correct live cascades. + let pending_vsm_pages = self.shadow_map.virtual_map.pending().to_vec(); + if !pending_vsm_pages.is_empty() { + profiler.begin("virtual_shadow_pages"); + let stride = crate::shadows::SHADOW_UNIFORM_STRIDE as usize; + let max_entries = crate::shadows::SHADOW_MAX_NODES as usize; + let page_region = stride * max_entries; + let mut rendered_vsm_pages = Vec::with_capacity(pending_vsm_pages.len()); + + for (page_slot, request) in pending_vsm_pages.iter().enumerate() { + let level = request.page.level as usize; + let page_vp = crate::virtual_shadows::directional_page_vp( + self.shadow_map.light_vps[level], + request.page, + ); + let page_planes = crate::scene::extract_frustum_planes(&page_vp); + let page_entries: Vec = cascade_indices[level] + .iter() + .copied() + .filter(|&entry_index| { + let entry = &shadow_nodes[entry_index]; + if entry.dynamic { + return false; + } + entry.wmin[0] > entry.wmax[0] + || !crate::scene::aabb_outside_frustum( + &page_planes, + entry.wmin, + entry.wmax, + ) + }) + .take(max_entries) + .collect(); + + let mut uniform_data = vec![0u8; stride * page_entries.len().max(1)]; + for (slot, &entry_index) in page_entries.iter().enumerate() { + let entry = &shadow_nodes[entry_index]; + let uniforms = crate::shadows::ShadowUniforms { + light_vp: page_vp, + model: entry.transform, + misc: [entry.joint_offset, 0.0, entry.foliage, 0.0], + wind: self.lighting_uniforms.wind, + }; + let offset = slot * stride; + uniform_data[offset + ..offset + std::mem::size_of::()] + .copy_from_slice(bytemuck::bytes_of(&uniforms)); + } + let page_base = page_slot * page_region; + if !page_entries.is_empty() { + self.queue.write_buffer( + self.shadow_map + .virtual_map + .render_uniform_buffer() + .expect("pending VSM pages require GPU resources"), + page_base as u64, + &uniform_data[..page_entries.len() * stride], + ); + } + + { + let page_view = self + .shadow_map + .virtual_map + .physical_page_view(request.physical_page) + .expect("resident VSM page requires a physical view"); + let vsm_uniforms = self + .shadow_map + .virtual_map + .render_uniform_bind_group() + .expect("pending VSM pages require render uniforms"); + let shadow_ts = profiler.pass_timestamp_writes("virtual_shadow_pages"); + let mut page_pass = + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("virtual_shadow_page"), + color_attachments: &[], + depth_stencil_attachment: Some( + wgpu::RenderPassDepthStencilAttachment { + view: page_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }, + ), + timestamp_writes: shadow_ts, + occlusion_query_set: None, + multiview_mask: None, + }); + let mut current_kind = 0u8; + page_pass.set_pipeline(&self.shadow_map.pipeline); + for (slot, &entry_index) in page_entries.iter().enumerate() { + let entry = &shadow_nodes[entry_index]; + let offset = (page_base + slot * stride) as u32; + let kind = if entry.cutout_idx >= 0 { 1 } else { 0 }; + if kind != current_kind { + page_pass.set_pipeline(if kind == 1 { + &self.shadow_map.pipeline_cutout + } else { + &self.shadow_map.pipeline + }); + current_kind = kind; + } + page_pass.set_bind_group(0, vsm_uniforms, &[offset]); + if kind == 1 { + page_pass.set_bind_group( + 1, + cutout_bgs[entry.cutout_idx as usize], + &[], + ); + } + page_pass.set_vertex_buffer(0, shadow_vbs[entry.vb_idx].slice(..)); + page_pass.set_index_buffer( + shadow_ibs[entry.ib_idx].slice(..), + wgpu::IndexFormat::Uint32, + ); + page_pass.draw_indexed( + entry.index_start..entry.index_start + entry.index_count, + entry.base_vertex, + 0..1, + ); } - shadow_pass.set_vertex_buffer(0, shadow_vbs[entry.vb_idx].slice(..)); - shadow_pass.set_index_buffer(shadow_ibs[entry.ib_idx].slice(..), wgpu::IndexFormat::Uint32); - shadow_pass.draw_indexed( - entry.index_start..entry.index_start + entry.index_count, + } + rendered_vsm_pages.push((request.page, cascade_sigs[level])); + } + self.shadow_map + .virtual_map + .finish_rendered_pages(&self.queue, &rendered_vsm_pages); + profiler.end("virtual_shadow_pages"); + } + if vsm_requested { + let has_dynamic = shadow_nodes.iter().any(|entry| entry.dynamic); + if has_dynamic && !self.shadow_map.virtual_map.dynamic_page_mask_worthwhile() { + self.shadow_map + .virtual_map + .set_global_dynamic_fallback(&self.queue, true); + } else { + let dynamic_bounds: Vec<([f32; 3], [f32; 3])> = shadow_nodes + .iter() + .filter(|entry| entry.dynamic) + .map(|entry| (entry.wmin, entry.wmax)) + .collect(); + let dynamic_fallback_pages = + crate::virtual_shadows::directional_dynamic_fallback_pages( + self.shadow_map.light_vps, + &dynamic_bounds, 0, - 0..1, ); - } + self.shadow_map + .virtual_map + .set_dynamic_fallback_pages(&self.queue, dynamic_fallback_pages); } } - self.shadow_map.had_dynamic[cascade] = dyn_now; - } - // Cache bookkeeping — next frame skips every cascade whose VP - // and caster content stay put. - self.shadow_caster_tf = caster_ids_now; - self.shadow_map.rendered_light_vps = Some(self.shadow_map.light_vps); - self.shadow_map.rendered_light_dir = Some(light_dir); - self.shadow_map.rendered_scene_version = scene_ver; - self.shadow_map.dirty = false; - } + // Cache bookkeeping — next frame skips every cascade whose VP + // and caster content stay put. + self.shadow_caster_tf = caster_ids_now; + self.shadow_map.rendered_light_vps = Some(self.shadow_map.light_vps); + self.shadow_map.rendered_light_dir = Some(light_dir); + self.shadow_map.rendered_scene_version = scene_ver; + self.shadow_map.dirty = false; + } - profiler.end("shadow_pass"); + profiler.end("shadow_pass"); } } diff --git a/native/shared/src/renderer/sorted_transparency.rs b/native/shared/src/renderer/sorted_transparency.rs new file mode 100644 index 00000000..5e118d33 --- /dev/null +++ b/native/shared/src/renderer/sorted_transparency.rs @@ -0,0 +1,482 @@ +//! Shared sorted-translucency collection and dispatch. +//! +//! Imported BLEND geometry and custom translucent material commands used to +//! sort independently and then render as two whole lists. Each list was +//! internally stable, but a nearer draw from the first list could be blended +//! before a farther draw from the second. This module gives conventional +//! sorted translucency one depth/source/stable-ID order and one dispatcher. + +use super::*; + +pub(super) fn sorted_interleaving_enabled() -> bool { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *ENABLED.get_or_init(|| { + std::env::var("BLOOM_SORTED_INTERLEAVING") + .map(|value| { + !matches!( + value.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "disabled" + ) + }) + .unwrap_or(true) + }) +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum SortedPipelineKey { + Imported { + double_sided: bool, + layered: bool, + secondary_uv: bool, + }, + Custom(material_system::MaterialHandle), +} + +#[derive(Copy, Clone, Debug)] +struct SortedTransparencyKey { + view_depth: f32, + /// Imported draws precede custom draws only at exactly equal depth. This + /// is a deterministic tie policy, not a pass boundary. + source_rank: u8, + stable_id: usize, +} + +fn compare_sorted_transparency( + left: &SortedTransparencyKey, + right: &SortedTransparencyKey, +) -> std::cmp::Ordering { + right + .view_depth + .total_cmp(&left.view_depth) + .then_with(|| left.source_rank.cmp(&right.source_rank)) + .then_with(|| left.stable_id.cmp(&right.stable_id)) +} + +impl material_system::MaterialSystem { + pub(crate) fn has_temporal_reactive_commands(&self) -> bool { + self.translucent_commands.iter().any(|command| { + command + .material + .checked_sub(1) + .and_then(|index| self.pipelines.get(index as usize)) + .and_then(|pipeline| pipeline.as_ref()) + .is_some_and(|pipeline| pipeline.writes_reactive) + }) + } + + /// Compile attachment-compatible siblings only for custom materials that + /// actually enter a mixed imported/custom TAA-reactive sorted frame. + pub(crate) fn ensure_translucent_reactive_pipelines(&mut self, device: &wgpu::Device) { + for command_index in 0..self.translucent_commands.len() { + let material = self.translucent_commands[command_index].material; + let Some(index) = material.checked_sub(1).map(|value| value as usize) else { + continue; + }; + if let Some(Some(pipeline)) = self.pipelines.get_mut(index) { + if pipeline.ensure_reactive_pipeline(device) { + self.pipeline_creation_count = self.pipeline_creation_count.saturating_add(1); + } + } + } + } + + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub(crate) fn reactive_translucent_pipeline_count(&self) -> usize { + self.pipelines + .iter() + .flatten() + .filter(|pipeline| pipeline.reactive_pipeline.is_some()) + .count() + } + + /// Draw one custom translucent command. `bind_material_state` is false + /// only for an adjacent command using the same custom material; any + /// imported draw between them resets the caller's state key. + pub(crate) fn dispatch_translucent_command<'pass>( + &'pass self, + pass: &mut wgpu::RenderPass<'pass>, + command: &material_system::MaterialDrawCommand, + mesh: MeshDrawRef<'pass>, + reactive_compatible: bool, + bind_material_state: bool, + ) -> bool { + let Some(index) = command.material.checked_sub(1).map(|value| value as usize) else { + return false; + }; + let Some(Some(material)) = self.pipelines.get(index) else { + return false; + }; + let pipeline = if reactive_compatible { + let Some(pipeline) = material.reactive_pipeline.as_ref() else { + return false; + }; + pipeline + } else { + &material.pipeline + }; + if bind_material_state { + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &self.per_frame_bg, &[]); + pass.set_bind_group(1, &self.per_view_bg, &[]); + pass.set_bind_group(2, self.per_material_bg_for(command.material), &[]); + if material.reads_scene && cfg!(not(fold_scene_inputs)) { + if let Some(bind_group) = self.scene_inputs_bg.as_ref() { + pass.set_bind_group(4, bind_group, &[]); + } + } + } + pass.set_bind_group(3, &self.per_draw_bgs[command.draw_slot], &[]); + pass.set_vertex_buffer(0, mesh.vertex.slice(..)); + pass.set_index_buffer(mesh.index.slice(..), wgpu::IndexFormat::Uint32); + let instance_range = self.bind_instance_buffer(pass, &command.instance); + if instance_range.end > instance_range.start { + pass.draw_indexed(mesh.index_range(), mesh.base_vertex, instance_range); + } + true + } + + pub(crate) fn dispatch_translucent_reactive<'pass, F>( + &'pass self, + pass: &mut wgpu::RenderPass<'pass>, + mut mesh_fetch: F, + ) where + F: FnMut(u64, usize) -> Option>, + { + let mut last_material = 0; + for command in &self.translucent_commands { + if let Some(mesh) = mesh_fetch(command.mesh_handle, command.mesh_idx) { + let bind_material_state = command.material != last_material; + if self.dispatch_translucent_command(pass, command, mesh, true, bind_material_state) + { + last_material = command.material; + } + } + } + } +} + +impl Renderer { + fn collect_imported_transparency<'a>( + &'a self, + scene: &'a crate::scene::SceneGraph, + ) -> Vec> { + let camera_vp = mat4_multiply( + self.current_proj_matrix_unjittered, + self.current_view_matrix, + ); + let camera_planes = crate::scene::extract_frustum_planes(&camera_vp); + let mut draws = Vec::new(); + for (stable_id, command) in self.model_draw_commands.iter().enumerate() { + let Some(Some(meshes)) = self.model_gpu_cache.get(&command.cache_handle) else { + continue; + }; + let Some(mesh) = meshes.get(command.mesh_idx) else { + continue; + }; + if mesh.alpha_mode != MaterialAlphaMode::Blend + || (self.imported_refraction_enabled && mesh.transmission.is_active()) + { + continue; + } + let (world_min, world_max) = command + .bounds_override + .unwrap_or_else(|| transform_aabb(&command.model, mesh.local_min, mesh.local_max)); + if world_min[0] <= world_max[0] + && crate::scene::aabb_outside_frustum(&camera_planes, world_min, world_max) + { + continue; + } + let center = if world_min[0] <= world_max[0] { + [ + (world_min[0] + world_max[0]) * 0.5, + (world_min[1] + world_max[1]) * 0.5, + (world_min[2] + world_max[2]) * 0.5, + ] + } else { + [ + command.model[3][0], + command.model[3][1], + command.model[3][2], + ] + }; + let pivot = mat4_mul_vec4( + &self.current_vp_matrix, + &[center[0], center[1], center[2], 1.0], + ); + let layered_material = mesh.layered_material_bg.as_ref(); + let secondary_uv = if layered_material.is_some() && mesh.layered_uses_uv1 { + let Some(buffer) = mesh.layered_uv1_buffer.as_ref() else { + continue; + }; + Some(buffer) + } else { + None + }; + let (mesh_draw, vertex_byte_offset, index_byte_offset) = if secondary_uv.is_some() { + self.gpu_driven + .mesh_draw_localized(&mesh.geometry, mesh.index_count) + } else { + ( + self.gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count), + 0, + 0, + ) + }; + draws.push(ImportedTransparentDrawRef { + view_depth: pivot[3], + stable_id, + double_sided: mesh.double_sided, + layered: layered_material.is_some(), + uniforms: &self.model_uniform_bind_groups[command.uniform_slot], + material: layered_material.unwrap_or(&mesh.material_bg), + mesh: mesh_draw, + secondary_uv, + vertex_byte_offset, + index_byte_offset, + }); + } + scene.append_transparent_draws( + &mut draws, + &self.current_vp_matrix, + self.model_draw_commands.len(), + self.imported_refraction_enabled, + ); + draws + } + + /// Imported-only dispatcher retained for weighted OIT. Weighted + /// accumulation is intentionally independent from custom material + /// composition and therefore does not use the conventional sort list. + pub(super) fn draw_imported_transparency<'a>( + &'a self, + pass: &mut wgpu::RenderPass<'a>, + scene: &'a crate::scene::SceneGraph, + weighted: bool, + reactive: bool, + ) { + let mut draws = self.collect_imported_transparency(scene); + draws.sort_by(|left, right| { + right + .view_depth + .total_cmp(&left.view_depth) + .then_with(|| left.stable_id.cmp(&right.stable_id)) + }); + let (single_sided, double_sided) = if weighted { + ( + self.scene_weighted_transparent_pipeline + .as_ref() + .expect("weighted transparency initialized its pipeline"), + self.scene_weighted_transparent_double_sided_pipeline + .as_ref() + .expect("weighted transparency initialized its double-sided pipeline"), + ) + } else if reactive { + ( + self.scene_transparent_reactive_pipeline + .as_ref() + .expect("reactive sorted transparency initialized its pipeline"), + self.scene_transparent_reactive_double_sided_pipeline + .as_ref() + .expect("reactive sorted transparency initialized its double-sided pipeline"), + ) + } else { + ( + &self.scene_transparent_pipeline, + &self.scene_transparent_double_sided_pipeline, + ) + }; + pass.set_bind_group(1, &self.lighting_bind_group, &[]); + pass.set_bind_group(3, &self.joint_bind_group, &[]); + let mut current_pipeline_key = None; + for draw in draws { + let secondary_uv = draw.secondary_uv.is_some(); + let pipeline_key = (draw.layered, secondary_uv, draw.double_sided); + if current_pipeline_key != Some(pipeline_key) { + let pipeline = if draw.layered { + self.scene_layered_pbr_resources + .as_ref() + .expect("layered material initialized its pipelines") + .transparent_pipeline(secondary_uv, draw.double_sided, reactive, weighted) + } else if draw.double_sided { + double_sided + } else { + single_sided + }; + pass.set_pipeline(pipeline); + current_pipeline_key = Some(pipeline_key); + } + pass.set_bind_group(0, draw.uniforms, &[]); + pass.set_bind_group(2, draw.material, &[]); + pass.set_vertex_buffer(0, draw.mesh.vertex.slice(draw.vertex_byte_offset..)); + if let Some(secondary_uv) = draw.secondary_uv { + pass.set_vertex_buffer(1, secondary_uv.slice(..)); + } + pass.set_index_buffer( + draw.mesh.index.slice(draw.index_byte_offset..), + wgpu::IndexFormat::Uint32, + ); + pass.draw_indexed(draw.mesh.index_range(), draw.mesh.base_vertex, 0..1); + } + } + + /// Draw conventional imported and custom translucency in one global order. + /// When `reactive` is true, imported pipelines write real R8 coverage and + /// custom attachment-compatible siblings leave that target untouched. + pub(super) fn draw_sorted_transparency<'a>( + &'a self, + pass: &mut wgpu::RenderPass<'a>, + scene: &'a crate::scene::SceneGraph, + reactive: bool, + ) { + let mut imported = self.collect_imported_transparency(scene); + imported.sort_by(|left, right| { + right + .view_depth + .total_cmp(&left.view_depth) + .then_with(|| left.stable_id.cmp(&right.stable_id)) + }); + let mut imported = imported.into_iter().peekable(); + let mut custom = self + .material_system + .translucent_commands + .iter() + .enumerate() + .peekable(); + + let imported_pipelines = if reactive { + ( + self.scene_transparent_reactive_pipeline + .as_ref() + .expect("reactive sorted transparency initialized its pipeline"), + self.scene_transparent_reactive_double_sided_pipeline + .as_ref() + .expect("reactive sorted transparency initialized its double-sided pipeline"), + ) + } else { + ( + &self.scene_transparent_pipeline, + &self.scene_transparent_double_sided_pipeline, + ) + }; + let mut current_pipeline = None; + while imported.peek().is_some() || custom.peek().is_some() { + let take_imported = match (imported.peek(), custom.peek()) { + (Some(imported), Some((stable_id, custom))) => compare_sorted_transparency( + &SortedTransparencyKey { + view_depth: imported.view_depth, + source_rank: 0, + stable_id: imported.stable_id, + }, + &SortedTransparencyKey { + view_depth: custom.view_depth, + source_rank: 1, + stable_id: *stable_id, + }, + ) + .is_le(), + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => break, + }; + if take_imported { + let draw = imported.next().expect("peeked imported draw"); + let key = SortedPipelineKey::Imported { + double_sided: draw.double_sided, + layered: draw.layered, + secondary_uv: draw.secondary_uv.is_some(), + }; + if current_pipeline != Some(key) { + let pipeline = if draw.layered { + self.scene_layered_pbr_resources + .as_ref() + .expect("layered material initialized its pipelines") + .transparent_pipeline( + draw.secondary_uv.is_some(), + draw.double_sided, + reactive, + false, + ) + } else if draw.double_sided { + imported_pipelines.1 + } else { + imported_pipelines.0 + }; + pass.set_pipeline(pipeline); + pass.set_bind_group(1, &self.lighting_bind_group, &[]); + pass.set_bind_group(3, &self.joint_bind_group, &[]); + current_pipeline = Some(key); + } + pass.set_bind_group(0, draw.uniforms, &[]); + pass.set_bind_group(2, draw.material, &[]); + pass.set_vertex_buffer(0, draw.mesh.vertex.slice(draw.vertex_byte_offset..)); + if let Some(secondary_uv) = draw.secondary_uv { + pass.set_vertex_buffer(1, secondary_uv.slice(..)); + } + pass.set_index_buffer( + draw.mesh.index.slice(draw.index_byte_offset..), + wgpu::IndexFormat::Uint32, + ); + pass.draw_indexed(draw.mesh.index_range(), draw.mesh.base_vertex, 0..1); + } else { + let (_, command) = custom.next().expect("peeked custom draw"); + let Some(Some(meshes)) = self.model_gpu_cache.get(&command.mesh_handle) else { + continue; + }; + let Some(mesh) = meshes.get(command.mesh_idx) else { + continue; + }; + let key = SortedPipelineKey::Custom(command.material); + let bind_material_state = current_pipeline != Some(key); + let mesh = self.gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count); + if self.material_system.dispatch_translucent_command( + pass, + command, + mesh, + reactive, + bind_material_state, + ) { + current_pipeline = Some(key); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(depth: f32, source_rank: u8, stable_id: usize) -> SortedTransparencyKey { + SortedTransparencyKey { + view_depth: depth, + source_rank, + stable_id, + } + } + + #[test] + fn global_key_orders_depth_then_source_then_stable_id() { + let mut draws = vec![ + key(5.0, 1, 2), + key(20.0, 1, 0), + key(10.0, 1, 1), + key(10.0, 0, 8), + key(10.0, 0, 3), + ]; + draws.sort_by(compare_sorted_transparency); + let order = draws + .iter() + .map(|draw| (draw.view_depth, draw.source_rank, draw.stable_id)) + .collect::>(); + assert_eq!( + order, + vec![ + (20.0, 1, 0), + (10.0, 0, 3), + (10.0, 0, 8), + (10.0, 1, 1), + (5.0, 1, 2), + ] + ); + } +} diff --git a/native/shared/src/renderer/ssgi_pass.rs b/native/shared/src/renderer/ssgi_pass.rs index 62a2c40d..05431fbc 100644 --- a/native/shared/src/renderer/ssgi_pass.rs +++ b/native/shared/src/renderer/ssgi_pass.rs @@ -6,6 +6,39 @@ use super::*; impl Renderer { + /// Toggle SSGI (screen-space global illumination) on/off. Off means no + /// probe work; either transition invalidates radiance from the old route. + pub fn set_ssgi_enabled(&mut self, enabled: bool) { + if self.ssgi_enabled != enabled { + self.ssgi_enabled = enabled; + self.probe_history_idx = 0; + self.probe_history_valid = false; + } + } + + /// SSGI intensity multiplier (0 = off, 0.5 = default, 1+ = strong). + /// Controls the brightness of indirect bounce light. + pub fn set_ssgi_intensity(&mut self, intensity: f32) { + let intensity = intensity.max(0.0); + if self.ssgi_intensity != intensity { + self.ssgi_intensity = intensity; + self.probe_history_idx = 0; + self.probe_history_valid = false; + } + } + + /// SSGI max march distance in view-space meters (default 20). + /// Tune to the scene scale: small for tight rooms, large for + /// open-world interiors. + pub fn set_ssgi_radius(&mut self, radius: f32) { + let radius = radius.max(0.1); + if self.ssgi_radius != radius { + self.ssgi_radius = radius; + self.probe_history_idx = 0; + self.probe_history_valid = false; + } + } + pub(super) fn record_ssgi_passes( &mut self, encoder: &mut wgpu::CommandEncoder, @@ -13,380 +46,670 @@ impl Renderer { surf_w: u32, surf_h: u32, ) { - // ============================================================ - // Ticket 007a: Lumen-style screen-probe SSGI. - // place → trace (SW Hi-Z) → temporal (EMA ping-pong) → resolve. - // Resolve writes `ssgi_rt_view` so downstream compositing is - // unchanged. When disabled we just clear `ssgi_rt_view` to - // transparent (same fallback shape as the old per-pixel path). - // ============================================================ - let half_w = (surf_w / 2).max(1); - let half_h = (surf_h / 2).max(1); - let gw = self.probe_grid_w; - let gh = self.probe_grid_h; - let write_idx = self.probe_history_idx; - let prev_idx = 1 - write_idx; + // ============================================================ + // Ticket 007a: Lumen-style screen-probe SSGI. + // place → trace (SW Hi-Z) → temporal (EMA ping-pong) → resolve. + // Resolve writes `ssgi_rt_view` so downstream compositing is + // unchanged. When disabled we just clear `ssgi_rt_view` to + // transparent (same fallback shape as the old per-pixel path). + // ============================================================ + let half_w = (surf_w / 2).max(1); + let half_h = (surf_h / 2).max(1); + let gw = self.probe_grid_w; + let gh = self.probe_grid_h; + let write_idx = self.probe_history_idx; + let prev_idx = 1 - write_idx; - // PT-1: while the path tracer owns the frame its output already - // contains full GI — probe SSGI would burn ~2ms to be composited - // over by nothing (the else-branch clear keeps compose additive- - // safe either way). pt_owns_frame, not pt_active: progressive mode - // shows raster frames until accumulation warms up, and those still - // want SSGI. - if self.ssgi_enabled && !self.pt_owns_frame() { - let p00 = self.current_proj_matrix[0][0]; - let p11 = self.current_proj_matrix[1][1]; - let p20 = self.current_proj_matrix[2][0]; - let p21 = self.current_proj_matrix[2][1]; - let inv_view = mat4_invert(self.current_view_matrix); + // PT-1: while the path tracer owns the frame its output already + // contains full GI — probe SSGI would burn ~2ms to be composited + // over by nothing (the else-branch clear keeps compose additive- + // safe either way). pt_owns_frame, not pt_active: progressive mode + // shows raster frames until accumulation warms up, and those still + // want SSGI. + if self.ssgi_enabled && !self.pt_owns_frame() { + let p00 = self.current_proj_matrix[0][0]; + let p11 = self.current_proj_matrix[1][1]; + let p20 = self.current_proj_matrix[2][0]; + let p21 = self.current_proj_matrix[2][1]; + let inv_view = mat4_invert(self.current_view_matrix); - // ---- place ---- - let place_params = ProbePlaceParams { - inv_view, - proj_row01: [p00, p11, p20, p21], - size: [half_w, half_h, gw, gh], - params: [self.taa_frame_index as f32, PROBE_TILE_SIZE as f32, 0.0, 0.0], - }; - self.queue.write_buffer(&self.probe_place_uniform, 0, bytemuck::bytes_of(&place_params)); - if self.probe_place_bg_cache.is_none() { - self.probe_place_bg_cache = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("probe_place_bg"), - layout: &self.probe_place_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.probe_place_uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.hiz_views[0]) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.hiz_sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: self.probe_header_buffer.as_entire_binding() }, + // ---- place ---- + let place_params = ProbePlaceParams { + inv_view, + proj_row01: [p00, p11, p20, p21], + size: [half_w, half_h, gw, gh], + params: [ + self.taa_frame_index as f32, + PROBE_TILE_SIZE as f32, + 0.0, + 0.0, ], - })); - } - { - let ts = profiler.compute_pass_timestamp_writes("probe_place_pass"); - let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { - label: Some("probe_place_pass"), timestamp_writes: ts, - }); - pass.set_pipeline(&self.probe_place_pipeline); - pass.set_bind_group(0, self.probe_place_bg_cache.as_ref().unwrap(), &[]); - pass.dispatch_workgroups((gw + 7) / 8, (gh + 7) / 8, 1); - } + }; + self.queue.write_buffer( + &self.probe_place_uniform, + 0, + bytemuck::bytes_of(&place_params), + ); + if self.probe_place_bg_cache.is_none() { + self.probe_place_bg_cache = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("probe_place_bg"), + layout: &self.probe_place_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.probe_place_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&self.hiz_views[0]), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.hiz_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: self.probe_header_buffer.as_entire_binding(), + }, + ], + })); + } + { + let ts = profiler.compute_pass_timestamp_writes("probe_place_pass"); + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("probe_place_pass"), + timestamp_writes: ts, + }); + pass.set_pipeline(&self.probe_place_pipeline); + pass.set_bind_group(0, self.probe_place_bg_cache.as_ref().unwrap(), &[]); + pass.dispatch_workgroups((gw + 7) / 8, (gh + 7) / 8, 1); + } - // ---- trace ---- - // Sun direction in world space — inverted because our - // `light_dir` points from light toward the scene, while the - // shader's NdotL expects the vector from the shading point - // toward the light. Normalised because the shader doesn't. - let ld = self.lighting_uniforms.light_dir; - let sun_inv_len = 1.0 / (ld[0]*ld[0] + ld[1]*ld[1] + ld[2]*ld[2]).sqrt().max(1e-4); - let sun_dir_ws = [ - -ld[0] * sun_inv_len, - -ld[1] * sun_inv_len, - -ld[2] * sun_inv_len, - ld[3], - ]; - // Sun colour = light_color × light intensity (ld.w). Sky - // colour = ambient × ambient intensity (ambient.w) — a - // crude dome irradiance, good enough for a one-bounce - // shading estimate. Both fields are ignored by the SW - // shader which inherits the same uniform struct layout. - let lc = self.lighting_uniforms.light_color; - let sun_intensity = ld[3].max(0.0); - let sun_color = [ - lc[0] * sun_intensity, - lc[1] * sun_intensity, - lc[2] * sun_intensity, - 0.0, - ]; - let amb = self.lighting_uniforms.ambient; - let sky_intensity = amb[3].max(0.0); - let sky_color = [ - amb[0] * sky_intensity, - amb[1] * sky_intensity, - amb[2] * sky_intensity, - 0.0, - ]; - let trace_params = ProbeTraceParams { - view: self.current_view_matrix, - proj: self.current_proj_matrix, - inv_view, - proj_row01: [p00, p11, p20, p21], - size: [half_w, half_h, gw, gh], - params: [ - self.taa_frame_index as f32, - self.ssgi_intensity, - self.ssgi_radius, - 10.0, // firefly luma cap - ], - sun_dir: sun_dir_ws, - sun_color, - sky_color, - // Ticket 014 V3 — clipmap origin xyz + full extent w. - // The SDF trace variant reads these; HW + Hi-Z ignore. - clipmap: [ - self.scene_sdf_clipmap_origin[0], - self.scene_sdf_clipmap_origin[1], - self.scene_sdf_clipmap_origin[2], - SCENE_SDF_CLIPMAP_EXTENT, - ], - // Ticket 014 V6/V13 — WSRC cascade cubes. `extent = - // 0` marks an unbaked cascade; the shader's - // `pick_cascade` helper skips those and falls through - // to the next cascade (or returns black if none are - // ready). First frame after startup all three are - // unbaked → miss returns black, matching pre-V6. - wsrc_cascades: [ - [ - self.wsrc_origin[0][0], - self.wsrc_origin[0][1], - self.wsrc_origin[0][2], - if self.wsrc_built[0] { WSRC_CASCADE_EXTENTS[0] } else { 0.0 }, - ], - [ - self.wsrc_origin[1][0], - self.wsrc_origin[1][1], - self.wsrc_origin[1][2], - if self.wsrc_built[1] { WSRC_CASCADE_EXTENTS[1] } else { 0.0 }, + // ---- trace ---- + // Sun direction in world space — inverted because our + // `light_dir` points from light toward the scene, while the + // shader's NdotL expects the vector from the shading point + // toward the light. Normalised because the shader doesn't. + let ld = self.lighting_uniforms.light_dir; + let sun_inv_len = 1.0 + / (ld[0] * ld[0] + ld[1] * ld[1] + ld[2] * ld[2]) + .sqrt() + .max(1e-4); + let sun_dir_ws = [ + -ld[0] * sun_inv_len, + -ld[1] * sun_inv_len, + -ld[2] * sun_inv_len, + ld[3], + ]; + // Sun colour = light_color × light intensity (ld.w). Sky + // colour = ambient × ambient intensity (ambient.w) — a + // crude dome irradiance, good enough for a one-bounce + // shading estimate. Both fields are ignored by the SW + // shader which inherits the same uniform struct layout. + let lc = self.lighting_uniforms.light_color; + let sun_intensity = ld[3].max(0.0); + let sun_color = [ + lc[0] * sun_intensity, + lc[1] * sun_intensity, + lc[2] * sun_intensity, + 0.0, + ]; + let amb = self.lighting_uniforms.ambient; + let sky_intensity = amb[3].max(0.0); + let sky_color = [ + amb[0] * sky_intensity, + amb[1] * sky_intensity, + amb[2] * sky_intensity, + 0.0, + ]; + let trace_params = ProbeTraceParams { + view: self.current_view_matrix, + proj: self.current_proj_matrix, + inv_view, + proj_row01: [p00, p11, p20, p21], + size: [half_w, half_h, gw, gh], + params: [ + self.taa_frame_index as f32, + self.ssgi_intensity, + self.ssgi_radius, + 10.0, // firefly luma cap ], - [ - self.wsrc_origin[2][0], - self.wsrc_origin[2][1], - self.wsrc_origin[2][2], - if self.wsrc_built[2] { WSRC_CASCADE_EXTENTS[2] } else { 0.0 }, + sun_dir: sun_dir_ws, + sun_color, + sky_color, + // Ticket 014 V3 — clipmap origin xyz + full extent w. + // The SDF trace variant reads these; HW + Hi-Z ignore. + clipmap: [ + self.scene_sdf_clipmap_origin[0], + self.scene_sdf_clipmap_origin[1], + self.scene_sdf_clipmap_origin[2], + SCENE_SDF_CLIPMAP_EXTENT, ], - ], - }; - self.queue.write_buffer(&self.probe_trace_uniform, 0, bytemuck::bytes_of(&trace_params)); - // V3 — trace BG now binds the prev-frame history view at - // binding 11. `prev_idx` ping-pongs every frame so we - // cache both slots independently. - if self.probe_trace_bg_cache[prev_idx].is_none() { - self.probe_trace_bg_cache[prev_idx] = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("probe_trace_bg"), - layout: &self.probe_trace_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.probe_trace_uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: self.probe_header_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.hiz_views[0]) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.hiz_views[1]) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self.hiz_views[2]) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&self.hiz_views[3]) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self.hiz_views[4]) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::Sampler(&self.hiz_sampler) }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self.hdr_rt_view) }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 10, resource: wgpu::BindingResource::TextureView(&self.probe_trace_view) }, - wgpu::BindGroupEntry { binding: 11, resource: wgpu::BindingResource::TextureView(&self.probe_history_views[prev_idx]) }, + // Ticket 014 V6/V13 — WSRC cascade cubes. `extent = + // 0` marks an unbaked cascade; the shader's + // `pick_cascade` helper skips those and falls through + // to the next cascade (or returns black if none are + // ready). First frame after startup all three are + // unbaked → miss returns black, matching pre-V6. + wsrc_cascades: [ + [ + self.wsrc_origin[0][0], + self.wsrc_origin[0][1], + self.wsrc_origin[0][2], + if self.wsrc_built[0] { + WSRC_CASCADE_EXTENTS[0] + } else { + 0.0 + }, + ], + [ + self.wsrc_origin[1][0], + self.wsrc_origin[1][1], + self.wsrc_origin[1][2], + if self.wsrc_built[1] { + WSRC_CASCADE_EXTENTS[1] + } else { + 0.0 + }, + ], + [ + self.wsrc_origin[2][0], + self.wsrc_origin[2][1], + self.wsrc_origin[2][2], + if self.wsrc_built[2] { + WSRC_CASCADE_EXTENTS[2] + } else { + 0.0 + }, + ], ], - })); - } - // HW trace needs both the TLAS (at least one instance) and - // the instance-data buffer to exist. Fall back to SDF or - // Hi-Z when either is missing on an HW-enabled adapter - // (e.g. first frame before the scene has loaded any - // geometry). - let use_hw = self.hw_rt_enabled - && self.probe_trace_hw_pipeline.is_some() - && self.tlas.is_some() - && self.tlas_instance_data_buffer.is_some(); - // Ticket 014 V3/V4 — pick SDF sphere-trace over Hi-Z when - // the scene clipmap is baked AND the instance-data buffer - // is ready (needed for broad-phase textured hit sampling - // added in V4). Otherwise fall through to Hi-Z. HW still - // wins over both when the feature was granted. - let use_sdf = !use_hw - && self.scene_sdf_clipmap_built - && self.tlas_instance_data_buffer.is_some(); - // Log the backend once (and again if it changes, e.g. clipmap - // finishing its first bake promotes hiz → sdf). Nothing else in - // the engine reveals which tier actually runs. - let backend = if use_hw { "hw-ray-query" } else if use_sdf { "sdf-clipmap" } else { "hiz-screen" }; - if self.ssgi_backend_logged != Some(backend) { - self.ssgi_backend_logged = Some(backend); - eprintln!("bloom: ssgi trace backend = {}", backend); - } + }; + self.queue.write_buffer( + &self.probe_trace_uniform, + 0, + bytemuck::bytes_of(&trace_params), + ); + // V3 — trace BG now binds the prev-frame history view at + // binding 11. `prev_idx` ping-pongs every frame so we + // cache both slots independently. + if self.probe_trace_bg_cache[prev_idx].is_none() { + self.probe_trace_bg_cache[prev_idx] = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("probe_trace_bg"), + layout: &self.probe_trace_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.probe_trace_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: self.probe_header_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&self.hiz_views[0]), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&self.hiz_views[1]), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&self.hiz_views[2]), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&self.hiz_views[3]), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&self.hiz_views[4]), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::Sampler(&self.hiz_sampler), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView(&self.hdr_rt_view), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 10, + resource: wgpu::BindingResource::TextureView( + &self.probe_trace_view, + ), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: wgpu::BindingResource::TextureView( + &self.probe_history_views[prev_idx], + ), + }, + ], + })); + } + // HW trace needs both the TLAS (at least one instance) and + // the instance-data buffer to exist. Fall back to SDF or + // Hi-Z when either is missing on an HW-enabled adapter + // (e.g. first frame before the scene has loaded any + // geometry). + let use_hw = self.hw_rt_enabled + && self.probe_trace_hw_pipeline.is_some() + && self.tlas.is_some() + && self.tlas_instance_data_buffer.is_some(); + // Ticket 014 V3/V4 — pick SDF sphere-trace over Hi-Z when + // the scene clipmap is baked AND the instance-data buffer + // is ready (needed for broad-phase textured hit sampling + // added in V4). Otherwise fall through to Hi-Z. HW still + // wins over both when the feature was granted. + let use_sdf = + !use_hw && self.scene_sdf_clipmap_built && self.tlas_instance_data_buffer.is_some(); + // Log the backend once (and again if it changes, e.g. clipmap + // finishing its first bake promotes hiz → sdf). Nothing else in + // the engine reveals which tier actually runs. + let backend = if use_hw { + "hw-ray-query" + } else if use_sdf { + "sdf-clipmap" + } else { + "hiz-screen" + }; + if self.ssgi_backend_logged != Some(backend) { + self.ssgi_backend_logged = Some(backend); + eprintln!("bloom: ssgi trace backend = {}", backend); + } - if use_hw { - // Build the HW bind group lazily. V3 uses a per- - // prev_idx slot since the prev-frame history view - // ping-pongs each frame. - if self.probe_trace_hw_bg_cache[prev_idx].is_none() { - let tlas = self.tlas.as_ref().unwrap(); - self.probe_trace_hw_bg_cache[prev_idx] = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("probe_trace_hw_bg"), - layout: self.probe_trace_hw_layout.as_ref().unwrap(), - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.probe_trace_uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: self.probe_header_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: tlas.as_binding() }, - wgpu::BindGroupEntry { binding: 3, resource: self.tlas_instance_data_buffer.as_ref().unwrap().as_entire_binding() }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self.probe_trace_view) }, - // Ticket 013 V2: the HW trace samples the - // *radiance* atlas (pre-lit by card_light_pass) - // at hit, not the raw albedo atlas. - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&self.mesh_card_radiance_view) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::Sampler(&self.mesh_card_atlas_sampler) }, - // V7/V10 — WSRC atlas + linear sampler. - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&self.wsrc_atlas_view) }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::Sampler(&self.wsrc_atlas_sampler) }, - // V3 — prev-frame probe history. - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::TextureView(&self.probe_history_views[prev_idx]) }, - ], - })); + if use_hw { + // Build the HW bind group lazily. V3 uses a per- + // prev_idx slot since the prev-frame history view + // ping-pongs each frame. + if self.probe_trace_hw_bg_cache[prev_idx].is_none() { + let tlas = self.tlas.as_ref().unwrap(); + self.probe_trace_hw_bg_cache[prev_idx] = Some( + self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("probe_trace_hw_bg"), + layout: self.probe_trace_hw_layout.as_ref().unwrap(), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.probe_trace_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: self.probe_header_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: tlas.as_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: self + .tlas_instance_data_buffer + .as_ref() + .unwrap() + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView( + &self.probe_trace_view, + ), + }, + // Ticket 013 V2: the HW trace samples the + // *radiance* atlas (pre-lit by card_light_pass) + // at hit, not the raw albedo atlas. + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView( + &self.mesh_card_radiance_view, + ), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler( + &self.mesh_card_atlas_sampler, + ), + }, + // V7/V10 — WSRC atlas + linear sampler. + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView( + &self.wsrc_atlas_view, + ), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::Sampler( + &self.wsrc_atlas_sampler, + ), + }, + // V3 — prev-frame probe history. + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::TextureView( + &self.probe_history_views[prev_idx], + ), + }, + ], + }), + ); + } + let ts = profiler.compute_pass_timestamp_writes("probe_trace_hw_pass"); + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("probe_trace_hw_pass"), + timestamp_writes: ts, + }); + let pipeline = if self.transparent_gi_active { + self.probe_trace_hw_transparent_pipeline + .as_ref() + .expect("active transparent GI has a lazy HW specialization") + } else { + self.probe_trace_hw_pipeline.as_ref().unwrap() + }; + pass.set_pipeline(pipeline); + pass.set_bind_group( + 0, + self.probe_trace_hw_bg_cache[prev_idx].as_ref().unwrap(), + &[], + ); + pass.dispatch_workgroups(gw, gh, 1); + } else if use_sdf { + // Ticket 014 V3 — SW SDF sphere-trace path. + // V3 (ticket 016) uses a per-prev_idx slot for the + // prev-frame history binding. + if self.probe_trace_sdf_bg_cache[prev_idx].is_none() { + let nf_samp = self.device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("clipmap_nonfiltering_sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + mipmap_filter: wgpu::MipmapFilterMode::Nearest, + ..Default::default() + }); + let instance_buf = self + .tlas_instance_data_buffer + .as_ref() + .expect("V4: instance_data buffer must exist before SDF dispatch"); + self.probe_trace_sdf_bg_cache[prev_idx] = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("probe_trace_sdf_bg"), + layout: &self.probe_trace_sdf_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.probe_trace_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: self.probe_header_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView( + &self.scene_sdf_clipmap_view, + ), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&nf_samp), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView( + &self.probe_trace_view, + ), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: instance_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView( + &self.mesh_card_radiance_view, + ), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::Sampler( + &self.mesh_card_atlas_sampler, + ), + }, + // V6/V10 — WSRC atlas + linear sampler. + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView( + &self.wsrc_atlas_view, + ), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::Sampler( + &self.wsrc_atlas_sampler, + ), + }, + // V3 — prev-frame probe history. + wgpu::BindGroupEntry { + binding: 10, + resource: wgpu::BindingResource::TextureView( + &self.probe_history_views[prev_idx], + ), + }, + ], + })); + } + let ts = profiler.compute_pass_timestamp_writes("probe_trace_sdf_pass"); + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("probe_trace_sdf_pass"), + timestamp_writes: ts, + }); + let pipeline = if self.transparent_gi_active { + self.probe_trace_sdf_transparent_pipeline + .as_ref() + .expect("active transparent GI has a lazy SDF specialization") + } else { + &self.probe_trace_sdf_pipeline + }; + pass.set_pipeline(pipeline); + pass.set_bind_group( + 0, + self.probe_trace_sdf_bg_cache[prev_idx].as_ref().unwrap(), + &[], + ); + pass.dispatch_workgroups(gw, gh, 1); + } else { + let ts = profiler.compute_pass_timestamp_writes("probe_trace_pass"); + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("probe_trace_pass"), + timestamp_writes: ts, + }); + pass.set_pipeline(&self.probe_trace_pipeline); + pass.set_bind_group( + 0, + self.probe_trace_bg_cache[prev_idx].as_ref().unwrap(), + &[], + ); + pass.dispatch_workgroups(gw, gh, 1); } - let ts = profiler.compute_pass_timestamp_writes("probe_trace_hw_pass"); - let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { - label: Some("probe_trace_hw_pass"), timestamp_writes: ts, - }); - pass.set_pipeline(self.probe_trace_hw_pipeline.as_ref().unwrap()); - pass.set_bind_group(0, self.probe_trace_hw_bg_cache[prev_idx].as_ref().unwrap(), &[]); - pass.dispatch_workgroups(gw, gh, 1); - } else if use_sdf { - // Ticket 014 V3 — SW SDF sphere-trace path. - // V3 (ticket 016) uses a per-prev_idx slot for the - // prev-frame history binding. - if self.probe_trace_sdf_bg_cache[prev_idx].is_none() { - let nf_samp = self.device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("clipmap_nonfiltering_sampler"), - address_mode_u: wgpu::AddressMode::ClampToEdge, - address_mode_v: wgpu::AddressMode::ClampToEdge, - address_mode_w: wgpu::AddressMode::ClampToEdge, - mag_filter: wgpu::FilterMode::Nearest, - min_filter: wgpu::FilterMode::Nearest, - mipmap_filter: wgpu::MipmapFilterMode::Nearest, - ..Default::default() + + // ---- temporal (EMA) ---- + // Invalid SSGI history is replaced independently of the TAA + // counter: the effects have different enable/ownership lifetimes. + let force_refresh = + if !self.probe_history_valid || self.transparent_gi_force_probe_refresh { + 1.0_f32 + } else { + 0.0_f32 + }; + self.transparent_gi_force_probe_refresh = false; + let temporal_params = ProbeTemporalParams { + params: [0.25, force_refresh, gw as f32, gh as f32], + }; + self.queue.write_buffer( + &self.probe_temporal_uniform, + 0, + bytemuck::bytes_of(&temporal_params), + ); + // Bind group indexed by write_idx: each direction of the + // ping-pong (read prev, write write) gets its own cached BG. + if self.probe_temporal_bg_cache[write_idx].is_none() { + self.probe_temporal_bg_cache[write_idx] = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("probe_temporal_bg"), + layout: &self.probe_temporal_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.probe_temporal_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView( + &self.probe_trace_view, + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView( + &self.probe_history_views[prev_idx], + ), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView( + &self.probe_history_views[write_idx], + ), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: self.probe_header_buffer.as_entire_binding(), + }, + ], + })); + } + { + let ts = profiler.compute_pass_timestamp_writes("probe_temporal_pass"); + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("probe_temporal_pass"), + timestamp_writes: ts, }); - let instance_buf = self.tlas_instance_data_buffer.as_ref() - .expect("V4: instance_data buffer must exist before SDF dispatch"); - self.probe_trace_sdf_bg_cache[prev_idx] = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("probe_trace_sdf_bg"), - layout: &self.probe_trace_sdf_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.probe_trace_uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: self.probe_header_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.scene_sdf_clipmap_view) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&nf_samp) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self.probe_trace_view) }, - wgpu::BindGroupEntry { binding: 5, resource: instance_buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self.mesh_card_radiance_view) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::Sampler(&self.mesh_card_atlas_sampler) }, - // V6/V10 — WSRC atlas + linear sampler. - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self.wsrc_atlas_view) }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&self.wsrc_atlas_sampler) }, - // V3 — prev-frame probe history. - wgpu::BindGroupEntry { binding: 10, resource: wgpu::BindingResource::TextureView(&self.probe_history_views[prev_idx]) }, - ], - })); + pass.set_pipeline(&self.probe_temporal_pipeline); + pass.set_bind_group( + 0, + self.probe_temporal_bg_cache[write_idx].as_ref().unwrap(), + &[], + ); + pass.dispatch_workgroups(gw, gh, 1); } - let ts = profiler.compute_pass_timestamp_writes("probe_trace_sdf_pass"); - let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { - label: Some("probe_trace_sdf_pass"), timestamp_writes: ts, + #[cfg(not(target_arch = "wasm32"))] + if self.pending_quality_capture_dir.is_some() { + self.record_ssgi_temporal_diagnostics(encoder, prev_idx, gw, gh); + } + self.probe_history_valid = true; + + // ---- resolve ---- + let resolve_params = ProbeResolveParams { + inv_view, + proj_row01: [p00, p11, p20, p21], + size: [half_w, half_h, gw, gh], + params: [PROBE_TILE_SIZE as f32, 1.0, 0.0, 0.0], + }; + self.queue.write_buffer( + &self.probe_resolve_uniform, + 0, + bytemuck::bytes_of(&resolve_params), + ); + if self.probe_resolve_bg_cache[write_idx].is_none() { + self.probe_resolve_bg_cache[write_idx] = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("probe_resolve_bg"), + layout: &self.probe_resolve_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.probe_resolve_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: self.probe_header_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView( + &self.probe_history_views[write_idx], + ), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&self.hiz_views[0]), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler(&self.hiz_sampler), + }, + ], + })); + } + let ts = profiler.pass_timestamp_writes("probe_resolve_pass"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("probe_resolve_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.ssgi_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: ts, + occlusion_query_set: None, + multiview_mask: None, }); - pass.set_pipeline(&self.probe_trace_sdf_pipeline); - pass.set_bind_group(0, self.probe_trace_sdf_bg_cache[prev_idx].as_ref().unwrap(), &[]); - pass.dispatch_workgroups(gw, gh, 1); + pass.set_pipeline(&self.probe_resolve_pipeline); + pass.set_bind_group( + 0, + self.probe_resolve_bg_cache[write_idx].as_ref().unwrap(), + &[], + ); + pass.draw(0..3, 0..1); } else { - let ts = profiler.compute_pass_timestamp_writes("probe_trace_pass"); - let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { - label: Some("probe_trace_pass"), timestamp_writes: ts, - }); - pass.set_pipeline(&self.probe_trace_pipeline); - pass.set_bind_group(0, self.probe_trace_bg_cache[prev_idx].as_ref().unwrap(), &[]); - pass.dispatch_workgroups(gw, gh, 1); - } - - // ---- temporal (EMA) ---- - // First frame forces alpha=1 so the history is seeded from - // the current trace rather than blending against a zero clear. - let force_refresh = if self.taa_frame_index == 0 { 1.0_f32 } else { 0.0_f32 }; - let temporal_params = ProbeTemporalParams { - params: [0.25, force_refresh, gw as f32, gh as f32], - }; - self.queue.write_buffer(&self.probe_temporal_uniform, 0, bytemuck::bytes_of(&temporal_params)); - // Bind group indexed by write_idx: each direction of the - // ping-pong (read prev, write write) gets its own cached BG. - if self.probe_temporal_bg_cache[write_idx].is_none() { - self.probe_temporal_bg_cache[write_idx] = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("probe_temporal_bg"), - layout: &self.probe_temporal_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.probe_temporal_uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.probe_trace_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.probe_history_views[prev_idx]) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.probe_history_views[write_idx]) }, - ], - })); - } - { - let ts = profiler.compute_pass_timestamp_writes("probe_temporal_pass"); - let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { - label: Some("probe_temporal_pass"), timestamp_writes: ts, + // Suppressed frames do not produce probe history. This also + // catches progressive PT changing ownership without a mode change. + self.probe_history_valid = false; + // SSGI disabled — clear the resolve target so downstream + // composite reads contribute zero. + let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("ssgi_clear"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.ssgi_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, }); - pass.set_pipeline(&self.probe_temporal_pipeline); - pass.set_bind_group(0, self.probe_temporal_bg_cache[write_idx].as_ref().unwrap(), &[]); - pass.dispatch_workgroups(gw, gh, 1); + drop(pass); } - - // ---- resolve ---- - let resolve_params = ProbeResolveParams { - inv_view, - proj_row01: [p00, p11, p20, p21], - size: [half_w, half_h, gw, gh], - params: [PROBE_TILE_SIZE as f32, 1.0, 0.0, 0.0], - }; - self.queue.write_buffer(&self.probe_resolve_uniform, 0, bytemuck::bytes_of(&resolve_params)); - if self.probe_resolve_bg_cache[write_idx].is_none() { - self.probe_resolve_bg_cache[write_idx] = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("probe_resolve_bg"), - layout: &self.probe_resolve_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.probe_resolve_uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: self.probe_header_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.probe_history_views[write_idx]) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self.hiz_views[0]) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(&self.hiz_sampler) }, - ], - })); - } - let ts = profiler.pass_timestamp_writes("probe_resolve_pass"); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("probe_resolve_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.ssgi_rt_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: ts, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(&self.probe_resolve_pipeline); - pass.set_bind_group(0, self.probe_resolve_bg_cache[write_idx].as_ref().unwrap(), &[]); - pass.draw(0..3, 0..1); - } else { - // SSGI disabled — clear the resolve target so downstream - // composite reads contribute zero. - let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("ssgi_clear"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.ssgi_rt_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - drop(pass); - } } } diff --git a/native/shared/src/renderer/ssgi_temporal_diagnostics.rs b/native/shared/src/renderer/ssgi_temporal_diagnostics.rs new file mode 100644 index 00000000..ee171697 --- /dev/null +++ b/native/shared/src/renderer/ssgi_temporal_diagnostics.rs @@ -0,0 +1,213 @@ +//! Capture-only SSGI probe-history diagnostics. +//! +//! SSGI accumulates in a 3D probe × octel domain rather than screen space. +//! This flattens that domain into a probe atlas without changing its temporal +//! shader, textures, or normal frame graph. + +use super::*; + +pub(super) const SSGI_TEMPORAL_DIAGNOSTIC_NAMES: [&str; 2] = + ["ssgi-rejection-reason", "ssgi-temporal-confidence"]; +const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +pub(super) struct SsgiTemporalDiagnosticResources { + textures: Vec, + views: Vec, + pipeline: wgpu::ComputePipeline, + layout: wgpu::BindGroupLayout, + width: u32, + height: u32, +} + +const SHADER: &str = include_str!("ssgi_temporal_diagnostics.wgsl"); + +impl SsgiTemporalDiagnosticResources { + fn new(device: &wgpu::Device, width: u32, height: u32) -> Self { + let textures = SSGI_TEMPORAL_DIAGNOSTIC_NAMES + .iter() + .map(|name| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some(name), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FORMAT, + usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }) + }) + .collect::>(); + let views = textures + .iter() + .map(|texture| texture.create_view(&wgpu::TextureViewDescriptor::default())) + .collect::>(); + let uniform = |binding| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }; + let texture = |binding| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D3, + multisampled: false, + }, + count: None, + }; + let storage_buffer = |binding| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }; + let storage = |binding| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: FORMAT, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }; + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("ssgi_temporal_diagnostic_layout"), + entries: &[ + uniform(0), + texture(1), + texture(2), + storage_buffer(3), + storage(4), + storage(5), + ], + }); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("ssgi_temporal_diagnostic_shader"), + source: wgpu::ShaderSource::Wgsl(SHADER.into()), + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("ssgi_temporal_diagnostic_pipeline_layout"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("ssgi_temporal_diagnostic_pipeline"), + layout: Some(&pipeline_layout), + module: &shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }); + Self { + textures, + views, + pipeline, + layout, + width, + height, + } + } +} + +impl Renderer { + pub(super) fn record_ssgi_temporal_diagnostics( + &mut self, + encoder: &mut wgpu::CommandEncoder, + previous_history_index: usize, + grid_width: u32, + grid_height: u32, + ) { + let (width, height) = (grid_width * PROBE_OCT_SIZE, grid_height * PROBE_OCT_SIZE); + let resize = self + .ssgi_temporal_diagnostics + .as_ref() + .is_some_and(|resources| resources.width != width || resources.height != height); + if resize { + self.ssgi_temporal_diagnostics = None; + } + if self.ssgi_temporal_diagnostics.is_none() { + self.ssgi_temporal_diagnostics = Some(SsgiTemporalDiagnosticResources::new( + &self.device, + width, + height, + )); + } + let resources = self.ssgi_temporal_diagnostics.as_ref().unwrap(); + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("ssgi_temporal_diagnostic_bg"), + layout: &resources.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.probe_temporal_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&self.probe_trace_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView( + &self.probe_history_views[previous_history_index], + ), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: self.probe_header_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&resources.views[0]), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&resources.views[1]), + }, + ], + }); + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("ssgi_temporal_diagnostic_pass"), + timestamp_writes: None, + }); + pass.set_pipeline(&resources.pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1); + } + + pub(super) fn ssgi_temporal_diagnostic_textures(&self) -> Option<&[wgpu::Texture]> { + self.ssgi_temporal_diagnostics + .as_ref() + .map(|resources| resources.textures.as_slice()) + } + + pub(super) fn release_ssgi_temporal_diagnostics(&mut self) { + self.ssgi_temporal_diagnostics = None; + } +} + +#[cfg(test)] +mod tests { + use super::SHADER; + + #[test] + fn probe_diagnostic_shader_parses_without_touching_production_history() { + wgpu::naga::front::wgsl::parse_str(SHADER) + .unwrap_or_else(|error| panic!("SSGI temporal diagnostics WGSL failed: {error}")); + } +} diff --git a/native/shared/src/renderer/ssgi_temporal_diagnostics.wgsl b/native/shared/src/renderer/ssgi_temporal_diagnostics.wgsl new file mode 100644 index 00000000..d0563859 --- /dev/null +++ b/native/shared/src/renderer/ssgi_temporal_diagnostics.wgsl @@ -0,0 +1,73 @@ +struct TemporalParams { + params: vec4, +}; + +struct ProbeHeader { + world_pos: vec4, + normal: vec4, + diffuse: vec4, +}; + +@group(0) @binding(0) var u: TemporalParams; +@group(0) @binding(1) var radiance_in: texture_3d; +@group(0) @binding(2) var history_in: texture_3d; +@group(0) @binding(3) var probes: array; +@group(0) @binding(4) var reason_out: texture_storage_2d; +@group(0) @binding(5) var confidence_out: texture_storage_2d; + +@compute @workgroup_size(8, 8, 1) +fn cs_main(@builtin(global_invocation_id) gid: vec3) { + let dimensions = textureDimensions(reason_out); + if (gid.x >= dimensions.x || gid.y >= dimensions.y) { + return; + } + let probe = vec2(gid.xy / vec2(8u)); + let probe_idx = probe.y * (dimensions.x / 8u) + probe.x; + let valid_probe = probes[probe_idx].world_pos.w >= 0.5; + let octel = gid.xy % vec2(8u); + let coord = vec3( + vec2(probe), + i32(octel.y * 8u + octel.x), + ); + let curr = textureLoad(radiance_in, coord, 0).rgb; + var hist = textureLoad(history_in, coord, 0).rgb; + // Production reserves octel zero for the probe's cosine-convolved + // diffuse result and seeds that lane's directional history from current. + if (octel.x == 0u && octel.y == 0u) { + hist = curr; + } + let curr_finite = all(abs(curr) <= vec3(65504.0)); + let hist_finite = all(abs(hist) <= vec3(65504.0)); + let finite = curr_finite && hist_finite; + let curr_luma = dot(curr, vec3(0.2126, 0.7152, 0.0722)); + let hist_luma = dot(hist, vec3(0.2126, 0.7152, 0.0722)); + let delta = abs(curr_luma - hist_luma); + var alpha = min(1.0, u.params.x + delta * 0.6); + if (u.params.y > 0.5) { + alpha = 1.0; + } + + // Shared palette in probe space: gray seed, magenta adaptive radiance + // refresh/invalid data, green retained history. Screen-space categories + // such as off-screen UV and motion do not exist for this representation. + var reason = vec3(0.05, 0.65, 0.10); + if (!valid_probe || u.params.y > 0.5) { + reason = vec3(0.25); + } else if (!finite || delta * 0.6 >= u.params.x) { + reason = vec3(1.0, 0.0, 0.8); + } + var variation_heat = 1.0; + var current_heat = 0.0; + var retained_history = 0.0; + if (finite && valid_probe) { + variation_heat = 1.0 - exp(-delta * 4.0); + current_heat = 1.0 - exp(-max(curr_luma, 0.0) * 4.0); + retained_history = clamp(1.0 - alpha, 0.0, 1.0); + } + textureStore(reason_out, vec2(gid.xy), vec4(reason, 1.0)); + textureStore( + confidence_out, + vec2(gid.xy), + vec4(variation_heat, current_heat, retained_history, 1.0), + ); +} diff --git a/native/shared/src/renderer/ssr_pass.rs b/native/shared/src/renderer/ssr_pass.rs index 5a09a8c9..50d60225 100644 --- a/native/shared/src/renderer/ssr_pass.rs +++ b/native/shared/src/renderer/ssr_pass.rs @@ -4,7 +4,113 @@ use super::{Renderer, SsrParams, SsrTemporalParams}; +fn ssr_temporal_alpha(history_valid: bool) -> f32 { + if history_valid { + 0.1 + } else { + 1.0 + } +} + impl Renderer { + /// Toggle SSR on/off. SSR contributes nothing in scenes with + /// no on-screen geometry to reflect (e.g., single object + /// against sky) — turning it off there saves a fullscreen pass. + pub fn set_ssr_enabled(&mut self, enabled: bool) { + if self.ssr_enabled != enabled { + self.ssr_enabled = enabled; + self.ssr_history_idx = 0; + self.ssr_history_valid = false; + } + } + + /// SSR strength multiplier (0 = off, 0.5 = default, 1+ = strong). + /// Changing the radiance multiplier invalidates incompatible history. + pub fn set_ssr_strength(&mut self, strength: f32) { + let strength = strength.max(0.0); + if self.ssr_strength != strength { + self.ssr_strength = strength; + self.ssr_history_idx = 0; + self.ssr_history_valid = false; + } + } + + fn create_ssr_bind_group( + &self, + label: &str, + layout: &wgpu::BindGroupLayout, + iridescence_metadata: Option<&wgpu::TextureView>, + ) -> wgpu::BindGroup { + // EN-021 — env panorama (or the 1×1 default) for the miss fallback. + // Both caches are invalidated wherever the lighting source or render + // targets change. + let env_view = self + .sky_texture + .as_ref() + .map(|texture| texture.create_view(&wgpu::TextureViewDescriptor::default())) + .unwrap_or_else(|| { + self._scene_env_default_texture + .create_view(&wgpu::TextureViewDescriptor::default()) + }); + let mut entries = vec![ + wgpu::BindGroupEntry { + binding: 0, + resource: self.ssr_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&self.depth_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&self.hdr_rt_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&self.material_rt_view), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(&self.albedo_rt_view), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::TextureView(&env_view), + }, + wgpu::BindGroupEntry { + binding: 10, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + ]; + if let Some(metadata) = iridescence_metadata { + entries.push(wgpu::BindGroupEntry { + binding: 11, + resource: wgpu::BindingResource::TextureView(metadata), + }); + } + self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(label), + layout, + entries: &entries, + }) + } + /// Quarter-res stochastic SSR ray march (GGX-sampled directions, /// jittered starts; temporal accumulation makes it converge). pub(super) fn record_ssr_march( @@ -12,177 +118,242 @@ impl Renderer { encoder: &mut wgpu::CommandEncoder, profiler: &mut crate::profiler::Profiler, ) { - // ============================================================ - // SSR: view-space ray march of the depth buffer + HDR sample. - // ============================================================ - // PT-1: skipped while the path tracer owns the frame — it marches - // the raster-lit HDR, which PT has already overwritten. - if self.ssr_enabled && !self.pt_owns_frame() { - let inv_proj = self.current_inv_proj_matrix; - // EN-021 — view→world rotation for the env-miss fallback: the - // transpose of the view matrix's 3×3 (rigid view ⇒ inverse - // rotation = transpose). Column j of the inverse is row j of - // the view rotation. - let v = self.current_view_matrix; - let inv_view_rot = [ - [v[0][0], v[1][0], v[2][0], 0.0], - [v[0][1], v[1][1], v[2][1], 0.0], - [v[0][2], v[1][2], v[2][2], 0.0], - [0.0, 0.0, 0.0, 1.0], - ]; - let sp = SsrParams { - inv_proj, - proj: self.current_proj_matrix, - // n_steps lowered from 32 → 8 for stochastic SSR: the - // GGX-sampled ray direction + jittered start offset + - // temporal accumulation over 4–8 frames fills in the - // gaps that any single-frame coarse march leaves behind. - // Thickness tolerance grows proportionally with - // step_size so the relative-error reject heuristic - // still works with the larger strides. - params: [self.ssr_strength, 8.0, 8.0, self.taa_frame_index as f32], - inv_view_rot, - // Env max LOD 6.0 matches the material path's roughness×6 - // mip ramp; intensity rides lighting camera_pos.w exactly - // like sample_env does. - params2: [6.0, self.lighting_uniforms.camera_pos[3], 0.0, 0.0], - }; - self.queue.write_buffer(&self.ssr_uniform_buffer, 0, bytemuck::bytes_of(&sp)); + // ============================================================ + // SSR: view-space ray march of the depth buffer + HDR sample. + // ============================================================ + // PT-1: skipped while the path tracer owns the frame — it marches + // the raster-lit HDR, which PT has already overwritten. + if self.ssr_enabled && !self.pt_owns_frame() { + let inv_proj = self.current_inv_proj_matrix; + // EN-021 — view→world rotation for the env-miss fallback: the + // transpose of the view matrix's 3×3 (rigid view ⇒ inverse + // rotation = transpose). Column j of the inverse is row j of + // the view rotation. + let v = self.current_view_matrix; + let inv_view_rot = [ + [v[0][0], v[1][0], v[2][0], 0.0], + [v[0][1], v[1][1], v[2][1], 0.0], + [v[0][2], v[1][2], v[2][2], 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + let sp = SsrParams { + inv_proj, + proj: self.current_proj_matrix, + // n_steps lowered from 32 → 8 for stochastic SSR: the + // GGX-sampled ray direction + jittered start offset + + // temporal accumulation over 4–8 frames fills in the + // gaps that any single-frame coarse march leaves behind. + // Thickness tolerance grows proportionally with + // step_size so the relative-error reject heuristic + // still works with the larger strides. + params: [self.ssr_strength, 8.0, 8.0, self.taa_frame_index as f32], + inv_view_rot, + // Env max LOD 6.0 matches the material path's roughness×6 + // mip ramp; intensity rides lighting camera_pos.w exactly + // like sample_env does. + params2: [6.0, self.lighting_uniforms.camera_pos[3], 0.0, 0.0], + }; + self.queue + .write_buffer(&self.ssr_uniform_buffer, 0, bytemuck::bytes_of(&sp)); - if self.ssr_bg_cache.is_none() { - // EN-021 — env panorama (or the 1×1 default) for the miss - // fallback. The cache is invalidated wherever the lighting - // bind group swaps env sources. - let env_view = self - .sky_texture - .as_ref() - .map(|t| t.create_view(&wgpu::TextureViewDescriptor::default())) - .unwrap_or_else(|| { - self._scene_env_default_texture - .create_view(&wgpu::TextureViewDescriptor::default()) - }); - self.ssr_bg_cache = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("ssr_bg"), - layout: &self.ssr_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.ssr_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.depth_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.hdr_rt_view) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&self.material_rt_view) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&self.albedo_rt_view) }, - wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::TextureView(&env_view) }, - wgpu::BindGroupEntry { binding: 10, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - ], - })); + if self.iridescence_ssr_active { + if self.ssr_layered_bg_cache.is_none() { + let resources = self + .scene_iridescence_ssr_resources + .as_ref() + .expect("active iridescence SSR has lazy resources"); + self.ssr_layered_bg_cache = Some(self.create_ssr_bind_group( + "iridescence_ssr_bg", + &resources.ssr_layout, + Some(&resources.metadata_view), + )); + } + } else if self.ssr_bg_cache.is_none() { + self.ssr_bg_cache = + Some(self.create_ssr_bind_group("ssr_bg", &self.ssr_layout, None)); + } + let (pipeline, bg) = if self.iridescence_ssr_active { + let resources = self + .scene_iridescence_ssr_resources + .as_ref() + .expect("active iridescence SSR has lazy resources"); + ( + &resources.ssr_pipeline, + self.ssr_layered_bg_cache + .as_ref() + .expect("layered SSR bind group initialized"), + ) + } else { + ( + &self.ssr_pipeline, + self.ssr_bg_cache + .as_ref() + .expect("base SSR bind group initialized"), + ) + }; + let ssr_ts = profiler.pass_timestamp_writes("ssr_pass"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("ssr_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.ssr_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: ssr_ts, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, bg, &[]); + pass.draw(0..3, 0..1); + } else { + // SSR disabled — clear the RT so TAA's read returns 0 + // (transparent black). One-time clear is cheaper than a + // full clear+pipeline switch every frame. + let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("ssr_clear"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.ssr_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + drop(pass); } - let bg = self.ssr_bg_cache.as_ref().unwrap(); - let ssr_ts = profiler.pass_timestamp_writes("ssr_pass"); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("ssr_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.ssr_rt_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: ssr_ts, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(&self.ssr_pipeline); - pass.set_bind_group(0, bg, &[]); - pass.draw(0..3, 0..1); - } else { - // SSR disabled — clear the RT so TAA's read returns 0 - // (transparent black). One-time clear is cheaper than a - // full clear+pipeline switch every frame. - let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("ssr_clear"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.ssr_rt_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - drop(pass); - } } /// SSR temporal denoiser: 3x3 pre-filter + neighborhood-clamped /// history blend; compose reads ssr_history[cur]. - pub(super) fn record_ssr_temporal( - &mut self, - encoder: &mut wgpu::CommandEncoder, - ) { - // ============================================================ - // SSR temporal denoiser: blend the noisy single-ray SSR with - // the reprojected previous history so 4–8 frames of GGX-sampled - // rays converge to a smooth reflection. 3×3 pre-filter of the - // noisy current frame + neighborhood clamp of reprojected - // history. Compose then reads ssr_history[cur] instead of - // ssr_rt. - // ============================================================ - // PT-1: same gate as the march — no fresh rays, nothing to blend. - if self.ssr_enabled && !self.pt_owns_frame() { - let prev_idx = 1 - self.ssr_history_idx; - let cur_idx = self.ssr_history_idx; + pub(super) fn record_ssr_temporal(&mut self, encoder: &mut wgpu::CommandEncoder) { + // ============================================================ + // SSR temporal denoiser: blend the noisy single-ray SSR with + // the reprojected previous history so 4–8 frames of GGX-sampled + // rays converge to a smooth reflection. 3×3 pre-filter of the + // noisy current frame + neighborhood clamp of reprojected + // history. Compose then reads ssr_history[cur] instead of + // ssr_rt. + // ============================================================ + // PT-1: same gate as the march — no fresh rays, nothing to blend. + if self.ssr_enabled && !self.pt_owns_frame() { + let prev_idx = 1 - self.ssr_history_idx; + let cur_idx = self.ssr_history_idx; - // First frame: alpha=1 so we initialize history from the - // current noisy frame rather than blending with zeros. - let alpha = if self.taa_frame_index == 0 { 1.0_f32 } else { 0.1_f32 }; - let tp = SsrTemporalParams { - params: [alpha, 0.0, 0.0, 0.0], - }; - self.queue.write_buffer(&self.ssr_temporal_uniform_buffer, 0, bytemuck::bytes_of(&tp)); + // Explicit validity owns initialization. TAA's frame counter is + // unrelated to SSR lifetime: SSR can be toggled, resized, or + // suspended by PT long after TAA frame zero. + let alpha = ssr_temporal_alpha(self.ssr_history_valid); + let tp = SsrTemporalParams { + params: [alpha, 0.0, 0.0, 0.0], + }; + self.queue.write_buffer( + &self.ssr_temporal_uniform_buffer, + 0, + bytemuck::bytes_of(&tp), + ); - let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("ssr_temporal_bg"), - layout: &self.ssr_temporal_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.ssr_temporal_uniform_buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.ssr_rt_view) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.ssr_history_views[prev_idx]) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&self.velocity_rt_view) }, - wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, - ], - }); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("ssr_temporal_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.ssr_history_views[cur_idx], - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(&self.ssr_temporal_pipeline); - pass.set_bind_group(0, &bg, &[]); - pass.draw(0..3, 0..1); + if self.ssr_temporal_bind_group_cache[prev_idx].is_none() { + self.frame_resource_stats.created_bind_group( + super::frame_resource_stats::BindGroupCreationSite::SsrTemporal, + ); + self.ssr_temporal_bind_group_cache[prev_idx] = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("ssr_temporal_bg"), + layout: &self.ssr_temporal_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.ssr_temporal_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&self.ssr_rt_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView( + &self.ssr_history_views[prev_idx], + ), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView( + &self.velocity_rt_view, + ), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + ], + })); + } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("ssr_temporal_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.ssr_history_views[cur_idx], + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.ssr_temporal_pipeline); + pass.set_bind_group( + 0, + self.ssr_temporal_bind_group_cache[prev_idx] + .as_ref() + .expect("SSR temporal bind group was initialized"), + &[], + ); + pass.draw(0..3, 0..1); + drop(pass); + #[cfg(not(target_arch = "wasm32"))] + if self.pending_quality_capture_dir.is_some() { + let diagnostic_bg = self.ssr_temporal_bind_group_cache[prev_idx] + .as_ref() + .expect("SSR temporal bind group was initialized") + .clone(); + self.record_ssr_temporal_diagnostics(encoder, &diagnostic_bg); + } + self.ssr_history_valid = true; + } } +} + +#[cfg(test)] +mod tests { + use super::ssr_temporal_alpha; + + #[test] + fn invalid_ssr_history_is_replaced_before_temporal_blending() { + assert_eq!(ssr_temporal_alpha(false), 1.0); + assert_eq!(ssr_temporal_alpha(true), 0.1); } } diff --git a/native/shared/src/renderer/ssr_temporal_diagnostics.rs b/native/shared/src/renderer/ssr_temporal_diagnostics.rs new file mode 100644 index 00000000..e8c24c0f --- /dev/null +++ b/native/shared/src/renderer/ssr_temporal_diagnostics.rs @@ -0,0 +1,250 @@ +//! Capture-only SSR temporal diagnostics. +//! +//! The diagnostic shader is derived from the production temporal shader and +//! reuses its bind group after the real SSR history has been written. Normal +//! frames compile no pipeline, allocate no target, and execute no pass. + +use super::*; + +pub(super) const SSR_TEMPORAL_DIAGNOSTIC_NAMES: [&str; 2] = + ["ssr-rejection-reason", "ssr-temporal-confidence"]; +const DIAGNOSTIC_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +pub(super) struct SsrTemporalDiagnosticResources { + textures: Vec, + views: Vec, + pipeline: wgpu::RenderPipeline, + width: u32, + height: u32, +} + +fn diagnostic_shader_source() -> String { + let signature = "@fragment\nfn fs_main(in: VsOut) -> @location(0) vec4 {"; + let diagnostic_signature = r#"struct SsrTemporalDiagnosticOut { + @location(0) rejection_reason: vec4, + @location(1) temporal_confidence: vec4, +}; + +@fragment +fn fs_diagnostics(in: VsOut) -> SsrTemporalDiagnosticOut {"#; + let mut source = SSR_TEMPORAL_SHADER_WGSL.replacen(signature, diagnostic_signature, 1); + assert!( + source.contains("fn fs_diagnostics"), + "SSR temporal entry point changed; diagnostics must follow it" + ); + source = source.replacen( + " if (off_screen) { return current; }", + " // Diagnostics continue with a clamped lookup so they can classify\n\ + // the off-screen rejection without sampling outside the history.", + 1, + ); + source = source.replacen( + "textureSampleLevel(history_tex, history_samp, prev_uv, 0.0)", + "textureSampleLevel(\n\ + history_tex,\n\ + history_samp,\n\ + clamp(prev_uv, vec2(0.0), vec2(1.0)),\n\ + 0.0,\n\ + )", + 1, + ); + let final_return = " return select(current, blended, blended == blended);"; + let diagnostic_return = r#" let history_finite = all(history_raw == history_raw); + let clamp_delta = length(history_raw.rgb - clamped_history.rgb); + let local_luma_range = abs(dot( + nmax.rgb - nmin.rgb, + vec3(0.2126, 0.7152, 0.0722), + )); + let variation_heat = 1.0 - exp(-local_luma_range * 4.0); + let clamp_heat = select( + 1.0, + 1.0 - exp(-clamp_delta * 4.0), + history_finite, + ); + let history_in_bounds = !off_screen; + let history_confidence = select( + 0.0, + clamp(1.0 - u.params.x, 0.0, 1.0), + history_in_bounds && history_finite, + ); + + // Shared temporal palette: gray seed, red off-screen, magenta invalid + // history, yellow neighborhood clamp, and green accepted history. + var reason = vec3(0.05, 0.65, 0.10); + if (u.params.x >= 0.999) { + reason = vec3(0.25); + } else if (!history_in_bounds) { + reason = vec3(1.0, 0.05, 0.02); + } else if (!history_finite) { + reason = vec3(1.0, 0.0, 0.8); + } else if (clamp_delta > 0.0001) { + reason = vec3(1.0, 0.75, 0.0); + } + return SsrTemporalDiagnosticOut( + vec4(reason, 1.0), + vec4( + variation_heat, + clamp_heat, + history_confidence, + 1.0, + ), + );"#; + source = source.replacen(final_return, diagnostic_return, 1); + assert!( + source.contains("return SsrTemporalDiagnosticOut"), + "SSR temporal final output changed; diagnostics must follow it" + ); + source +} + +impl SsrTemporalDiagnosticResources { + fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, width: u32, height: u32) -> Self { + let textures = SSR_TEMPORAL_DIAGNOSTIC_NAMES + .iter() + .map(|name| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some(name), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: DIAGNOSTIC_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }) + }) + .collect::>(); + let views = textures + .iter() + .map(|texture| texture.create_view(&wgpu::TextureViewDescriptor::default())) + .collect::>(); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("ssr_temporal_diagnostic_shader"), + source: wgpu::ShaderSource::Wgsl(diagnostic_shader_source().into()), + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("ssr_temporal_diagnostic_pipeline_layout"), + bind_group_layouts: &[Some(layout)], + immediate_size: 0, + }); + let targets = SSR_TEMPORAL_DIAGNOSTIC_NAMES + .iter() + .map(|_| { + Some(wgpu::ColorTargetState { + format: DIAGNOSTIC_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }) + }) + .collect::>(); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("ssr_temporal_diagnostic_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_diagnostics"), + targets: &targets, + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + Self { + textures, + views, + pipeline, + width, + height, + } + } +} + +impl Renderer { + pub(super) fn record_ssr_temporal_diagnostics( + &mut self, + encoder: &mut wgpu::CommandEncoder, + bind_group: &wgpu::BindGroup, + ) { + let size = self.ssr_rt_texture.size(); + let resize = self + .ssr_temporal_diagnostics + .as_ref() + .is_some_and(|resources| { + resources.width != size.width || resources.height != size.height + }); + if resize { + self.ssr_temporal_diagnostics = None; + } + if self.ssr_temporal_diagnostics.is_none() { + self.ssr_temporal_diagnostics = Some(SsrTemporalDiagnosticResources::new( + &self.device, + &self.ssr_temporal_layout, + size.width, + size.height, + )); + } + let resources = self.ssr_temporal_diagnostics.as_ref().unwrap(); + let attachments = resources + .views + .iter() + .map(|view| { + Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + }) + }) + .collect::>(); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("ssr_temporal_diagnostic_pass"), + color_attachments: &attachments, + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&resources.pipeline); + pass.set_bind_group(0, bind_group, &[]); + pass.draw(0..3, 0..1); + } + + pub(super) fn ssr_temporal_diagnostic_textures(&self) -> Option<&[wgpu::Texture]> { + self.ssr_temporal_diagnostics + .as_ref() + .map(|resources| resources.textures.as_slice()) + } + + pub(super) fn release_ssr_temporal_diagnostics(&mut self) { + self.ssr_temporal_diagnostics = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn diagnostic_shader_parses_without_modifying_production_ssr() { + wgpu::naga::front::wgsl::parse_str(&diagnostic_shader_source()) + .unwrap_or_else(|error| panic!("SSR temporal diagnostics WGSL failed: {error}")); + assert!(!SSR_TEMPORAL_SHADER_WGSL.contains("SsrTemporalDiagnosticOut")); + assert!(!SSR_TEMPORAL_SHADER_WGSL.contains("fs_diagnostics")); + } +} diff --git a/native/shared/src/renderer/temporal_diagnostics.rs b/native/shared/src/renderer/temporal_diagnostics.rs new file mode 100644 index 00000000..8f38c7b1 --- /dev/null +++ b/native/shared/src/renderer/temporal_diagnostics.rs @@ -0,0 +1,292 @@ +//! Capture-only TAA/TSR diagnostics. +//! +//! Resources exist only for a requested qualification capture. The production +//! shader and frame path remain unchanged, and all diagnostic GPU objects are +//! released after readback. + +use super::*; + +pub(super) const TAA_DIAGNOSTIC_NAMES: [&str; 4] = [ + "taa-rejection-reason", + "taa-motion", + "taa-reprojected-uv", + "taa-temporal-confidence", +]; +pub(super) const TAA_DIAGNOSTIC_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +pub(super) struct TaaDiagnosticResources { + textures: Vec, + views: Vec, + pipeline: wgpu::RenderPipeline, + reactive_pipeline: Option, + width: u32, + height: u32, +} + +fn diagnostic_shader_source(reactive: bool) -> String { + let source = if reactive { + super::temporal_reactive::taa_reactive_shader_source() + } else { + TAA_SHADER_WGSL.to_owned() + }; + let signature = "@fragment\nfn fs_main(in: VsOut) -> @location(0) vec4 {"; + let diagnostic_signature = r#"struct TaaDiagnosticOut { + @location(0) rejection_reason: vec4, + @location(1) motion: vec4, + @location(2) reprojected_uv: vec4, + @location(3) temporal_confidence: vec4, +}; + +@fragment +fn fs_diagnostics(in: VsOut) -> TaaDiagnosticOut {"#; + let source = source.replacen(signature, diagnostic_signature, 1); + assert!( + source.contains("fn fs_diagnostics"), + "TAA entry point changed; diagnostics must follow it" + ); + + let final_return = " return vec4(blended, blended_w);"; + let reactive_value = if reactive { "reactive" } else { "0.0" }; + let diagnostic_return = format!( + r#" let history_in_bounds = + prev_uv.x >= 0.0 && prev_uv.x <= 1.0 && + prev_uv.y >= 0.0 && prev_uv.y <= 1.0; + let clamped_ycocg = vec3(history_y_clamped, co_clamped, cg_clamped); + let clamp_delta = length(history_ycocg - clamped_ycocg); + let variance_heat = 1.0 - exp(-stddev.x * 4.0); + let clamp_heat = 1.0 - exp(-clamp_delta * 4.0); + let history_confidence = select( + 0.0, clamp(1.0 - alpha, 0.0, 1.0), history_in_bounds); + let reactive_weight = {reactive_value}; + + // Categorical dominant reason: gray seed, red off-screen, cyan reactive, + // magenta disocclusion, yellow neighborhood clamp, blue motion, green keep. + var reason = vec3(0.05, 0.65, 0.10); + if (u.params.x >= 0.999) {{ + reason = vec3(0.25); + }} else if (!history_in_bounds) {{ + reason = vec3(1.0, 0.05, 0.02); + }} else if (reactive_weight > 0.01 && + reactive_weight >= max(disocclusion, motion_ramped)) {{ + reason = vec3(0.0, 0.9, 1.0); + }} else if (disocclusion > 0.01 && disocclusion >= motion_ramped) {{ + reason = vec3(1.0, 0.0, 0.8); + }} else if (clamp_delta > 0.0001) {{ + reason = vec3(1.0, 0.75, 0.0); + }} else if (motion_alpha > 0.01) {{ + reason = vec3(0.05, 0.25, 1.0); + }} + + // Motion RG is signed around 0.5; B is magnitude. Reprojection RG stores + // previous-frame UV and B is its validity. Confidence RGB stores local + // luma variance, clamp magnitude, and retained-history contribution. + let motion_debug = vec3( + clamp(0.5 + vel.x * 32.0, 0.0, 1.0), + clamp(0.5 - vel.y * 32.0, 0.0, 1.0), + clamp(vel_len * 64.0, 0.0, 1.0), + ); + return TaaDiagnosticOut( + vec4(reason, 1.0), + vec4(motion_debug, 1.0), + vec4(clamp(prev_uv, vec2(0.0), vec2(1.0)), + select(0.0, 1.0, history_in_bounds), 1.0), + vec4(variance_heat, clamp_heat, history_confidence, 1.0), + );"# + ); + let source = source.replacen(final_return, &diagnostic_return, 1); + assert!( + source.contains("return TaaDiagnosticOut"), + "TAA final output changed; diagnostics must follow it" + ); + source +} + +fn create_pipeline( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + reactive: bool, +) -> wgpu::RenderPipeline { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("taa_diagnostic_shader"), + source: wgpu::ShaderSource::Wgsl(diagnostic_shader_source(reactive).into()), + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("taa_diagnostic_pipeline_layout"), + bind_group_layouts: &[Some(layout)], + immediate_size: 0, + }); + let targets = (0..TAA_DIAGNOSTIC_NAMES.len()) + .map(|_| { + Some(wgpu::ColorTargetState { + format: TAA_DIAGNOSTIC_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }) + }) + .collect::>(); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("taa_diagnostic_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_diagnostics"), + targets: &targets, + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) +} + +fn create_targets( + device: &wgpu::Device, + width: u32, + height: u32, +) -> (Vec, Vec) { + let textures = TAA_DIAGNOSTIC_NAMES + .iter() + .map(|name| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some(name), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: TAA_DIAGNOSTIC_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }) + }) + .collect::>(); + let views = textures + .iter() + .map(|texture| texture.create_view(&wgpu::TextureViewDescriptor::default())) + .collect(); + (textures, views) +} + +impl TaaDiagnosticResources { + fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, width: u32, height: u32) -> Self { + let (textures, views) = create_targets(device, width, height); + Self { + textures, + views, + pipeline: create_pipeline(device, layout, false), + reactive_pipeline: None, + width, + height, + } + } +} + +impl Renderer { + pub(super) fn record_taa_diagnostics( + &mut self, + encoder: &mut wgpu::CommandEncoder, + bind_group: &wgpu::BindGroup, + reactive: bool, + ) { + let (width, height) = (self.surface_config.width, self.surface_config.height); + let resize = self + .temporal_diagnostics + .as_ref() + .is_some_and(|resources| resources.width != width || resources.height != height); + if resize { + self.temporal_diagnostics = None; + } + if self.temporal_diagnostics.is_none() { + self.temporal_diagnostics = Some(TaaDiagnosticResources::new( + &self.device, + &self.taa_layout, + width, + height, + )); + } + if reactive + && self + .temporal_diagnostics + .as_ref() + .is_some_and(|resources| resources.reactive_pipeline.is_none()) + { + let layout = self + .taa_reactive_layout + .as_ref() + .expect("reactive diagnostics require the reactive TAA layout"); + let pipeline = create_pipeline(&self.device, layout, true); + self.temporal_diagnostics + .as_mut() + .unwrap() + .reactive_pipeline = Some(pipeline); + } + + let resources = self.temporal_diagnostics.as_ref().unwrap(); + let attachments = resources + .views + .iter() + .map(|view| { + Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + }) + }) + .collect::>(); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("taa_diagnostic_pass"), + color_attachments: &attachments, + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(if reactive { + resources.reactive_pipeline.as_ref().unwrap() + } else { + &resources.pipeline + }); + pass.set_bind_group(0, bind_group, &[]); + pass.draw(0..3, 0..1); + } + + pub(super) fn taa_diagnostic_textures(&self) -> Option<&[wgpu::Texture]> { + self.temporal_diagnostics + .as_ref() + .map(|resources| resources.textures.as_slice()) + } + + pub(super) fn release_temporal_diagnostics(&mut self) { + self.temporal_diagnostics = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn diagnostic_variants_parse_without_modifying_production_taa() { + for reactive in [false, true] { + wgpu::naga::front::wgsl::parse_str(&diagnostic_shader_source(reactive)) + .unwrap_or_else(|error| panic!("TAA diagnostics WGSL failed: {error}")); + } + assert!(!TAA_SHADER_WGSL.contains("TaaDiagnosticOut")); + assert!(!TAA_SHADER_WGSL.contains("fs_diagnostics")); + } +} diff --git a/native/shared/src/renderer/temporal_history.rs b/native/shared/src/renderer/temporal_history.rs new file mode 100644 index 00000000..96e12883 --- /dev/null +++ b/native/shared/src/renderer/temporal_history.rs @@ -0,0 +1,112 @@ +//! Common temporal-history invalidation and camera-cut ownership. + +use super::*; + +/// Reapply the current frame's projection jitter to the previous unjittered +/// projection. Both current and previous clip positions then contain the same +/// sampling offset, so the velocity target represents scene/camera motion +/// only rather than the Halton phase delta. +pub(super) fn velocity_reference_projection( + mut previous_projection_unjittered: [[f32; 4]; 4], + current_jitter_ndc: [f32; 2], +) -> [[f32; 4]; 4] { + previous_projection_unjittered[2][0] += current_jitter_ndc[0]; + previous_projection_unjittered[2][1] += current_jitter_ndc[1]; + previous_projection_unjittered +} + +impl Renderer { + /// Invalidate every temporal consumer before a deliberate camera cut, + /// teleport, FOV discontinuity, or world load. Call before the next + /// `begin_mode_3d`; existing GPU allocations are retained. + pub fn reset_temporal_history(&mut self) { + self.taa_current_idx = 0; + self.taa_frame_index = 0; + self.taa_history_valid = false; + self.taa_history_written = false; + self.ssao_history_idx = 0; + self.ssao_history_frame = 0; + self.ssr_history_idx = 0; + self.ssr_history_valid = false; + self.probe_history_idx = 0; + self.probe_history_valid = false; + self.exposure_current_idx = 0; + self.exposure_history_valid = false; + self.exposure_history_written = false; + self.reset_path_tracing_history(0); + self.immediate_motion.reset(); + self.temporal_camera_cut_pending = true; + } +} + +#[cfg(test)] +mod tests { + use super::velocity_reference_projection; + use crate::renderer::{mat4_mul_vec4, mat4_perspective}; + + fn assert_vec2_close(actual: [f32; 2], expected: [f32; 2]) { + for axis in 0..2 { + assert!( + (actual[axis] - expected[axis]).abs() <= 1.0e-6, + "axis {axis}: {} != {}", + actual[axis], + expected[axis] + ); + } + } + + fn ndc_to_uv(ndc: [f32; 2]) -> [f32; 2] { + [ndc[0] * 0.5 + 0.5, 0.5 - ndc[1] * 0.5] + } + + fn velocity_from_clip(current: [f32; 4], previous: [f32; 4]) -> [f32; 2] { + [ + (current[0] / current[3] - previous[0] / previous[3]) * 0.5, + (current[1] / current[3] - previous[1] / previous[3]) * 0.5, + ] + } + + fn previous_uv_from_velocity(current_uv: [f32; 2], velocity: [f32; 2]) -> [f32; 2] { + [current_uv[0] - velocity[0], current_uv[1] + velocity[1]] + } + + #[test] + fn current_minus_previous_velocity_reprojects_with_texture_y_orientation() { + let current_ndc = [0.6, -0.2]; + let previous_ndc = [-0.2, 0.4]; + let current_clip = [current_ndc[0], current_ndc[1], 0.5, 1.0]; + let previous_clip = [previous_ndc[0], previous_ndc[1], 0.5, 1.0]; + let velocity = velocity_from_clip(current_clip, previous_clip); + + assert_vec2_close(velocity, [0.4, -0.3]); + assert_vec2_close( + previous_uv_from_velocity(ndc_to_uv(current_ndc), velocity), + ndc_to_uv(previous_ndc), + ); + assert_vec2_close(ndc_to_uv(previous_ndc), [0.4, 0.3]); + } + + #[test] + fn current_jitter_on_previous_projection_makes_static_velocity_zero() { + let projection = mat4_perspective(60.0_f32.to_radians(), 16.0 / 9.0, 0.1, 100.0); + let jitter = [0.00325, -0.0045]; + // Match the independent current-projection construction in + // begin_mode_3d; the helper owns only the previous-frame reference. + let mut current_projection = projection; + current_projection[2][0] += jitter[0]; + current_projection[2][1] += jitter[1]; + let previous_projection = velocity_reference_projection(projection, jitter); + let point = [1.25, -0.75, -5.0, 1.0]; + + let current_clip = mat4_mul_vec4(¤t_projection, &point); + let previous_clip = mat4_mul_vec4(&previous_projection, &point); + assert_vec2_close(velocity_from_clip(current_clip, previous_clip), [0.0, 0.0]); + + let uncorrected_previous_clip = mat4_mul_vec4(&projection, &point); + let jitter_leak = velocity_from_clip(current_clip, uncorrected_previous_clip); + assert!( + jitter_leak[0].abs() > 1.0e-4 && jitter_leak[1].abs() > 1.0e-4, + "negative control did not expose the jitter delta: {jitter_leak:?}" + ); + } +} diff --git a/native/shared/src/renderer/temporal_reactive.rs b/native/shared/src/renderer/temporal_reactive.rs new file mode 100644 index 00000000..28e8b111 --- /dev/null +++ b/native/shared/src/renderer/temporal_reactive.rs @@ -0,0 +1,837 @@ +//! Lazy temporal-reactive coverage for imported transparency, transmission, +//! and custom translucent materials that author an `fs_reactive` entry point. +//! +//! The established TAA, sorted-alpha, refraction, and weighted-OIT pipelines +//! remain untouched. These variants are compiled only after a TAA frame has a +//! visible imported draw whose current color depends on transparency coverage +//! or the current opaque scene behind it. + +use super::*; + +pub(super) const TEMPORAL_REACTIVE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm; + +pub(super) fn temporal_reactive_enabled() -> bool { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *ENABLED.get_or_init(|| { + !matches!( + std::env::var("BLOOM_TEMPORAL_REACTIVE") + .unwrap_or_else(|_| "on".to_owned()) + .trim() + .to_ascii_lowercase() + .as_str(), + "0" | "off" | "false" | "disabled" + ) + }) +} + +pub(super) fn temporal_reactive_selected( + taa_enabled: bool, + imported_transparency_draw_count: usize, + custom_reactive_draw: impl FnOnce() -> bool, + imported_refraction_draw_count: impl FnOnce() -> usize, +) -> bool { + temporal_reactive_selected_for( + temporal_reactive_enabled(), + taa_enabled, + imported_transparency_draw_count, + custom_reactive_draw, + imported_refraction_draw_count, + ) +} + +fn temporal_reactive_selected_for( + feature_enabled: bool, + taa_enabled: bool, + imported_transparency_draw_count: usize, + custom_reactive_draw: impl FnOnce() -> bool, + imported_refraction_draw_count: impl FnOnce() -> usize, +) -> bool { + feature_enabled + && taa_enabled + && (imported_transparency_draw_count > 0 + || custom_reactive_draw() + || imported_refraction_draw_count() > 0) +} + +pub(super) fn reactive_union_blend() -> wgpu::BlendState { + let union = wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrc, + operation: wgpu::BlendOperation::Add, + }; + wgpu::BlendState { + color: union, + alpha: union, + } +} + +pub(super) fn create_taa_bind_group_layout( + device: &wgpu::Device, + label: &'static str, + reactive: bool, +) -> wgpu::BindGroupLayout { + let entries = [ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 7, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 8, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ]; + if !reactive { + return device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some(label), + entries: &entries, + }); + } + let mut reactive_entries = entries.to_vec(); + reactive_entries.push(wgpu::BindGroupLayoutEntry { + binding: 9, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }); + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some(label), + entries: &reactive_entries, + }) +} + +pub(super) fn taa_reactive_shader_source() -> String { + let source = TAA_SHADER_WGSL.replacen( + "@group(0) @binding(8) var velocity_samp: sampler;", + "@group(0) @binding(8) var velocity_samp: sampler;\n\ + @group(0) @binding(9) var reactive_tex: texture_2d;", + 1, + ); + assert_ne!( + source, TAA_SHADER_WGSL, + "TAA binding declarations changed; reactive injection must be updated" + ); + let source = source.replacen( + " let alpha = max(motion_ramped, disocclusion);", + " // The mask follows the same unjittered current-frame coordinate as\n\ + // the color sample. Coverage is the minimum current-frame weight: a\n\ + // 20% glass layer rejects at least 20% stale history, while fully\n\ + // refractive pixels consume the current result immediately.\n\ + let reactive = textureSampleLevel(\n\ + reactive_tex,\n\ + composed_samp,\n\ + clamp(src_uv, vec2(0.0), vec2(1.0)),\n\ + 0.0,\n\ + ).r;\n\ + let alpha = max(max(motion_ramped, disocclusion), reactive);", + 1, + ); + assert!( + source.contains("var reactive_tex"), + "reactive TAA shader must declare its coverage input" + ); + source +} + +pub(super) fn scene_transparent_reactive_shader_source(base_scene_shader: &str) -> String { + let mut source = String::with_capacity(base_scene_shader.len() + 700); + source.push_str(base_scene_shader); + source.push_str( + r#" + +struct TransparentReactiveOut { + @location(0) color: vec4, + @location(1) reactive: f32, +}; + +@fragment +fn fs_transparent_scene_reactive( + in: VertexOutputScene, + @builtin(front_facing) front_facing: bool, +) -> TransparentReactiveOut { + let color = shade_main_scene(in, front_facing).color; + return TransparentReactiveOut(color, clamp(color.a, 0.0, 1.0)); +} +"#, + ); + source +} + +fn scene_refractive_reactive_shader_source( + base_scene_shader: &str, + folded_scene_inputs: bool, + screen_space_reflections: bool, + secondary_uv: bool, +) -> String { + let source = scene_refractive_shader_source( + base_scene_shader, + folded_scene_inputs, + screen_space_reflections, + secondary_uv, + ) + .replacen( + " @location(1) velocity: vec2,\n};", + " @location(1) velocity: vec2,\n\ + @location(2) reactive: f32,\n\ + };", + 1, + ); + let source = source.replacen( + " return RefractiveSceneOut(vec4(hdr, 1.0), surface.velocity);", + " // Opaque transmission is reactive in proportion to the portion\n\ + // sourced through the current scene/environment. BLEND+transmission\n\ + // composites the complete material response by base alpha, so its\n\ + // full visible contribution is the correct temporal coverage.\n\ + let reactive = select(\n\ + transmission_weight,\n\ + clamp(base_alpha, 0.0, 1.0),\n\ + material.metal_rough.w < 0.0,\n\ + );\n\ + return RefractiveSceneOut(\n\ + vec4(hdr, 1.0), surface.velocity, reactive,\n\ + );", + 1, + ); + assert!( + source.contains("@location(2) reactive"), + "refractive output changed; reactive injection must be updated" + ); + source +} + +const WEIGHTED_TRANSPARENCY_REACTIVE_RESOLVE_SHADER: &str = r#" +@group(0) @binding(0) var accumulation_tex: texture_2d; +@group(0) @binding(1) var revealage_tex: texture_2d; + +@vertex +fn vs_weighted_transparency_resolve( + @builtin(vertex_index) vertex_index: u32, +) -> @builtin(position) vec4 { + var positions = array, 3>( + vec2(-1.0, -1.0), + vec2(3.0, -1.0), + vec2(-1.0, 3.0), + ); + return vec4(positions[vertex_index], 0.0, 1.0); +} + +struct WeightedReactiveResolveOut { + @location(0) color: vec4, + @location(1) reactive: f32, +}; + +@fragment +fn fs_weighted_transparency_resolve( + @builtin(position) position: vec4, +) -> WeightedReactiveResolveOut { + let pixel = vec2(position.xy); + let accumulation = textureLoad(accumulation_tex, pixel, 0); + let revealage = clamp(textureLoad(revealage_tex, pixel, 0).r, 0.0, 1.0); + let opacity = 1.0 - revealage; + let color = accumulation.rgb / max(accumulation.a, 0.00001); + let finite_color = select(vec3(0.0), color, color == color); + return WeightedReactiveResolveOut(vec4(finite_color, opacity), opacity); +} +"#; + +impl Renderer { + pub(super) fn ensure_taa_reactive_resources(&mut self) { + if self.taa_reactive_pipeline.is_some() { + return; + } + let layout = create_taa_bind_group_layout(&self.device, "taa_reactive_layout", true); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("taa_reactive_shader"), + source: wgpu::ShaderSource::Wgsl(taa_reactive_shader_source().into()), + }); + let pipeline_layout = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("taa_reactive_pipeline_layout"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let pipeline = self + .device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("taa_reactive_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + self.taa_reactive_layout = Some(layout); + self.taa_reactive_pipeline = Some(pipeline); + self.created_pipelines(1); + } + + pub(super) fn ensure_scene_transparent_reactive_resources(&mut self) { + if self.scene_transparent_reactive_pipeline.is_some() { + return; + } + let source = scene_transparent_reactive_shader_source(&specialized_scene_shader_source( + self.froxel.is_some(), + self.shadow_map.virtual_map.requested(), + )); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_transparent_reactive_shader"), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + let layout = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("scene_transparent_reactive_pipeline_layout"), + bind_group_layouts: &[ + Some(&self.uniform_3d_layout), + Some(&self.lighting_layout), + Some(&self.scene_material_layout), + Some(&self.joint_layout), + ], + immediate_size: 0, + }); + let create_pipeline = |label: &'static str, cull_mode: Option| { + self.device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main_scene"), + buffers: &[Vertex3D::desc()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_transparent_scene_reactive"), + targets: &[ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: TEMPORAL_REACTIVE_FORMAT, + blend: Some(reactive_union_blend()), + write_mask: wgpu::ColorWrites::RED, + }), + ], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) + }; + self.scene_transparent_reactive_pipeline = Some(create_pipeline( + "scene_transparent_reactive_pipeline", + Some(wgpu::Face::Back), + )); + self.scene_transparent_reactive_double_sided_pipeline = Some(create_pipeline( + "scene_transparent_reactive_double_sided_pipeline", + None, + )); + self.created_pipelines(2); + } + + pub(super) fn ensure_scene_refraction_reactive_resources(&mut self) { + if self.scene_refractive_reactive_pipeline.is_some() { + return; + } + self.ensure_scene_refraction_resources(); + let material_layout = self + .scene_refractive_material_layout + .as_ref() + .expect("ordinary imported-refraction resources initialize first"); + #[cfg(fold_scene_inputs)] + let screen_space_reflections = false; + #[cfg(not(fold_scene_inputs))] + let screen_space_reflections = self.scene_refractive_inputs_layout.is_some(); + let source = scene_refractive_reactive_shader_source( + &specialized_scene_shader_source( + self.froxel.is_some(), + self.shadow_map.virtual_map.requested(), + ), + cfg!(fold_scene_inputs), + screen_space_reflections, + false, + ); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_refractive_reactive_shader"), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + #[cfg(fold_scene_inputs)] + let bind_group_layouts = vec![ + Some(&self.uniform_3d_layout), + Some(&self.lighting_layout), + Some(material_layout), + Some(&self.joint_layout), + ]; + #[cfg(not(fold_scene_inputs))] + let bind_group_layouts = vec![ + Some(&self.uniform_3d_layout), + Some(&self.lighting_layout), + Some(material_layout), + Some(&self.joint_layout), + Some( + self.scene_refractive_inputs_layout + .as_ref() + .unwrap_or(&self.material_system.layouts.scene_inputs), + ), + ]; + let layout = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("scene_refractive_reactive_pipeline_layout"), + bind_group_layouts: &bind_group_layouts, + immediate_size: 0, + }); + let create_pipeline = |label: &'static str, cull_mode: Option| { + self.device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main_scene"), + buffers: &[Vertex3D::desc()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_refractive_scene"), + targets: &[ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: TEMPORAL_REACTIVE_FORMAT, + blend: Some(reactive_union_blend()), + write_mask: wgpu::ColorWrites::RED, + }), + ], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) + }; + self.scene_refractive_reactive_pipeline = Some(create_pipeline( + "scene_refractive_reactive_pipeline", + Some(wgpu::Face::Back), + )); + self.scene_refractive_reactive_double_sided_pipeline = Some(create_pipeline( + "scene_refractive_reactive_double_sided_pipeline", + None, + )); + self.created_pipelines(2); + if self.scene_refractive_uv1_pipeline.is_some() { + self.ensure_scene_refraction_reactive_uv1_resources(); + } + } + + pub(super) fn ensure_scene_refraction_reactive_uv1_resources(&mut self) { + if self.scene_refractive_reactive_uv1_pipeline.is_some() { + return; + } + self.ensure_scene_refraction_uv1_resources(); + if self.scene_refractive_reactive_pipeline.is_none() { + self.ensure_scene_refraction_reactive_resources(); + return; + } + let material_layout = self + .scene_refractive_material_layout + .as_ref() + .expect("ordinary imported-refraction resources initialize first"); + #[cfg(fold_scene_inputs)] + let screen_space_reflections = false; + #[cfg(not(fold_scene_inputs))] + let screen_space_reflections = self.scene_refractive_inputs_layout.is_some(); + let source = scene_refractive_reactive_shader_source( + &specialized_scene_shader_source( + self.froxel.is_some(), + self.shadow_map.virtual_map.requested(), + ), + cfg!(fold_scene_inputs), + screen_space_reflections, + true, + ); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_refractive_reactive_uv1_shader"), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + #[cfg(fold_scene_inputs)] + let bind_group_layouts = vec![ + Some(&self.uniform_3d_layout), + Some(&self.lighting_layout), + Some(material_layout), + Some(&self.joint_layout), + ]; + #[cfg(not(fold_scene_inputs))] + let bind_group_layouts = vec![ + Some(&self.uniform_3d_layout), + Some(&self.lighting_layout), + Some(material_layout), + Some(&self.joint_layout), + Some( + self.scene_refractive_inputs_layout + .as_ref() + .unwrap_or(&self.material_system.layouts.scene_inputs), + ), + ]; + let layout = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("scene_refractive_reactive_uv1_pipeline_layout"), + bind_group_layouts: &bind_group_layouts, + immediate_size: 0, + }); + let vertex_layouts = [Vertex3D::desc(), secondary_uv_desc()]; + let create_pipeline = |label: &'static str, cull_mode: Option| { + self.device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main_scene"), + buffers: &vertex_layouts, + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_refractive_scene"), + targets: &[ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: VELOCITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: TEMPORAL_REACTIVE_FORMAT, + blend: Some(reactive_union_blend()), + write_mask: wgpu::ColorWrites::RED, + }), + ], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) + }; + self.scene_refractive_reactive_uv1_pipeline = Some(create_pipeline( + "scene_refractive_reactive_uv1_pipeline", + Some(wgpu::Face::Back), + )); + self.scene_refractive_reactive_uv1_double_sided_pipeline = Some(create_pipeline( + "scene_refractive_reactive_uv1_double_sided_pipeline", + None, + )); + self.created_pipelines(2); + } + + pub(super) fn ensure_weighted_transparency_reactive_resources(&mut self) { + if self + .weighted_transparency_reactive_resolve_pipeline + .is_some() + { + return; + } + self.ensure_weighted_transparency_resources(); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("weighted_transparency_reactive_resolve_shader"), + source: wgpu::ShaderSource::Wgsl( + WEIGHTED_TRANSPARENCY_REACTIVE_RESOLVE_SHADER.into(), + ), + }); + let layout = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("weighted_transparency_reactive_resolve_pipeline_layout"), + bind_group_layouts: &[Some( + self.weighted_transparency_resolve_layout + .as_ref() + .expect("ordinary weighted resolve layout initializes first"), + )], + immediate_size: 0, + }); + self.weighted_transparency_reactive_resolve_pipeline = Some( + self.device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("weighted_transparency_reactive_resolve_pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_weighted_transparency_resolve"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_weighted_transparency_resolve"), + targets: &[ + Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: TEMPORAL_REACTIVE_FORMAT, + blend: Some(reactive_union_blend()), + write_mask: wgpu::ColorWrites::RED, + }), + ], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }), + ); + self.created_pipelines(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_reactive_shader_variants_parse() { + for source in [ + taa_reactive_shader_source(), + scene_transparent_reactive_shader_source(SCENE_SHADER), + scene_refractive_reactive_shader_source(SCENE_SHADER, false, false, false), + scene_refractive_reactive_shader_source(SCENE_SHADER, false, true, false), + scene_refractive_reactive_shader_source(SCENE_SHADER, true, false, false), + scene_refractive_reactive_shader_source(SCENE_SHADER, false, false, true), + scene_refractive_reactive_shader_source(SCENE_SHADER, false, true, true), + scene_refractive_reactive_shader_source(SCENE_SHADER, true, false, true), + WEIGHTED_TRANSPARENCY_REACTIVE_RESOLVE_SHADER.to_owned(), + ] { + wgpu::naga::front::wgsl::parse_str(&source) + .unwrap_or_else(|error| panic!("reactive WGSL failed: {error}")); + } + } + + #[test] + fn established_shader_sources_do_not_gain_reactive_bindings_or_outputs() { + assert!(!TAA_SHADER_WGSL.contains("reactive_tex")); + assert!(!SCENE_SHADER.contains("TransparentReactiveOut")); + assert!(!WEIGHTED_TRANSPARENCY_RESOLVE_SHADER.contains("reactive")); + } + + #[test] + fn selection_requires_taa_and_a_visible_reactive_contributor() { + assert!(!temporal_reactive_selected_for( + false, + true, + 1, + || panic!("disabled feature must not scan custom materials"), + || { panic!("disabled feature must not scan refraction") } + )); + assert!(!temporal_reactive_selected_for( + true, + false, + 1, + || panic!("TAA-off path must not scan custom materials"), + || { panic!("TAA-off path must not scan refraction") } + )); + assert!(!temporal_reactive_selected_for( + true, + true, + 0, + || false, + || 0 + )); + assert!(temporal_reactive_selected_for( + true, + true, + 1, + || { panic!("visible BLEND must short-circuit the custom-material scan") }, + || { panic!("visible BLEND must short-circuit the refraction scan") } + )); + assert!(temporal_reactive_selected_for( + true, + true, + 0, + || true, + || { panic!("custom reactive draw must short-circuit the refraction scan") } + )); + assert!(temporal_reactive_selected_for( + true, + true, + 0, + || false, + || 1 + )); + } +} diff --git a/native/shared/src/renderer/texture_store.rs b/native/shared/src/renderer/texture_store.rs index ae5ebc83..d5b0aa74 100644 --- a/native/shared/src/renderer/texture_store.rs +++ b/native/shared/src/renderer/texture_store.rs @@ -5,10 +5,34 @@ //! backing fields (`textures`, `texture_bind_groups`, `texture_sizes`) //! still live on [`Renderer`]. +use super::material_indirection::{ResidentTexture, TextureColorSpace, TextureId, TextureSemantic}; use super::Renderer; use wgpu::util::DeviceExt; impl Renderer { + fn register_global_texture( + &mut self, + view: &wgpu::TextureView, + width: u32, + height: u32, + mip_count: u32, + color_space: TextureColorSpace, + semantic: TextureSemantic, + hardware_srgb_decode: bool, + ) -> TextureId { + self.material_system + .indirection + .register_texture(ResidentTexture { + view: view.clone(), + width, + height, + mip_count, + color_space, + semantic, + hardware_srgb_decode, + global_2d: true, + }) + } // (encode_png_simple is defined as a free function below the impl // block so it can be reused by other capture paths if needed.) @@ -23,58 +47,111 @@ impl Renderer { &self.queue, &wgpu::TextureDescriptor { label: Some("atlas_no_mips"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, - mip_level_count: 1, sample_count: 1, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8Unorm, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }, - wgpu::util::TextureDataOrder::LayerMajor, data, + wgpu::util::TextureDataOrder::LayerMajor, + data, ); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("atlas_bg"), layout: &self.texture_bind_group_layout, + label: Some("atlas_bg"), + layout: &self.texture_bind_group_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, ], }); let idx = self.texture_bind_groups.len() as u32; + let global_id = self.register_global_texture( + &view, + width, + height, + 1, + TextureColorSpace::Srgb, + TextureSemantic::General, + false, + ); self.texture_bind_groups.push(bind_group); self.textures.push(texture); self.texture_sizes.push((width, height)); + self.global_texture_ids.push(global_id); idx } /// Replace an existing no-mips texture in-place. pub fn replace_texture_no_mips(&mut self, idx: u32, width: u32, height: u32, data: &[u8]) { let i = idx as usize; - if i >= self.textures.len() { return; } + if i >= self.textures.len() { + return; + } let texture = self.device.create_texture_with_data( &self.queue, &wgpu::TextureDescriptor { label: Some("atlas_replaced"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, - mip_level_count: 1, sample_count: 1, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8Unorm, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }, - wgpu::util::TextureDataOrder::LayerMajor, data, + wgpu::util::TextureDataOrder::LayerMajor, + data, ); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("atlas_replaced_bg"), layout: &self.texture_bind_group_layout, + label: Some("atlas_replaced_bg"), + layout: &self.texture_bind_group_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, ], }); + let global_id = self.register_global_texture( + &view, + width, + height, + 1, + TextureColorSpace::Srgb, + TextureSemantic::General, + false, + ); + if let Some(old_id) = self.global_texture_ids.get(i).copied() { + self.material_system + .indirection + .retire_texture(&self.queue, old_id); + } self.textures[i] = texture; self.texture_bind_groups[i] = bind_group; self.texture_sizes[i] = (width, height); + self.global_texture_ids[i] = global_id; } /// Register a texture with optional normal-map preprocessing. @@ -94,7 +171,19 @@ impl Renderer { data: &[u8], is_normal_map: bool, ) -> u32 { - let max_dim = if width > height { width } else { height }; + self.register_texture_kind_with_alpha_coverage(width, height, data, is_normal_map, None) + } + + /// Kind-aware registration with MASK-only coverage mips. Ordinary color + /// and normal-map callers retain their exact established chains. + pub fn register_texture_kind_with_alpha_coverage( + &mut self, + width: u32, + height: u32, + data: &[u8], + is_normal_map: bool, + alpha_coverage_reference: Option, + ) -> u32 { // On Android/Vulkan, multi-level mipmap upload was observed to // fail silently (black textures) when this path was written. // Single mip costs aliasing + texture bandwidth on exactly the @@ -105,11 +194,13 @@ impl Renderer { #[cfg(target_os = "android")] let mip_count = 1u32; #[cfg(not(target_os = "android"))] - let mip_count = (max_dim as f32).log2().floor() as u32 + 1; + let mip_count = { + let max_dim = if width > height { width } else { height }; + (max_dim as f32).log2().floor() as u32 + 1 + }; - // Generate mip chain data - let mut mip_data = Vec::with_capacity(data.len() * 2); // overallocate - if is_normal_map { + let (mut mip_data, mut mip_offsets) = if is_normal_map { + let mut mip_data = Vec::with_capacity(data.len() * 2); // Level 0: normalize input RGB and clear alpha to 0 (no // variance at the finest level — each texel is assumed unit). mip_data.reserve(data.len()); @@ -122,13 +213,21 @@ impl Renderer { mip_data.push(b); mip_data.push(0); } + (mip_data, vec![0usize]) } else { - mip_data.extend_from_slice(data); - } - let mut mip_offsets = vec![0usize]; // byte offset of each mip level + super::alpha_coverage::build_color_mip_chain( + width, + height, + data, + mip_count, + alpha_coverage_reference, + ) + }; + + // Normal-map mips keep their established vector/variance filter. let mut mw = width; let mut mh = height; - for _ in 1..mip_count { + for _ in 1..if is_normal_map { mip_count } else { 1 } { let prev_offset = *mip_offsets.last().unwrap(); let pw = mw as usize; // previous width let ph = mh as usize; // previous height @@ -141,58 +240,61 @@ impl Renderer { let sy = y * 2; let sx1 = (sx + 1).min(pw - 1); let sy1 = (sy + 1).min(ph - 1); - if is_normal_map { - // Decode 4 children to signed [-1, 1] vectors - let dec = |r: u8, g: u8, b: u8| -> [f32; 3] { - [ - r as f32 * (2.0 / 255.0) - 1.0, - g as f32 * (2.0 / 255.0) - 1.0, - b as f32 * (2.0 / 255.0) - 1.0, - ] - }; - let idx = |sx: usize, sy: usize| -> usize { - prev_offset + (sy * pw + sx) * 4 - }; - let n00 = dec(mip_data[idx(sx, sy)], mip_data[idx(sx, sy) + 1], mip_data[idx(sx, sy) + 2]); - let n10 = dec(mip_data[idx(sx1, sy)], mip_data[idx(sx1, sy) + 1], mip_data[idx(sx1, sy) + 2]); - let n01 = dec(mip_data[idx(sx, sy1)], mip_data[idx(sx, sy1) + 1], mip_data[idx(sx, sy1) + 2]); - let n11 = dec(mip_data[idx(sx1, sy1)], mip_data[idx(sx1, sy1) + 1], mip_data[idx(sx1, sy1) + 2]); - // Previous-mip baked variances - let v00 = mip_data[idx(sx, sy) + 3] as f32 / 255.0; - let v10 = mip_data[idx(sx1, sy) + 3] as f32 / 255.0; - let v01 = mip_data[idx(sx, sy1) + 3] as f32 / 255.0; - let v11 = mip_data[idx(sx1, sy1) + 3] as f32 / 255.0; - // Average the vectors - let avg_x = (n00[0] + n10[0] + n01[0] + n11[0]) * 0.25; - let avg_y = (n00[1] + n10[1] + n01[1] + n11[1]) * 0.25; - let avg_z = (n00[2] + n10[2] + n01[2] + n11[2]) * 0.25; - let len_sq = avg_x * avg_x + avg_y * avg_y + avg_z * avg_z; - let len = len_sq.sqrt().max(1e-6); - // Normalize direction (what the shader reads as - // the shading normal). Re-encode to [0, 255]. - let encode = |v: f32| -> u8 { - ((v * 0.5 + 0.5).clamp(0.0, 1.0) * 255.0 + 0.5) as u8 - }; - mip_data.push(encode(avg_x / len)); - mip_data.push(encode(avg_y / len)); - mip_data.push(encode(avg_z / len)); - // Variance at this mip = disagreement among the - // 4 children (1 - |avg|²) PLUS the weighted mean - // of the children's own variances. Both live in - // [0, 1]; combined variance clamped. - let v_children_avg = (v00 + v10 + v01 + v11) * 0.25; - let v_local = (1.0 - len_sq).max(0.0); - let v_out = (v_local + v_children_avg).min(1.0); - mip_data.push((v_out * 255.0).round().clamp(0.0, 255.0) as u8); - } else { - for c in 0..4usize { - let p00 = mip_data[prev_offset + (sy * pw + sx) * 4 + c] as u32; - let p10 = mip_data[prev_offset + (sy * pw + sx1) * 4 + c] as u32; - let p01 = mip_data[prev_offset + (sy1 * pw + sx) * 4 + c] as u32; - let p11 = mip_data[prev_offset + (sy1 * pw + sx1) * 4 + c] as u32; - mip_data.push(((p00 + p10 + p01 + p11 + 2) / 4) as u8); - } - } + // Decode 4 children to signed [-1, 1] vectors + let dec = |r: u8, g: u8, b: u8| -> [f32; 3] { + [ + r as f32 * (2.0 / 255.0) - 1.0, + g as f32 * (2.0 / 255.0) - 1.0, + b as f32 * (2.0 / 255.0) - 1.0, + ] + }; + let idx = |sx: usize, sy: usize| -> usize { prev_offset + (sy * pw + sx) * 4 }; + let n00 = dec( + mip_data[idx(sx, sy)], + mip_data[idx(sx, sy) + 1], + mip_data[idx(sx, sy) + 2], + ); + let n10 = dec( + mip_data[idx(sx1, sy)], + mip_data[idx(sx1, sy) + 1], + mip_data[idx(sx1, sy) + 2], + ); + let n01 = dec( + mip_data[idx(sx, sy1)], + mip_data[idx(sx, sy1) + 1], + mip_data[idx(sx, sy1) + 2], + ); + let n11 = dec( + mip_data[idx(sx1, sy1)], + mip_data[idx(sx1, sy1) + 1], + mip_data[idx(sx1, sy1) + 2], + ); + // Previous-mip baked variances + let v00 = mip_data[idx(sx, sy) + 3] as f32 / 255.0; + let v10 = mip_data[idx(sx1, sy) + 3] as f32 / 255.0; + let v01 = mip_data[idx(sx, sy1) + 3] as f32 / 255.0; + let v11 = mip_data[idx(sx1, sy1) + 3] as f32 / 255.0; + // Average the vectors + let avg_x = (n00[0] + n10[0] + n01[0] + n11[0]) * 0.25; + let avg_y = (n00[1] + n10[1] + n01[1] + n11[1]) * 0.25; + let avg_z = (n00[2] + n10[2] + n01[2] + n11[2]) * 0.25; + let len_sq = avg_x * avg_x + avg_y * avg_y + avg_z * avg_z; + let len = len_sq.sqrt().max(1e-6); + // Normalize direction (what the shader reads as + // the shading normal). Re-encode to [0, 255]. + let encode = + |v: f32| -> u8 { ((v * 0.5 + 0.5).clamp(0.0, 1.0) * 255.0 + 0.5) as u8 }; + mip_data.push(encode(avg_x / len)); + mip_data.push(encode(avg_y / len)); + mip_data.push(encode(avg_z / len)); + // Variance at this mip = disagreement among the + // 4 children (1 - |avg|²) PLUS the weighted mean + // of the children's own variances. Both live in + // [0, 1]; combined variance clamped. + let v_children_avg = (v00 + v10 + v01 + v11) * 0.25; + let v_local = (1.0 - len_sq).max(0.0); + let v_out = (v_local + v_children_avg).min(1.0); + mip_data.push((v_out * 255.0).round().clamp(0.0, 255.0) as u8); } } } @@ -203,7 +305,11 @@ impl Renderer { &self.queue, &wgpu::TextureDescriptor { label: Some("registered_texture"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -218,7 +324,11 @@ impl Renderer { // Multi-mip path: create texture, upload each level let tex = self.device.create_texture(&wgpu::TextureDescriptor { label: Some("registered_texture"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: mip_count, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -233,14 +343,22 @@ impl Renderer { let level_size = (lw * lh * 4) as usize; self.queue.write_texture( wgpu::TexelCopyTextureInfo { - texture: &tex, mip_level: level, - origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, + texture: &tex, + mip_level: level, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, }, &mip_data[offset..offset + level_size], wgpu::TexelCopyBufferLayout { - offset: 0, bytes_per_row: Some(4 * lw), rows_per_image: Some(lh), + offset: 0, + bytes_per_row: Some(4 * lw), + rows_per_image: Some(lh), + }, + wgpu::Extent3d { + width: lw, + height: lh, + depth_or_array_layers: 1, }, - wgpu::Extent3d { width: lw, height: lh, depth_or_array_layers: 1 }, ); lw = if lw > 1 { lw / 2 } else { 1 }; lh = if lh > 1 { lh / 2 } else { 1 }; @@ -253,21 +371,50 @@ impl Renderer { label: Some("texture_bg"), layout: &self.texture_bind_group_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, ], }); let idx = self.texture_bind_groups.len() as u32; + let global_id = self.register_global_texture( + &view, + width, + height, + mip_count, + if is_normal_map { + TextureColorSpace::Linear + } else { + TextureColorSpace::Srgb + }, + if is_normal_map { + TextureSemantic::Normal + } else { + TextureSemantic::BaseColor + }, + false, + ); self.texture_bind_groups.push(bind_group); self.textures.push(texture); self.texture_sizes.push((width, height)); + self.global_texture_ids.push(global_id); + if alpha_coverage_reference.is_some() && mip_count > 1 { + self.mask_coverage_texture_count = self.mask_coverage_texture_count.saturating_add(1); + } idx } pub fn update_texture(&mut self, idx: u32, width: u32, height: u32, data: &[u8]) { let i = idx as usize; - if i >= self.textures.len() { return; } + if i >= self.textures.len() { + return; + } self.queue.write_texture( wgpu::TexelCopyTextureInfo { texture: &self.textures[i], @@ -281,7 +428,11 @@ impl Renderer { bytes_per_row: Some(4 * width), rows_per_image: Some(height), }, - wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, ); } @@ -300,11 +451,21 @@ impl Renderer { if i == 0 || i >= self.textures.len() { return; } + if let Some(old_id) = self.global_texture_ids.get(i).copied() { + self.material_system + .indirection + .retire_texture(&self.queue, old_id); + self.global_texture_ids[i] = TextureId::FALLBACK; + } let white = self.device.create_texture_with_data( &self.queue, &wgpu::TextureDescriptor { label: Some("unloaded_texture_placeholder"), - size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -321,8 +482,14 @@ impl Renderer { label: Some("unloaded_texture_placeholder_bg"), layout: &self.texture_bind_group_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, ], }); self.textures[i] = white; @@ -335,21 +502,51 @@ impl Renderer { /// the buffers leak — and a future model whose handle reuses the slot /// would render the stale cached geometry. pub fn evict_model_cache(&mut self, handle_bits: u64) { - self.model_gpu_cache.remove(&handle_bits); + if let Some(Some(meshes)) = self.model_gpu_cache.remove(&handle_bits) { + let shared_slices: Vec<_> = meshes + .iter() + .filter_map(|mesh| match &mesh.geometry { + super::gpu_driven::MeshGeometry::Shared(slice) => Some(*slice), + super::gpu_driven::MeshGeometry::Dedicated { .. } => None, + }) + .collect(); + for mesh in meshes { + self.material_system.indirection.retire_cached_mesh( + &self.queue, + mesh.material_id, + mesh.mesh_id, + &[mesh.vertex_buffer_view_id, mesh.index_buffer_view_id], + ); + } + self.gpu_driven.retire_shared(&self.queue, shared_slices); + } self.model_skinned.remove(&handle_bits); + self.model_blended.remove(&handle_bits); } pub fn set_texture_filter(&mut self, idx: u32, nearest: bool) { let i = idx as usize; - if i >= self.textures.len() { return; } + if i >= self.textures.len() { + return; + } let view = self.textures[i].create_view(&wgpu::TextureViewDescriptor::default()); - let chosen_sampler = if nearest { &self.nearest_sampler } else { &self.sampler }; + let chosen_sampler = if nearest { + &self.nearest_sampler + } else { + &self.sampler + }; let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("texture_bg_refiltered"), layout: &self.texture_bind_group_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(chosen_sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(chosen_sampler), + }, ], }); self.texture_bind_groups[i] = bind_group; @@ -358,8 +555,13 @@ impl Renderer { pub fn create_render_texture(&mut self, width: u32, height: u32) -> (u32, usize) { let texture = self.device.create_texture(&wgpu::TextureDescriptor { label: Some("render_texture"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, - mip_level_count: 1, sample_count: 1, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, dimension: wgpu::TextureDimension::D2, format: self.surface_config.format, // COPY_SRC so render-target contents are readable (tests, @@ -371,17 +573,39 @@ impl Renderer { }); let tex_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("rt_bg"), layout: &self.texture_bind_group_layout, + label: Some("rt_bg"), + layout: &self.texture_bind_group_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&tex_view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&tex_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, ], }); let idx = self.texture_bind_groups.len() as u32; let tex_idx = self.textures.len(); + let hardware_srgb_decode = self.surface_config.format.is_srgb(); + let global_id = self.register_global_texture( + &tex_view, + width, + height, + 1, + if hardware_srgb_decode { + TextureColorSpace::Srgb + } else { + TextureColorSpace::Linear + }, + TextureSemantic::General, + hardware_srgb_decode, + ); self.texture_bind_groups.push(bind_group); self.textures.push(texture); self.texture_sizes.push((width, height)); + self.global_texture_ids.push(global_id); (idx, tex_idx) } @@ -401,7 +625,11 @@ impl Renderer { /// decode through the normal RGBA path. #[cfg(feature = "image-extras")] pub fn register_texture_dds(&mut self, dds: &image_dds::ddsfile::Dds) -> Option { - if !self.device.features().contains(wgpu::Features::TEXTURE_COMPRESSION_BC) { + if !self + .device + .features() + .contains(wgpu::Features::TEXTURE_COMPRESSION_BC) + { return None; } let surface = image_dds::Surface::from_dds(dds).ok()?; @@ -409,9 +637,14 @@ impl Renderer { // pipeline applies the transfer itself — see register_texture's // Rgba8Unorm). Both BC7 variants therefore map to the Unorm // view; the sRGB flag in the DDS only records authoring intent. - let format = match surface.image_format { + let dds_srgb = matches!( + surface.image_format, image_dds::ImageFormat::BC7RgbaUnormSrgb - | image_dds::ImageFormat::BC7RgbaUnorm => wgpu::TextureFormat::Bc7RgbaUnorm, + ); + let format = match surface.image_format { + image_dds::ImageFormat::BC7RgbaUnormSrgb | image_dds::ImageFormat::BC7RgbaUnorm => { + wgpu::TextureFormat::Bc7RgbaUnorm + } // Other BCn variants can map here as the cook tool grows. _ => return None, }; @@ -421,7 +654,11 @@ impl Renderer { let texture = self.device.create_texture(&wgpu::TextureDescriptor { label: Some("cooked_bc7"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: mip_count, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -451,7 +688,11 @@ impl Renderer { bytes_per_row: Some(blocks_w * 16), rows_per_image: Some(blocks_h), }, - wgpu::Extent3d { width: mw, height: mh, depth_or_array_layers: 1 }, + wgpu::Extent3d { + width: mw, + height: mh, + depth_or_array_layers: 1, + }, ); } @@ -460,14 +701,34 @@ impl Renderer { label: Some("cooked_bc7_bg"), layout: &self.texture_bind_group_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, ], }); let idx = self.texture_bind_groups.len() as u32; + let global_id = self.register_global_texture( + &view, + width, + height, + mip_count, + if dds_srgb { + TextureColorSpace::Srgb + } else { + TextureColorSpace::Linear + }, + TextureSemantic::BaseColor, + false, + ); self.texture_bind_groups.push(bind_group); self.textures.push(texture); self.texture_sizes.push((width, height)); + self.global_texture_ids.push(global_id); Some(idx) } } diff --git a/native/shared/src/renderer/transient.rs b/native/shared/src/renderer/transient.rs index dd7b8fba..34526eaa 100644 --- a/native/shared/src/renderer/transient.rs +++ b/native/shared/src/renderer/transient.rs @@ -1,4 +1,4 @@ -// Transient resource pool — Phase 3 of RFC 0001. +// Transient resource pool — RFC 0001 Phase 3 + issue #129. // // Manages the short-lived textures that a render graph needs: scene- // colour snapshots, depth-as-sampled linearisations, bloom mip chains, @@ -6,28 +6,29 @@ // `Transient(u32)` input/output refers to a handle this module hands // out. // -// Phase 3 goals: +// Legacy on-demand pool goals: // // 1. Allocate textures by (format, size, usage) on demand. // 2. Reuse textures when their previous caller releases them. // 3. Resize cleanly when the swapchain changes — invalidates caches // sized relative to the swapchain. -// 4. Stay independent of `renderer::graph` — the graph module uses -// this pool as a consumer, not the other way around. // -// Deferred to a later phase: +// The compiled path consumes graph lifetime/physical-slot assignments through +// `prepare_compiled_plan`. Logical resources assigned to one conservative +// slot share one wgpu texture allocation; persistent/history imports never +// enter this pool. The legacy acquire/release API remains for older internal +// consumers and its focused tests, but the live scene snapshot path uses only +// compiled allocations. // -// - **True aliasing.** Two transients with non-overlapping lifetimes -// can physically share the same backing texture on Vulkan/D3D12 -// via aliased resources. This module ref-counts + reuses but does -// not alias. Graph-driven lifetime analysis (Phase 3b) is a -// prerequisite — the pool can't know lifetimes without a -// schedule to introspect. +// Deferred: // // - **Async acquire/release.** The pool is single-threaded. Every // real engine backend is single-threaded on the render queue // anyway; multi-queue support is a later concern. +use super::graph; +use std::collections::HashMap; + /// Size policy for a transient. Resolved against the swapchain each /// time the pool is asked for extents. #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] @@ -49,10 +50,10 @@ impl SizePolicy { pub fn extent(self, swap_w: u32, swap_h: u32) -> (u32, u32) { let max1 = |n: u32| n.max(1); match self { - SizePolicy::Swapchain => (max1(swap_w), max1(swap_h)), - SizePolicy::HalfSwapchain => (max1(swap_w / 2), max1(swap_h / 2)), + SizePolicy::Swapchain => (max1(swap_w), max1(swap_h)), + SizePolicy::HalfSwapchain => (max1(swap_w / 2), max1(swap_h / 2)), SizePolicy::QuarterSwapchain => (max1(swap_w / 4), max1(swap_h / 4)), - SizePolicy::Fixed(w, h) => (max1(w), max1(h)), + SizePolicy::Fixed(w, h) => (max1(w), max1(h)), } } } @@ -63,20 +64,28 @@ impl SizePolicy { #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] pub struct TransientDesc { pub format: wgpu::TextureFormat, - pub usage: wgpu::TextureUsages, - pub size: SizePolicy, + pub usage: wgpu::TextureUsages, + pub size: SizePolicy, /// Mip levels. 1 for most RTs, N for bloom's mip chain etc. - pub mips: u32, + pub mips: u32, /// Single-sample unless you need MSAA — we don't right now. pub samples: u32, } impl TransientDesc { - pub fn new(format: wgpu::TextureFormat, usage: wgpu::TextureUsages, - size: SizePolicy) -> Self { - Self { format, usage, size, mips: 1, samples: 1 } + pub fn new(format: wgpu::TextureFormat, usage: wgpu::TextureUsages, size: SizePolicy) -> Self { + Self { + format, + usage, + size, + mips: 1, + samples: 1, + } + } + pub fn with_mips(mut self, mips: u32) -> Self { + self.mips = mips; + self } - pub fn with_mips(mut self, mips: u32) -> Self { self.mips = mips; self } } /// Opaque handle to an allocated transient. The graph module sees @@ -88,16 +97,35 @@ pub struct TransientId(pub u32); struct Slot { /// Stable handle id — survives `Vec` reordering when invalidation /// drops other slots, so `TransientId` lookups stay valid. - id: u32, - desc: TransientDesc, + id: u32, + desc: TransientDesc, /// Physical extent at allocation time — used for resize detection. - extent: (u32, u32), - texture: wgpu::Texture, - view: wgpu::TextureView, + extent: (u32, u32), + texture: wgpu::Texture, + view: wgpu::TextureView, /// True while the allocation is checked out. False means "in the /// reuse bucket, safe to hand back to the next caller matching /// the same desc". - in_use: bool, + in_use: bool, +} + +enum CompiledSlot { + Texture { + texture: wgpu::Texture, + view: wgpu::TextureView, + }, + Buffer(wgpu::Buffer), +} + +struct CompiledPlan { + slots: Vec, + resource_to_slot: HashMap, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct PhysicalCreationStats { + pub textures: u32, + pub buffers: u32, } /// Per-frame transient manager. Usage pattern: @@ -115,15 +143,19 @@ struct Slot { /// Tests in this module validate the contract. Real integration with /// `renderer::graph` happens in Phase 3b. pub struct TransientPool { - slots: Vec, + slots: Vec, /// Next free id for newly-allocated slots. Ids don't get reused /// across slot teardowns so graph edges stay stable. - next_id: u32, + next_id: u32, /// Cached swapchain extent from the most recent `begin_frame`. swap_size: (u32, u32), /// Rebuild counter — bumped whenever the pool drops all slots /// (e.g. resize). Tests use it to detect that invalidation fired. pub rebuild_epoch: u64, + /// #129 — physical allocations owned by an immutable compiled lifetime + /// plan. Exact extents form a separate resize generation. + compiled_plans: HashMap, + compiled_extent: ((u32, u32), (u32, u32)), } impl TransientPool { @@ -133,6 +165,8 @@ impl TransientPool { next_id: 0, swap_size: (0, 0), rebuild_epoch: 0, + compiled_plans: HashMap::new(), + compiled_extent: ((0, 0), (0, 0)), } } @@ -154,20 +188,166 @@ impl TransientPool { /// manually if a render target format changes. pub fn invalidate_swapchain_relative(&mut self) { let before = self.slots.len(); - self.slots.retain(|s| matches!(s.desc.size, SizePolicy::Fixed(_, _))); - if self.slots.len() != before { + self.slots + .retain(|s| matches!(s.desc.size, SizePolicy::Fixed(_, _))); + let had_compiled = !self.compiled_plans.is_empty(); + self.compiled_plans.clear(); + self.compiled_extent = ((0, 0), (0, 0)); + if self.slots.len() != before || had_compiled { self.rebuild_epoch += 1; } } /// Nuke everything. For tests, and for catastrophic format changes. pub fn clear(&mut self) { - if !self.slots.is_empty() { + if !self.slots.is_empty() || !self.compiled_plans.is_empty() { self.slots.clear(); + self.compiled_plans.clear(); + self.compiled_extent = ((0, 0), (0, 0)); self.rebuild_epoch += 1; } } + /// Materialize the texture allocations selected by a compiled graph. + /// Repeated calls for a stable plan/extent are allocation-free. + pub fn prepare_compiled_plan( + &mut self, + device: &wgpu::Device, + plan: &graph::CompiledGraph, + render_extent: (u32, u32), + output_extent: (u32, u32), + ) -> Result { + let extent_key = (render_extent, output_extent); + if self.compiled_extent != extent_key { + if self.compiled_extent != ((0, 0), (0, 0)) && !self.compiled_plans.is_empty() { + self.rebuild_epoch = self.rebuild_epoch.saturating_add(1); + } + self.compiled_plans.clear(); + self.compiled_extent = extent_key; + } + if self.compiled_plans.contains_key(&plan.plan_id) { + return Ok(PhysicalCreationStats::default()); + } + + let mut slots = Vec::with_capacity(plan.allocations.len()); + let mut physical_to_slot = HashMap::with_capacity(plan.allocations.len()); + let mut creations = PhysicalCreationStats::default(); + for allocation in &plan.allocations { + let label = format!( + "bloom_graph_{:016x}_physical_{}", + plan.plan_id, allocation.id.0 + ); + let slot = match &allocation.desc { + graph::ResourceDesc::Texture(desc) => { + let (width, height, depth_or_array_layers) = + desc.extent.resolve(render_extent, output_extent); + let dimension = match desc.dimension { + graph::TextureDimension::D1 => wgpu::TextureDimension::D1, + graph::TextureDimension::D2 => wgpu::TextureDimension::D2, + graph::TextureDimension::D3 => wgpu::TextureDimension::D3, + }; + let usage = compiled_texture_usage(desc.allowed_usage); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(&label), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers, + }, + mip_level_count: desc.mip_count.max(1), + sample_count: desc.sample_count.max(1), + dimension, + format: desc.format, + usage, + view_formats: &[], + }); + creations.textures = creations.textures.saturating_add(1); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + CompiledSlot::Texture { texture, view } + } + graph::ResourceDesc::Buffer(desc) => { + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some(&label), + size: desc.size.max(1), + usage: compiled_buffer_usage(desc.allowed_usage), + mapped_at_creation: false, + }); + creations.buffers = creations.buffers.saturating_add(1); + CompiledSlot::Buffer(buffer) + } + }; + let slot_index = slots.len(); + slots.push(slot); + physical_to_slot.insert(allocation.id, slot_index); + } + + let mut resource_to_slot = HashMap::new(); + for resource in &plan.resources { + let Some(physical) = resource.physical else { + continue; + }; + let slot = physical_to_slot + .get(&physical) + .copied() + .ok_or_else(|| format!("missing physical allocation {}", physical.0))?; + resource_to_slot.insert(resource.id, slot); + } + self.compiled_plans.insert( + plan.plan_id, + CompiledPlan { + slots, + resource_to_slot, + }, + ); + Ok(creations) + } + + pub fn compiled_texture( + &self, + plan_id: u64, + resource: graph::ResourceId, + ) -> Option<&wgpu::Texture> { + let plan = self.compiled_plans.get(&plan_id)?; + let slot = *plan.resource_to_slot.get(&resource)?; + match plan.slots.get(slot)? { + CompiledSlot::Texture { texture, .. } => Some(texture), + CompiledSlot::Buffer(_) => None, + } + } + + pub fn compiled_view( + &self, + plan_id: u64, + resource: graph::ResourceId, + ) -> Option<&wgpu::TextureView> { + let plan = self.compiled_plans.get(&plan_id)?; + let slot = *plan.resource_to_slot.get(&resource)?; + match plan.slots.get(slot)? { + CompiledSlot::Texture { view, .. } => Some(view), + CompiledSlot::Buffer(_) => None, + } + } + + pub fn compiled_buffer( + &self, + plan_id: u64, + resource: graph::ResourceId, + ) -> Option<&wgpu::Buffer> { + let plan = self.compiled_plans.get(&plan_id)?; + let slot = *plan.resource_to_slot.get(&resource)?; + match plan.slots.get(slot)? { + CompiledSlot::Buffer(buffer) => Some(buffer), + CompiledSlot::Texture { .. } => None, + } + } + + pub fn compiled_slot_count(&self) -> usize { + self.compiled_plans + .values() + .map(|plan| plan.slots.len()) + .sum() + } + /// Acquire a transient matching `desc`. Returns either a freed /// slot from the reuse pool or allocates a new one via `device`. /// The returned handle is valid until `release()` or the next @@ -177,10 +357,7 @@ impl TransientPool { // Look for an existing free slot with identical desc + extent. for slot in self.slots.iter_mut() { - if !slot.in_use - && slot.desc == desc - && slot.extent == target_extent - { + if !slot.in_use && slot.desc == desc && slot.extent == target_extent { slot.in_use = true; return TransientId(slot.id); } @@ -222,7 +399,14 @@ impl TransientPool { // Ids are stable — surviving slots keep theirs across resizes // (which can drop swapchain-relative slots and shrink the Vec), // so handle lookups stay valid even when the slot index shifts. - self.slots.push(Slot { id, desc, extent: target_extent, texture, view, in_use: true }); + self.slots.push(Slot { + id, + desc, + extent: target_extent, + texture, + view, + in_use: true, + }); TransientId(id) } @@ -254,7 +438,9 @@ impl TransientPool { /// Diagnostic — how many slots are currently allocated (both /// in-use and in the reuse pool). Useful for memory footprint /// assertions in tests. - pub fn slot_count(&self) -> usize { self.slots.len() } + pub fn slot_count(&self) -> usize { + self.slots.len() + } /// Diagnostic — how many allocated slots are currently in use. pub fn in_use_count(&self) -> usize { @@ -263,7 +449,71 @@ impl TransientPool { } impl Default for TransientPool { - fn default() -> Self { Self::new() } + fn default() -> Self { + Self::new() + } +} + +fn compiled_texture_usage(usage: graph::TextureUsage) -> wgpu::TextureUsages { + let mut result = wgpu::TextureUsages::empty(); + for (declared, backend) in [ + (graph::TextureUsage::COPY_SRC, wgpu::TextureUsages::COPY_SRC), + (graph::TextureUsage::COPY_DST, wgpu::TextureUsages::COPY_DST), + ( + graph::TextureUsage::SAMPLED, + wgpu::TextureUsages::TEXTURE_BINDING, + ), + ( + graph::TextureUsage::STORAGE_READ, + wgpu::TextureUsages::STORAGE_BINDING, + ), + ( + graph::TextureUsage::STORAGE_WRITE, + wgpu::TextureUsages::STORAGE_BINDING, + ), + ( + graph::TextureUsage::COLOR_ATTACHMENT, + wgpu::TextureUsages::RENDER_ATTACHMENT, + ), + ( + graph::TextureUsage::DEPTH_ATTACHMENT_READ, + wgpu::TextureUsages::RENDER_ATTACHMENT, + ), + ( + graph::TextureUsage::DEPTH_ATTACHMENT_WRITE, + wgpu::TextureUsages::RENDER_ATTACHMENT, + ), + ] { + if usage.contains(declared) { + result |= backend; + } + } + result +} + +fn compiled_buffer_usage(usage: graph::BufferUsage) -> wgpu::BufferUsages { + let mut result = wgpu::BufferUsages::empty(); + for (declared, backend) in [ + (graph::BufferUsage::COPY_SRC, wgpu::BufferUsages::COPY_SRC), + (graph::BufferUsage::COPY_DST, wgpu::BufferUsages::COPY_DST), + (graph::BufferUsage::UNIFORM, wgpu::BufferUsages::UNIFORM), + ( + graph::BufferUsage::STORAGE_READ, + wgpu::BufferUsages::STORAGE, + ), + ( + graph::BufferUsage::STORAGE_WRITE, + wgpu::BufferUsages::STORAGE, + ), + (graph::BufferUsage::VERTEX, wgpu::BufferUsages::VERTEX), + (graph::BufferUsage::INDEX, wgpu::BufferUsages::INDEX), + (graph::BufferUsage::INDIRECT, wgpu::BufferUsages::INDIRECT), + ] { + if usage.contains(declared) { + result |= backend; + } + } + result } // ===================================================================== @@ -303,15 +553,15 @@ mod tests { power_preference: wgpu::PowerPreference::LowPower, compatible_surface: None, force_fallback_adapter: true, - })).ok()?; - let (device, queue) = pollster::block_on(adapter.request_device( - &wgpu::DeviceDescriptor { - label: Some("transient-test-device"), - required_features: wgpu::Features::empty(), - required_limits: wgpu::Limits::downlevel_defaults(), - ..Default::default() - }, - )).ok()?; + })) + .ok()?; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("transient-test-device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_defaults(), + ..Default::default() + })) + .ok()?; Some((device, queue)) } @@ -320,7 +570,10 @@ mod tests { assert_eq!(SizePolicy::Swapchain.extent(1920, 1080), (1920, 1080)); assert_eq!(SizePolicy::HalfSwapchain.extent(1920, 1080), (960, 540)); assert_eq!(SizePolicy::QuarterSwapchain.extent(1920, 1080), (480, 270)); - assert_eq!(SizePolicy::Fixed(2048, 2048).extent(1920, 1080), (2048, 2048)); + assert_eq!( + SizePolicy::Fixed(2048, 2048).extent(1920, 1080), + (2048, 2048) + ); } #[test] @@ -364,7 +617,9 @@ mod tests { #[test] fn acquire_returns_new_slot_when_pool_empty() { - let Some((device, _queue)) = try_create_device() else { return; }; + let Some((device, _queue)) = try_create_device() else { + return; + }; let mut pool = TransientPool::new(); pool.begin_frame(1024, 768); let id = pool.acquire(&device, hdr_desc()); @@ -376,7 +631,9 @@ mod tests { #[test] fn release_then_acquire_reuses_slot() { - let Some((device, _queue)) = try_create_device() else { return; }; + let Some((device, _queue)) = try_create_device() else { + return; + }; let mut pool = TransientPool::new(); pool.begin_frame(1024, 768); let a = pool.acquire(&device, hdr_desc()); @@ -391,7 +648,9 @@ mod tests { #[test] fn different_descs_dont_share_slots() { - let Some((device, _queue)) = try_create_device() else { return; }; + let Some((device, _queue)) = try_create_device() else { + return; + }; let mut pool = TransientPool::new(); pool.begin_frame(1024, 768); let a = pool.acquire(&device, hdr_desc()); @@ -403,33 +662,43 @@ mod tests { #[test] fn resize_drops_swapchain_relative_slots() { - let Some((device, _queue)) = try_create_device() else { return; }; + let Some((device, _queue)) = try_create_device() else { + return; + }; let mut pool = TransientPool::new(); pool.begin_frame(1024, 768); - let a = pool.acquire(&device, hdr_desc()); // Swapchain - let f = pool.acquire(&device, TransientDesc::new( - wgpu::TextureFormat::Depth32Float, - wgpu::TextureUsages::RENDER_ATTACHMENT, - SizePolicy::Fixed(2048, 2048), - )); + let a = pool.acquire(&device, hdr_desc()); // Swapchain + let f = pool.acquire( + &device, + TransientDesc::new( + wgpu::TextureFormat::Depth32Float, + wgpu::TextureUsages::RENDER_ATTACHMENT, + SizePolicy::Fixed(2048, 2048), + ), + ); pool.release(a); pool.release(f); assert_eq!(pool.slot_count(), 2); - pool.begin_frame(1920, 1080); // resize! - // Swapchain-relative slot dropped; fixed slot survived. + pool.begin_frame(1920, 1080); // resize! + // Swapchain-relative slot dropped; fixed slot survived. assert_eq!(pool.slot_count(), 1); assert_eq!(pool.rebuild_epoch, 1); // New acquire with the same swapchain-relative desc gets a // fresh slot sized to the new swapchain. let a2 = pool.acquire(&device, hdr_desc()); - assert_ne!(a.0, a2.0, "post-resize slot ids should not collide with released ones"); + assert_ne!( + a.0, a2.0, + "post-resize slot ids should not collide with released ones" + ); } #[test] fn clear_resets_everything() { - let Some((device, _queue)) = try_create_device() else { return; }; + let Some((device, _queue)) = try_create_device() else { + return; + }; let mut pool = TransientPool::new(); pool.begin_frame(1024, 768); let _ = pool.acquire(&device, hdr_desc()); @@ -454,7 +723,9 @@ mod tests { /// (CPU-only environments). #[test] fn depth_snapshot_preserves_cleared_value() { - let Some((device, queue)) = try_create_device() else { return; }; + let Some((device, queue)) = try_create_device() else { + return; + }; let mut pool = TransientPool::new(); let (width, height) = (64u32, 64u32); pool.begin_frame(width, height); @@ -466,7 +737,11 @@ mod tests { // translucent sub-pass snapshots it. let src = device.create_texture(&wgpu::TextureDescriptor { label: Some("depth_snapshot_src"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -537,7 +812,11 @@ mod tests { origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::DepthOnly, }, - wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, ); // Drain the snapshot to a buffer we can map back. encoder.copy_texture_to_buffer( @@ -555,14 +834,23 @@ mod tests { rows_per_image: Some(height), }, }, - wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, ); queue.submit(std::iter::once(encoder.finish())); let slice = snap_staging.slice(..); let (tx, rx) = std::sync::mpsc::channel(); - slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); - let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); rx.recv().expect("map sender").expect("map failed"); let data = slice.get_mapped_range(); let mut snap_floats: Vec = Vec::with_capacity((width * height) as usize); @@ -585,7 +873,113 @@ mod tests { close_count, snap_floats.len(), "all snapshot texels should equal the cleared depth value {} (got {} of {} matching)", - cleared_value, close_count, snap_floats.len(), + cleared_value, + close_count, + snap_floats.len(), + ); + } + + #[test] + fn compiled_plan_allocates_once_per_plan_and_resize_generation() { + let Some((device, _queue)) = try_create_device() else { + return; + }; + let key = graph::FramePlanKey { + resolution: graph::ResolutionClass::Medium, + quality_tier: 3, + feature_mask: graph::FRAME_FEATURE_SCENE_SNAPSHOTS, + capability: graph::CapabilityTier::Raster, + path_tracing: graph::PathTracingMode::Off, + post_pass_count: 0, + render_target_output: false, + }; + let plan = graph::build_renderer_frame_plan(key, wgpu::TextureFormat::Bgra8UnormSrgb) + .compile(graph::CompileOptions::CONSERVATIVE_ALIASING) + .unwrap(); + let color = plan.resource("translucent-scene-color").unwrap().id; + let depth = plan.resource("translucent-scene-depth").unwrap().id; + let mut pool = TransientPool::new(); + + let first = pool + .prepare_compiled_plan(&device, &plan, (960, 540), (1920, 1080)) + .unwrap(); + assert_eq!( + first, + PhysicalCreationStats { + textures: 2, + buffers: 0 + } + ); + assert_eq!(pool.compiled_slot_count(), 2); + assert!(pool.compiled_texture(plan.plan_id, color).is_some()); + assert!(pool.compiled_view(plan.plan_id, depth).is_some()); + let epoch = pool.rebuild_epoch; + + // Stable plan/extent is a pure cache hit. + let stable = pool + .prepare_compiled_plan(&device, &plan, (960, 540), (1920, 1080)) + .unwrap(); + assert_eq!(stable, PhysicalCreationStats::default()); + assert_eq!(pool.compiled_slot_count(), 2); + assert_eq!(pool.rebuild_epoch, epoch); + + // Internal resolution changes establish a new allocation generation + // even when the output surface itself did not resize. + let resized = pool + .prepare_compiled_plan(&device, &plan, (1280, 720), (1920, 1080)) + .unwrap(); + assert_eq!(resized, first); + assert_eq!(pool.compiled_slot_count(), 2); + assert_eq!(pool.rebuild_epoch, epoch + 1); + } + + #[test] + fn compiled_plan_materializes_aliased_buffer_slots() { + let Some((device, _queue)) = try_create_device() else { + return; + }; + let usage = graph::BufferUsage::COPY_DST.union(graph::BufferUsage::STORAGE_READ); + let desc = graph::BufferDesc { + size: 1024, + allowed_usage: usage, + alias_class: graph::AliasClass::Storage, + }; + let mut builder = graph::GraphBuilder::new("buffer-materialization"); + let a = builder.create_buffer("a", desc.clone()); + let b = builder.create_buffer("b", desc); + let write_a = builder.add_pass("write-a"); + let a = builder.write_buffer(write_a, a, graph::BufferUsage::COPY_DST); + let read_a = builder.add_pass("read-a"); + builder.after(read_a, write_a); + builder.read_buffer(read_a, a, graph::BufferUsage::STORAGE_READ); + let write_b = builder.add_pass("write-b"); + builder.after(write_b, read_a); + let b = builder.write_buffer(write_b, b, graph::BufferUsage::COPY_DST); + let read_b = builder.add_pass("read-b"); + builder.after(read_b, write_b); + builder.read_buffer(read_b, b, graph::BufferUsage::STORAGE_READ); + let plan = builder + .compile(graph::CompileOptions::CONSERVATIVE_ALIASING) + .unwrap(); + assert_eq!(plan.allocations.len(), 1); + + let mut pool = TransientPool::new(); + let creations = pool + .prepare_compiled_plan(&device, &plan, (1, 1), (1, 1)) + .unwrap(); + assert_eq!( + creations, + PhysicalCreationStats { + textures: 0, + buffers: 1 + } ); + assert_eq!(pool.compiled_slot_count(), 1); + assert!(pool + .compiled_buffer(plan.plan_id, plan.resource("a").unwrap().id) + .is_some()); + assert!(pool + .compiled_buffer(plan.plan_id, plan.resource("b").unwrap().id) + .is_some()); } } diff --git a/native/shared/src/renderer/transmitted_shadows.rs b/native/shared/src/renderer/transmitted_shadows.rs new file mode 100644 index 00000000..b3834897 --- /dev/null +++ b/native/shared/src/renderer/transmitted_shadows.rs @@ -0,0 +1,1472 @@ +//! Lazy, bounded colored shadows for imported physical transmission. +//! +//! The established 2048² opaque/MASK CSM remains the authority for opaque +//! visibility. When (and only when) a physical-transmission material exists, +//! this module adds one lower-resolution nearest-layer transmittance + depth +//! pair per cascade. A post-opaque fullscreen pass subtracts the portion of +//! primary-sun radiance absorbed by that layer. Keeping the correction +//! separate means ordinary scene/material pipelines, layouts, and fragments +//! remain byte-identical in opaque-only applications. + +use super::*; + +pub(super) const TRANSMITTED_SHADOW_MAP_SIZE: u32 = 1024; +pub(super) const TRANSMITTED_SHADOW_COLOR_FORMAT: wgpu::TextureFormat = + wgpu::TextureFormat::Rgba8Unorm; +pub(super) const TRANSMITTED_SHADOW_DEPTH_FORMAT: wgpu::TextureFormat = + wgpu::TextureFormat::Depth16Unorm; +pub(super) const TRANSMITTED_SHADOW_PERSISTENT_BYTES: u64 = TRANSMITTED_SHADOW_MAP_SIZE as u64 + * TRANSMITTED_SHADOW_MAP_SIZE as u64 + * crate::shadows::NUM_CASCADES as u64 + * (4 + 2); + +pub(super) fn transmitted_shadows_enabled() -> bool { + std::env::var("BLOOM_TRANSMITTED_SHADOWS") + .ok() + .map(|value| { + !matches!( + value.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "disabled" + ) + }) + .unwrap_or(true) +} + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct TransmittedShadowDrawUniforms { + light_vp: [[f32; 4]; 4], + model: [[f32; 4]; 4], + tint: [f32; 4], + /// x = cached-skinned joint offset. Remaining lanes are reserved. + misc: [f32; 4], +} + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct TransmittedShadowResolveUniforms { + inv_vp: [[f32; 4]; 4], + cascade_vps: [[[f32; 4]; 4]; crate::shadows::NUM_CASCADES], + camera_pos: [f32; 4], + cascade_splits: [f32; 4], + sun_dir: [f32; 4], + sun_color: [f32; 4], + wind: [f32; 4], + cloud: [f32; 4], + /// xy = render extent, zw reserved. + target_size: [f32; 4], +} + +const TRANSMITTED_SHADOW_CASTER_WGSL: &str = r#" +struct DrawUniforms { + light_vp: mat4x4, + model: mat4x4, + tint: vec4, + misc: vec4, +}; + +struct MaterialFactors { + metal_rough: vec4, + emissive: vec4, +}; + +struct TransmissionFactors { + transmission: vec4, + attenuation: vec4, + transmission_uv: vec4, + transmission_rotation: vec4, + thickness_uv: vec4, + thickness_rotation: vec4, +}; + +struct JointMatrices { + matrices: array, 1024>, +}; + +@group(0) @binding(0) var u: DrawUniforms; +@group(1) @binding(0) var base_color_tex: texture_2d; +@group(1) @binding(1) var base_color_samp: sampler; +@group(1) @binding(4) var mr_tex: texture_2d; +@group(1) @binding(5) var mr_samp: sampler; +@group(1) @binding(8) var material: MaterialFactors; +@group(1) @binding(11) var transmission_tex: texture_2d; +@group(1) @binding(12) var transmission_samp: sampler; +@group(1) @binding(13) var thickness_tex: texture_2d; +@group(1) @binding(14) var thickness_samp: sampler; +@group(1) @binding(15) var physical: TransmissionFactors; +@group(2) @binding(0) var opaque_depth: texture_depth_2d; +@group(3) @binding(0) var joints: JointMatrices; + +struct VertexIn { + @location(0) position: vec3, + @location(1) normal: vec3, + @location(2) color: vec4, + @location(3) uv: vec2, + @location(4) joint_indices: vec4, + @location(5) weights: vec4, +}; + +struct VertexOut { + @builtin(position) position: vec4, + @location(0) uv: vec2, + @location(1) color: vec4, + @location(2) model_scale: f32, +}; + +fn physical_uv( + uv: vec2, + offset_scale: vec4, + rotation: vec2, +) -> vec2 { + let scaled = uv * offset_scale.zw; + return offset_scale.xy + vec2( + rotation.x * scaled.x - rotation.y * scaled.y, + rotation.y * scaled.x + rotation.x * scaled.y, + ); +} + +@vertex +fn vs_main(v: VertexIn) -> VertexOut { + var local = vec4(v.position, 1.0); + let weight_sum = v.weights.x + v.weights.y + v.weights.z + v.weights.w; + var world = u.model * local; + if (weight_sum > 0.01) { + let j0 = u32(v.joint_indices.x + u.misc.x); + let j1 = u32(v.joint_indices.y + u.misc.x); + let j2 = u32(v.joint_indices.z + u.misc.x); + let j3 = u32(v.joint_indices.w + u.misc.x); + world = + joints.matrices[j0] * local * v.weights.x + + joints.matrices[j1] * local * v.weights.y + + joints.matrices[j2] * local * v.weights.z + + joints.matrices[j3] * local * v.weights.w; + } + + var out: VertexOut; + out.position = u.light_vp * world; + out.uv = v.uv; + out.color = v.color * u.tint; + out.model_scale = ( + length(u.model[0].xyz) + + length(u.model[1].xyz) + + length(u.model[2].xyz) + ) / 3.0; + return out; +} + +@fragment +fn fs_main(in: VertexOut) -> @location(0) vec4 { + // The color target is half the opaque CSM extent. Compare against the + // matching opaque texel explicitly: color/depth attachments of different + // sizes cannot share a render pass, and hidden glass must not tint an + // opaque blocker's shadow. + let blocker_dims = textureDimensions(opaque_depth); + let color_dims = vec2( + f32(TRANSMITTED_SHADOW_MAP_SIZE), + f32(TRANSMITTED_SHADOW_MAP_SIZE), + ); + let blocker_pixel = clamp( + vec2( + floor(in.position.xy * vec2(blocker_dims) / color_dims) + ), + vec2(0), + vec2(blocker_dims) - vec2(1), + ); + let blocker = textureLoad(opaque_depth, blocker_pixel, 0); + if (blocker + 0.00075 < in.position.z) { + discard; + } + + let base_texel = textureSample(base_color_tex, base_color_samp, in.uv); + let base_color = base_texel.rgb * in.color.rgb; + let base_alpha = clamp(base_texel.a * in.color.a, 0.0, 1.0); + let alpha_mode = material.metal_rough.w; + if (alpha_mode > 0.0 && base_alpha < alpha_mode) { + discard; + } + + let mr = textureSample(mr_tex, mr_samp, in.uv); + let metallic = select( + clamp(material.metal_rough.x, 0.0, 1.0), + clamp(material.metal_rough.x * mr.b, 0.0, 1.0), + material.metal_rough.z > 0.5, + ); + let transmission_uv = physical_uv( + in.uv, + physical.transmission_uv, + physical.transmission_rotation.xy, + ); + let transmission_texture = select( + 1.0, + textureSample( + transmission_tex, + transmission_samp, + transmission_uv, + ).r, + physical.transmission.w > 0.5, + ); + let transmission_weight = clamp( + physical.transmission.x * transmission_texture * (1.0 - metallic), + 0.0, + 1.0, + ); + + let thickness_uv = physical_uv( + in.uv, + physical.thickness_uv, + physical.thickness_rotation.xy, + ); + let thickness_texture = select( + 1.0, + textureSample(thickness_tex, thickness_samp, thickness_uv).g, + physical.transmission_rotation.z > 0.5, + ); + let thickness_world = max( + physical.transmission.z * thickness_texture * in.model_scale, + 0.0, + ); + var absorption = vec3(1.0); + if (physical.attenuation.w > 0.0 && thickness_world > 0.0) { + absorption = pow( + max(physical.attenuation.rgb, vec3(0.000001)), + vec3(thickness_world / physical.attenuation.w), + ); + } + + // Bound the directional-light energy exactly like the camera-facing + // transmission lobe: metallic content does not transmit, normal-incidence + // dielectric Fresnel reflects instead of passing, and BLEND alpha denotes + // geometric coverage (uncovered area remains fully lit). + let ior = max(physical.transmission.y, 1.0); + let f0 = pow((ior - 1.0) / (ior + 1.0), 2.0); + let physical_transmittance = clamp( + base_color * absorption * transmission_weight * (1.0 - f0), + vec3(0.0), + vec3(1.0), + ); + let coverage = select(1.0, base_alpha, alpha_mode < 0.0); + let transmittance = mix(vec3(1.0), physical_transmittance, coverage); + return vec4(transmittance, 1.0); +} +"#; + +fn transmitted_shadow_caster_shader_source(secondary_uv: bool) -> String { + let mut source = TRANSMITTED_SHADOW_CASTER_WGSL.replace( + "TRANSMITTED_SHADOW_MAP_SIZE", + &format!("{TRANSMITTED_SHADOW_MAP_SIZE}.0"), + ); + if !secondary_uv { + return source; + } + source = source.replacen( + " @location(5) weights: vec4,\n};", + " @location(5) weights: vec4,\n\ + @location(7) secondary_uv: vec2,\n\ + };", + 1, + ); + source = source.replacen( + " @location(2) model_scale: f32,\n};", + " @location(2) model_scale: f32,\n\ + @location(3) secondary_uv: vec2,\n\ + };", + 1, + ); + source = source.replacen( + " out.uv = v.uv;", + " out.uv = v.uv;\n out.secondary_uv = v.secondary_uv;", + 1, + ); + source = source.replacen( + " let transmission_uv = physical_uv(\n in.uv,", + " let transmission_source_uv = select(\n\ + in.uv,\n\ + in.secondary_uv,\n\ + physical.transmission_rotation.w > 0.5,\n\ + );\n\ + let transmission_uv = physical_uv(\n\ + transmission_source_uv,", + 1, + ); + source = source.replacen( + " let thickness_uv = physical_uv(\n in.uv,", + " let thickness_source_uv = select(\n\ + in.uv,\n\ + in.secondary_uv,\n\ + physical.thickness_rotation.z > 0.5,\n\ + );\n\ + let thickness_uv = physical_uv(\n\ + thickness_source_uv,", + 1, + ); + assert!( + source.contains("@location(3) secondary_uv"), + "transmitted-shadow vertex ABI changed; UV1 injection must be updated" + ); + source +} + +const TRANSMITTED_SHADOW_RESOLVE_WGSL: &str = concat!( + include_str!("../../shaders/common/clouds.wgsl"), + r#" +struct ResolveUniforms { + inv_vp: mat4x4, + cascade_vps: array, 3>, + camera_pos: vec4, + cascade_splits: vec4, + sun_dir: vec4, + sun_color: vec4, + wind: vec4, + cloud: vec4, + target_size: vec4, +}; + +@group(0) @binding(0) var u: ResolveUniforms; +@group(0) @binding(1) var scene_depth: texture_depth_2d; +@group(0) @binding(2) var scene_albedo: texture_2d; +@group(0) @binding(3) var scene_material: texture_2d; +@group(0) @binding(4) var trans_color_0: texture_2d; +@group(0) @binding(5) var trans_color_1: texture_2d; +@group(0) @binding(6) var trans_color_2: texture_2d; +@group(0) @binding(7) var trans_depth_0: texture_depth_2d; +@group(0) @binding(8) var trans_depth_1: texture_depth_2d; +@group(0) @binding(9) var trans_depth_2: texture_depth_2d; + +struct VertexOut { + @builtin(position) position: vec4, +}; + +@vertex +fn vs_main(@builtin(vertex_index) index: u32) -> VertexOut { + var out: VertexOut; + let x = f32((index << 1u) & 2u); + let y = f32(index & 2u); + out.position = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + return out; +} + +fn load_trans_depth(cascade: i32, pixel: vec2) -> f32 { + if (cascade == 0) { + return textureLoad(trans_depth_0, pixel, 0); + } + if (cascade == 1) { + return textureLoad(trans_depth_1, pixel, 0); + } + return textureLoad(trans_depth_2, pixel, 0); +} + +fn load_trans_color(cascade: i32, pixel: vec2) -> vec3 { + if (cascade == 0) { + return textureLoad(trans_color_0, pixel, 0).rgb; + } + if (cascade == 1) { + return textureLoad(trans_color_1, pixel, 0).rgb; + } + return textureLoad(trans_color_2, pixel, 0).rgb; +} + +fn transmitted_visibility( + cascade: i32, + world_pos: vec3, + receiver_bias: f32, +) -> vec3 { + let clip = u.cascade_vps[cascade] * vec4(world_pos, 1.0); + let ndc = clip.xyz / max(abs(clip.w), 0.000001); + if ( + ndc.x < -1.0 || ndc.x > 1.0 + || ndc.y < -1.0 || ndc.y > 1.0 + || ndc.z < 0.0 || ndc.z > 1.0 + ) { + return vec3(1.0); + } + + let uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5); + let dims = vec2(textureDimensions(trans_depth_0)); + let texel = uv * vec2(dims) - vec2(0.5); + let base = vec2(floor(texel)); + let fraction = fract(texel); + var taps = array, 4>(); + let offsets = array, 4>( + vec2(0, 0), + vec2(1, 0), + vec2(0, 1), + vec2(1, 1), + ); + for (var i = 0; i < 4; i = i + 1) { + let pixel = clamp(base + offsets[i], vec2(0), dims - vec2(1)); + let caster_depth = load_trans_depth(cascade, pixel); + taps[i] = select( + vec3(1.0), + load_trans_color(cascade, pixel), + ndc.z > caster_depth + receiver_bias, + ); + } + let row0 = mix(taps[0], taps[1], fraction.x); + let row1 = mix(taps[2], taps[3], fraction.x); + return mix(row0, row1, fraction.y); +} + +fn sample_transmitted_shadow(world_pos: vec3) -> vec3 { + let distance_to_camera = length(world_pos - u.camera_pos.xyz); + var cascade = 2; + if (distance_to_camera <= u.cascade_splits.x) { + cascade = 0; + } else if (distance_to_camera <= u.cascade_splits.y) { + cascade = 1; + } + let receiver_bias = 0.0015; + let value = transmitted_visibility(cascade, world_pos, receiver_bias); + + var split_near = 0.0; + var split_far = u.cascade_splits.x; + if (cascade == 1) { + split_near = u.cascade_splits.x; + split_far = u.cascade_splits.y; + } else if (cascade == 2) { + split_near = u.cascade_splits.y; + split_far = u.cascade_splits.z; + } + let blend_zone = max((split_far - split_near) * 0.1, 0.0001); + let distance_to_edge = split_far - distance_to_camera; + if (distance_to_edge < blend_zone && cascade < 2) { + let next_value = transmitted_visibility( + cascade + 1, + world_pos, + receiver_bias, + ); + return mix(next_value, value, clamp(distance_to_edge / blend_zone, 0.0, 1.0)); + } + return value; +} + +const PI: f32 = 3.14159265; + +fn f_schlick(v_dot_h: f32, f0: vec3) -> vec3 { + let fc = pow(clamp(1.0 - v_dot_h, 0.0, 1.0), 5.0); + return f0 + (vec3(1.0) - f0) * fc; +} + +fn d_ggx(n_dot_h: f32, alpha2: f32) -> f32 { + let x = n_dot_h * n_dot_h * (alpha2 - 1.0) + 1.0; + return alpha2 / (PI * x * x); +} + +fn v_smith_ggx_correlated( + n_dot_l: f32, + n_dot_v: f32, + alpha2: f32, +) -> f32 { + let ggxv = n_dot_l * sqrt(n_dot_v * n_dot_v * (1.0 - alpha2) + alpha2); + let ggxl = n_dot_v * sqrt(n_dot_l * n_dot_l * (1.0 - alpha2) + alpha2); + return 0.5 / max(ggxv + ggxl, 0.00001); +} + +fn primary_direct( + n: vec3, + v: vec3, + l: vec3, + base_color: vec3, + metallic: f32, + roughness: f32, +) -> vec3 { + let n_dot_l = max(dot(n, l), 0.0); + let n_dot_v = max(dot(n, v), 0.0001); + if (n_dot_l <= 0.0) { + return vec3(0.0); + } + let h = normalize(v + l); + let n_dot_h = max(dot(n, h), 0.0); + let v_dot_h = max(dot(v, h), 0.0); + let alpha = max(roughness * roughness, 0.002025); + let alpha2 = alpha * alpha; + let f0 = mix(vec3(0.04), base_color, metallic); + let f = f_schlick(v_dot_h, f0); + let d = d_ggx(n_dot_h, alpha2); + let vis = v_smith_ggx_correlated(n_dot_l, n_dot_v, alpha2); + let specular_raw = d * vis * f; + + let direct_luma = dot(specular_raw, vec3(0.2126, 0.7152, 0.0722)); + let direct_cap = 1.0 / (1.0 + direct_luma / 0.3); + let universal_damp = smoothstep(0.05, 0.75, roughness); + let dielectric_factor = 1.0 - metallic; + let dielectric_direct_amp = mix(0.08, 1.0, smoothstep(0.15, 0.75, roughness)); + let direct_spec_scale = mix(1.0, dielectric_direct_amp, dielectric_factor); + let specular = specular_raw * direct_spec_scale * universal_damp * direct_cap; + let kd = (vec3(1.0) - f) * (1.0 - metallic); + let diffuse = kd * base_color / PI; + return (diffuse + specular) + * u.sun_color.rgb + * u.sun_dir.w + * n_dot_l; +} + +@fragment +fn fs_main(in: VertexOut) -> @location(0) vec4 { + let pixel = vec2(in.position.xy); + let depth = textureLoad(scene_depth, pixel, 0); + if (depth >= 0.999999) { + return vec4(0.0); + } + let uv = (in.position.xy + vec2(0.5)) / u.target_size.xy; + let ndc = vec4( + uv.x * 2.0 - 1.0, + 1.0 - uv.y * 2.0, + depth, + 1.0, + ); + let world_h = u.inv_vp * ndc; + let world_pos = world_h.xyz / max(abs(world_h.w), 0.000001); + + var n = normalize(cross(dpdy(world_pos), dpdx(world_pos))); + let v = normalize(u.camera_pos.xyz - world_pos); + if (dot(n, v) < 0.0) { + n = -n; + } + let albedo_sample = textureLoad(scene_albedo, pixel, 0); + let material_sample = textureLoad(scene_material, pixel, 0); + let base_color = clamp(albedo_sample.rgb, vec3(0.0), vec3(1.0)); + let metallic = clamp(material_sample.r, 0.0, 1.0); + let roughness = clamp(material_sample.g, 0.045, 1.0); + let transmittance = sample_transmitted_shadow(world_pos); + if (all(transmittance >= vec3(0.999))) { + return vec4(0.0); + } + + // albedo.a stores 1 - opaque CSM visibility. Scale only the sun samples + // that survived opaque blockers; the 3% artistic bounce floor remains an + // indirect term and must not be recolored by glass. + let opaque_visibility = clamp(1.0 - albedo_sample.a, 0.0, 1.0); + let light_dir = normalize(u.sun_dir.xyz); + let cloud_visibility = cloud_shadow_at( + world_pos, + light_dir, + u.wind.xy, + u.wind.w, + u.cloud, + ); + let direct = primary_direct( + n, + v, + light_dir, + base_color, + metallic, + roughness, + ) * opaque_visibility * cloud_visibility; + let correction = direct * (transmittance - vec3(1.0)); + return vec4(correction, 0.0); +} +"# +); + +fn create_transmitted_shadow_caster_pipeline( + device: &wgpu::Device, + layout: &wgpu::PipelineLayout, + secondary_uv: bool, +) -> wgpu::RenderPipeline { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(if secondary_uv { + "transmitted_shadow_caster_uv1_shader" + } else { + "transmitted_shadow_caster_shader" + }), + source: wgpu::ShaderSource::Wgsl( + transmitted_shadow_caster_shader_source(secondary_uv).into(), + ), + }); + let base_layouts = [Vertex3D::desc()]; + let secondary_layouts; + let vertex_layouts = if secondary_uv { + secondary_layouts = [Vertex3D::desc(), secondary_uv_desc()]; + &secondary_layouts[..] + } else { + &base_layouts[..] + }; + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(if secondary_uv { + "transmitted_shadow_caster_uv1_pipeline" + } else { + "transmitted_shadow_caster_pipeline" + }), + layout: Some(layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: vertex_layouts, + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: TRANSMITTED_SHADOW_COLOR_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + front_face: wgpu::FrontFace::Ccw, + // Nearest-layer depth removes the back surface of closed volumes + // while still accepting either orientation of a pane. + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: TRANSMITTED_SHADOW_DEPTH_FORMAT, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: Default::default(), + bias: wgpu::DepthBiasState { + constant: 1, + slope_scale: 1.0, + clamp: 0.0, + }, + }), + multisample: Default::default(), + multiview_mask: None, + cache: None, + }) +} + +pub(super) struct TransmittedShadowResources { + _color_textures: [wgpu::Texture; crate::shadows::NUM_CASCADES], + pub(super) color_views: [wgpu::TextureView; crate::shadows::NUM_CASCADES], + _depth_textures: [wgpu::Texture; crate::shadows::NUM_CASCADES], + pub(super) depth_views: [wgpu::TextureView; crate::shadows::NUM_CASCADES], + draw_uniform_buffer: wgpu::Buffer, + draw_uniform_bind_group: wgpu::BindGroup, + blocker_bind_groups: [wgpu::BindGroup; crate::shadows::NUM_CASCADES], + caster_pipeline_layout: wgpu::PipelineLayout, + caster_pipeline: wgpu::RenderPipeline, + caster_uv1_pipeline: Option, + resolve_uniform_buffer: wgpu::Buffer, + resolve_layout: wgpu::BindGroupLayout, + resolve_pipeline: wgpu::RenderPipeline, + resolve_bind_group: Option, + rendered_signatures: [u64; crate::shadows::NUM_CASCADES], + rendered_vps: Option<[[[f32; 4]; 4]; crate::shadows::NUM_CASCADES]>, + blocker_generations: [u64; crate::shadows::NUM_CASCADES], + pub(super) last_caster_count: u32, + warned_overflow: bool, +} + +impl TransmittedShadowResources { + pub(super) fn new( + device: &wgpu::Device, + shadow_map: &crate::shadows::ShadowMap, + material_layout: &wgpu::BindGroupLayout, + joint_layout: &wgpu::BindGroupLayout, + ) -> Self { + let make_color = |cascade: usize| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some(&format!("transmitted_shadow_color_{cascade}")), + size: wgpu::Extent3d { + width: TRANSMITTED_SHADOW_MAP_SIZE, + height: TRANSMITTED_SHADOW_MAP_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: TRANSMITTED_SHADOW_COLOR_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + }; + let make_depth = |cascade: usize| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some(&format!("transmitted_shadow_depth_{cascade}")), + size: wgpu::Extent3d { + width: TRANSMITTED_SHADOW_MAP_SIZE, + height: TRANSMITTED_SHADOW_MAP_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: TRANSMITTED_SHADOW_DEPTH_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + }; + let color_textures = std::array::from_fn(make_color); + let color_views = + std::array::from_fn(|i| color_textures[i].create_view(&Default::default())); + let depth_textures = std::array::from_fn(make_depth); + let depth_views = + std::array::from_fn(|i| depth_textures[i].create_view(&Default::default())); + + let draw_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("transmitted_shadow_draw_layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: true, + min_binding_size: std::num::NonZeroU64::new(std::mem::size_of::< + TransmittedShadowDrawUniforms, + >() as u64), + }, + count: None, + }], + }); + let draw_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("transmitted_shadow_draw_uniforms"), + size: u64::from( + crate::shadows::SHADOW_UNIFORM_STRIDE + * crate::shadows::SHADOW_MAX_NODES + * crate::shadows::NUM_CASCADES as u32, + ), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let draw_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("transmitted_shadow_draw_bg"), + layout: &draw_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &draw_uniform_buffer, + offset: 0, + size: std::num::NonZeroU64::new( + std::mem::size_of::() as u64, + ), + }), + }], + }); + let blocker_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("transmitted_shadow_blocker_layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }], + }); + let blocker_bind_groups = std::array::from_fn(|cascade| { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("transmitted_shadow_blocker_bg"), + layout: &blocker_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&shadow_map.depth_views[cascade]), + }], + }) + }); + let caster_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("transmitted_shadow_caster_pipeline_layout"), + bind_group_layouts: &[ + Some(&draw_layout), + Some(material_layout), + Some(&blocker_layout), + Some(joint_layout), + ], + immediate_size: 0, + }); + let caster_pipeline = + create_transmitted_shadow_caster_pipeline(device, &caster_pipeline_layout, false); + + let resolve_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("transmitted_shadow_resolve_layout"), + entries: &[ + uniform_layout_entry(0, wgpu::ShaderStages::FRAGMENT), + depth_texture_layout_entry(1), + float_texture_layout_entry(2), + float_texture_layout_entry(3), + float_texture_layout_entry(4), + float_texture_layout_entry(5), + float_texture_layout_entry(6), + depth_texture_layout_entry(7), + depth_texture_layout_entry(8), + depth_texture_layout_entry(9), + ], + }); + let resolve_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("transmitted_shadow_resolve_shader"), + source: wgpu::ShaderSource::Wgsl(TRANSMITTED_SHADOW_RESOLVE_WGSL.into()), + }); + let resolve_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("transmitted_shadow_resolve_pipeline_layout"), + bind_group_layouts: &[Some(&resolve_layout)], + immediate_size: 0, + }); + let resolve_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("transmitted_shadow_resolve_pipeline"), + layout: Some(&resolve_pipeline_layout), + vertex: wgpu::VertexState { + module: &resolve_shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &resolve_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent::REPLACE, + }), + write_mask: wgpu::ColorWrites::RED + | wgpu::ColorWrites::GREEN + | wgpu::ColorWrites::BLUE, + })], + compilation_options: Default::default(), + }), + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }); + let resolve_uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("transmitted_shadow_resolve_uniforms"), + contents: bytemuck::bytes_of(&TransmittedShadowResolveUniforms { + inv_vp: IDENTITY_MAT4, + cascade_vps: [IDENTITY_MAT4; crate::shadows::NUM_CASCADES], + camera_pos: [0.0; 4], + cascade_splits: [0.0; 4], + sun_dir: [0.0; 4], + sun_color: [0.0; 4], + wind: [0.0; 4], + cloud: [0.0; 4], + target_size: [1.0, 1.0, 0.0, 0.0], + }), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + Self { + _color_textures: color_textures, + color_views, + _depth_textures: depth_textures, + depth_views, + draw_uniform_buffer, + draw_uniform_bind_group, + blocker_bind_groups, + caster_pipeline_layout, + caster_pipeline, + caster_uv1_pipeline: None, + resolve_uniform_buffer, + resolve_layout, + resolve_pipeline, + resolve_bind_group: None, + rendered_signatures: [0; crate::shadows::NUM_CASCADES], + rendered_vps: None, + blocker_generations: [0; crate::shadows::NUM_CASCADES], + last_caster_count: 0, + warned_overflow: false, + } + } + + pub(super) fn invalidate_resolve_bind_group(&mut self) { + self.resolve_bind_group = None; + } + + fn ensure_uv1_pipeline(&mut self, device: &wgpu::Device) -> bool { + if self.caster_uv1_pipeline.is_none() { + self.caster_uv1_pipeline = Some(create_transmitted_shadow_caster_pipeline( + device, + &self.caster_pipeline_layout, + true, + )); + return true; + } + false + } +} + +fn uniform_layout_entry( + binding: u32, + visibility: wgpu::ShaderStages, +) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + } +} + +fn float_texture_layout_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + } +} + +fn depth_texture_layout_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + } +} + +fn transmission_hash(mut hash: u64, transmission: crate::models::MaterialTransmission) -> u64 { + hash = fnv1a_bytes(hash, &[u8::from(transmission.authored)]); + for value in [ + transmission.factor, + transmission.ior, + transmission.thickness_factor, + transmission.attenuation_distance, + transmission.attenuation_color[0], + transmission.attenuation_color[1], + transmission.attenuation_color[2], + ] { + hash = fnv1a_bytes(hash, &value.to_bits().to_le_bytes()); + } + for binding in [transmission.texture, transmission.thickness_texture] { + match binding { + Some(binding) => { + hash = fnv1a_bytes(hash, &[1]); + hash = fnv1a_bytes(hash, &binding.source_texture_index.to_le_bytes()); + hash = fnv1a_bytes( + hash, + &binding + .runtime_texture_idx + .unwrap_or_default() + .to_le_bytes(), + ); + hash = fnv1a_bytes(hash, &binding.transform.tex_coord.to_le_bytes()); + for value in [ + binding.transform.offset[0], + binding.transform.offset[1], + binding.transform.rotation, + binding.transform.scale[0], + binding.transform.scale[1], + ] { + hash = fnv1a_bytes(hash, &value.to_bits().to_le_bytes()); + } + } + None => hash = fnv1a_bytes(hash, &[0]), + } + } + hash +} + +impl Renderer { + pub(super) fn ensure_transmitted_shadow_resources(&mut self) { + if !transmitted_shadows_enabled() || self.transmitted_shadow_resources.is_some() { + return; + } + let Some(material_layout) = self.scene_refractive_material_layout.as_ref() else { + return; + }; + self.transmitted_shadow_resources = Some(TransmittedShadowResources::new( + &self.device, + &self.shadow_map, + material_layout, + &self.joint_layout, + )); + self.created_pipelines(2); + log::info!( + "bloom materials: transmitted directional shadows enabled \ + (nearest-layer, {}x{}, rgba8+depth16, lazy)", + TRANSMITTED_SHADOW_MAP_SIZE, + TRANSMITTED_SHADOW_MAP_SIZE, + ); + } + + pub(super) fn ensure_transmitted_shadow_uv1_resources(&mut self) { + if !transmitted_shadows_enabled() || !self.shadow_map.enabled { + return; + } + self.ensure_transmitted_shadow_resources(); + let created = self + .transmitted_shadow_resources + .as_mut() + .is_some_and(|resources| resources.ensure_uv1_pipeline(&self.device)); + if created { + self.created_pipelines(1); + } + } + + pub(super) fn select_transmitted_shadow_route(&mut self, scene: &crate::scene::SceneGraph) { + if !transmitted_shadows_enabled() + || !self.imported_refraction_enabled + || !self.shadow_map.enabled + { + self.transmitted_shadows_active = false; + return; + } + let has_caster = self.has_refractive_model_draws + || (self.has_refractive_scene_nodes && scene.has_transmitted_shadow_casters()); + if has_caster && self.transmitted_shadow_resources.is_none() { + self.ensure_transmitted_shadow_resources(); + } + self.transmitted_shadows_active = has_caster && self.transmitted_shadow_resources.is_some(); + } + + pub(super) fn record_transmitted_shadow_maps( + &mut self, + encoder: &mut wgpu::CommandEncoder, + profiler: &mut crate::profiler::Profiler, + scene: &crate::scene::SceneGraph, + ) { + if !self.transmitted_shadows_active { + return; + } + let Some(mut resources) = self.transmitted_shadow_resources.take() else { + return; + }; + + struct Draw<'a> { + vertex: &'a wgpu::Buffer, + secondary_uv: Option<&'a wgpu::Buffer>, + index: &'a wgpu::Buffer, + material: &'a wgpu::BindGroup, + vertex_byte_offset: u64, + index_byte_offset: u64, + first_index: u32, + index_count: u32, + base_vertex: i32, + model: [[f32; 4]; 4], + tint: [f32; 4], + bounds_min: [f32; 3], + bounds_max: [f32; 3], + joint_offset: f32, + signature: u64, + } + + let mut draws = Vec::new(); + for (index, (_handle, node)) in scene.nodes.iter().enumerate() { + if !node.visible + || node.gi_only + || !node.cast_shadow + || !node.material.transmission.is_active() + || node.indices.is_empty() + { + continue; + } + let (Some(vertex), Some(index_buffer), Some(material)) = ( + node.gpu_vb.as_ref(), + node.gpu_ib.as_ref(), + node.gpu_refractive_material_bg.as_ref(), + ) else { + continue; + }; + let secondary_uv = if node.gpu_refractive_uses_uv1 { + let Some(buffer) = node.gpu_secondary_uv_vb.as_ref() else { + continue; + }; + Some(buffer) + } else { + None + }; + let mut signature = fnv1a_bytes(FNV_OFFSET, &[0]); + signature = fnv1a_bytes(signature, &(index as u64).to_le_bytes()); + signature = fnv1a_bytes(signature, bytemuck::bytes_of(&node.transform)); + signature = transmission_hash(signature, node.material.transmission); + signature = fnv1a_bytes(signature, &node.material.texture_idx.to_le_bytes()); + for value in [ + node.material.color[0], + node.material.color[1], + node.material.color[2], + node.material.opacity, + ] { + signature = fnv1a_bytes(signature, &value.to_bits().to_le_bytes()); + } + let opacity = if node.material.opacity.is_finite() { + node.material.opacity + } else { + 1.0 + }; + draws.push(Draw { + vertex, + secondary_uv, + index: index_buffer, + material, + vertex_byte_offset: 0, + index_byte_offset: 0, + first_index: 0, + index_count: node.gpu_index_count, + base_vertex: 0, + model: node.transform, + tint: [ + node.material.color[0], + node.material.color[1], + node.material.color[2], + opacity, + ], + bounds_min: node.world_bounds_min, + bounds_max: node.world_bounds_max, + joint_offset: 0.0, + signature, + }); + } + for command in &self.model_draw_commands { + let Some(Some(meshes)) = self.model_gpu_cache.get(&command.cache_handle) else { + continue; + }; + let Some(mesh) = meshes.get(command.mesh_idx) else { + continue; + }; + if !mesh.transmission.is_active() { + continue; + } + let Some(material) = mesh.refractive_material_bg.as_ref() else { + continue; + }; + let secondary_uv = if mesh.refractive_uses_uv1 { + let Some(buffer) = mesh.refractive_uv1_buffer.as_ref() else { + continue; + }; + Some(buffer) + } else { + None + }; + let (geometry, vertex_byte_offset, index_byte_offset) = if secondary_uv.is_some() { + self.gpu_driven + .mesh_draw_localized(&mesh.geometry, mesh.index_count) + } else { + ( + self.gpu_driven.mesh_draw(&mesh.geometry, mesh.index_count), + 0, + 0, + ) + }; + let (bounds_min, bounds_max) = command + .bounds_override + .unwrap_or_else(|| transform_aabb(&command.model, mesh.local_min, mesh.local_max)); + let mut signature = fnv1a_bytes(FNV_OFFSET, &[1]); + signature = fnv1a_bytes(signature, &command.cache_handle.to_le_bytes()); + signature = fnv1a_bytes(signature, &(command.mesh_idx as u64).to_le_bytes()); + signature = fnv1a_bytes(signature, bytemuck::bytes_of(&command.model)); + signature = transmission_hash(signature, mesh.transmission); + signature = fnv1a_bytes(signature, &mesh.base_color_idx.to_le_bytes()); + for value in command.tint { + signature = fnv1a_bytes(signature, &value.to_bits().to_le_bytes()); + } + if command.skinned { + signature = fnv1a_bytes(signature, &self.shadow_map.frame_nonce.to_le_bytes()); + } + draws.push(Draw { + vertex: geometry.vertex, + secondary_uv, + index: geometry.index, + material, + vertex_byte_offset, + index_byte_offset, + first_index: geometry.first_index, + index_count: geometry.index_count, + base_vertex: geometry.base_vertex, + model: command.model, + tint: command.tint, + bounds_min, + bounds_max, + joint_offset: command.joint_offset, + signature, + }); + } + + resources.last_caster_count = draws.len().min(u32::MAX as usize) as u32; + let cascade_planes: [[[f32; 4]; 6]; crate::shadows::NUM_CASCADES] = + std::array::from_fn(|cascade| { + crate::scene::extract_frustum_planes(&self.shadow_map.light_vps[cascade]) + }); + profiler.begin("transmitted_shadow_maps"); + for cascade in 0..crate::shadows::NUM_CASCADES { + let mut indices = Vec::with_capacity(draws.len()); + let mut signature = FNV_OFFSET; + for (index, draw) in draws.iter().enumerate() { + let has_bounds = draw.bounds_min[0] <= draw.bounds_max[0]; + if has_bounds + && crate::scene::aabb_outside_frustum( + &cascade_planes[cascade], + draw.bounds_min, + draw.bounds_max, + ) + { + continue; + } + signature = fnv1a_bytes(signature, &draw.signature.to_le_bytes()); + indices.push(index); + } + let stale = resources.rendered_vps.map_or(true, |vps| { + vps[cascade] != self.shadow_map.light_vps[cascade] + }) || resources.rendered_signatures[cascade] != signature + || resources.blocker_generations[cascade] + != self.shadow_map.live_cascade_generation[cascade]; + if !stale { + continue; + } + + let max = crate::shadows::SHADOW_MAX_NODES as usize; + if indices.len() > max { + indices.truncate(max); + if !resources.warned_overflow { + log::warn!( + "bloom transmitted shadows: caster budget exceeded ({} > {}); \ + keeping deterministic submission prefix", + draws.len(), + max, + ); + resources.warned_overflow = true; + } + } + let stride = crate::shadows::SHADOW_UNIFORM_STRIDE as usize; + let cascade_base = cascade * max * stride; + let mut payload = vec![0_u8; indices.len().max(1) * stride]; + for (slot, &draw_index) in indices.iter().enumerate() { + let draw = &draws[draw_index]; + let uniforms = TransmittedShadowDrawUniforms { + light_vp: self.shadow_map.light_vps[cascade], + model: draw.model, + tint: draw.tint, + misc: [draw.joint_offset, 0.0, 0.0, 0.0], + }; + let offset = slot * stride; + payload[offset..offset + std::mem::size_of_val(&uniforms)] + .copy_from_slice(bytemuck::bytes_of(&uniforms)); + } + if !indices.is_empty() { + self.queue.write_buffer( + &resources.draw_uniform_buffer, + cascade_base as u64, + &payload[..indices.len() * stride], + ); + } + let timestamp_writes = profiler.pass_timestamp_writes("transmitted_shadow_maps"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("transmitted_shadow_map"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &resources.color_views[cascade], + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &resources.depth_views[cascade], + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_bind_group(2, &resources.blocker_bind_groups[cascade], &[]); + pass.set_bind_group(3, &self.joint_bind_group, &[]); + let mut current_uses_uv1 = None; + for (slot, &draw_index) in indices.iter().enumerate() { + let draw = &draws[draw_index]; + let uses_uv1 = draw.secondary_uv.is_some(); + if current_uses_uv1 != Some(uses_uv1) { + pass.set_pipeline(if uses_uv1 { + resources + .caster_uv1_pipeline + .as_ref() + .expect("UV1 transmitted-shadow pipeline initialized on material use") + } else { + &resources.caster_pipeline + }); + current_uses_uv1 = Some(uses_uv1); + } + let offset = (cascade_base + slot * stride) as u32; + pass.set_bind_group(0, &resources.draw_uniform_bind_group, &[offset]); + pass.set_bind_group(1, draw.material, &[]); + pass.set_vertex_buffer(0, draw.vertex.slice(draw.vertex_byte_offset..)); + if let Some(secondary_uv) = draw.secondary_uv { + pass.set_vertex_buffer(1, secondary_uv.slice(..)); + } + pass.set_index_buffer( + draw.index.slice(draw.index_byte_offset..), + wgpu::IndexFormat::Uint32, + ); + pass.draw_indexed( + draw.first_index..draw.first_index + draw.index_count, + draw.base_vertex, + 0..1, + ); + } + drop(pass); + resources.rendered_signatures[cascade] = signature; + resources.blocker_generations[cascade] = + self.shadow_map.live_cascade_generation[cascade]; + } + resources.rendered_vps = Some(self.shadow_map.light_vps); + profiler.end("transmitted_shadow_maps"); + self.transmitted_shadow_resources = Some(resources); + } + + pub(super) fn record_transmitted_shadow_resolve( + &mut self, + encoder: &mut wgpu::CommandEncoder, + profiler: &mut crate::profiler::Profiler, + ) { + if !self.transmitted_shadows_active { + return; + } + let (width, height) = self.render_extent(); + let Some(resources) = self.transmitted_shadow_resources.as_mut() else { + return; + }; + let uniforms = TransmittedShadowResolveUniforms { + inv_vp: self.current_inv_vp_matrix, + cascade_vps: self.shadow_map.light_vps, + camera_pos: [ + self.current_camera_pos[0], + self.current_camera_pos[1], + self.current_camera_pos[2], + 0.0, + ], + cascade_splits: self.lighting_uniforms.shadow_cascade_splits, + sun_dir: self.lighting_uniforms.light_dir, + sun_color: self.lighting_uniforms.light_color, + wind: self.lighting_uniforms.wind, + cloud: self.lighting_uniforms.cloud, + target_size: [width as f32, height as f32, 0.0, 0.0], + }; + self.queue.write_buffer( + &resources.resolve_uniform_buffer, + 0, + bytemuck::bytes_of(&uniforms), + ); + if resources.resolve_bind_group.is_none() { + resources.resolve_bind_group = + Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("transmitted_shadow_resolve_bg"), + layout: &resources.resolve_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: resources.resolve_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&self.depth_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&self.albedo_rt_view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&self.material_rt_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&resources.color_views[0]), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&resources.color_views[1]), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&resources.color_views[2]), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(&resources.depth_views[0]), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView(&resources.depth_views[1]), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::TextureView(&resources.depth_views[2]), + }, + ], + })); + } + + profiler.begin("transmitted_shadow_resolve"); + let timestamp_writes = profiler.pass_timestamp_writes("transmitted_shadow_resolve"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("transmitted_shadow_resolve"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.hdr_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&resources.resolve_pipeline); + pass.set_bind_group( + 0, + resources + .resolve_bind_group + .as_ref() + .expect("transmitted-shadow resolve bind group was initialized"), + &[], + ); + pass.draw(0..3, 0..1); + drop(pass); + profiler.end("transmitted_shadow_resolve"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transmitted_shadow_shaders_parse() { + for secondary_uv in [false, true] { + let caster = transmitted_shadow_caster_shader_source(secondary_uv); + wgpu::naga::front::wgsl::parse_str(&caster).unwrap_or_else(|error| { + panic!( + "transmitted-shadow caster{} WGSL failed: {error:?}", + if secondary_uv { " UV1" } else { "" } + ) + }); + assert_eq!( + caster.contains("@location(3) secondary_uv"), + secondary_uv, + "ordinary transmitted shadows must not fetch a second vertex stream" + ); + } + wgpu::naga::front::wgsl::parse_str(TRANSMITTED_SHADOW_RESOLVE_WGSL) + .unwrap_or_else(|error| panic!("transmitted-shadow resolve WGSL failed: {error:?}")); + } + + #[test] + fn transmitted_shadow_memory_is_bounded_and_lazy_by_contract() { + assert_eq!(TRANSMITTED_SHADOW_PERSISTENT_BYTES, 18_874_368); + assert!(TRANSMITTED_SHADOW_PERSISTENT_BYTES < 20 * 1024 * 1024); + } +} diff --git a/native/shared/src/renderer/transparent_gi.rs b/native/shared/src/renderer/transparent_gi.rs new file mode 100644 index 00000000..5628407d --- /dev/null +++ b/native/shared/src/renderer/transparent_gi.rs @@ -0,0 +1,327 @@ +//! Lazy, bounded physical-transmission representation for probe GI. +//! +//! Opaque scenes keep the established first-hit shaders and TLAS masks. When +//! SSGI and imported transmission are both live, specialized kernels continue +//! through at most one nearest glass instance. The specialization reuses spare +//! lanes in `InstanceGiDataCpu`; it allocates no texture or storage buffer. + +use super::*; + +pub(super) const TRANSPARENT_GI_SHADER_SWITCH: &str = "const BLOOM_TRANSPARENT_GI: bool = false;"; +const TRANSPARENT_GI_SHADER_ENABLED: &str = "const BLOOM_TRANSPARENT_GI: bool = true;"; + +pub(super) fn transparent_gi_enabled() -> bool { + std::env::var("BLOOM_TRANSPARENT_GI") + .ok() + .map(|value| { + !matches!( + value.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "disabled" + ) + }) + .unwrap_or(true) +} + +#[derive(Copy, Clone, Debug)] +pub(super) struct InstanceTransport { + pub absorption: [f32; 3], + pub coverage: f32, + pub transmission_weight: f32, + pub fresnel_pass: f32, +} + +impl InstanceTransport { + pub const OPAQUE: Self = Self { + absorption: [1.0; 3], + coverage: 1.0, + transmission_weight: 0.0, + fresnel_pass: 0.0, + }; + + pub fn active(self) -> bool { + self.transmission_weight > 0.0 + } +} + +pub(super) fn instance_transport( + material: &crate::scene::PbrMaterial, + transform: &[[f32; 4]; 4], + enabled: bool, +) -> InstanceTransport { + let transmission = material.transmission; + if !enabled || !material.has_gi_transmission() { + return InstanceTransport::OPAQUE; + } + + let metallic = material.metalness.clamp(0.0, 1.0); + let transmission_weight = transmission.factor.clamp(0.0, 1.0) * (1.0 - metallic); + if transmission_weight <= 0.0 { + return InstanceTransport::OPAQUE; + } + + let ior = transmission.effective_ior(); + let f0 = ((ior - 1.0) / (ior + 1.0)).powi(2); + let model_scale = + ((transform[0][0].powi(2) + transform[0][1].powi(2) + transform[0][2].powi(2)).sqrt() + + (transform[1][0].powi(2) + transform[1][1].powi(2) + transform[1][2].powi(2)).sqrt() + + (transform[2][0].powi(2) + transform[2][1].powi(2) + transform[2][2].powi(2)).sqrt()) + / 3.0; + let thickness = transmission.thickness_factor.max(0.0) * model_scale; + let absorption = if transmission.attenuation_distance.is_finite() + && transmission.attenuation_distance > 0.0 + && thickness > 0.0 + { + std::array::from_fn(|channel| { + transmission.attenuation_color[channel] + .clamp(1.0e-6, 1.0) + .powf(thickness / transmission.attenuation_distance) + }) + } else { + [1.0; 3] + }; + let coverage = if material.alpha_mode == crate::models::MaterialAlphaMode::Blend { + material.opacity.clamp(0.0, 1.0) + } else { + 1.0 + }; + + InstanceTransport { + absorption, + coverage, + transmission_weight, + fresnel_pass: 1.0 - f0, + } +} + +fn transmission_source(source: &str) -> String { + let replaced = source.replacen( + TRANSPARENT_GI_SHADER_SWITCH, + TRANSPARENT_GI_SHADER_ENABLED, + 1, + ); + debug_assert_ne!(replaced, source, "transparent-GI shader switch is missing"); + replaced +} + +impl Renderer { + pub(super) fn select_transparent_gi_route(&mut self, scene: &crate::scene::SceneGraph) { + let was_active = self.transparent_gi_active; + let instance_count = scene.transparent_gi_instance_count(); + self.transparent_gi_instance_count = instance_count; + self.transparent_gi_active = instance_count > 0 + && self.ssgi_enabled + && self.imported_refraction_enabled + && transparent_gi_enabled(); + + if self.transparent_gi_active { + self.ensure_transparent_gi_pipelines(); + } + if self.transparent_gi_active != was_active { + // Probe history and the long-lived WSRC encode the old visibility + // representation. Refresh them on the exact frame the route flips. + self.transparent_gi_force_probe_refresh = true; + self.probe_history_idx = 0; + self.probe_history_valid = false; + self.wsrc_built = [false; WSRC_CASCADE_COUNT as usize]; + self.scene_sdf_clipmap_rebake_needed = true; + } + } + + fn ensure_transparent_gi_pipelines(&mut self) { + if self.probe_trace_sdf_transparent_pipeline.is_some() + && (!self.hw_rt_enabled + || (self.probe_trace_hw_transparent_pipeline.is_some() + && self.wsrc_bake_hw_transparent_pipeline.is_some())) + { + return; + } + if self.probe_trace_sdf_transparent_pipeline.is_none() { + let source = format!( + "{}{}", + PROBE_HELPERS_WGSL, + transmission_source(SSGI_PROBE_TRACE_SDF_WGSL) + ); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("probe_trace_sdf_transparent_shader"), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + let layout = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("probe_trace_sdf_transparent_pl"), + bind_group_layouts: &[Some(&self.probe_trace_sdf_layout)], + immediate_size: 0, + }); + self.probe_trace_sdf_transparent_pipeline = Some(self.device.create_compute_pipeline( + &wgpu::ComputePipelineDescriptor { + label: Some("probe_trace_sdf_transparent_pipeline"), + layout: Some(&layout), + module: &shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }, + )); + self.created_pipelines(1); + } + + if self.hw_rt_enabled && self.probe_trace_hw_transparent_pipeline.is_none() { + let source = format!( + "enable wgpu_ray_query;\n{}{}{}", + ray_query_backend_variant(&self.device), + PROBE_HELPERS_WGSL, + transmission_source(SSGI_PROBE_TRACE_HW_WGSL), + ); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("probe_trace_hw_transparent_shader"), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + let bind_layout = self + .probe_trace_hw_layout + .as_ref() + .expect("HW transparent GI requires the established HW trace layout"); + let layout = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("probe_trace_hw_transparent_pl"), + bind_group_layouts: &[Some(bind_layout)], + immediate_size: 0, + }); + self.probe_trace_hw_transparent_pipeline = Some(self.device.create_compute_pipeline( + &wgpu::ComputePipelineDescriptor { + label: Some("probe_trace_hw_transparent_pipeline"), + layout: Some(&layout), + module: &shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }, + )); + self.created_pipelines(1); + } + + if self.hw_rt_enabled && self.wsrc_bake_hw_transparent_pipeline.is_none() { + let source = format!( + "enable wgpu_ray_query;\n{}{}{}", + ray_query_backend_variant(&self.device), + PROBE_HELPERS_WGSL, + transmission_source(WSRC_BAKE_HW_WGSL), + ); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("wsrc_bake_hw_transparent_shader"), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + let bind_layout = self + .wsrc_bake_hw_layout + .as_ref() + .expect("HW transparent GI requires the established WSRC layout"); + let layout = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("wsrc_bake_hw_transparent_pl"), + bind_group_layouts: &[Some(bind_layout)], + immediate_size: 0, + }); + self.wsrc_bake_hw_transparent_pipeline = Some(self.device.create_compute_pipeline( + &wgpu::ComputePipelineDescriptor { + label: Some("wsrc_bake_hw_transparent_pipeline"), + layout: Some(&layout), + module: &shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }, + )); + self.created_pipelines(1); + } + + log::info!( + "bloom GI: physical transmission enabled \ + (one-layer colored continuation, lazy, zero additional textures/buffers)" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transparent_specializations_replace_exactly_one_compile_time_switch() { + for source in [ + SSGI_PROBE_TRACE_HW_WGSL, + SSGI_PROBE_TRACE_SDF_WGSL, + WSRC_BAKE_HW_WGSL, + ] { + assert_eq!(source.matches(TRANSPARENT_GI_SHADER_SWITCH).count(), 1); + let specialized = transmission_source(source); + assert!(!specialized.contains(TRANSPARENT_GI_SHADER_SWITCH)); + assert_eq!( + specialized.matches(TRANSPARENT_GI_SHADER_ENABLED).count(), + 1 + ); + } + } + + #[test] + fn ordinary_and_transparent_gi_shader_variants_parse() { + let probe_prefix = format!( + "enable wgpu_ray_query;\nconst BLOOM_RAY_QUERY_NEEDS_PROCEED: bool = false;\n{}", + PROBE_HELPERS_WGSL + ); + for source in [ + format!("{probe_prefix}{SSGI_PROBE_TRACE_HW_WGSL}"), + format!( + "{probe_prefix}{}", + transmission_source(SSGI_PROBE_TRACE_HW_WGSL) + ), + format!("{PROBE_HELPERS_WGSL}{SSGI_PROBE_TRACE_SDF_WGSL}"), + format!( + "{PROBE_HELPERS_WGSL}{}", + transmission_source(SSGI_PROBE_TRACE_SDF_WGSL) + ), + format!("{probe_prefix}{WSRC_BAKE_HW_WGSL}"), + format!("{probe_prefix}{}", transmission_source(WSRC_BAKE_HW_WGSL)), + ] { + wgpu::naga::front::wgsl::parse_str(&source) + .unwrap_or_else(|error| panic!("transparent-GI WGSL failed: {error:?}")); + } + } + + #[test] + fn instance_transport_is_bounded_and_preserves_opaque_sentinel() { + assert_eq!( + std::mem::size_of::(), + 144, + "CPU records must retain the WGSL array stride" + ); + let material = crate::scene::PbrMaterial::default(); + assert!(!instance_transport(&material, &IDENTITY_MAT4, true).active()); + + let mut glass = material; + glass.transmission.authored = true; + glass.transmission.factor = 1.0; + glass.transmission.ior = 1.5; + glass.transmission.thickness_factor = 1.0; + glass.transmission.attenuation_distance = 2.0; + glass.transmission.attenuation_color = [0.25, 0.5, 1.0]; + let transport = instance_transport(&glass, &IDENTITY_MAT4, true); + assert!(transport.active()); + assert_eq!(transport.coverage, 1.0); + assert!(transport.absorption[0] < transport.absorption[1]); + assert!(transport.absorption[1] < transport.absorption[2]); + assert!((transport.fresnel_pass - 0.96).abs() < 1.0e-5); + + glass.metalness = 1.0; + assert!( + !instance_transport(&glass, &IDENTITY_MAT4, true).active(), + "metallic suppression must keep the instance in the opaque mask" + ); + } +} diff --git a/native/shared/src/renderer/types.rs b/native/shared/src/renderer/types.rs index 4b919d0d..449f4f8d 100644 --- a/native/shared/src/renderer/types.rs +++ b/native/shared/src/renderer/types.rs @@ -32,17 +32,17 @@ pub(super) struct Uniforms2D { #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -pub(super) struct Uniforms3D { - pub(super) mvp: [[f32; 4]; 4], - pub(super) model: [[f32; 4]; 4], - pub(super) prev_mvp: [[f32; 4]; 4], - pub(super) model_tint: [f32; 4], +pub(crate) struct Uniforms3D { + pub(crate) mvp: [[f32; 4]; 4], + pub(crate) model: [[f32; 4]; 4], + pub(crate) prev_mvp: [[f32; 4]; 4], + pub(crate) model_tint: [f32; 4], /// x = joint-buffer base offset for this draw (added to vertex joint /// indices by the scene VS), y = 1.0 for skinned cached draws else /// 0.0, zw unused. Lets GPU-resident skinned models share the static /// cached-model path: the VB keeps RAW joint indices and the per-draw /// uniform carries the frame's pose offset instead. - pub(super) misc: [f32; 4], + pub(crate) misc: [f32; 4], } /// Scene-pipeline per-material factors — the scalar parts of a glTF @@ -57,7 +57,7 @@ pub struct SceneMaterialUniforms { /// w = alpha_cutoff (0.0 = OPAQUE mode, >0 = MASK/BLEND — fragments /// whose base-colour alpha is below this are discarded). pub metal_rough: [f32; 4], - /// rgb = emissive_factor, w = padding + /// rgb = emissive_factor, w = base-color lower mips store MASK coverage. pub emissive: [f32; 4], } @@ -68,6 +68,7 @@ impl SceneMaterialUniforms { emissive: [f32; 3], has_mr_texture: bool, alpha_cutoff: f32, + alpha_coverage_mips: bool, ) -> Self { Self { metal_rough: [ @@ -76,7 +77,101 @@ impl SceneMaterialUniforms { if has_mr_texture { 1.0 } else { 0.0 }, alpha_cutoff, ], - emissive: [emissive[0], emissive[1], emissive[2], 0.0], + emissive: [ + emissive[0], + emissive[1], + emissive[2], + if alpha_coverage_mips { 1.0 } else { 0.0 }, + ], + } + } +} + +/// Scalar/UV contract used only by imported refractive scene materials. +/// +/// Keeping this in a separate UBO and bind-group layout is deliberate: +/// ordinary opaque/masked/BLEND materials retain their original 32-byte +/// material UBO and binding footprint. +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub(crate) struct SceneTransmissionUniforms { + /// x = transmission factor, y = IOR, z = authored thickness factor, + /// w = transmission texture is bound and usable. + pub(crate) transmission: [f32; 4], + /// rgb = attenuation color, w = attenuation distance in world units. + /// w == 0 denotes the glTF infinite-distance/no-absorption default. + pub(crate) attenuation: [f32; 4], + /// xy = offset, zw = scale for KHR_texture_transform. + pub(crate) transmission_uv: [f32; 4], + /// xy = cos/sin(rotation), z = thickness texture is bound/usable, + /// w = transmission texture UV selector (0 = UV0, 1 = UV1). + pub(crate) transmission_rotation: [f32; 4], + /// xy = offset, zw = scale for the thickness texture. + pub(crate) thickness_uv: [f32; 4], + /// xy = cos/sin(rotation), z = thickness texture UV selector, w reserved. + pub(crate) thickness_rotation: [f32; 4], +} + +impl SceneTransmissionUniforms { + pub(crate) fn new( + transmission: crate::models::MaterialTransmission, + has_transmission_texture: bool, + has_thickness_texture: bool, + ) -> Self { + let transmission_transform = transmission + .texture + .map(|binding| binding.transform) + .unwrap_or_default(); + let thickness_transform = transmission + .thickness_texture + .map(|binding| binding.transform) + .unwrap_or_default(); + let attenuation_distance = if transmission.attenuation_distance.is_finite() + && transmission.attenuation_distance > 0.0 + { + transmission.attenuation_distance + } else { + 0.0 + }; + let (transmission_sin, transmission_cos) = transmission_transform.rotation.sin_cos(); + let (thickness_sin, thickness_cos) = thickness_transform.rotation.sin_cos(); + Self { + transmission: [ + transmission.factor.clamp(0.0, 1.0), + transmission.effective_ior(), + transmission.thickness_factor.max(0.0), + has_transmission_texture as u8 as f32, + ], + attenuation: [ + transmission.attenuation_color[0].clamp(0.0, 1.0), + transmission.attenuation_color[1].clamp(0.0, 1.0), + transmission.attenuation_color[2].clamp(0.0, 1.0), + attenuation_distance, + ], + transmission_uv: [ + transmission_transform.offset[0], + transmission_transform.offset[1], + transmission_transform.scale[0], + transmission_transform.scale[1], + ], + transmission_rotation: [ + transmission_cos, + transmission_sin, + has_thickness_texture as u8 as f32, + transmission_transform.tex_coord as f32, + ], + thickness_uv: [ + thickness_transform.offset[0], + thickness_transform.offset[1], + thickness_transform.scale[0], + thickness_transform.scale[1], + ], + thickness_rotation: [ + thickness_cos, + thickness_sin, + thickness_transform.tex_coord as f32, + 0.0, + ], } } } @@ -94,27 +189,27 @@ pub(crate) const MAX_POINT_LIGHTS: usize = 256; #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub(super) struct DirLight { - pub(super) direction: [f32; 4], // xyz + intensity - pub(super) color: [f32; 4], // rgb + _pad + pub(super) direction: [f32; 4], // xyz + intensity + pub(super) color: [f32; 4], // rgb + _pad } #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub(super) struct PointLight { - pub(super) position: [f32; 4], // xyz + range - pub(super) color: [f32; 4], // rgb + intensity + pub(super) position: [f32; 4], // xyz + range + pub(super) color: [f32; 4], // rgb + intensity } #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub(super) struct LightingUniforms { - pub(super) ambient: [f32; 4], // rgb + intensity - pub(super) light_dir: [f32; 4], // xyz + intensity (legacy, kept for compat) - pub(super) light_color: [f32; 4], // rgb + _pad (legacy) - pub(super) dir_light_count: [f32; 4], // [count, 0, 0, 0] - pub(super) dir_lights: [DirLight; MAX_DIR_LIGHTS], // additional directional lights - pub(super) point_light_count: [f32; 4], // [count, 0, 0, 0] - pub(super) point_lights: [PointLight; MAX_POINT_LIGHTS], // point lights + pub(super) ambient: [f32; 4], // rgb + intensity + pub(super) light_dir: [f32; 4], // xyz + intensity (legacy, kept for compat) + pub(super) light_color: [f32; 4], // rgb + _pad (legacy) + pub(super) dir_light_count: [f32; 4], // [count, 0, 0, 0] + pub(super) dir_lights: [DirLight; MAX_DIR_LIGHTS], // additional directional lights + pub(super) point_light_count: [f32; 4], // [count, 0, 0, 0] + pub(super) point_lights: [PointLight; MAX_POINT_LIGHTS], // point lights /// Camera world-space position (xyz) + env intensity multiplier /// (w). Scene shader uses xyz to compute V = normalize(camera_pos /// - world_pos) for GGX specular, and multiplies w into every env @@ -158,9 +253,15 @@ impl LightingUniforms { light_dir: [0.5, 1.0, 0.3, 0.7], light_color: [1.0, 1.0, 1.0, 0.0], dir_light_count: [0.0; 4], - dir_lights: [DirLight { direction: [0.0; 4], color: [0.0; 4] }; MAX_DIR_LIGHTS], + dir_lights: [DirLight { + direction: [0.0; 4], + color: [0.0; 4], + }; MAX_DIR_LIGHTS], point_light_count: [0.0; 4], - point_lights: [PointLight { position: [0.0; 4], color: [0.0; 4] }; MAX_POINT_LIGHTS], + point_lights: [PointLight { + position: [0.0; 4], + color: [0.0; 4], + }; MAX_POINT_LIGHTS], // w = env_intensity multiplier for IBL + sky. 1.0 matches // the path-traced reference; apps with bright HDR envs // typically dial to 0.2–0.5 via set_env_intensity. @@ -189,9 +290,21 @@ impl Vertex2D { array_stride: std::mem::size_of::() as wgpu::BufferAddress, step_mode: wgpu::VertexStepMode::Vertex, attributes: &[ - wgpu::VertexAttribute { offset: 0, shader_location: 0, format: wgpu::VertexFormat::Float32x2 }, - wgpu::VertexAttribute { offset: 8, shader_location: 1, format: wgpu::VertexFormat::Float32x2 }, - wgpu::VertexAttribute { offset: 16, shader_location: 2, format: wgpu::VertexFormat::Float32x4 }, + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: 8, + shader_location: 1, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: 16, + shader_location: 2, + format: wgpu::VertexFormat::Float32x4, + }, ], } } @@ -204,9 +317,9 @@ pub struct Vertex3D { pub normal: [f32; 3], pub color: [f32; 4], pub uv: [f32; 2], - pub joints: [f32; 4], // bone indices (as floats for simplicity) - pub weights: [f32; 4], // bone weights (sum to 1.0, or all 0.0 for unskinned) - pub tangent: [f32; 4], // xyz = tangent direction, w = bitangent sign (±1). All zero = no tangent data; scene shader then skips normal mapping. + pub joints: [f32; 4], // bone indices (as floats for simplicity) + pub weights: [f32; 4], // bone weights (sum to 1.0, or all 0.0 for unskinned) + pub tangent: [f32; 4], // xyz = tangent direction, w = bitangent sign (±1). All zero = no tangent data; scene shader then skips normal mapping. } impl Default for Vertex3D { @@ -221,18 +334,63 @@ impl Vertex3D { array_stride: std::mem::size_of::() as wgpu::BufferAddress, step_mode: wgpu::VertexStepMode::Vertex, attributes: &[ - wgpu::VertexAttribute { offset: 0, shader_location: 0, format: wgpu::VertexFormat::Float32x3 }, // position - wgpu::VertexAttribute { offset: 12, shader_location: 1, format: wgpu::VertexFormat::Float32x3 }, // normal - wgpu::VertexAttribute { offset: 24, shader_location: 2, format: wgpu::VertexFormat::Float32x4 }, // color - wgpu::VertexAttribute { offset: 40, shader_location: 3, format: wgpu::VertexFormat::Float32x2 }, // uv - wgpu::VertexAttribute { offset: 48, shader_location: 4, format: wgpu::VertexFormat::Float32x4 }, // joints - wgpu::VertexAttribute { offset: 64, shader_location: 5, format: wgpu::VertexFormat::Float32x4 }, // weights - wgpu::VertexAttribute { offset: 80, shader_location: 6, format: wgpu::VertexFormat::Float32x4 }, // tangent + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }, // position + wgpu::VertexAttribute { + offset: 12, + shader_location: 1, + format: wgpu::VertexFormat::Float32x3, + }, // normal + wgpu::VertexAttribute { + offset: 24, + shader_location: 2, + format: wgpu::VertexFormat::Float32x4, + }, // color + wgpu::VertexAttribute { + offset: 40, + shader_location: 3, + format: wgpu::VertexFormat::Float32x2, + }, // uv + wgpu::VertexAttribute { + offset: 48, + shader_location: 4, + format: wgpu::VertexFormat::Float32x4, + }, // joints + wgpu::VertexAttribute { + offset: 64, + shader_location: 5, + format: wgpu::VertexFormat::Float32x4, + }, // weights + wgpu::VertexAttribute { + offset: 80, + shader_location: 6, + format: wgpu::VertexFormat::Float32x4, + }, // tangent ], } } } +/// Refractive-only glTF TEXCOORD_1 stream. +/// +/// This is deliberately a separate vertex buffer at slot 1/location 7. It is +/// allocated and fetched only for physical materials that actually sample +/// UV1, so `Vertex3D` and every ordinary scene pipeline remain unchanged. +pub(crate) fn secondary_uv_desc() -> wgpu::VertexBufferLayout<'static> { + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::<[f32; 2]>() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[wgpu::VertexAttribute { + offset: 0, + shader_location: 7, + format: wgpu::VertexFormat::Float32x2, + }], + } +} + /// Per-instance data for materials compiled with `wants_instancing = true`. /// Bound at vertex buffer slot 1, step_mode = Instance. Layout is fixed /// at engine V1; future extensions can parameterise from a material desc. @@ -245,10 +403,10 @@ impl Vertex3D { #[repr(C)] #[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] pub struct InstanceData3D { - pub position: [f32; 3], // world-space position - pub rot_y: f32, // Y-axis rotation in radians - pub scale: f32, // uniform scale multiplier (1.0 = no scale) - pub tint: [f32; 4], // RGBA tint multiplier (1,1,1,1 = no tint) + pub position: [f32; 3], // world-space position + pub rot_y: f32, // Y-axis rotation in radians + pub scale: f32, // uniform scale multiplier (1.0 = no scale) + pub tint: [f32; 4], // RGBA tint multiplier (1,1,1,1 = no tint) /// EN-026 — was pure padding to the 16-byte boundary; now carried to the /// shader as `@location(11) instance_extra: vec3`. The three floats /// were already being uploaded, so exposing them costs nothing: no stride @@ -256,7 +414,7 @@ pub struct InstanceData3D { /// velocity-stretch length, random seed); anything else can leave them 0 /// and simply not declare location 11 — a vertex buffer may carry /// attributes the shader does not consume. - pub extra: [f32; 3], + pub extra: [f32; 3], } impl InstanceData3D { @@ -265,11 +423,31 @@ impl InstanceData3D { array_stride: std::mem::size_of::() as wgpu::BufferAddress, step_mode: wgpu::VertexStepMode::Instance, attributes: &[ - wgpu::VertexAttribute { offset: 0, shader_location: 7, format: wgpu::VertexFormat::Float32x3 }, // position - wgpu::VertexAttribute { offset: 12, shader_location: 8, format: wgpu::VertexFormat::Float32 }, // rot_y - wgpu::VertexAttribute { offset: 16, shader_location: 9, format: wgpu::VertexFormat::Float32 }, // scale - wgpu::VertexAttribute { offset: 20, shader_location: 10, format: wgpu::VertexFormat::Float32x4 }, // tint - wgpu::VertexAttribute { offset: 36, shader_location: 11, format: wgpu::VertexFormat::Float32x3 }, // extra (EN-026) + wgpu::VertexAttribute { + offset: 0, + shader_location: 7, + format: wgpu::VertexFormat::Float32x3, + }, // position + wgpu::VertexAttribute { + offset: 12, + shader_location: 8, + format: wgpu::VertexFormat::Float32, + }, // rot_y + wgpu::VertexAttribute { + offset: 16, + shader_location: 9, + format: wgpu::VertexFormat::Float32, + }, // scale + wgpu::VertexAttribute { + offset: 20, + shader_location: 10, + format: wgpu::VertexFormat::Float32x4, + }, // tint + wgpu::VertexAttribute { + offset: 36, + shader_location: 11, + format: wgpu::VertexFormat::Float32x3, + }, // extra (EN-026) ], } } @@ -430,7 +608,6 @@ pub(super) struct AerialParams { pub(super) knobs: [f32; 4], } - #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub(super) struct HizLinearizeParams { @@ -636,22 +813,36 @@ pub(super) struct ProbeResolveParams { } /// On-GPU `ProbeHeader` layout (must match PROBE_HELPERS_WGSL's struct). -/// 32 bytes per probe. +/// 48 bytes per probe; diffuse stores the cosine-convolved result so resolve +/// needs no separate probe-history texture lookup. #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub(super) struct ProbeHeaderCpu { pub(super) world_pos: [f32; 4], pub(super) normal: [f32; 4], + pub(super) diffuse: [f32; 4], } +pub(super) const PROBE_HEADER_RW_LAYOUT_ENTRY: wgpu::BindGroupLayoutEntry = + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }; + /// Ticket 013 V3 — CardCaptureParams. ortho_vp + base_color + emissive. /// 96 bytes; we allocate 128 for uniform-alignment headroom. #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub(super) struct CardCaptureParams { pub(super) ortho_vp: [[f32; 4]; 4], - pub(super) base_color: [f32; 4], // rgb = factor, w = has_base_texture (0/1) - pub(super) emissive: [f32; 4], // rgb = emissive_factor, w = has_emissive_texture (0/1) + pub(super) base_color: [f32; 4], // rgb = factor, w = has_base_texture (0/1) + pub(super) emissive: [f32; 4], // rgb = emissive_factor, w = has_emissive_texture (0/1) } /// Ticket 013 V2 — orthographic projection for the 6 signed axes. @@ -673,44 +864,44 @@ pub(super) fn build_card_ortho_v2(face_axis: u32, bmin: [f32; 3], bmax: [f32; 3] // +X → project onto YZ; clip.x = y, clip.y = z. 0 => [ [0.0, 0.0, 0.0, 0.0], - [2.0/wy, 0.0, 0.0, 0.0], - [0.0, 2.0/wz, 0.0, 0.0], - [-cy/wy, -cz/wz, 0.0, 1.0], + [2.0 / wy, 0.0, 0.0, 0.0], + [0.0, 2.0 / wz, 0.0, 0.0], + [-cy / wy, -cz / wz, 0.0, 1.0], ], // -X → flip u so the -X view is the mirror of +X. clip.x = -y, clip.y = z. 1 => [ [0.0, 0.0, 0.0, 0.0], - [-2.0/wy, 0.0, 0.0, 0.0], - [0.0, 2.0/wz, 0.0, 0.0], - [cy/wy, -cz/wz, 0.0, 1.0], + [-2.0 / wy, 0.0, 0.0, 0.0], + [0.0, 2.0 / wz, 0.0, 0.0], + [cy / wy, -cz / wz, 0.0, 1.0], ], // +Y → project onto XZ; clip.x = x, clip.y = z. 2 => [ - [2.0/wx, 0.0, 0.0, 0.0], + [2.0 / wx, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], - [0.0, 2.0/wz, 0.0, 0.0], - [-cx/wx, -cz/wz, 0.0, 1.0], + [0.0, 2.0 / wz, 0.0, 0.0], + [-cx / wx, -cz / wz, 0.0, 1.0], ], // -Y → flip u; clip.x = -x, clip.y = z. 3 => [ - [-2.0/wx, 0.0, 0.0, 0.0], + [-2.0 / wx, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], - [0.0, 2.0/wz, 0.0, 0.0], - [cx/wx, -cz/wz, 0.0, 1.0], + [0.0, 2.0 / wz, 0.0, 0.0], + [cx / wx, -cz / wz, 0.0, 1.0], ], // +Z → project onto XY; clip.x = x, clip.y = y. 4 => [ - [2.0/wx, 0.0, 0.0, 0.0], - [0.0, 2.0/wy, 0.0, 0.0], + [2.0 / wx, 0.0, 0.0, 0.0], + [0.0, 2.0 / wy, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], - [-cx/wx, -cy/wy, 0.0, 1.0], + [-cx / wx, -cy / wy, 0.0, 1.0], ], // -Z → flip u; clip.x = -x, clip.y = y. _ => [ - [-2.0/wx, 0.0, 0.0, 0.0], - [0.0, 2.0/wy, 0.0, 0.0], + [-2.0 / wx, 0.0, 0.0, 0.0], + [0.0, 2.0 / wy, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], - [cx/wx, -cy/wy, 0.0, 1.0], + [cx / wx, -cy / wy, 0.0, 1.0], ], } } @@ -735,6 +926,8 @@ pub(super) struct SdfClipmapBakeJob { pub(super) origin: [f32; 3], pub(super) aabb_min: [f32; 4], pub(super) aabb_max: [f32; 4], + pub(super) scene_version: u64, + pub(super) transparent_gi: bool, pub(super) uniform: wgpu::Buffer, pub(super) bind_group: wgpu::BindGroup, pub(super) next_z: u32, @@ -831,11 +1024,11 @@ pub(super) struct InstanceGiDataCpu { /// `card_slot.w` = flag (1.0 = card captured, 0.0 = no card → fall /// back to `albedo` flat value). pub(super) card_slot: [f32; 4], - /// Object-space AABB min (xyz) + unused pad (w). The HW paths + /// Object-space AABB min (xyz) + transmission absorption R (w). The HW paths /// transform hits into object space (hit.world_to_object) and /// compare against THESE — do not world-ify them. pub(super) card_aabb_min: [f32; 4], - /// Object-space AABB max (xyz) + unused pad (w). + /// Object-space AABB max (xyz) + transmission absorption G (w). pub(super) card_aabb_max: [f32; 4], /// EN-023 — WORLD-space AABB min/max. The SDF trace has no /// world_to_object (it marches a world-space clipmap), so its @@ -843,7 +1036,9 @@ pub(super) struct InstanceGiDataCpu { /// object-space-only bounds, every transformed instance fell /// through to the flat-gray analytic fallback — zero colored /// bounce on non-RT adapters (round-2 audit F4). + /// `world_aabb_min.w` stores transmission absorption B. pub(super) world_aabb_min: [f32; 4], + /// `world_aabb_max.w` stores BLEND coverage (1 for OPAQUE/MASK). pub(super) world_aabb_max: [f32; 4], /// PT-2 — geometry window into the PT megabuffers + texture id. /// x = first vertex (Vertex3D-stride slot) in pt_geo_vertices, @@ -852,7 +1047,9 @@ pub(super) struct InstanceGiDataCpu { /// z == 0 marks "no geometry window" (kernel falls back to the /// flat normal + card albedo, i.e. PT-1 behaviour). pub(super) geo: [u32; 4], - /// PT-2 — x = roughness, y = metalness, z/w unused. + /// PT-2 — x = roughness, y = metalness. Transparent GI reuses the + /// formerly-unused lanes: z = dielectric transmission weight, + /// w = normal-incidence Fresnel pass fraction. pub(super) mat_params: [f32; 4], } @@ -939,3 +1136,51 @@ pub(super) struct CompositeParams { /// y = sharpen strength, zw padding. pub(super) misc: [f32; 4], } +#[cfg(test)] +mod physical_uv_tests { + use super::*; + use crate::models::{MaterialTextureBinding, MaterialTextureTransform, MaterialTransmission}; + + fn binding(tex_coord: u32) -> MaterialTextureBinding { + MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: Some(1), + transform: MaterialTextureTransform { + tex_coord, + ..Default::default() + }, + } + } + + #[test] + fn secondary_uv_keeps_established_vertex_and_uniform_abis() { + assert_eq!(std::mem::size_of::(), 96); + assert_eq!(std::mem::size_of::(), 96); + let layout = secondary_uv_desc(); + assert_eq!(layout.array_stride, 8); + assert_eq!(layout.attributes.len(), 1); + assert_eq!(layout.attributes[0].shader_location, 7); + } + + #[test] + fn probe_header_matches_shader_storage_abi() { + assert_eq!(std::mem::size_of::(), 48); + } + + #[test] + fn physical_uniform_carries_independent_texture_uv_selectors() { + let transmission = MaterialTransmission { + authored: true, + factor: 1.0, + texture: Some(binding(1)), + thickness_texture: Some(binding(0)), + ..Default::default() + }; + let uniforms = SceneTransmissionUniforms::new(transmission, true, true); + assert_eq!(uniforms.transmission_rotation[3], 1.0); + assert_eq!(uniforms.thickness_rotation[2], 0.0); + assert_eq!(uniforms.transmission[3], 1.0); + assert_eq!(uniforms.transmission_rotation[2], 1.0); + } +} diff --git a/native/shared/src/renderer/util.rs b/native/shared/src/renderer/util.rs index 19337360..454263d0 100644 --- a/native/shared/src/renderer/util.rs +++ b/native/shared/src/renderer/util.rs @@ -30,7 +30,14 @@ pub fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [[f32; 4 ] } -pub fn mat4_ortho(left: f32, right: f32, bottom: f32, top: f32, near: f32, far: f32) -> [[f32; 4]; 4] { +pub fn mat4_ortho( + left: f32, + right: f32, + bottom: f32, + top: f32, + near: f32, + far: f32, +) -> [[f32; 4]; 4] { // wgpu NDC z range is [0, 1] (not OpenGL's [-1, 1]). Matching // this so shadow-map fragments at near half-depth don't get // clipped and sample_shadow's in-frustum test (z in [0, 1]) @@ -41,7 +48,7 @@ pub fn mat4_ortho(left: f32, right: f32, bottom: f32, top: f32, near: f32, far: [ [-2.0 * lr, 0.0, 0.0, 0.0], [0.0, -2.0 * bt, 0.0, 0.0], - [0.0, 0.0, nf, 0.0], + [0.0, 0.0, nf, 0.0], [(left + right) * lr, (top + bottom) * bt, near * nf, 1.0], ] } @@ -50,14 +57,14 @@ pub fn mat4_look_at(eye: [f32; 3], center: [f32; 3], up: [f32; 3]) -> [[f32; 4]; let fx = center[0] - eye[0]; let fy = center[1] - eye[1]; let fz = center[2] - eye[2]; - let flen = (fx*fx + fy*fy + fz*fz).sqrt(); - let (fx, fy, fz) = (fx/flen, fy/flen, fz/flen); + let flen = (fx * fx + fy * fy + fz * fz).sqrt(); + let (fx, fy, fz) = (fx / flen, fy / flen, fz / flen); let sx = fy * up[2] - fz * up[1]; let sy = fz * up[0] - fx * up[2]; let sz = fx * up[1] - fy * up[0]; - let slen = (sx*sx + sy*sy + sz*sz).sqrt(); - let (sx, sy, sz) = (sx/slen, sy/slen, sz/slen); + let slen = (sx * sx + sy * sy + sz * sz).sqrt(); + let (sx, sy, sz) = (sx / slen, sy / slen, sz / slen); let ux = sy * fz - sz * fy; let uy = sz * fx - sx * fz; @@ -67,7 +74,12 @@ pub fn mat4_look_at(eye: [f32; 3], center: [f32; 3], up: [f32; 3]) -> [[f32; 4]; [sx, ux, -fx, 0.0], [sy, uy, -fy, 0.0], [sz, uz, -fz, 0.0], - [-(sx*eye[0]+sy*eye[1]+sz*eye[2]), -(ux*eye[0]+uy*eye[1]+uz*eye[2]), fx*eye[0]+fy*eye[1]+fz*eye[2], 1.0], + [ + -(sx * eye[0] + sy * eye[1] + sz * eye[2]), + -(ux * eye[0] + uy * eye[1] + uz * eye[2]), + fx * eye[0] + fy * eye[1] + fz * eye[2], + 1.0, + ], ] } @@ -75,7 +87,10 @@ pub fn mat4_multiply(a: [[f32; 4]; 4], b: [[f32; 4]; 4]) -> [[f32; 4]; 4] { let mut out = [[0.0f32; 4]; 4]; for col in 0..4 { for row in 0..4 { - out[col][row] = a[0][row]*b[col][0] + a[1][row]*b[col][1] + a[2][row]*b[col][2] + a[3][row]*b[col][3]; + out[col][row] = a[0][row] * b[col][0] + + a[1][row] * b[col][1] + + a[2][row] * b[col][2] + + a[3][row] * b[col][3]; } } out @@ -84,51 +99,123 @@ pub fn mat4_multiply(a: [[f32; 4]; 4], b: [[f32; 4]; 4]) -> [[f32; 4]; 4] { /// Multiply a column-major 4x4 matrix by a column vector. pub fn mat4_mul_vec4(m: &[[f32; 4]; 4], v: &[f32; 4]) -> [f32; 4] { [ - m[0][0]*v[0] + m[1][0]*v[1] + m[2][0]*v[2] + m[3][0]*v[3], - m[0][1]*v[0] + m[1][1]*v[1] + m[2][1]*v[2] + m[3][1]*v[3], - m[0][2]*v[0] + m[1][2]*v[1] + m[2][2]*v[2] + m[3][2]*v[3], - m[0][3]*v[0] + m[1][3]*v[1] + m[2][3]*v[2] + m[3][3]*v[3], + m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2] + m[3][0] * v[3], + m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2] + m[3][1] * v[3], + m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2] + m[3][2] * v[3], + m[0][3] * v[0] + m[1][3] * v[1] + m[2][3] * v[2] + m[3][3] * v[3], ] } pub fn mat4_translate(m: [[f32; 4]; 4], v: [f32; 3]) -> [[f32; 4]; 4] { let mut out = m; for i in 0..4 { - out[3][i] += m[0][i]*v[0] + m[1][i]*v[1] + m[2][i]*v[2]; + out[3][i] += m[0][i] * v[0] + m[1][i] * v[1] + m[2][i] * v[2]; } out } pub fn mat4_scale(m: [[f32; 4]; 4], v: [f32; 3]) -> [[f32; 4]; 4] { let mut out = m; - for i in 0..4 { out[0][i] *= v[0]; } - for i in 0..4 { out[1][i] *= v[1]; } - for i in 0..4 { out[2][i] *= v[2]; } + for i in 0..4 { + out[0][i] *= v[0]; + } + for i in 0..4 { + out[1][i] *= v[1]; + } + for i in 0..4 { + out[2][i] *= v[2]; + } out } pub fn mat4_invert(m: [[f32; 4]; 4]) -> [[f32; 4]; 4] { let m = |r: usize, c: usize| m[c][r]; // accessor for row-major style let mut inv = [0.0f32; 16]; - inv[0] = m(1,1)*m(2,2)*m(3,3) - m(1,1)*m(2,3)*m(3,2) - m(2,1)*m(1,2)*m(3,3) + m(2,1)*m(1,3)*m(3,2) + m(3,1)*m(1,2)*m(2,3) - m(3,1)*m(1,3)*m(2,2); - inv[4] = -m(1,0)*m(2,2)*m(3,3) + m(1,0)*m(2,3)*m(3,2) + m(2,0)*m(1,2)*m(3,3) - m(2,0)*m(1,3)*m(3,2) - m(3,0)*m(1,2)*m(2,3) + m(3,0)*m(1,3)*m(2,2); - inv[8] = m(1,0)*m(2,1)*m(3,3) - m(1,0)*m(2,3)*m(3,1) - m(2,0)*m(1,1)*m(3,3) + m(2,0)*m(1,3)*m(3,1) + m(3,0)*m(1,1)*m(2,3) - m(3,0)*m(1,3)*m(2,1); - inv[12] = -m(1,0)*m(2,1)*m(3,2) + m(1,0)*m(2,2)*m(3,1) + m(2,0)*m(1,1)*m(3,2) - m(2,0)*m(1,2)*m(3,1) - m(3,0)*m(1,1)*m(2,2) + m(3,0)*m(1,2)*m(2,1); - inv[1] = -m(0,1)*m(2,2)*m(3,3) + m(0,1)*m(2,3)*m(3,2) + m(2,1)*m(0,2)*m(3,3) - m(2,1)*m(0,3)*m(3,2) - m(3,1)*m(0,2)*m(2,3) + m(3,1)*m(0,3)*m(2,2); - inv[5] = m(0,0)*m(2,2)*m(3,3) - m(0,0)*m(2,3)*m(3,2) - m(2,0)*m(0,2)*m(3,3) + m(2,0)*m(0,3)*m(3,2) + m(3,0)*m(0,2)*m(2,3) - m(3,0)*m(0,3)*m(2,2); - inv[9] = -m(0,0)*m(2,1)*m(3,3) + m(0,0)*m(2,3)*m(3,1) + m(2,0)*m(0,1)*m(3,3) - m(2,0)*m(0,3)*m(3,1) - m(3,0)*m(0,1)*m(2,3) + m(3,0)*m(0,3)*m(2,1); - inv[13] = m(0,0)*m(2,1)*m(3,2) - m(0,0)*m(2,2)*m(3,1) - m(2,0)*m(0,1)*m(3,2) + m(2,0)*m(0,2)*m(3,1) + m(3,0)*m(0,1)*m(2,2) - m(3,0)*m(0,2)*m(2,1); - inv[2] = m(0,1)*m(1,2)*m(3,3) - m(0,1)*m(1,3)*m(3,2) - m(1,1)*m(0,2)*m(3,3) + m(1,1)*m(0,3)*m(3,2) + m(3,1)*m(0,2)*m(1,3) - m(3,1)*m(0,3)*m(1,2); - inv[6] = -m(0,0)*m(1,2)*m(3,3) + m(0,0)*m(1,3)*m(3,2) + m(1,0)*m(0,2)*m(3,3) - m(1,0)*m(0,3)*m(3,2) - m(3,0)*m(0,2)*m(1,3) + m(3,0)*m(0,3)*m(1,2); - inv[10] = m(0,0)*m(1,1)*m(3,3) - m(0,0)*m(1,3)*m(3,1) - m(1,0)*m(0,1)*m(3,3) + m(1,0)*m(0,3)*m(3,1) + m(3,0)*m(0,1)*m(1,3) - m(3,0)*m(0,3)*m(1,1); - inv[14] = -m(0,0)*m(1,1)*m(3,2) + m(0,0)*m(1,2)*m(3,1) + m(1,0)*m(0,1)*m(3,2) - m(1,0)*m(0,2)*m(3,1) - m(3,0)*m(0,1)*m(1,2) + m(3,0)*m(0,2)*m(1,1); - inv[3] = -m(0,1)*m(1,2)*m(2,3) + m(0,1)*m(1,3)*m(2,2) + m(1,1)*m(0,2)*m(2,3) - m(1,1)*m(0,3)*m(2,2) - m(2,1)*m(0,2)*m(1,3) + m(2,1)*m(0,3)*m(1,2); - inv[7] = m(0,0)*m(1,2)*m(2,3) - m(0,0)*m(1,3)*m(2,2) - m(1,0)*m(0,2)*m(2,3) + m(1,0)*m(0,3)*m(2,2) + m(2,0)*m(0,2)*m(1,3) - m(2,0)*m(0,3)*m(1,2); - inv[11] = -m(0,0)*m(1,1)*m(2,3) + m(0,0)*m(1,3)*m(2,1) + m(1,0)*m(0,1)*m(2,3) - m(1,0)*m(0,3)*m(2,1) - m(2,0)*m(0,1)*m(1,3) + m(2,0)*m(0,3)*m(1,1); - inv[15] = m(0,0)*m(1,1)*m(2,2) - m(0,0)*m(1,2)*m(2,1) - m(1,0)*m(0,1)*m(2,2) + m(1,0)*m(0,2)*m(2,1) + m(2,0)*m(0,1)*m(1,2) - m(2,0)*m(0,2)*m(1,1); + inv[0] = + m(1, 1) * m(2, 2) * m(3, 3) - m(1, 1) * m(2, 3) * m(3, 2) - m(2, 1) * m(1, 2) * m(3, 3) + + m(2, 1) * m(1, 3) * m(3, 2) + + m(3, 1) * m(1, 2) * m(2, 3) + - m(3, 1) * m(1, 3) * m(2, 2); + inv[4] = + -m(1, 0) * m(2, 2) * m(3, 3) + m(1, 0) * m(2, 3) * m(3, 2) + m(2, 0) * m(1, 2) * m(3, 3) + - m(2, 0) * m(1, 3) * m(3, 2) + - m(3, 0) * m(1, 2) * m(2, 3) + + m(3, 0) * m(1, 3) * m(2, 2); + inv[8] = + m(1, 0) * m(2, 1) * m(3, 3) - m(1, 0) * m(2, 3) * m(3, 1) - m(2, 0) * m(1, 1) * m(3, 3) + + m(2, 0) * m(1, 3) * m(3, 1) + + m(3, 0) * m(1, 1) * m(2, 3) + - m(3, 0) * m(1, 3) * m(2, 1); + inv[12] = + -m(1, 0) * m(2, 1) * m(3, 2) + m(1, 0) * m(2, 2) * m(3, 1) + m(2, 0) * m(1, 1) * m(3, 2) + - m(2, 0) * m(1, 2) * m(3, 1) + - m(3, 0) * m(1, 1) * m(2, 2) + + m(3, 0) * m(1, 2) * m(2, 1); + inv[1] = + -m(0, 1) * m(2, 2) * m(3, 3) + m(0, 1) * m(2, 3) * m(3, 2) + m(2, 1) * m(0, 2) * m(3, 3) + - m(2, 1) * m(0, 3) * m(3, 2) + - m(3, 1) * m(0, 2) * m(2, 3) + + m(3, 1) * m(0, 3) * m(2, 2); + inv[5] = + m(0, 0) * m(2, 2) * m(3, 3) - m(0, 0) * m(2, 3) * m(3, 2) - m(2, 0) * m(0, 2) * m(3, 3) + + m(2, 0) * m(0, 3) * m(3, 2) + + m(3, 0) * m(0, 2) * m(2, 3) + - m(3, 0) * m(0, 3) * m(2, 2); + inv[9] = + -m(0, 0) * m(2, 1) * m(3, 3) + m(0, 0) * m(2, 3) * m(3, 1) + m(2, 0) * m(0, 1) * m(3, 3) + - m(2, 0) * m(0, 3) * m(3, 1) + - m(3, 0) * m(0, 1) * m(2, 3) + + m(3, 0) * m(0, 3) * m(2, 1); + inv[13] = + m(0, 0) * m(2, 1) * m(3, 2) - m(0, 0) * m(2, 2) * m(3, 1) - m(2, 0) * m(0, 1) * m(3, 2) + + m(2, 0) * m(0, 2) * m(3, 1) + + m(3, 0) * m(0, 1) * m(2, 2) + - m(3, 0) * m(0, 2) * m(2, 1); + inv[2] = + m(0, 1) * m(1, 2) * m(3, 3) - m(0, 1) * m(1, 3) * m(3, 2) - m(1, 1) * m(0, 2) * m(3, 3) + + m(1, 1) * m(0, 3) * m(3, 2) + + m(3, 1) * m(0, 2) * m(1, 3) + - m(3, 1) * m(0, 3) * m(1, 2); + inv[6] = + -m(0, 0) * m(1, 2) * m(3, 3) + m(0, 0) * m(1, 3) * m(3, 2) + m(1, 0) * m(0, 2) * m(3, 3) + - m(1, 0) * m(0, 3) * m(3, 2) + - m(3, 0) * m(0, 2) * m(1, 3) + + m(3, 0) * m(0, 3) * m(1, 2); + inv[10] = + m(0, 0) * m(1, 1) * m(3, 3) - m(0, 0) * m(1, 3) * m(3, 1) - m(1, 0) * m(0, 1) * m(3, 3) + + m(1, 0) * m(0, 3) * m(3, 1) + + m(3, 0) * m(0, 1) * m(1, 3) + - m(3, 0) * m(0, 3) * m(1, 1); + inv[14] = + -m(0, 0) * m(1, 1) * m(3, 2) + m(0, 0) * m(1, 2) * m(3, 1) + m(1, 0) * m(0, 1) * m(3, 2) + - m(1, 0) * m(0, 2) * m(3, 1) + - m(3, 0) * m(0, 1) * m(1, 2) + + m(3, 0) * m(0, 2) * m(1, 1); + inv[3] = + -m(0, 1) * m(1, 2) * m(2, 3) + m(0, 1) * m(1, 3) * m(2, 2) + m(1, 1) * m(0, 2) * m(2, 3) + - m(1, 1) * m(0, 3) * m(2, 2) + - m(2, 1) * m(0, 2) * m(1, 3) + + m(2, 1) * m(0, 3) * m(1, 2); + inv[7] = + m(0, 0) * m(1, 2) * m(2, 3) - m(0, 0) * m(1, 3) * m(2, 2) - m(1, 0) * m(0, 2) * m(2, 3) + + m(1, 0) * m(0, 3) * m(2, 2) + + m(2, 0) * m(0, 2) * m(1, 3) + - m(2, 0) * m(0, 3) * m(1, 2); + inv[11] = + -m(0, 0) * m(1, 1) * m(2, 3) + m(0, 0) * m(1, 3) * m(2, 1) + m(1, 0) * m(0, 1) * m(2, 3) + - m(1, 0) * m(0, 3) * m(2, 1) + - m(2, 0) * m(0, 1) * m(1, 3) + + m(2, 0) * m(0, 3) * m(1, 1); + inv[15] = + m(0, 0) * m(1, 1) * m(2, 2) - m(0, 0) * m(1, 2) * m(2, 1) - m(1, 0) * m(0, 1) * m(2, 2) + + m(1, 0) * m(0, 2) * m(2, 1) + + m(2, 0) * m(0, 1) * m(1, 2) + - m(2, 0) * m(0, 2) * m(1, 1); - let det = m(0,0)*inv[0] + m(0,1)*inv[4] + m(0,2)*inv[8] + m(0,3)*inv[12]; - if det.abs() < 1e-10 { return IDENTITY_MAT4; } + let det = m(0, 0) * inv[0] + m(0, 1) * inv[4] + m(0, 2) * inv[8] + m(0, 3) * inv[12]; + if det.abs() < 1e-10 { + return IDENTITY_MAT4; + } let inv_det = 1.0 / det; let mut out = [[0.0f32; 4]; 4]; for col in 0..4 { diff --git a/native/shared/src/renderer/weighted_transparency.rs b/native/shared/src/renderer/weighted_transparency.rs new file mode 100644 index 00000000..0e536d9e --- /dev/null +++ b/native/shared/src/renderer/weighted_transparency.rs @@ -0,0 +1,396 @@ +use super::*; + +/// User-selected conventional-transparency composition policy. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub(super) enum TransparencyCompositionPreference { + Sorted, + Auto, + Weighted, +} + +impl TransparencyCompositionPreference { + pub(super) fn from_code(code: u32) -> Self { + match code { + 0 => Self::Sorted, + 2 => Self::Weighted, + _ => Self::Auto, + } + } + + pub(super) fn code(self) -> u32 { + match self { + Self::Sorted => 0, + Self::Auto => 1, + Self::Weighted => 2, + } + } + + pub(super) fn from_environment() -> Self { + match std::env::var("BLOOM_TRANSPARENCY") + .unwrap_or_else(|_| "auto".to_owned()) + .trim() + .to_ascii_lowercase() + .as_str() + { + "sorted" | "off" | "0" => Self::Sorted, + "weighted" | "oit" | "2" => Self::Weighted, + _ => Self::Auto, + } + } +} + +/// Auto keeps the exact sorted-alpha path for ordinary scenes and only pays +/// for OIT when a frame submits a genuinely high-count imported BLEND set. +pub(super) const WEIGHTED_TRANSPARENCY_AUTO_DRAW_THRESHOLD: usize = 64; + +fn weighted_transparency_selected( + preference: TransparencyCompositionPreference, + visible_draw_count: usize, +) -> bool { + match preference { + TransparencyCompositionPreference::Sorted => false, + TransparencyCompositionPreference::Auto => { + visible_draw_count >= WEIGHTED_TRANSPARENCY_AUTO_DRAW_THRESHOLD + } + TransparencyCompositionPreference::Weighted => visible_draw_count > 0, + } +} + +impl Renderer { + /// Configure conventional imported-transparency composition: + /// 0 = sorted, 1 = automatic high-count OIT, 2 = force weighted OIT. + pub fn set_transparency_composition_mode(&mut self, mode: u32) { + self.transparency_composition_preference = + TransparencyCompositionPreference::from_code(mode); + } + + pub fn transparency_composition_mode_code(&self) -> u32 { + self.transparency_composition_preference.code() + } + + /// Route selected for the most recently prepared/current frame: + /// 0 = deterministic sorted alpha, 1 = weighted-blended OIT. + pub fn active_transparency_composition_mode_code(&self) -> u32 { + u32::from(self.weighted_transparency_active) + } + + fn imported_transparent_draw_count(&self, scene: &crate::scene::SceneGraph) -> usize { + let cached = if self.has_blend_model_draws { + let camera_vp = mat4_multiply( + self.current_proj_matrix_unjittered, + self.current_view_matrix, + ); + let camera_planes = crate::scene::extract_frustum_planes(&camera_vp); + self.model_draw_commands + .iter() + .filter_map(|command| { + let mesh = self + .model_gpu_cache + .get(&command.cache_handle) + .and_then(|meshes| meshes.as_ref()) + .and_then(|meshes| meshes.get(command.mesh_idx))?; + if mesh.alpha_mode != MaterialAlphaMode::Blend + || (self.imported_refraction_enabled && mesh.transmission.is_active()) + { + return None; + } + let (world_min, world_max) = command.bounds_override.unwrap_or_else(|| { + transform_aabb(&command.model, mesh.local_min, mesh.local_max) + }); + if world_min[0] <= world_max[0] + && crate::scene::aabb_outside_frustum(&camera_planes, world_min, world_max) + { + return None; + } + Some(()) + }) + .count() + } else { + 0 + }; + cached + scene.visible_transparent_node_count(self.imported_refraction_enabled) + } + + fn imported_refractive_draw_count(&self, scene: &crate::scene::SceneGraph) -> usize { + if !self.imported_refraction_enabled { + return 0; + } + let cached = if self.has_refractive_model_draws { + let camera_vp = mat4_multiply( + self.current_proj_matrix_unjittered, + self.current_view_matrix, + ); + let camera_planes = crate::scene::extract_frustum_planes(&camera_vp); + self.model_draw_commands + .iter() + .filter_map(|command| { + let mesh = self + .model_gpu_cache + .get(&command.cache_handle) + .and_then(|meshes| meshes.as_ref()) + .and_then(|meshes| meshes.get(command.mesh_idx))?; + if !mesh.transmission.is_active() || mesh.refractive_material_bg.is_none() { + return None; + } + let (world_min, world_max) = command.bounds_override.unwrap_or_else(|| { + transform_aabb(&command.model, mesh.local_min, mesh.local_max) + }); + if world_min[0] <= world_max[0] + && crate::scene::aabb_outside_frustum(&camera_planes, world_min, world_max) + { + return None; + } + Some(()) + }) + .count() + } else { + 0 + }; + cached + scene.visible_refractive_node_count() + } + + /// Select both imported-transparency routes from one visible BLEND scan. + /// Reactive coverage additionally considers visible physical transmission, + /// but only while TAA can consume the result. + pub(super) fn select_transparency_routes( + &self, + scene: &crate::scene::SceneGraph, + ) -> (bool, bool) { + let transparent_draw_count = if self.has_blend_model_draws || scene.has_transparent_nodes() + { + self.imported_transparent_draw_count(scene) + } else { + 0 + }; + let weighted = weighted_transparency_selected( + self.transparency_composition_preference, + transparent_draw_count, + ); + let reactive = temporal_reactive::temporal_reactive_selected( + self.taa_enabled, + transparent_draw_count, + || self.material_system.has_temporal_reactive_commands(), + || self.imported_refractive_draw_count(scene), + ); + (weighted, reactive) + } + + /// Compile weighted accumulation + resolve pipelines on first activation. + /// Sorted-only and opaque applications retain the established startup and + /// memory footprint. + pub(super) fn ensure_weighted_transparency_resources(&mut self) { + if self.scene_weighted_transparent_pipeline.is_some() { + return; + } + + let source = scene_weighted_transparency_shader_source(&specialized_scene_shader_source( + self.froxel.is_some(), + self.shadow_map.virtual_map.requested(), + )); + let shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_weighted_transparency_shader"), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + let scene_layout = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("scene_weighted_transparency_pipeline_layout"), + bind_group_layouts: &[ + Some(&self.uniform_3d_layout), + Some(&self.lighting_layout), + Some(&self.scene_material_layout), + Some(&self.joint_layout), + ], + immediate_size: 0, + }); + let accumulation_blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + }; + let revealage_blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Zero, + dst_factor: wgpu::BlendFactor::OneMinusSrc, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Zero, + dst_factor: wgpu::BlendFactor::OneMinusSrc, + operation: wgpu::BlendOperation::Add, + }, + }; + let create_accumulation_pipeline = |label: &'static str, cull_mode: Option| { + self.device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&scene_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main_scene"), + buffers: &[Vertex3D::desc()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_weighted_transparent_scene"), + targets: &[ + Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba16Float, + blend: Some(accumulation_blend), + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::R16Float, + blend: Some(revealage_blend), + write_mask: wgpu::ColorWrites::RED, + }), + ], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) + }; + let single_sided = create_accumulation_pipeline( + "scene_weighted_transparency_pipeline", + Some(wgpu::Face::Back), + ); + let double_sided = + create_accumulation_pipeline("scene_weighted_transparency_double_sided_pipeline", None); + + let resolve_layout = + self.device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("weighted_transparency_resolve_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + ], + }); + let resolve_shader = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("weighted_transparency_resolve_shader"), + source: wgpu::ShaderSource::Wgsl(WEIGHTED_TRANSPARENCY_RESOLVE_SHADER.into()), + }); + let resolve_pipeline_layout = + self.device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("weighted_transparency_resolve_pipeline_layout"), + bind_group_layouts: &[Some(&resolve_layout)], + immediate_size: 0, + }); + let resolve_pipeline = + self.device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("weighted_transparency_resolve_pipeline"), + layout: Some(&resolve_pipeline_layout), + vertex: wgpu::VertexState { + module: &resolve_shader, + entry_point: Some("vs_weighted_transparency_resolve"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &resolve_shader, + entry_point: Some("fs_weighted_transparency_resolve"), + targets: &[Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + self.scene_weighted_transparent_pipeline = Some(single_sided); + self.scene_weighted_transparent_double_sided_pipeline = Some(double_sided); + self.weighted_transparency_resolve_pipeline = Some(resolve_pipeline); + self.weighted_transparency_resolve_layout = Some(resolve_layout); + self.created_pipelines(3); + log::info!( + "bloom materials: weighted transparency initialized \ + (auto_threshold={}, accumulation=rgba16float, revealage=r16float)", + WEIGHTED_TRANSPARENCY_AUTO_DRAW_THRESHOLD + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hybrid_threshold_preserves_simple_sorted_sets() { + assert!(!weighted_transparency_selected( + TransparencyCompositionPreference::Auto, + WEIGHTED_TRANSPARENCY_AUTO_DRAW_THRESHOLD - 1, + )); + assert!(weighted_transparency_selected( + TransparencyCompositionPreference::Auto, + WEIGHTED_TRANSPARENCY_AUTO_DRAW_THRESHOLD, + )); + assert!(!weighted_transparency_selected( + TransparencyCompositionPreference::Sorted, + usize::MAX, + )); + assert!(!weighted_transparency_selected( + TransparencyCompositionPreference::Weighted, + 0, + )); + assert!(weighted_transparency_selected( + TransparencyCompositionPreference::Weighted, + 1, + )); + } +} diff --git a/native/shared/src/scene.rs b/native/shared/src/scene.rs index 81de5a08..5e18aead 100644 --- a/native/shared/src/scene.rs +++ b/native/shared/src/scene.rs @@ -4,9 +4,16 @@ //! persistent meshes that survive across frames. Systems update geometry and //! transforms; the renderer draws all visible nodes each frame automatically. -use wgpu::util::DeviceExt; use crate::handles::HandleRegistry; -use crate::renderer::Vertex3D; +use crate::models::{MaterialAlphaMode, MaterialLayeredPbr, MaterialTransmission}; +use crate::renderer::{ + gpu_driven::{GeometrySlice, GpuDrawRecord}, + material_indirection::MaterialId, + ImportedIridescenceDrawRef, ImportedRefractiveDrawRef, ImportedTransparentDrawRef, MeshDrawRef, + Uniforms3D, Vertex3D, +}; +use std::collections::HashMap; +use wgpu::util::DeviceExt; // ============================================================ // PBR Material @@ -20,10 +27,16 @@ pub struct PbrMaterial { pub opacity: f32, pub emissive: [f32; 3], pub double_sided: bool, + pub alpha_mode: MaterialAlphaMode, + pub transmission: MaterialTransmission, + pub layered_pbr: MaterialLayeredPbr, /// glTF MASK alpha cutoff. 0.0 = OPAQUE. Non-zero routes the node /// through the scene shader's alpha-cutout path (discard below the /// cutoff, two-sided foliage shading, wind sway). pub alpha_cutoff: f32, + /// Lower base-color mips store MASK survival coverage rather than raw + /// opacity. The shader uses this only during minification. + pub alpha_coverage_mips: bool, pub texture_idx: u32, /// Normal-map texture. 0 means "no normal map" — scene shader falls /// back to the geometric normal. Stored as a texture index rather @@ -44,7 +57,11 @@ impl Default for PbrMaterial { opacity: 1.0, emissive: [0.0, 0.0, 0.0], double_sided: false, + alpha_mode: MaterialAlphaMode::Opaque, + transmission: MaterialTransmission::default(), + layered_pbr: MaterialLayeredPbr::default(), alpha_cutoff: 0.0, + alpha_coverage_mips: false, texture_idx: 0, normal_texture_idx: 0, metallic_roughness_texture_idx: 0, @@ -54,13 +71,27 @@ impl Default for PbrMaterial { } } +impl PbrMaterial { + /// True only when the dielectric transmission lobe retains non-zero + /// energy after metallic suppression. Camera refraction still keeps an + /// authored all-metal material in its established material bucket, but GI + /// must treat it as opaque so an opaque-only continuation cannot skip it. + pub(crate) fn has_gi_transmission(&self) -> bool { + if !self.transmission.is_active() { + return false; + } + let dielectric_weight = 1.0 - self.metalness.clamp(0.0, 1.0); + dielectric_weight.is_finite() && dielectric_weight > 0.0 + } +} + // ============================================================ // Scene Node Uniforms (matches Uniforms3D in renderer) // ============================================================ #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -struct NodeUniforms { +pub(crate) struct NodeUniforms { mvp: [[f32; 4]; 4], model: [[f32; 4]; 4], prev_mvp: [[f32; 4]; 4], @@ -79,6 +110,7 @@ struct NodeUniforms { pub struct SceneNode { // Geometry (CPU-side, updated by systems) pub vertices: Vec, + pub(crate) secondary_tex_coords: Option>, pub indices: Vec, // Material pub material: PbrMaterial, @@ -119,7 +151,16 @@ pub struct SceneNode { pub world_bounds_max: [f32; 3], // GPU resources (lazily created) pub gpu_vb: Option, + pub(crate) gpu_secondary_uv_vb: Option, pub gpu_ib: Option, + /// Deduplicated geometry used only by the GPU-driven main/depth path. + /// Compatibility consumers retain `gpu_vb`/`gpu_ib`, so shadows, + /// reflections, GI, LODs and ray tracing remain byte-for-byte unchanged. + pub(crate) gpu_geometry: Option, + gpu_geometry_key: Option, + /// Stable global material-table entry used by GPU-driven draws. + pub(crate) gpu_material_id: MaterialId, + gpu_material_key: Option, pub gpu_index_count: u32, /// Vertex count, cached at VB upload so the ray-tracing BLAS build /// can reference it without re-reading the full `vertices` Vec. @@ -185,6 +226,14 @@ pub struct SceneNode { /// changes (tracked via `mat_dirty`). pub gpu_material_bg: Option, pub gpu_material_uniform_buf: Option, + pub(crate) gpu_refractive_material_bg: Option, + gpu_refractive_uniform_buf: Option, + gpu_refractive_layered_uniform_buf: Option, + pub(crate) gpu_refractive_layered: bool, + pub(crate) gpu_refractive_uses_uv1: bool, + pub(crate) gpu_layered_material_bg: Option, + gpu_layered_uniform_buf: Option, + pub(crate) gpu_layered_uses_uv1: bool, /// Alpha-tested shadow-caster bind group for MASK materials (base /// colour + sampler + cutoff). None for opaque casters. Built with /// the material bind group; consumed by the shadow pass's cutout @@ -206,11 +255,13 @@ pub struct SceneNode { /// One reduced-detail variant for a SceneNode. pub struct LodLevel { pub vertices: Vec, + secondary_tex_coords: Option>, pub indices: Vec, /// Use this level when the node's projected screen coverage (longest /// NDC extent of its world AABB, 0..1) drops below this value. pub max_coverage: f32, gpu_vb: Option, + gpu_secondary_uv_vb: Option, gpu_ib: Option, gpu_index_count: u32, dirty: bool, @@ -220,6 +271,7 @@ impl SceneNode { fn new() -> Self { Self { vertices: Vec::new(), + secondary_tex_coords: None, indices: Vec::new(), material: PbrMaterial::default(), transform: crate::renderer::IDENTITY_MAT4, @@ -235,7 +287,12 @@ impl SceneNode { world_bounds_min: [f32::MAX; 3], world_bounds_max: [f32::MIN; 3], gpu_vb: None, + gpu_secondary_uv_vb: None, gpu_ib: None, + gpu_geometry: None, + gpu_geometry_key: None, + gpu_material_id: MaterialId::FALLBACK, + gpu_material_key: None, gpu_index_count: 0, gpu_vertex_count: 0, blas: None, @@ -252,6 +309,14 @@ impl SceneNode { occluded: false, gpu_material_bg: None, gpu_material_uniform_buf: None, + gpu_refractive_material_bg: None, + gpu_refractive_uniform_buf: None, + gpu_refractive_layered_uniform_buf: None, + gpu_refractive_layered: false, + gpu_refractive_uses_uv1: false, + gpu_layered_material_bg: None, + gpu_layered_uniform_buf: None, + gpu_layered_uses_uv1: false, gpu_shadow_cutout_bg: None, mat_dirty: true, geo_dirty: true, @@ -270,6 +335,33 @@ impl SceneNode { /// `min_uniform_buffer_offset_alignment`. 256 is safe on every platform. const NODE_UNIFORM_STRIDE: u64 = 256; +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +struct SceneGeometryKey { + hash_a: u64, + hash_b: u64, + vertex_count: u32, + index_count: u32, +} + +#[derive(Copy, Clone)] +struct SharedSceneGeometry { + slice: GeometrySlice, + references: u32, +} + +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +struct SceneMaterialKey { + metal_rough: [u32; 4], + emissive: [u32; 4], + textures: [u32; 5], +} + +#[derive(Copy, Clone)] +struct SharedSceneMaterial { + id: MaterialId, + references: u32, +} + pub struct SceneGraph { pub nodes: HandleRegistry, /// Shared uniform buffer holding one 256B slot per scene node. All @@ -332,6 +424,24 @@ pub struct SceneGraph { /// in a per-frame budget via a compute pass. Static meshes /// never re-bake once their SDF lands. pub pending_sdf_bakes: Vec, + shared_gpu_geometry: HashMap, + retired_gpu_geometry: Vec, + shared_gpu_materials: HashMap, + retired_gpu_materials: Vec, + /// True only when the retained scene's opaque subset is both order-safe + /// and large enough to amortize compute culling plus indirect submission. + /// Dedicated BLEND nodes are submitted later by the translucent pass and + /// therefore do not disable acceleration for the opaque subset. + gpu_driven_scene_active: bool, + /// Updated while `prepare()` already visits visible nodes, so the + /// translucent-pass gate remains O(1) on opaque-only scenes. + has_transparent_nodes: bool, + has_layered_transparent_nodes: bool, + has_refractive_nodes: bool, + /// Visible physical transmission including GI-only proxy nodes. Kept + /// separate from `has_refractive_nodes`, which gates camera composition. + has_transmission_gi_nodes: bool, + imported_refraction_enabled: bool, } impl SceneGraph { @@ -352,20 +462,34 @@ impl SceneGraph { next_card_slot: 0, free_card_blocks: Vec::new(), pending_sdf_bakes: Vec::new(), + shared_gpu_geometry: HashMap::new(), + retired_gpu_geometry: Vec::new(), + shared_gpu_materials: HashMap::new(), + retired_gpu_materials: Vec::new(), + gpu_driven_scene_active: false, + has_transparent_nodes: false, + has_layered_transparent_nodes: false, + has_refractive_nodes: false, + has_transmission_gi_nodes: false, + imported_refraction_enabled: false, } } + pub(crate) fn reset_motion_history(&mut self) { + self.nodes + .iter_mut() + .for_each(|(_, n)| n.prev_transform = n.transform); + } + pub fn create_node(&mut self) -> f64 { - // New nodes default to `cast_shadow = true`, so the first - // `set_transform` + `update_geometry` will dirty shadows - // anyway. Bumping here too costs nothing and keeps the - // invalidation story simple. self.shadow_version = self.shadow_version.wrapping_add(1); self.tlas_version = self.tlas_version.wrapping_add(1); self.nodes.alloc(SceneNode::new()) } pub fn destroy_node(&mut self, handle: f64) { + let mut geometry_key = None; + let mut material_key = None; if let Some(node) = self.nodes.get(handle) { if node.visible && node.cast_shadow { self.shadow_version = self.shadow_version.wrapping_add(1); @@ -373,12 +497,26 @@ impl SceneGraph { if node.visible { self.tlas_version = self.tlas_version.wrapping_add(1); } - // Recycle the node's 6-slot card block. The freed node's GPU - // buffers/BLAS/SDF drop with the SceneNode itself (wgpu - // releases them once in-flight work completes). + // Recycle its card block; owned GPU resources drop with the node. if let Some(first) = node.card_first_slot { self.free_card_blocks.push(first); } + geometry_key = node.gpu_geometry_key; + material_key = node.gpu_material_key; + } + if let Some(key) = geometry_key { + release_scene_geometry( + &mut self.shared_gpu_geometry, + &mut self.retired_gpu_geometry, + key, + ); + } + if let Some(key) = material_key { + release_scene_material( + &mut self.shared_gpu_materials, + &mut self.retired_gpu_materials, + key, + ); } self.nodes.free(handle); } @@ -474,6 +612,17 @@ impl SceneGraph { /// meaningful for the UDF (normals/colour/UV are left zero). /// Returns (vertex_buf, index_buf, total_triangle_count). pub fn build_world_triangles(&self) -> (Vec, Vec, u32) { + self.build_world_triangles_for_gi(false) + } + + /// World-space triangle soup for the scene SDF. Physical transmission is + /// optionally omitted because the transparent-GI specialization traces the + /// opaque field and represents one glass layer separately in instance + /// metadata. The legacy wrapper above deliberately includes every surface. + pub(crate) fn build_world_triangles_for_gi( + &self, + exclude_physical_transmission: bool, + ) -> (Vec, Vec, u32) { // 12 floats per vertex to match `Vertex3D` stride the bake // shader indexes with. position + zero-padding. const STRIDE: usize = 12; @@ -481,7 +630,11 @@ impl SceneGraph { let mut ibuf: Vec = Vec::new(); let mut tri_count: u32 = 0; for (_, node) in self.nodes.iter() { - if !node.visible || node.vertices.is_empty() || node.indices.is_empty() { + if !node.visible + || node.vertices.is_empty() + || node.indices.is_empty() + || (exclude_physical_transmission && node.material.has_gi_transmission()) + { continue; } let base = (vbuf.len() / STRIDE) as u32; @@ -490,9 +643,9 @@ impl SceneGraph { let px = v.position[0]; let py = v.position[1]; let pz = v.position[2]; - let wx = t[0][0]*px + t[1][0]*py + t[2][0]*pz + t[3][0]; - let wy = t[0][1]*px + t[1][1]*py + t[2][1]*pz + t[3][1]; - let wz = t[0][2]*px + t[1][2]*py + t[2][2]*pz + t[3][2]; + let wx = t[0][0] * px + t[1][0] * py + t[2][0] * pz + t[3][0]; + let wy = t[0][1] * px + t[1][1] * py + t[2][1] * pz + t[3][1]; + let wz = t[0][2] * px + t[1][2] * py + t[2][2] * pz + t[3][2]; // Zero-pad the remaining 9 floats — only position is // read by `SDF_BAKE_WGSL`. vbuf.extend_from_slice(&[wx, wy, wz, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]); @@ -524,7 +677,28 @@ impl SceneGraph { indices: Vec, max_coverage: f32, ) { - let Some(node) = self.nodes.get_mut(handle) else { return }; + self.set_lod_geometry_with_secondary_uv( + handle, + lod_index, + vertices, + None, + indices, + max_coverage, + ); + } + + pub fn set_lod_geometry_with_secondary_uv( + &mut self, + handle: f64, + lod_index: usize, + vertices: Vec, + secondary_tex_coords: Option>, + indices: Vec, + max_coverage: f32, + ) { + let Some(node) = self.nodes.get_mut(handle) else { + return; + }; if vertices.is_empty() { if lod_index < node.lods.len() { node.lods.remove(lod_index); @@ -534,30 +708,51 @@ impl SceneGraph { while node.lods.len() <= lod_index { node.lods.push(LodLevel { vertices: Vec::new(), + secondary_tex_coords: None, indices: Vec::new(), max_coverage: 0.0, gpu_vb: None, + gpu_secondary_uv_vb: None, gpu_ib: None, gpu_index_count: 0, dirty: true, }); } let lod = &mut node.lods[lod_index]; + let secondary_tex_coords = + secondary_tex_coords.filter(|coords| coords.len() == vertices.len()); lod.vertices = vertices; + lod.secondary_tex_coords = secondary_tex_coords; lod.indices = indices; lod.max_coverage = max_coverage; lod.dirty = true; } pub fn update_geometry(&mut self, handle: f64, vertices: Vec, indices: Vec) { + self.update_geometry_with_secondary_uv(handle, vertices, None, indices); + } + + pub fn update_geometry_with_secondary_uv( + &mut self, + handle: f64, + vertices: Vec, + secondary_tex_coords: Option>, + indices: Vec, + ) { if let Some(node) = self.nodes.get_mut(handle) { + let secondary_tex_coords = + secondary_tex_coords.filter(|coords| coords.len() == vertices.len()); // Recompute bounds from vertex positions (Q5). let mut bmin = [f32::MAX; 3]; let mut bmax = [f32::MIN; 3]; for v in &vertices { for k in 0..3 { - if v.position[k] < bmin[k] { bmin[k] = v.position[k]; } - if v.position[k] > bmax[k] { bmax[k] = v.position[k]; } + if v.position[k] < bmin[k] { + bmin[k] = v.position[k]; + } + if v.position[k] > bmax[k] { + bmax[k] = v.position[k]; + } } } if vertices.is_empty() { @@ -566,7 +761,19 @@ impl SceneGraph { } node.bounds_min = bmin; node.bounds_max = bmax; + if node.secondary_tex_coords.is_some() != secondary_tex_coords.is_some() { + node.mat_dirty = true; + node.gpu_refractive_material_bg = None; + node.gpu_refractive_uniform_buf = None; + node.gpu_refractive_layered_uniform_buf = None; + node.gpu_refractive_layered = false; + node.gpu_refractive_uses_uv1 = false; + node.gpu_layered_material_bg = None; + node.gpu_layered_uniform_buf = None; + node.gpu_layered_uses_uv1 = false; + } node.vertices = vertices; + node.secondary_tex_coords = secondary_tex_coords; node.indices = indices; node.geo_dirty = true; if node.cast_shadow && node.visible { @@ -606,6 +813,13 @@ impl SceneGraph { /// Returns `None` if the scene is empty (caller should fall back /// to a safe default). pub fn compute_shadow_bounds(&self) -> Option<([f32; 3], [f32; 3])> { + self.compute_shadow_bounds_with_refraction(false) + } + + pub(crate) fn compute_shadow_bounds_with_refraction( + &self, + imported_refraction_enabled: bool, + ) -> Option<([f32; 3], [f32; 3])> { let mut bmin = [f32::MAX; 3]; let mut bmax = [f32::MIN; 3]; let mut any = false; @@ -622,6 +836,15 @@ impl SceneGraph { if !node.gi_only && !node.cast_shadow { continue; } + if !node.gi_only && node.material.alpha_mode == MaterialAlphaMode::Blend { + continue; + } + if !node.gi_only + && imported_refraction_enabled + && node.material.transmission.is_active() + { + continue; + } if node.bounds_min[0] > node.bounds_max[0] { continue; // empty bounds } @@ -631,25 +854,53 @@ impl SceneGraph { for ix in 0..2 { for iy in 0..2 { for iz in 0..2 { - let lx = if ix == 0 { node.bounds_min[0] } else { node.bounds_max[0] }; - let ly = if iy == 0 { node.bounds_min[1] } else { node.bounds_max[1] }; - let lz = if iz == 0 { node.bounds_min[2] } else { node.bounds_max[2] }; + let lx = if ix == 0 { + node.bounds_min[0] + } else { + node.bounds_max[0] + }; + let ly = if iy == 0 { + node.bounds_min[1] + } else { + node.bounds_max[1] + }; + let lz = if iz == 0 { + node.bounds_min[2] + } else { + node.bounds_max[2] + }; // column-major mat4 * vec4(x,y,z,1) - let wx = t[0][0]*lx + t[1][0]*ly + t[2][0]*lz + t[3][0]; - let wy = t[0][1]*lx + t[1][1]*ly + t[2][1]*lz + t[3][1]; - let wz = t[0][2]*lx + t[1][2]*ly + t[2][2]*lz + t[3][2]; - if wx < bmin[0] { bmin[0] = wx; } - if wy < bmin[1] { bmin[1] = wy; } - if wz < bmin[2] { bmin[2] = wz; } - if wx > bmax[0] { bmax[0] = wx; } - if wy > bmax[1] { bmax[1] = wy; } - if wz > bmax[2] { bmax[2] = wz; } + let wx = t[0][0] * lx + t[1][0] * ly + t[2][0] * lz + t[3][0]; + let wy = t[0][1] * lx + t[1][1] * ly + t[2][1] * lz + t[3][1]; + let wz = t[0][2] * lx + t[1][2] * ly + t[2][2] * lz + t[3][2]; + if wx < bmin[0] { + bmin[0] = wx; + } + if wy < bmin[1] { + bmin[1] = wy; + } + if wz < bmin[2] { + bmin[2] = wz; + } + if wx > bmax[0] { + bmax[0] = wx; + } + if wy > bmax[1] { + bmax[1] = wy; + } + if wz > bmax[2] { + bmax[2] = wz; + } any = true; } } } } - if any { Some((bmin, bmax)) } else { None } + if any { + Some((bmin, bmax)) + } else { + None + } } // ---- Q7: user data ----------------------------------------------------- @@ -675,7 +926,14 @@ impl SceneGraph { } pub fn set_material_pbr(&mut self, handle: f64, roughness: f32, metalness: f32) { + let mut changed_visible_transmission = false; if let Some(node) = self.nodes.get_mut(handle) { + if node.material.roughness == roughness && node.material.metalness == metalness { + return; + } + changed_visible_transmission = node.visible + && node.material.transmission.is_active() + && node.material.metalness != metalness; node.material.roughness = roughness; node.material.metalness = metalness; // Factors live in the material uniform, which is only rebuilt @@ -683,6 +941,11 @@ impl SceneGraph { // changes after the first render never applied. node.mat_dirty = true; } + if changed_visible_transmission { + // Metallic suppression changes transparent-GI transport and can + // move the instance between the glass and opaque TLAS/SDF masks. + self.tlas_version = self.tlas_version.wrapping_add(1); + } } /// glTF MASK alpha cutoff for the node's material (0 = opaque). @@ -690,6 +953,63 @@ impl SceneGraph { pub fn set_material_alpha_cutoff(&mut self, handle: f64, cutoff: f32) { if let Some(node) = self.nodes.get_mut(handle) { node.material.alpha_cutoff = cutoff; + node.material.alpha_mode = if cutoff > 0.0 { + MaterialAlphaMode::Mask + } else { + MaterialAlphaMode::Opaque + }; + node.mat_dirty = true; + } + } + + pub fn set_material_gltf_alpha( + &mut self, + handle: f64, + alpha_mode: MaterialAlphaMode, + cutoff: f32, + double_sided: bool, + ) { + if let Some(node) = self.nodes.get_mut(handle) { + node.material.alpha_mode = alpha_mode; + node.material.alpha_cutoff = cutoff; + node.material.double_sided = double_sided; + node.mat_dirty = true; + } + } + + pub fn set_material_alpha_coverage_mips(&mut self, handle: f64, enabled: bool) { + if let Some(node) = self.nodes.get_mut(handle) { + if node.material.alpha_coverage_mips == enabled { + return; + } + node.material.alpha_coverage_mips = enabled; + node.mat_dirty = true; + } + } + + pub fn set_material_transmission(&mut self, handle: f64, transmission: MaterialTransmission) { + let mut changed_visible = false; + if let Some(node) = self.nodes.get_mut(handle) { + if node.material.transmission == transmission { + return; + } + node.material.transmission = transmission; + node.mat_dirty = true; + changed_visible = node.visible; + } + if changed_visible { + // Transmission changes the TLAS instance mask/transport metadata + // and whether this mesh belongs in the opaque SDF clipmap. + self.tlas_version = self.tlas_version.wrapping_add(1); + } + } + + pub fn set_material_layered_pbr(&mut self, handle: f64, layered_pbr: MaterialLayeredPbr) { + if let Some(node) = self.nodes.get_mut(handle) { + if node.material.layered_pbr == layered_pbr { + return; + } + node.material.layered_pbr = layered_pbr; node.mat_dirty = true; } } @@ -697,7 +1017,16 @@ impl SceneGraph { /// Q8: Set a water-like material on a scene node. The actual animated /// wave shader requires a dedicated WGSL pipeline pass (deferred). /// For now, this sets a translucent tinted material that approximates water. - pub fn set_material_water(&mut self, handle: f64, _wave_amp: f32, _wave_speed: f32, r: f32, g: f32, b: f32, a: f32) { + pub fn set_material_water( + &mut self, + handle: f64, + _wave_amp: f32, + _wave_speed: f32, + r: f32, + g: f32, + b: f32, + a: f32, + ) { if let Some(node) = self.nodes.get_mut(handle) { node.material.color = [r, g, b]; node.material.opacity = a; @@ -708,6 +1037,9 @@ impl SceneGraph { pub fn set_material_texture(&mut self, handle: f64, texture_idx: u32) { if let Some(node) = self.nodes.get_mut(handle) { + if node.material.texture_idx == texture_idx { + return; + } node.material.texture_idx = texture_idx; node.mat_dirty = true; } @@ -736,7 +1068,11 @@ impl SceneGraph { pub fn set_material_emissive_factor(&mut self, handle: f64, r: f32, g: f32, b: f32) { if let Some(node) = self.nodes.get_mut(handle) { - node.material.emissive = [r, g, b]; + let emissive = [r, g, b]; + if node.material.emissive == emissive { + return; + } + node.material.emissive = emissive; node.mat_dirty = true; } } @@ -754,8 +1090,51 @@ impl SceneGraph { prev_vp_matrix: &[[f32; 4]; 4], uniform_layout: &wgpu::BindGroupLayout, occlusion: Option<&crate::renderer::OcclusionCuller>, + gpu_driven: &mut crate::renderer::gpu_driven::GpuDrivenRenderer, + ) { + self.prepare_with_refraction( + device, + queue, + vp_matrix, + prev_vp_matrix, + uniform_layout, + occlusion, + gpu_driven, + false, + ); + } + + pub(crate) fn prepare_with_refraction( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + vp_matrix: &[[f32; 4]; 4], + prev_vp_matrix: &[[f32; 4]; 4], + uniform_layout: &wgpu::BindGroupLayout, + occlusion: Option<&crate::renderer::OcclusionCuller>, + gpu_driven: &mut crate::renderer::gpu_driven::GpuDrivenRenderer, + imported_refraction_enabled: bool, ) { + self.imported_refraction_enabled = imported_refraction_enabled; let frustum = extract_frustum_planes(vp_matrix); + let order_safe = retained_order_is_gpu_safe(&self.nodes); + let candidate_count = self + .nodes + .iter() + .filter(|(_, node)| { + node.visible + && !node.gi_only + && !node.indices.is_empty() + && node.material.alpha_cutoff <= 0.0 + && node.material.alpha_mode != MaterialAlphaMode::Blend + && !(imported_refraction_enabled && node.material.transmission.is_active()) + && !node.material.layered_pbr.is_active() + && (!node.material.opacity.is_finite() || node.material.opacity >= 1.0) + }) + .count(); + self.gpu_driven_scene_active = + order_safe && candidate_count >= crate::renderer::gpu_driven::GPU_DRIVEN_MIN_DRAWS; + gpu_driven.retire_shared(queue, self.retired_gpu_geometry.drain(..)); // Phase 1: upload geometry for any freshly-added or dirty nodes, // assign uniform slots, and count how many nodes we'll draw. @@ -767,11 +1146,42 @@ impl SceneGraph { let pending_sdf = &mut self.pending_sdf_bakes; let next_card_slot = &mut self.next_card_slot; let free_card_blocks = &mut self.free_card_blocks; + let shared_gpu_geometry = &mut self.shared_gpu_geometry; + let retired_gpu_geometry = &mut self.retired_gpu_geometry; + let gpu_driven_scene_active = self.gpu_driven_scene_active; let mut visible_count: u32 = 0; + self.has_transparent_nodes = false; + self.has_layered_transparent_nodes = false; + self.has_refractive_nodes = false; + self.has_transmission_gi_nodes = false; for (handle, node) in self.nodes.iter_mut() { + if !gpu_driven_scene_active { + if let Some(old_key) = node.gpu_geometry_key.take() { + release_scene_geometry(shared_gpu_geometry, retired_gpu_geometry, old_key); + } + node.gpu_geometry = None; + } + if node.indices.is_empty() { + if let Some(old_key) = node.gpu_geometry_key.take() { + release_scene_geometry(shared_gpu_geometry, retired_gpu_geometry, old_key); + } + node.gpu_geometry = None; + } if !node.visible || node.indices.is_empty() { continue; } + if !node.gi_only && node.material.alpha_mode == MaterialAlphaMode::Blend { + self.has_transparent_nodes = true; + self.has_layered_transparent_nodes |= node.material.layered_pbr.is_active(); + } + if node.material.transmission.is_active() { + if node.material.has_gi_transmission() { + self.has_transmission_gi_nodes = true; + } + if !node.gi_only { + self.has_refractive_nodes = true; + } + } // Frustum cull against world-space AABB. Transform the local // bounds into world space by applying the node's transform // to the 8 corners; use the min/max of the result. @@ -782,18 +1192,42 @@ impl SceneGraph { for ix in 0..2 { for iy in 0..2 { for iz in 0..2 { - let lx = if ix == 0 { node.bounds_min[0] } else { node.bounds_max[0] }; - let ly = if iy == 0 { node.bounds_min[1] } else { node.bounds_max[1] }; - let lz = if iz == 0 { node.bounds_min[2] } else { node.bounds_max[2] }; - let wx = t[0][0]*lx + t[1][0]*ly + t[2][0]*lz + t[3][0]; - let wy = t[0][1]*lx + t[1][1]*ly + t[2][1]*lz + t[3][1]; - let wz = t[0][2]*lx + t[1][2]*ly + t[2][2]*lz + t[3][2]; - if wx < wmin[0] { wmin[0] = wx; } - if wy < wmin[1] { wmin[1] = wy; } - if wz < wmin[2] { wmin[2] = wz; } - if wx > wmax[0] { wmax[0] = wx; } - if wy > wmax[1] { wmax[1] = wy; } - if wz > wmax[2] { wmax[2] = wz; } + let lx = if ix == 0 { + node.bounds_min[0] + } else { + node.bounds_max[0] + }; + let ly = if iy == 0 { + node.bounds_min[1] + } else { + node.bounds_max[1] + }; + let lz = if iz == 0 { + node.bounds_min[2] + } else { + node.bounds_max[2] + }; + let wx = t[0][0] * lx + t[1][0] * ly + t[2][0] * lz + t[3][0]; + let wy = t[0][1] * lx + t[1][1] * ly + t[2][1] * lz + t[3][1]; + let wz = t[0][2] * lx + t[1][2] * ly + t[2][2] * lz + t[3][2]; + if wx < wmin[0] { + wmin[0] = wx; + } + if wy < wmin[1] { + wmin[1] = wy; + } + if wz < wmin[2] { + wmin[2] = wz; + } + if wx > wmax[0] { + wmax[0] = wx; + } + if wy > wmax[1] { + wmax[1] = wy; + } + if wz > wmax[2] { + wmax[2] = wz; + } } } } @@ -803,8 +1237,8 @@ impl SceneGraph { // Hi-Z occlusion: only worth testing what survived the // frustum; every uncertain case inside test_aabb // resolves to visible. - node.occluded = node.in_view_frustum - && occlusion.is_some_and(|o| !o.test_aabb(wmin, wmax)); + node.occluded = + node.in_view_frustum && occlusion.is_some_and(|o| !o.test_aabb(wmin, wmax)); // LOD selection by projected screen coverage: longest // NDC extent of the world AABB. Corners at/behind the @@ -839,22 +1273,42 @@ impl SceneGraph { } // Upload any dirty LOD variants (plain vertex/index buffers — // LODs never feed BLAS/SDF, those read the base geometry). - for lod in node.lods.iter_mut().filter(|l| l.dirty) { - lod.gpu_vb = Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("scene_node_lod_vb"), - contents: bytemuck::cast_slice(&lod.vertices), - usage: wgpu::BufferUsages::VERTEX, - })); - lod.gpu_ib = Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("scene_node_lod_ib"), - contents: bytemuck::cast_slice(&lod.indices), - usage: wgpu::BufferUsages::INDEX, - })); - lod.gpu_index_count = lod.indices.len() as u32; - lod.dirty = false; + let needs_secondary_uv = node.material.transmission.has_resolved_tex_coord(1) + || node.material.layered_pbr.has_resolved_tex_coord(1); + for lod in &mut node.lods { + if lod.dirty { + lod.gpu_vb = Some(device.create_buffer_init( + &wgpu::util::BufferInitDescriptor { + label: Some("scene_node_lod_vb"), + contents: bytemuck::cast_slice(&lod.vertices), + usage: wgpu::BufferUsages::VERTEX, + }, + )); + lod.gpu_ib = Some(device.create_buffer_init( + &wgpu::util::BufferInitDescriptor { + label: Some("scene_node_lod_ib"), + contents: bytemuck::cast_slice(&lod.indices), + usage: wgpu::BufferUsages::INDEX, + }, + )); + lod.gpu_index_count = lod.indices.len() as u32; + lod.dirty = false; + } + if needs_secondary_uv && lod.gpu_secondary_uv_vb.is_none() { + lod.gpu_secondary_uv_vb = lod.secondary_tex_coords.as_ref().map(|coords| { + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_node_lod_refractive_uv1_vb"), + contents: bytemuck::cast_slice(coords), + usage: wgpu::BufferUsages::VERTEX, + }) + }); + } else if !needs_secondary_uv { + lod.gpu_secondary_uv_vb = None; + } } - if node.geo_dirty || node.gpu_vb.is_none() { + let geometry_changed = node.geo_dirty; + if geometry_changed || node.gpu_vb.is_none() { // Ticket 007b: widen buffer usage when HW RT is on so // the same buffer can back both the raster draw and // the BLAS build. Cheap — no measurable cost when RT @@ -880,16 +1334,20 @@ impl SceneGraph { } else { wgpu::BufferUsages::INDEX | wgpu::BufferUsages::STORAGE }; - node.gpu_vb = Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("scene_node_vb"), - contents: bytemuck::cast_slice(&node.vertices), - usage: vb_usage, - })); - node.gpu_ib = Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("scene_node_ib"), - contents: bytemuck::cast_slice(&node.indices), - usage: ib_usage, - })); + node.gpu_vb = Some( + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_node_vb"), + contents: bytemuck::cast_slice(&node.vertices), + usage: vb_usage, + }), + ); + node.gpu_ib = Some( + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_node_ib"), + contents: bytemuck::cast_slice(&node.indices), + usage: ib_usage, + }), + ); node.gpu_index_count = node.indices.len() as u32; node.gpu_vertex_count = node.vertices.len() as u32; node.geo_dirty = false; @@ -910,10 +1368,10 @@ impl SceneGraph { n_sum[2] += v.normal[2]; } let t = &node.transform; - let nx = t[0][0]*n_sum[0] + t[1][0]*n_sum[1] + t[2][0]*n_sum[2]; - let ny = t[0][1]*n_sum[0] + t[1][1]*n_sum[1] + t[2][1]*n_sum[2]; - let nz = t[0][2]*n_sum[0] + t[1][2]*n_sum[1] + t[2][2]*n_sum[2]; - let len = (nx*nx + ny*ny + nz*nz).sqrt(); + let nx = t[0][0] * n_sum[0] + t[1][0] * n_sum[1] + t[2][0] * n_sum[2]; + let ny = t[0][1] * n_sum[0] + t[1][1] * n_sum[1] + t[2][1] * n_sum[2]; + let nz = t[0][2] * n_sum[0] + t[1][2] * n_sum[1] + t[2][2] * n_sum[2]; + let len = (nx * nx + ny * ny + nz * nz).sqrt(); if len > 1e-4 { node.flat_normal_ws = [nx / len, ny / len, nz / len]; } else { @@ -968,10 +1426,11 @@ impl SceneGraph { && node.bounds_min[1] < node.bounds_max[1] && node.bounds_min[2] < node.bounds_max[2] { - let (sdf_tex, sdf_view) = crate::renderer::create_mesh_sdf_texture_public( - device, - "scene_node_sdf", - ); + let (sdf_tex, sdf_view) = + crate::renderer::create_mesh_sdf_texture_public( + device, + "scene_node_sdf", + ); // Ticket 022 — content-hash the geometry and // try the on-disk SDF cache before scheduling @@ -981,9 +1440,8 @@ impl SceneGraph { // geometry-relevant bytes. let positions: Vec<[f32; 3]> = node.vertices.iter().map(|v| v.position).collect(); - let hash = crate::sdf_cache::compute_mesh_hash( - &positions, &node.indices, - ); + let hash = + crate::sdf_cache::compute_mesh_hash(&positions, &node.indices); node.mesh_hash = Some(hash); if let Some(bytes) = crate::sdf_cache::load(hash) { @@ -998,10 +1456,11 @@ impl SceneGraph { const RES: u32 = crate::sdf_cache::VOXEL_RES; let row_tight = (RES * 4) as usize; let row_padded = ((row_tight + 255) & !255) as u32; - let mut padded = vec![ - 0u8; - (row_padded as usize) * (RES as usize) * (RES as usize) - ]; + let mut padded = + vec![ + 0u8; + (row_padded as usize) * (RES as usize) * (RES as usize) + ]; for z in 0..RES as usize { for y in 0..RES as usize { let src_off = (z * RES as usize + y) * row_tight; @@ -1039,6 +1498,56 @@ impl SceneGraph { } } } + if needs_secondary_uv && node.gpu_secondary_uv_vb.is_none() { + node.gpu_secondary_uv_vb = node.secondary_tex_coords.as_ref().map(|coords| { + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_node_refractive_uv1_vb"), + contents: bytemuck::cast_slice(coords), + usage: wgpu::BufferUsages::VERTEX, + }) + }); + } else if !needs_secondary_uv { + node.gpu_secondary_uv_vb = None; + } + // Main/depth GPU submission uses one deduplicated arena slice. + // The legacy buffers above deliberately stay alive for secondary + // passes, which makes this an additive and reversible fast path. + if geometry_changed && (node.vertices.is_empty() || node.indices.is_empty()) { + if let Some(old_key) = node.gpu_geometry_key.take() { + release_scene_geometry(shared_gpu_geometry, retired_gpu_geometry, old_key); + } + node.gpu_geometry = None; + } + if gpu_driven.enabled() + && gpu_driven_scene_active + && (geometry_changed || node.gpu_geometry.is_none()) + && !node.vertices.is_empty() + && !node.indices.is_empty() + { + let key = scene_geometry_key(&node.vertices, &node.indices); + if node.gpu_geometry_key != Some(key) { + if let Some(old_key) = node.gpu_geometry_key.take() { + release_scene_geometry(shared_gpu_geometry, retired_gpu_geometry, old_key); + } + let slice = if let Some(cached) = shared_gpu_geometry.get_mut(&key) { + cached.references = cached.references.saturating_add(1); + cached.slice + } else { + let slice = + gpu_driven.upload_static(device, queue, &node.vertices, &node.indices); + shared_gpu_geometry.insert( + key, + SharedSceneGeometry { + slice, + references: 1, + }, + ); + slice + }; + node.gpu_geometry = Some(slice); + node.gpu_geometry_key = Some(key); + } + } if node.uniform_slot.is_none() { node.uniform_slot = Some(self.next_slot); self.next_slot += 1; @@ -1076,7 +1585,9 @@ impl SceneGraph { } } - let Some(pool_buf) = self.uniform_pool.as_ref() else { return }; + let Some(pool_buf) = self.uniform_pool.as_ref() else { + return; + }; let uniform_size = std::mem::size_of::(); let stride = NODE_UNIFORM_STRIDE as usize; @@ -1089,7 +1600,9 @@ impl SceneGraph { if !node.visible || node.indices.is_empty() { continue; } - let Some(slot) = node.uniform_slot else { continue }; + let Some(slot) = node.uniform_slot else { + continue; + }; let mvp = mat4_mul(vp_matrix, &node.transform); let prev_mvp = mat4_mul(prev_vp_matrix, &node.prev_transform); @@ -1108,7 +1621,13 @@ impl SceneGraph { node.material.color[2], opacity, ]; - let uniforms = NodeUniforms { mvp, model: node.transform, prev_mvp, model_tint: tint, misc: [0.0; 4] }; + let uniforms = NodeUniforms { + mvp, + model: node.transform, + prev_mvp, + model_tint: tint, + misc: [0.0; 4], + }; node.prev_transform = node.transform; let off = (slot as usize) * stride; @@ -1137,17 +1656,30 @@ impl SceneGraph { if visible_count > 0 && max_byte_offset > 0 { queue.write_buffer(pool_buf, 0, &self.scratch[..max_byte_offset]); } + gpu_driven.retire_shared(queue, self.retired_gpu_geometry.drain(..)); } /// Build / refresh per-node material bind groups for the scene /// pipeline. Must be called every frame after `prepare` and before /// `render`. Only rebuilds when a material changed (mat_dirty). - pub fn prepare_materials(&mut self, renderer: &crate::renderer::Renderer) { + pub fn prepare_materials(&mut self, renderer: &mut crate::renderer::Renderer) { + renderer.retire_scene_gpu_materials(self.retired_gpu_materials.drain(..)); + let shared_gpu_materials = &mut self.shared_gpu_materials; + let retired_gpu_materials = &mut self.retired_gpu_materials; for (_handle, node) in self.nodes.iter_mut() { if !node.visible || node.indices.is_empty() { continue; } - if node.mat_dirty || node.gpu_material_bg.is_none() { + let material_changed = node.mat_dirty; + let needs_refractive = + renderer.imported_refraction_enabled() && node.material.transmission.is_active(); + let needs_layered = node.material.layered_pbr.is_active(); + let needs_layered_scene = needs_layered && !needs_refractive; + if material_changed + || node.gpu_material_bg.is_none() + || (needs_refractive && node.gpu_refractive_material_bg.is_none()) + || (needs_layered_scene && node.gpu_layered_material_bg.is_none()) + { // Allocate or reuse the per-material uniform buffer. // (Could be updated in place when factors change, but // the current path always rebuilds together with the @@ -1161,7 +1693,10 @@ impl SceneGraph { // attach_model carries it over from the glTF mesh so // foliage cards keep their cutout + two-sided shading // + wind sway on the scene-graph path. - node.material.alpha_cutoff, + node.material + .alpha_mode + .shader_alpha_value(node.material.alpha_cutoff), + node.material.alpha_coverage_mips, ); let bg = renderer.create_scene_material_bg( node.material.texture_idx, @@ -1171,8 +1706,79 @@ impl SceneGraph { node.material.occlusion_texture_idx, &uniform, ); + let combined_refractive = renderer.create_scene_layered_refractive_material_bg( + node.material.texture_idx, + node.material.normal_texture_idx, + node.material.metallic_roughness_texture_idx, + node.material.emissive_texture_idx, + node.material.occlusion_texture_idx, + &uniform, + node.material.transmission, + node.material.layered_pbr, + node.secondary_tex_coords.is_some(), + ); + let refractive = combined_refractive.is_none().then(|| { + renderer.create_scene_refractive_material_bg( + node.material.texture_idx, + node.material.normal_texture_idx, + node.material.metallic_roughness_texture_idx, + node.material.emissive_texture_idx, + node.material.occlusion_texture_idx, + &uniform, + node.material.transmission, + node.secondary_tex_coords.is_some(), + ) + }); + let layered = needs_layered_scene.then(|| { + renderer.create_scene_layered_pbr_material_bg( + node.material.texture_idx, + node.material.normal_texture_idx, + node.material.metallic_roughness_texture_idx, + node.material.emissive_texture_idx, + node.material.occlusion_texture_idx, + &uniform, + node.material.layered_pbr, + node.secondary_tex_coords.is_some(), + ) + }); node.gpu_material_bg = Some(bg); node.gpu_material_uniform_buf = Some(uniform); + let ( + physical_uniform, + refractive_layered_uniform, + physical_bg, + refractive_uses_uv1, + refractive_layered, + ) = if let Some((physical, layered, bind_group, uses_uv1)) = combined_refractive { + ( + Some(physical), + Some(layered), + Some(bind_group), + uses_uv1, + true, + ) + } else { + refractive + .flatten() + .map(|(uniform, bind_group, uses_uv1)| { + (Some(uniform), None, Some(bind_group), uses_uv1, false) + }) + .unwrap_or((None, None, None, false, false)) + }; + node.gpu_refractive_uniform_buf = physical_uniform; + node.gpu_refractive_layered_uniform_buf = refractive_layered_uniform; + node.gpu_refractive_material_bg = physical_bg; + node.gpu_refractive_uses_uv1 = refractive_uses_uv1; + node.gpu_refractive_layered = refractive_layered; + let (layered_uniform, layered_bg, layered_uses_uv1) = layered + .flatten() + .map(|(uniform, bind_group, uses_uv1)| { + (Some(uniform), Some(bind_group), uses_uv1) + }) + .unwrap_or((None, None, false)); + node.gpu_layered_uniform_buf = layered_uniform; + node.gpu_layered_material_bg = layered_bg; + node.gpu_layered_uses_uv1 = layered_uses_uv1; // MASK materials also get an alpha-tested shadow-caster // bind group so foliage casts dappled shadows on the // scene-graph path (mirrors the cached-model path). @@ -1180,50 +1786,676 @@ impl SceneGraph { Some(renderer.create_shadow_cutout_bg( node.material.texture_idx, node.material.alpha_cutoff, + node.material.alpha_coverage_mips, )) } else { None }; - node.mat_dirty = false; } + if !self.gpu_driven_scene_active || needs_refractive || needs_layered { + if let Some(old_key) = node.gpu_material_key.take() { + release_scene_material(shared_gpu_materials, retired_gpu_materials, old_key); + } + node.gpu_material_id = MaterialId::FALLBACK; + } else if renderer.gpu_driven_enabled() + && (material_changed || node.gpu_material_id == MaterialId::FALLBACK) + { + let key = scene_material_key(&node.material); + if node.gpu_material_key != Some(key) { + if let Some(old_key) = node.gpu_material_key.take() { + release_scene_material( + shared_gpu_materials, + retired_gpu_materials, + old_key, + ); + } + let id = if let Some(cached) = shared_gpu_materials.get_mut(&key) { + cached.references = cached.references.saturating_add(1); + cached.id + } else { + let id = renderer.allocate_scene_gpu_material(&node.material); + if id != MaterialId::FALLBACK { + shared_gpu_materials + .insert(key, SharedSceneMaterial { id, references: 1 }); + } + id + }; + node.gpu_material_id = id; + node.gpu_material_key = (id != MaterialId::FALLBACK).then_some(key); + } + } + node.mat_dirty = false; } + renderer.retire_scene_gpu_materials(self.retired_gpu_materials.drain(..)); } /// Render all visible scene nodes into the given render pass. /// Must be called after prepare() and after the pipeline/lighting/joints are set. - pub fn render<'a>( + pub fn render<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>, gpu_driven_enabled: bool) { + self.render_with_refraction(pass, gpu_driven_enabled, false); + } + + pub(crate) fn render_with_refraction<'a>( + &'a self, + pass: &mut wgpu::RenderPass<'a>, + gpu_driven_enabled: bool, + imported_refraction_enabled: bool, + ) { + self.render_with_material_pipeline_selection( + pass, + gpu_driven_enabled, + imported_refraction_enabled, + None, + None, + ); + } + + pub(crate) fn render_with_material_specializations<'a>( + &'a self, + pass: &mut wgpu::RenderPass<'a>, + gpu_driven_enabled: bool, + imported_refraction_enabled: bool, + base_pipeline: &'a wgpu::RenderPipeline, + layered: Option<&'a crate::renderer::layered_pbr_scene::SceneLayeredPbrResources>, + ) { + self.render_with_material_pipeline_selection( + pass, + gpu_driven_enabled, + imported_refraction_enabled, + Some(base_pipeline), + layered, + ); + } + + fn render_with_material_pipeline_selection<'a>( &'a self, pass: &mut wgpu::RenderPass<'a>, + gpu_driven_enabled: bool, + imported_refraction_enabled: bool, + base_pipeline: Option<&'a wgpu::RenderPipeline>, + layered: Option<&'a crate::renderer::layered_pbr_scene::SceneLayeredPbrResources>, ) { + // The renderer enters with the ordinary scene pipeline already bound. + // Public callers that do not opt into specialization retain the old + // contract and incur no per-node pipeline selection. + let mut current_pipeline = base_pipeline.map(|_| (false, false)); for (_handle, node) in self.nodes.iter() { - if !node.visible || node.gi_only || node.indices.is_empty() || !node.in_view_frustum || node.occluded { + if !node.visible + || node.gi_only + || node.indices.is_empty() + || !node.in_view_frustum + || node.occluded + { + continue; + } + if gpu_driven_enabled + && self.gpu_driven_scene_active + && scene_node_gpu_driven_ready(node, imported_refraction_enabled) + { + continue; + } + if node.material.alpha_mode == MaterialAlphaMode::Blend { + continue; + } + if imported_refraction_enabled && node.material.transmission.is_active() { continue; } // Active LOD overrides the base buffers for the camera pass // (shadows/picking/BLAS keep using the base geometry). - let (vb, ib, index_count) = match node + let active_lod = node .lods .get(node.active_lod.max(0) as usize) - .filter(|_| node.active_lod >= 0) - .and_then(|l| Some((l.gpu_vb.as_ref()?, l.gpu_ib.as_ref()?, l.gpu_index_count))) - { - Some(lod) => lod, + .filter(|_| node.active_lod >= 0); + let (vb, ib, index_count) = match active_lod { + Some(lod) => { + let (Some(vb), Some(ib)) = (lod.gpu_vb.as_ref(), lod.gpu_ib.as_ref()) else { + continue; + }; + (vb, ib, lod.gpu_index_count) + } None => { let Some(vb) = &node.gpu_vb else { continue }; let Some(ib) = &node.gpu_ib else { continue }; (vb, ib, node.gpu_index_count) } }; - let Some(bg) = &node.gpu_uniform_bg else { continue }; - let Some(mat_bg) = &node.gpu_material_bg else { continue }; + let Some(bg) = &node.gpu_uniform_bg else { + continue; + }; + let Some(base_material) = &node.gpu_material_bg else { + continue; + }; + let layered_material = layered.and_then(|_| node.gpu_layered_material_bg.as_ref()); + let uses_uv1 = layered_material.is_some() && node.gpu_layered_uses_uv1; + let secondary_uv = if uses_uv1 { + active_lod + .and_then(|lod| lod.gpu_secondary_uv_vb.as_ref()) + .or(node.gpu_secondary_uv_vb.as_ref()) + } else { + None + }; + if uses_uv1 && secondary_uv.is_none() { + continue; + } + if let Some(base_pipeline) = base_pipeline { + let key = (layered_material.is_some(), uses_uv1); + if current_pipeline != Some(key) { + if let (Some(layered), Some(_)) = (layered, layered_material) { + pass.set_pipeline(layered.opaque_pipeline(uses_uv1, false)); + } else { + pass.set_pipeline(base_pipeline); + } + current_pipeline = Some(key); + } + } pass.set_bind_group(0, bg, &[]); - pass.set_bind_group(2, mat_bg, &[]); + pass.set_bind_group(2, layered_material.unwrap_or(base_material), &[]); pass.set_vertex_buffer(0, vb.slice(..)); + if let Some(secondary_uv) = secondary_uv { + pass.set_vertex_buffer(1, secondary_uv.slice(..)); + } pass.set_index_buffer(ib.slice(..), wgpu::IndexFormat::Uint32); pass.draw_indexed(0..index_count, 0, 0..1); } } + pub fn has_transparent_nodes(&self) -> bool { + self.has_transparent_nodes + } + + pub(crate) fn has_layered_transparent_nodes(&self) -> bool { + self.has_layered_transparent_nodes + } + + pub fn has_refractive_nodes(&self) -> bool { + self.has_refractive_nodes + } + + /// Constant-time guard for renderer paths that only need to inspect + /// physical-transmission metadata when at least one retained GI instance + /// can contribute it. + pub(crate) fn has_transmission_gi_nodes(&self) -> bool { + self.has_transmission_gi_nodes + } + + pub(crate) fn transparent_gi_instance_count(&self) -> u32 { + if !self.has_transmission_gi_nodes { + return 0; + } + self.nodes + .iter() + .filter(|(_handle, node)| { + node.visible + && node.card_first_slot.is_some() + && !node.indices.is_empty() + && node.material.has_gi_transmission() + }) + .count() + .min(u32::MAX as usize) as u32 + } + + /// O(n) only after the O(1) refractive gate succeeds. Shadow visibility is + /// deliberately independent of the camera frustum: off-screen glass may + /// project into a visible receiver. + pub(crate) fn has_transmitted_shadow_casters(&self) -> bool { + self.has_refractive_nodes + && self.nodes.iter().any(|(_handle, node)| { + node.visible + && !node.gi_only + && node.cast_shadow + && !node.indices.is_empty() + && node.material.transmission.is_active() + && node.gpu_refractive_material_bg.is_some() + }) + } + + pub(crate) fn has_visible_opaque_iridescence(&self, imported_refraction_enabled: bool) -> bool { + self.nodes.iter().any(|(_handle, node)| { + node.visible + && !node.gi_only + && !node.indices.is_empty() + && node.in_view_frustum + && !node.occluded + && node.material.alpha_mode != MaterialAlphaMode::Blend + && !(imported_refraction_enabled && node.material.transmission.is_active()) + && node.material.layered_pbr.has_iridescence() + && node.gpu_layered_material_bg.is_some() + }) + } + + pub(crate) fn append_opaque_iridescence_draws<'a>( + &'a self, + out: &mut Vec>, + ) { + for (_handle, node) in self.nodes.iter() { + if !node.visible + || node.gi_only + || node.indices.is_empty() + || !node.in_view_frustum + || node.occluded + || node.material.alpha_mode == MaterialAlphaMode::Blend + || (self.imported_refraction_enabled && node.material.transmission.is_active()) + || !node.material.layered_pbr.has_iridescence() + { + continue; + } + let Some(uniforms) = node.gpu_uniform_bg.as_ref() else { + continue; + }; + let Some(material) = node.gpu_layered_material_bg.as_ref() else { + continue; + }; + let uses_uv1 = node.gpu_layered_uses_uv1; + let (vertex, index, index_count, secondary_uv) = match node + .lods + .get(node.active_lod.max(0) as usize) + .filter(|_| node.active_lod >= 0) + .filter(|lod| !uses_uv1 || lod.gpu_secondary_uv_vb.is_some()) + .and_then(|lod| { + Some(( + lod.gpu_vb.as_ref()?, + lod.gpu_ib.as_ref()?, + lod.gpu_index_count, + if uses_uv1 { + Some(lod.gpu_secondary_uv_vb.as_ref()?) + } else { + None + }, + )) + }) { + Some(lod) => lod, + None => { + let Some(vertex) = node.gpu_vb.as_ref() else { + continue; + }; + let Some(index) = node.gpu_ib.as_ref() else { + continue; + }; + let secondary_uv = if uses_uv1 { + let Some(buffer) = node.gpu_secondary_uv_vb.as_ref() else { + continue; + }; + Some(buffer) + } else { + None + }; + (vertex, index, node.gpu_index_count, secondary_uv) + } + }; + out.push(ImportedIridescenceDrawRef { + uniforms, + material, + mesh: MeshDrawRef { + vertex, + index, + first_index: 0, + index_count, + base_vertex: 0, + }, + secondary_uv, + vertex_byte_offset: 0, + index_byte_offset: 0, + }); + } + } + + pub(crate) fn visible_transparent_node_count( + &self, + imported_refraction_enabled: bool, + ) -> usize { + if !self.has_transparent_nodes { + return 0; + } + self.nodes + .iter() + .filter(|(_handle, node)| { + node.visible + && !node.gi_only + && !node.indices.is_empty() + && node.in_view_frustum + && !node.occluded + && node.material.alpha_mode == MaterialAlphaMode::Blend + && !(imported_refraction_enabled && node.material.transmission.is_active()) + }) + .count() + } + + pub(crate) fn visible_refractive_node_count(&self) -> usize { + if !self.has_refractive_nodes { + return 0; + } + self.nodes + .iter() + .filter(|(_handle, node)| { + node.visible + && !node.gi_only + && !node.indices.is_empty() + && node.in_view_frustum + && !node.occluded + && node.material.transmission.is_active() + }) + .count() + } + + pub(crate) fn append_refractive_draws<'a>( + &'a self, + out: &mut Vec>, + view_projection: &[[f32; 4]; 4], + stable_id_base: usize, + ) { + for (node_index, (_handle, node)) in self.nodes.iter().enumerate() { + if !node.visible + || node.gi_only + || node.indices.is_empty() + || !node.in_view_frustum + || node.occluded + || !node.material.transmission.is_active() + { + continue; + } + let uses_uv1 = node.gpu_refractive_uses_uv1; + let (vertex, index, index_count, secondary_uv) = match node + .lods + .get(node.active_lod.max(0) as usize) + .filter(|_| node.active_lod >= 0) + .filter(|lod| !uses_uv1 || lod.gpu_secondary_uv_vb.is_some()) + .and_then(|lod| { + Some(( + lod.gpu_vb.as_ref()?, + lod.gpu_ib.as_ref()?, + lod.gpu_index_count, + if uses_uv1 { + Some(lod.gpu_secondary_uv_vb.as_ref()?) + } else { + None + }, + )) + }) { + Some(lod) => lod, + None => { + let Some(vertex) = &node.gpu_vb else { + continue; + }; + let Some(index) = &node.gpu_ib else { + continue; + }; + let secondary_uv = if uses_uv1 { + let Some(buffer) = node.gpu_secondary_uv_vb.as_ref() else { + continue; + }; + Some(buffer) + } else { + None + }; + (vertex, index, node.gpu_index_count, secondary_uv) + } + }; + let Some(uniforms) = &node.gpu_uniform_bg else { + continue; + }; + let Some(material) = &node.gpu_refractive_material_bg else { + continue; + }; + let center = if node.world_bounds_min[0] <= node.world_bounds_max[0] { + [ + (node.world_bounds_min[0] + node.world_bounds_max[0]) * 0.5, + (node.world_bounds_min[1] + node.world_bounds_max[1]) * 0.5, + (node.world_bounds_min[2] + node.world_bounds_max[2]) * 0.5, + ] + } else { + [ + node.transform[3][0], + node.transform[3][1], + node.transform[3][2], + ] + }; + let pivot = crate::renderer::mat4_mul_vec4( + view_projection, + &[center[0], center[1], center[2], 1.0], + ); + out.push(ImportedRefractiveDrawRef { + view_depth: pivot[3], + stable_id: stable_id_base + node_index, + double_sided: node.material.double_sided, + layered: node.gpu_refractive_layered, + uniforms, + material, + mesh: MeshDrawRef { + vertex, + index, + first_index: 0, + index_count, + base_vertex: 0, + }, + secondary_uv, + vertex_byte_offset: 0, + index_byte_offset: 0, + }); + } + } + + /// Render glTF BLEND nodes back-to-front into the forward translucent pass. + /// Opaque depth remains read-only; these nodes never enter the depth prepass. + pub fn render_transparent<'a>( + &'a self, + pass: &mut wgpu::RenderPass<'a>, + single_sided_pipeline: &'a wgpu::RenderPipeline, + double_sided_pipeline: &'a wgpu::RenderPipeline, + view_projection: &[[f32; 4]; 4], + ) { + self.render_transparent_with_refraction( + pass, + single_sided_pipeline, + double_sided_pipeline, + view_projection, + false, + ); + } + + pub(crate) fn render_transparent_with_refraction<'a>( + &'a self, + pass: &mut wgpu::RenderPass<'a>, + single_sided_pipeline: &'a wgpu::RenderPipeline, + double_sided_pipeline: &'a wgpu::RenderPipeline, + view_projection: &[[f32; 4]; 4], + imported_refraction_enabled: bool, + ) { + let mut draws = Vec::new(); + self.append_transparent_draws(&mut draws, view_projection, 0, imported_refraction_enabled); + draws.sort_by(|left, right| { + right + .view_depth + .total_cmp(&left.view_depth) + .then_with(|| left.stable_id.cmp(&right.stable_id)) + }); + let mut current_double_sided = None; + for draw in draws { + if current_double_sided != Some(draw.double_sided) { + pass.set_pipeline(if draw.double_sided { + double_sided_pipeline + } else { + single_sided_pipeline + }); + current_double_sided = Some(draw.double_sided); + } + pass.set_bind_group(0, draw.uniforms, &[]); + pass.set_bind_group(2, draw.material, &[]); + pass.set_vertex_buffer(0, draw.mesh.vertex.slice(..)); + pass.set_index_buffer(draw.mesh.index.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(draw.mesh.index_range(), draw.mesh.base_vertex, 0..1); + } + } + + pub(crate) fn append_transparent_draws<'a>( + &'a self, + out: &mut Vec>, + view_projection: &[[f32; 4]; 4], + stable_id_base: usize, + imported_refraction_enabled: bool, + ) { + for (node_index, (_handle, node)) in self.nodes.iter().enumerate() { + if !node.visible + || node.gi_only + || node.indices.is_empty() + || !node.in_view_frustum + || node.occluded + || node.material.alpha_mode != MaterialAlphaMode::Blend + || (imported_refraction_enabled && node.material.transmission.is_active()) + { + continue; + } + let Some(uniforms) = &node.gpu_uniform_bg else { + continue; + }; + let Some(base_material) = &node.gpu_material_bg else { + continue; + }; + let layered_material = node.gpu_layered_material_bg.as_ref(); + let uses_uv1 = layered_material.is_some() && node.gpu_layered_uses_uv1; + let (vb, ib, index_count, secondary_uv) = match node + .lods + .get(node.active_lod.max(0) as usize) + .filter(|_| node.active_lod >= 0) + .filter(|lod| !uses_uv1 || lod.gpu_secondary_uv_vb.is_some()) + .and_then(|lod| { + Some(( + lod.gpu_vb.as_ref()?, + lod.gpu_ib.as_ref()?, + lod.gpu_index_count, + if uses_uv1 { + Some(lod.gpu_secondary_uv_vb.as_ref()?) + } else { + None + }, + )) + }) { + Some(lod) => lod, + None => { + let Some(vb) = &node.gpu_vb else { continue }; + let Some(ib) = &node.gpu_ib else { continue }; + let secondary_uv = if uses_uv1 { + let Some(buffer) = node.gpu_secondary_uv_vb.as_ref() else { + continue; + }; + Some(buffer) + } else { + None + }; + (vb, ib, node.gpu_index_count, secondary_uv) + } + }; + let center = if node.world_bounds_min[0] <= node.world_bounds_max[0] { + [ + (node.world_bounds_min[0] + node.world_bounds_max[0]) * 0.5, + (node.world_bounds_min[1] + node.world_bounds_max[1]) * 0.5, + (node.world_bounds_min[2] + node.world_bounds_max[2]) * 0.5, + ] + } else { + [ + node.transform[3][0], + node.transform[3][1], + node.transform[3][2], + ] + }; + let pivot = crate::renderer::mat4_mul_vec4( + view_projection, + &[center[0], center[1], center[2], 1.0], + ); + out.push(ImportedTransparentDrawRef { + view_depth: pivot[3], + stable_id: stable_id_base + node_index, + double_sided: node.material.double_sided, + layered: layered_material.is_some(), + uniforms, + material: layered_material.unwrap_or(base_material), + mesh: MeshDrawRef { + vertex: vb, + index: ib, + first_index: 0, + index_count, + base_vertex: 0, + }, + secondary_uv, + vertex_byte_offset: 0, + index_byte_offset: 0, + }); + } + } + + /// Append retained-node records directly into the renderer's reused + /// draw scratch. Off-frustum nodes remain in the list so the compute + /// culler, rather than the CPU submission loop, owns visibility. + pub(crate) fn append_gpu_driven_draws(&self, out: &mut Vec) -> [u32; 3] { + let mut compatibility = 0u32; + let mut visible = 0u32; + let mut culled = 0u32; + if !self.gpu_driven_scene_active { + compatibility = self + .nodes + .iter() + .filter(|(_, node)| { + node.visible + && !node.gi_only + && !node.indices.is_empty() + && node.in_view_frustum + && !node.occluded + }) + .count() as u32; + return [compatibility, visible, culled]; + } + for (_handle, node) in self.nodes.iter() { + if !node.visible || node.gi_only || node.indices.is_empty() || node.occluded { + continue; + } + if !scene_node_gpu_driven_ready(node, self.imported_refraction_enabled) { + compatibility += u32::from(node.in_view_frustum); + continue; + } + let Some(slice) = node.gpu_geometry else { + compatibility += u32::from(node.in_view_frustum); + continue; + }; + let Some(slot) = node.uniform_slot else { + compatibility += u32::from(node.in_view_frustum); + continue; + }; + let offset = slot as usize * NODE_UNIFORM_STRIDE as usize; + let end = offset + std::mem::size_of::(); + let Some(bytes) = self.scratch.get(offset..end) else { + compatibility += u32::from(node.in_view_frustum); + continue; + }; + let uniforms = bytemuck::pod_read_unaligned::(bytes); + out.push(GpuDrawRecord { + uniforms, + bounds_min: [ + node.world_bounds_min[0], + node.world_bounds_min[1], + node.world_bounds_min[2], + 0.0, + ], + bounds_max: [ + node.world_bounds_max[0], + node.world_bounds_max[1], + node.world_bounds_max[2], + 0.0, + ], + draw: [ + node.gpu_index_count, + slice.first_index, + slice.base_vertex as u32, + node.gpu_material_id.raw(), + ], + }); + if node.in_view_frustum { + visible += 1; + } else { + culled += 1; + } + } + [compatibility, visible, culled] + } + pub fn node_count(&self) -> usize { self.nodes.iter().count() } @@ -1236,28 +2468,173 @@ impl SceneGraph { /// half-res and consumed through a perturbed water lookup, where a /// LOD pop would be more visible than the detail it saves. /// (Treats node.transform as world — flat hierarchies.) - pub fn reflect_draw_list(&self) - -> Vec<(&wgpu::Buffer, &wgpu::Buffer, u32, &wgpu::BindGroup, [[f32; 4]; 4], [f32; 3], [f32; 3])> - { + pub fn reflect_draw_list( + &self, + ) -> Vec<( + &wgpu::Buffer, + &wgpu::Buffer, + u32, + &wgpu::BindGroup, + [[f32; 4]; 4], + [f32; 3], + [f32; 3], + )> { + self.reflect_draw_list_with_refraction(false) + } + + pub(crate) fn reflect_draw_list_with_refraction( + &self, + imported_refraction_enabled: bool, + ) -> Vec<( + &wgpu::Buffer, + &wgpu::Buffer, + u32, + &wgpu::BindGroup, + [[f32; 4]; 4], + [f32; 3], + [f32; 3], + )> { let mut out = Vec::new(); for (_handle, node) in self.nodes.iter() { - if !node.visible || node.gi_only || node.indices.is_empty() { continue; } + if !node.visible + || node.gi_only + || node.material.alpha_mode == MaterialAlphaMode::Blend + || (imported_refraction_enabled && node.material.transmission.is_active()) + || node.indices.is_empty() + { + continue; + } let Some(vb) = &node.gpu_vb else { continue }; let Some(ib) = &node.gpu_ib else { continue }; - let Some(mat_bg) = &node.gpu_material_bg else { continue }; + let Some(mat_bg) = &node.gpu_material_bg else { + continue; + }; // World bounds ride along so the probe pass can frustum-cull // against the MIRRORED camera (main-camera cull flags don't // apply there). Sentinel (min > max) = not yet computed → // never culled. out.push(( - vb, ib, node.gpu_index_count, mat_bg, node.transform, - node.world_bounds_min, node.world_bounds_max, + vb, + ib, + node.gpu_index_count, + mat_bg, + node.transform, + node.world_bounds_min, + node.world_bounds_max, )); } out } } +fn scene_node_gpu_driven_ready(node: &SceneNode, imported_refraction_enabled: bool) -> bool { + node.active_lod < 0 + // Retained MASK/transparent nodes historically blend in submission + // order while depth-writing in the same pass. A depth prepass would + // collapse overlapping translucent layers to the nearest surface, + // changing foliage colour even when its cutout silhouette matched. + // Keep those nodes on the compatibility path until they have a + // dedicated order-independent transparency bucket. + && node.material.alpha_cutoff <= 0.0 + && node.material.alpha_mode != MaterialAlphaMode::Blend + && !(imported_refraction_enabled && node.material.transmission.is_active()) + && !node.material.layered_pbr.is_active() + && (!node.material.opacity.is_finite() || node.material.opacity >= 1.0) + && node.gpu_geometry.is_some() + && node.gpu_material_id != MaterialId::FALLBACK + && node.uniform_slot.is_some() +} + +fn retained_order_is_gpu_safe(nodes: &HandleRegistry) -> bool { + !nodes.iter().any(|(_, node)| { + node.visible + && !node.gi_only + && !node.indices.is_empty() + && (node.material.alpha_cutoff > 0.0 + || (node.material.opacity.is_finite() && node.material.opacity < 1.0)) + }) +} + +fn scene_geometry_key(vertices: &[Vertex3D], indices: &[u32]) -> SceneGeometryKey { + const FNV_PRIME: u64 = 0x100000001b3; + fn hash(mut value: u64, bytes: &[u8]) -> u64 { + for &byte in bytes { + value ^= byte as u64; + value = value.wrapping_mul(FNV_PRIME); + } + value + } + let vertex_bytes = bytemuck::cast_slice(vertices); + let index_bytes = bytemuck::cast_slice(indices); + SceneGeometryKey { + hash_a: hash(hash(0xcbf29ce484222325, vertex_bytes), index_bytes), + hash_b: hash(hash(0x84222325cbf29ce4, index_bytes), vertex_bytes), + vertex_count: vertices.len() as u32, + index_count: indices.len() as u32, + } +} + +fn scene_material_key(material: &PbrMaterial) -> SceneMaterialKey { + SceneMaterialKey { + metal_rough: [ + material.metalness.to_bits(), + material.roughness.to_bits(), + (material.metallic_roughness_texture_idx != 0) as u32, + material + .alpha_mode + .shader_alpha_value(material.alpha_cutoff) + .to_bits(), + ], + emissive: [ + material.emissive[0].to_bits(), + material.emissive[1].to_bits(), + material.emissive[2].to_bits(), + u32::from(material.alpha_coverage_mips), + ], + textures: [ + material.texture_idx, + material.normal_texture_idx, + material.metallic_roughness_texture_idx, + material.emissive_texture_idx, + material.occlusion_texture_idx, + ], + } +} + +fn release_scene_geometry( + cache: &mut HashMap, + retired: &mut Vec, + key: SceneGeometryKey, +) { + let Some(entry) = cache.get_mut(&key) else { + return; + }; + if entry.references > 1 { + entry.references -= 1; + return; + } + if let Some(entry) = cache.remove(&key) { + retired.push(entry.slice); + } +} + +fn release_scene_material( + cache: &mut HashMap, + retired: &mut Vec, + key: SceneMaterialKey, +) { + let Some(entry) = cache.get_mut(&key) else { + return; + }; + if entry.references > 1 { + entry.references -= 1; + return; + } + if let Some(entry) = cache.remove(&key) { + retired.push(entry.id); + } +} + // ============================================================ // Matrix math (4x4, column-major) // ============================================================ @@ -1312,9 +2689,12 @@ fn aabb_screen_coverage(vp: &[[f32; 4]; 4], wmin: [f32; 3], wmax: [f32; 3]) -> f pub(crate) fn extract_frustum_planes(vp: &[[f32; 4]; 4]) -> [[f32; 4]; 6] { // Row vectors of the column-major matrix: row_i[col] = vp[col][i]. let row = |i: usize| [vp[0][i], vp[1][i], vp[2][i], vp[3][i]]; - let r0 = row(0); let r1 = row(1); let r2 = row(2); let r3 = row(3); - let add = |a: [f32;4], b: [f32;4]| [a[0]+b[0], a[1]+b[1], a[2]+b[2], a[3]+b[3]]; - let sub = |a: [f32;4], b: [f32;4]| [a[0]-b[0], a[1]-b[1], a[2]-b[2], a[3]-b[3]]; + let r0 = row(0); + let r1 = row(1); + let r2 = row(2); + let r3 = row(3); + let add = |a: [f32; 4], b: [f32; 4]| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]]; + let sub = |a: [f32; 4], b: [f32; 4]| [a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3]]; [ add(r3, r0), // left sub(r3, r0), // right @@ -1334,16 +2714,22 @@ pub(crate) fn aabb_outside_frustum(planes: &[[f32; 4]; 6], bmin: [f32; 3], bmax: let y = if iy == 0 { bmin[1] } else { bmax[1] }; for iz in 0..2 { let z = if iz == 0 { bmin[2] } else { bmax[2] }; - if p[0]*x + p[1]*y + p[2]*z + p[3] >= 0.0 { + if p[0] * x + p[1] * y + p[2] * z + p[3] >= 0.0 { all_outside = false; break; } } - if !all_outside { break; } + if !all_outside { + break; + } + } + if !all_outside { + break; } - if !all_outside { break; } } - if all_outside { return true; } + if all_outside { + return true; + } } false } @@ -1353,10 +2739,164 @@ fn mat4_mul(a: &[[f32; 4]; 4], b: &[[f32; 4]; 4]) -> [[f32; 4]; 4] { for col in 0..4 { for row in 0..4 { result[col][row] = a[0][row] * b[col][0] - + a[1][row] * b[col][1] - + a[2][row] * b[col][2] - + a[3][row] * b[col][3]; + + a[1][row] * b[col][1] + + a[2][row] * b[col][2] + + a[3][row] * b[col][3]; } } result } + +#[cfg(test)] +mod gpu_driven_cache_tests { + use super::*; + + fn test_slice() -> GeometrySlice { + GeometrySlice { + vertex_offset: 96, + vertex_size: 192, + index_offset: 24, + index_size: 48, + first_index: 6, + base_vertex: 1, + } + } + + #[test] + fn geometry_keys_are_content_addressed() { + let mut vertices = vec![Vertex3D::default()]; + let first = scene_geometry_key(&vertices, &[0, 1, 2]); + assert_eq!(first, scene_geometry_key(&vertices, &[0, 1, 2])); + vertices[0].position[0] = 1.0; + assert_ne!(first, scene_geometry_key(&vertices, &[0, 1, 2])); + assert_ne!( + first, + scene_geometry_key(&[Vertex3D::default()], &[0, 2, 1]) + ); + } + + #[test] + fn material_key_ignores_per_draw_tint_but_tracks_pbr_state() { + let first = PbrMaterial::default(); + let mut tint_only = first.clone(); + tint_only.color = [0.1, 0.2, 0.3]; + tint_only.opacity = 0.25; + assert_eq!(scene_material_key(&first), scene_material_key(&tint_only)); + + let mut changed = first.clone(); + changed.roughness = 0.35; + assert_ne!(scene_material_key(&first), scene_material_key(&changed)); + } + + #[test] + fn physical_metadata_round_trips_and_dirties_only_on_change() { + let mut scene = SceneGraph::new(); + let node = scene.create_node(); + scene.nodes.get_mut(node).unwrap().mat_dirty = false; + let initial_tlas_version = scene.tlas_version; + let transmission = MaterialTransmission { + authored: true, + factor: 0.8, + ior_authored: true, + ior: 1.45, + volume_authored: true, + thickness_factor: 0.25, + attenuation_distance: 2.0, + attenuation_color: [0.8, 0.9, 1.0], + thickness_source: crate::models::MaterialThicknessSource::Authored, + ..Default::default() + }; + scene.set_material_transmission(node, transmission); + let retained = scene.nodes.get(node).unwrap(); + assert_eq!(retained.material.transmission, transmission); + assert!( + retained.mat_dirty, + "the physical material bind group must rebuild when transmission changes" + ); + assert_ne!( + scene.tlas_version, initial_tlas_version, + "visible transmission changes must invalidate GI transport and TLAS masks" + ); + let changed_tlas_version = scene.tlas_version; + scene.nodes.get_mut(node).unwrap().mat_dirty = false; + scene.set_material_transmission(node, transmission); + assert!( + !scene.nodes.get(node).unwrap().mat_dirty, + "setting identical physical metadata must remain allocation-free" + ); + assert_eq!( + scene.tlas_version, changed_tlas_version, + "identical physical metadata must not trigger a redundant GI rebuild" + ); + + scene.set_material_pbr(node, 0.4, 1.0); + assert_ne!( + scene.tlas_version, changed_tlas_version, + "metallic suppression must invalidate transparent-GI membership" + ); + let metallic_tlas_version = scene.tlas_version; + scene.nodes.get_mut(node).unwrap().mat_dirty = false; + scene.set_material_pbr(node, 0.4, 1.0); + assert_eq!(scene.tlas_version, metallic_tlas_version); + assert!( + !scene.nodes.get(node).unwrap().mat_dirty, + "identical PBR factors must remain allocation-free" + ); + scene.set_material_emissive_factor(node, 0.0, 0.0, 0.0); + scene.set_material_texture(node, 0); + assert!( + !scene.nodes.get(node).unwrap().mat_dirty, + "default descriptor fields must not rebuild an unchanged material" + ); + } + + #[test] + fn shared_geometry_retires_only_after_last_reference() { + let key = SceneGeometryKey { + hash_a: 1, + hash_b: 2, + vertex_count: 3, + index_count: 3, + }; + let mut cache = HashMap::from([( + key, + SharedSceneGeometry { + slice: test_slice(), + references: 2, + }, + )]); + let mut retired = Vec::new(); + release_scene_geometry(&mut cache, &mut retired, key); + assert_eq!(cache[&key].references, 1); + assert!(retired.is_empty()); + release_scene_geometry(&mut cache, &mut retired, key); + assert!(!cache.contains_key(&key)); + assert_eq!(retired, vec![test_slice()]); + } + + #[test] + fn compatibility_alpha_disables_mixed_retained_submission() { + let mut scene = SceneGraph::new(); + let opaque = scene.create_node(); + scene.nodes.get_mut(opaque).unwrap().indices = vec![0, 1, 2]; + assert!(retained_order_is_gpu_safe(&scene.nodes)); + + let cutout = scene.create_node(); + let cutout_node = scene.nodes.get_mut(cutout).unwrap(); + cutout_node.indices = vec![0, 1, 2]; + cutout_node.material.alpha_cutoff = 0.5; + assert!(!retained_order_is_gpu_safe(&scene.nodes)); + + scene.nodes.get_mut(cutout).unwrap().visible = false; + assert!(retained_order_is_gpu_safe(&scene.nodes)); + + let blend = scene.create_node(); + let blend_node = scene.nodes.get_mut(blend).unwrap(); + blend_node.indices = vec![0, 1, 2]; + blend_node.material.alpha_mode = MaterialAlphaMode::Blend; + assert!( + retained_order_is_gpu_safe(&scene.nodes), + "dedicated translucent submission must not disable the opaque GPU-driven subset" + ); + } +} diff --git a/native/shared/src/sdf_cache.rs b/native/shared/src/sdf_cache.rs index 3fd7332c..d26f6a83 100644 --- a/native/shared/src/sdf_cache.rs +++ b/native/shared/src/sdf_cache.rs @@ -108,13 +108,19 @@ pub fn compute_mesh_hash(positions: &[[f32; 3]], indices: &[u32]) -> MeshHash { /// - wasm32: `None` pub fn cache_dir() -> Option { #[cfg(target_arch = "wasm32")] - { return None; } + { + return None; + } #[cfg(not(target_arch = "wasm32"))] { let dir = if cfg!(target_vendor = "apple") { let home = std::env::var_os("HOME")?; - PathBuf::from(home).join("Library").join("Caches").join("bloom").join("sdf") + PathBuf::from(home) + .join("Library") + .join("Caches") + .join("bloom") + .join("sdf") } else if cfg!(target_os = "windows") { let local = std::env::var_os("LOCALAPPDATA")?; PathBuf::from(local).join("bloom").join("cache").join("sdf") @@ -149,16 +155,24 @@ pub fn load(hash: MeshHash) -> Option> { let mut header = [0u8; 16]; f.read_exact(&mut header).ok()?; - if header[..6] != FILE_MAGIC { return None; } - if header[6] != FILE_VERSION { return None; } + if header[..6] != FILE_MAGIC { + return None; + } + if header[6] != FILE_VERSION { + return None; + } // header[7] reserved (alignment pad / future flags). let res = u32::from_le_bytes(header[8..12].try_into().ok()?); - if res != VOXEL_RES { return None; } + if res != VOXEL_RES { + return None; + } // header[12..16] reserved. let mut bytes = Vec::with_capacity(VOXEL_BYTES); f.read_to_end(&mut bytes).ok()?; - if bytes.len() != VOXEL_BYTES { return None; } + if bytes.len() != VOXEL_BYTES { + return None; + } Some(bytes) } @@ -210,7 +224,10 @@ mod tests { let pos1 = vec![[0.0_f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; let pos2 = vec![[0.0_f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.001, 0.0]]; let idx = vec![0_u32, 1, 2]; - assert_ne!(compute_mesh_hash(&pos1, &idx), compute_mesh_hash(&pos2, &idx)); + assert_ne!( + compute_mesh_hash(&pos1, &idx), + compute_mesh_hash(&pos2, &idx) + ); } #[test] @@ -218,7 +235,10 @@ mod tests { let pos = vec![[0.0_f32; 3]; 3]; let idx1 = vec![0_u32, 1, 2]; let idx2 = vec![0_u32, 2, 1]; - assert_ne!(compute_mesh_hash(&pos, &idx1), compute_mesh_hash(&pos, &idx2)); + assert_ne!( + compute_mesh_hash(&pos, &idx1), + compute_mesh_hash(&pos, &idx2) + ); } #[test] @@ -233,7 +253,9 @@ mod tests { #[test] fn store_then_load_roundtrips() { // Skip when the env doesn't expose a cache dir (CI sandbox can do this). - let Some(_) = cache_dir() else { return; }; + let Some(_) = cache_dir() else { + return; + }; // Use a hash unlikely to collide with anything else's tests. let h = MeshHash(0xfeed_cafe_dead_beef); let bytes: Vec = (0..VOXEL_BYTES).map(|i| (i * 7 + 13) as u8).collect(); @@ -241,14 +263,20 @@ mod tests { let got = load(h).expect("load hit"); assert_eq!(got, bytes); // Cleanup so the test is repeatable. - if let Some(p) = cache_path(h) { let _ = fs::remove_file(p); } + if let Some(p) = cache_path(h) { + let _ = fs::remove_file(p); + } } #[test] fn load_miss_returns_none() { - let Some(_) = cache_dir() else { return; }; + let Some(_) = cache_dir() else { + return; + }; let h = MeshHash(0x0000_0000_dead_dead); - if let Some(p) = cache_path(h) { let _ = fs::remove_file(p); } + if let Some(p) = cache_path(h) { + let _ = fs::remove_file(p); + } assert!(load(h).is_none()); } @@ -260,7 +288,9 @@ mod tests { #[test] fn load_rejects_wrong_magic() { - let Some(dir) = cache_dir() else { return; }; + let Some(dir) = cache_dir() else { + return; + }; let _ = fs::create_dir_all(&dir); let h = MeshHash(0xbad_0_bad_1); let p = dir.join(h.to_filename()); diff --git a/native/shared/src/shadows.rs b/native/shared/src/shadows.rs index 6e5b206a..6b75ee6f 100644 --- a/native/shared/src/shadows.rs +++ b/native/shared/src/shadows.rs @@ -69,7 +69,8 @@ fn vs_shadow(in: ShadowVertexInput) -> @builtin(position) vec4 { let world_pos = shadow_u.model * vec4(p, 1.0); return shadow_u.light_vp * world_pos; } -"#); +"# +); /// Alpha-tested shadow shader for cutout foliage (trees, grass, leaves). Same /// depth-only output as SHADOW_SHADER but samples the caster's base-colour @@ -87,7 +88,9 @@ struct ShadowUniforms { }; @group(0) @binding(0) var shadow_u: ShadowUniforms; -struct CutoutUniforms { cutoff: vec4 }; // x = alpha cutoff +struct CutoutUniforms { + cutoff: vec4, // x = alpha cutoff, y = lower mips store coverage +}; @group(1) @binding(0) var base_tex: texture_2d; @group(1) @binding(1) var base_samp: sampler; @group(1) @binding(2) var cut: CutoutUniforms; @@ -103,8 +106,27 @@ struct ShadowVertexInput { struct VsOut { @builtin(position) pos: vec4, @location(0) uv: vec2, + @location(1) alpha: f32, }; +fn mask_coverage_threshold(pixel: vec2) -> f32 { + let bayer = array( + 0.5 / 16.0, 8.5 / 16.0, 2.5 / 16.0, 10.5 / 16.0, + 12.5 / 16.0, 4.5 / 16.0, 14.5 / 16.0, 6.5 / 16.0, + 3.5 / 16.0, 11.5 / 16.0, 1.5 / 16.0, 9.5 / 16.0, + 15.5 / 16.0, 7.5 / 16.0, 13.5 / 16.0, 5.5 / 16.0, + ); + let x = u32(floor(pixel.x)) & 3u; + let y = u32(floor(pixel.y)) & 3u; + return bayer[y * 4u + x]; +} + +fn mask_texture_lod(uv: vec2, dimensions: vec2) -> f32 { + let extent = vec2(dimensions); + let dx = dpdx(uv) * extent; + let dy = dpdy(uv) * extent; + return max(0.5 * log2(max(max(dot(dx, dx), dot(dy, dy)), 1.0)), 0.0); +} @vertex fn vs_shadow_cutout(in: ShadowVertexInput) -> VsOut { @@ -115,15 +137,41 @@ fn vs_shadow_cutout(in: ShadowVertexInput) -> VsOut { let world_pos = shadow_u.model * vec4(p, 1.0); o.pos = shadow_u.light_vp * world_pos; o.uv = in.uv; + o.alpha = in.color.a; return o; } @fragment fn fs_shadow_cutout(in: VsOut) { - let a = textureSample(base_tex, base_samp, in.uv).a; - if (a < cut.cutoff.x) { discard; } + var survives = true; + if (cut.cutoff.y > 0.5) { + let lod = mask_texture_lod(in.uv, textureDimensions(base_tex)); + if (lod <= 0.5) { + let authored_alpha = + textureSampleLevel(base_tex, base_samp, in.uv, 0.0).a * in.alpha; + survives = authored_alpha >= cut.cutoff.x; + } else if (lod >= 1.0) { + let coverage = textureSampleLevel(base_tex, base_samp, in.uv, lod).a; + survives = coverage >= mask_coverage_threshold(in.pos.xy); + } else { + let authored_alpha = + textureSampleLevel(base_tex, base_samp, in.uv, 0.0).a * in.alpha; + let coverage = textureSampleLevel(base_tex, base_samp, in.uv, 1.0).a; + let probability = mix( + select(0.0, 1.0, authored_alpha >= cut.cutoff.x), + coverage, + smoothstep(0.5, 1.0, lod), + ); + survives = probability >= mask_coverage_threshold(in.pos.xy); + } + } else { + let raw_alpha = textureSample(base_tex, base_samp, in.uv).a * in.alpha; + survives = raw_alpha >= cut.cutoff.x; + } + if (!survives) { discard; } } -"#); +"# +); /// Skinned shadow shader for animated characters (player, enemies). Their /// vertices are *rest-pose* (cached model VBs with raw joint indices, or the @@ -280,6 +328,10 @@ pub struct ShadowMap { /// casters. A cascade whose dynamics all left still needs one /// refresh copy to clear their stale shadows. pub had_dynamic: [bool; NUM_CASCADES], + /// Monotonic per-cascade version of the *live* opaque depth. Lazy + /// secondary visibility representations use this instead of guessing + /// whether a static-cache copy or dynamic overlay changed their blocker. + pub(crate) live_cascade_generation: [u64; NUM_CASCADES], /// Monotonic counter folded into animated casters' signatures so /// any cascade containing one re-renders every frame. pub frame_nonce: u64, @@ -293,6 +345,9 @@ pub struct ShadowMap { /// near cascade is exempt and stays exact-fit). accepted_fit: [Option; NUM_CASCADES], accepted_light_dir: Option<[f32; 3]>, + /// Issue #132 — bounded virtual-page residency and invalidation. + /// Sampling remains on CSM until the physical page renderer is qualified. + pub(crate) virtual_map: crate::virtual_shadows::DirectionalVirtualShadowMap, } /// Accepted (slack-inflated) ortho fit for one cascade. `ls_x`/`ls_y` @@ -361,8 +416,7 @@ impl ShadowMap { sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Depth32Float, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::COPY_SRC, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let sview = stex.create_view(&wgpu::TextureViewDescriptor::default()); @@ -371,14 +425,18 @@ impl ShadowMap { } // Convert Vecs to fixed-size arrays - let depth_textures: [wgpu::Texture; NUM_CASCADES] = - depth_textures_vec.try_into().unwrap_or_else(|_| panic!("cascade texture count mismatch")); - let depth_views: [wgpu::TextureView; NUM_CASCADES] = - depth_views_vec.try_into().unwrap_or_else(|_| panic!("cascade view count mismatch")); - let static_depth_textures: [wgpu::Texture; NUM_CASCADES] = - static_textures_vec.try_into().unwrap_or_else(|_| panic!("static cascade texture count mismatch")); - let static_depth_views: [wgpu::TextureView; NUM_CASCADES] = - static_views_vec.try_into().unwrap_or_else(|_| panic!("static cascade view count mismatch")); + let depth_textures: [wgpu::Texture; NUM_CASCADES] = depth_textures_vec + .try_into() + .unwrap_or_else(|_| panic!("cascade texture count mismatch")); + let depth_views: [wgpu::TextureView; NUM_CASCADES] = depth_views_vec + .try_into() + .unwrap_or_else(|_| panic!("cascade view count mismatch")); + let static_depth_textures: [wgpu::Texture; NUM_CASCADES] = static_textures_vec + .try_into() + .unwrap_or_else(|_| panic!("static cascade texture count mismatch")); + let static_depth_views: [wgpu::TextureView; NUM_CASCADES] = static_views_vec + .try_into() + .unwrap_or_else(|_| panic!("static cascade view count mismatch")); // Comparison sampler for PCF let sampler = device.create_sampler(&wgpu::SamplerDescriptor { @@ -495,9 +553,7 @@ impl ShadowMap { resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &uniform_buffer, offset: 0, - size: std::num::NonZeroU64::new( - std::mem::size_of::() as u64, - ), + size: std::num::NonZeroU64::new(std::mem::size_of::() as u64), }), }], }); @@ -558,23 +614,28 @@ impl ShadowMap { label: Some("shadow_cutout_tex_layout"), entries: &[ wgpu::BindGroupLayoutEntry { - binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, }, count: None, }, wgpu::BindGroupLayoutEntry { - binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, wgpu::BindGroupLayoutEntry { - binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, min_binding_size: None, + has_dynamic_offset: false, + min_binding_size: None, }, count: None, }, @@ -597,7 +658,7 @@ impl ShadowMap { fragment: Some(wgpu::FragmentState { module: &cutout_shader, entry_point: Some("fs_shadow_cutout"), - targets: &[], // depth only + targets: &[], // depth only compilation_options: Default::default(), }), primitive: wgpu::PrimitiveState { @@ -611,7 +672,11 @@ impl ShadowMap { depth_write_enabled: Some(true), depth_compare: Some(wgpu::CompareFunction::Less), stencil: Default::default(), - bias: wgpu::DepthBiasState { constant: 1, slope_scale: 1.0, clamp: 0.0 }, + bias: wgpu::DepthBiasState { + constant: 1, + slope_scale: 1.0, + clamp: 0.0, + }, }), multisample: Default::default(), multiview_mask: None, @@ -651,12 +716,18 @@ impl ShadowMap { depth_write_enabled: Some(true), depth_compare: Some(wgpu::CompareFunction::Less), stencil: Default::default(), - bias: wgpu::DepthBiasState { constant: 1, slope_scale: 1.0, clamp: 0.0 }, + bias: wgpu::DepthBiasState { + constant: 1, + slope_scale: 1.0, + clamp: 0.0, + }, }), multisample: Default::default(), multiview_mask: None, cache: None, }); + let virtual_map = + crate::virtual_shadows::DirectionalVirtualShadowMap::new(device, &uniform_layout); Self { depth_textures, @@ -684,9 +755,11 @@ impl ShadowMap { pancake_hysteresis: [[0.0; 2]; NUM_CASCADES], rendered_cascade_sig: [0; NUM_CASCADES], had_dynamic: [false; NUM_CASCADES], + live_cascade_generation: [0; NUM_CASCADES], frame_nonce: 0, accepted_fit: [None; NUM_CASCADES], accepted_light_dir: None, + virtual_map, } } @@ -700,6 +773,7 @@ impl ShadowMap { self.rendered_cascade_sig = [0; NUM_CASCADES]; self.had_dynamic = [false; NUM_CASCADES]; self.accepted_fit = [None; NUM_CASCADES]; + self.virtual_map.invalidate(); } /// Compute cascade view-projection matrices by splitting the camera @@ -827,8 +901,10 @@ impl ShadowMap { let dx = world_corners[i][0] - center[0]; let dy = world_corners[i][1] - center[1]; let dz = world_corners[i][2] - center[2]; - let r2 = dx*dx + dy*dy + dz*dz; - if r2 > radius { radius = r2; } + let r2 = dx * dx + dy * dy + dz * dz; + if r2 > radius { + radius = r2; + } } radius = radius.sqrt(); @@ -883,8 +959,12 @@ impl ShadowMap { ], d, ); - if a > req_back { req_back = a; } - if -a > req_far { req_far = -a; } + if a > req_back { + req_back = a; + } + if -a > req_far { + req_far = -a; + } } } if fits_xy && req_back <= acc.back && req_far <= acc.far { @@ -918,7 +998,7 @@ impl ShadowMap { // into it. This is "pancaking" — cascade XY is tight to the // frustum sphere, but Z reaches back to the full scene. let mut pancake_back: f32 = radius; // +d distance (toward light) - let mut pancake_far: f32 = radius; // -d distance (away from light) + let mut pancake_far: f32 = radius; // -d distance (away from light) if let Some((bmin, bmax)) = scene_bounds { let corners = [ [bmin[0], bmin[1], bmin[2]], @@ -937,8 +1017,12 @@ impl ShadowMap { p[2] - snapped_center[2], ]; let along_d = dot3(rel, d); - if along_d > pancake_back { pancake_back = along_d; } - if -along_d > pancake_far { pancake_far = -along_d; } + if along_d > pancake_back { + pancake_back = along_d; + } + if -along_d > pancake_far { + pancake_far = -along_d; + } } } // Quantize Z range so scene-bounds drift doesn't shift depths. @@ -956,15 +1040,13 @@ impl ShadowMap { const PANCAKE_STEP: f32 = 2.0; let quantize = |v: f32| (v / PANCAKE_STEP).ceil() * PANCAKE_STEP; let prev = self.pancake_hysteresis[c]; - let pancake_back = if pancake_back > prev[0] - || pancake_back < prev[0] - 2.0 * PANCAKE_STEP - { - quantize(pancake_back) - } else { - prev[0] - }; - let pancake_far = if pancake_far > prev[1] - || pancake_far < prev[1] - 2.0 * PANCAKE_STEP + let pancake_back = + if pancake_back > prev[0] || pancake_back < prev[0] - 2.0 * PANCAKE_STEP { + quantize(pancake_back) + } else { + prev[0] + }; + let pancake_far = if pancake_far > prev[1] || pancake_far < prev[1] - 2.0 * PANCAKE_STEP { quantize(pancake_far) } else { @@ -983,8 +1065,10 @@ impl ShadowMap { let snapped_view = crate::renderer::mat4_look_at(light_pos, snapped_center, up_hint); let light_proj = crate::renderer::mat4_ortho( - -radius, radius, - -radius, radius, + -radius, + radius, + -radius, + radius, 0.0, eye_offset + pancake_far, ); @@ -1022,6 +1106,17 @@ impl ShadowMap { } } +#[cfg(test)] +mod shader_tests { + use super::SHADOW_SHADER_CUTOUT; + + #[test] + fn coverage_preserving_cutout_shadow_shader_parses() { + wgpu::naga::front::wgsl::parse_str(SHADOW_SHADER_CUTOUT) + .unwrap_or_else(|error| panic!("cutout shadow WGSL failed to parse: {error:?}")); + } +} + fn normalize3(v: [f32; 3]) -> [f32; 3] { let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); if len > 1e-6 { diff --git a/native/shared/src/staging.rs b/native/shared/src/staging.rs index e6fc0c17..0e97f7f8 100644 --- a/native/shared/src/staging.rs +++ b/native/shared/src/staging.rs @@ -1,7 +1,7 @@ -use std::sync::{Mutex, OnceLock}; +use crate::audio::SoundData; #[cfg(feature = "models3d")] use crate::models::ModelData; -use crate::audio::SoundData; +use std::sync::{Mutex, OnceLock}; pub struct StagedTexture { pub data: Vec, @@ -12,6 +12,9 @@ pub struct StagedTexture { /// flattens the shading. Set by `load_gltf_staged` from the material's /// `normal_texture` references, mirroring `load_gltf_with_textures`. pub is_normal: bool, + /// MASK-only texture-space cutoff used to build lower mips whose alpha + /// stores surviving texel coverage. None preserves ordinary color mips. + pub alpha_coverage_reference: Option, } #[cfg(feature = "models3d")] @@ -54,9 +57,13 @@ fn stage_into(store: &Mutex>>, item: T) -> f64 { fn take_from(store: &Mutex>>, handle: f64) -> Option { let idx = handle as usize; - if idx == 0 { return None; } + if idx == 0 { + return None; + } let mut vec = store.lock().unwrap(); - if idx > vec.len() { return None; } + if idx > vec.len() { + return None; + } vec[idx - 1].take() } @@ -72,7 +79,13 @@ pub fn decode_and_stage_texture(file_data: &[u8]) -> f64 { let height = img.height(); // Standalone staged textures are albedo-class; nothing routes a normal // map through this path (models carry theirs inside StagedModel). - stage_texture(StagedTexture { data: img.into_raw(), width, height, is_normal: false }) + stage_texture(StagedTexture { + data: img.into_raw(), + width, + height, + is_normal: false, + alpha_coverage_reference: None, + }) } pub fn stage_texture(tex: StagedTexture) -> f64 { diff --git a/native/shared/src/string_header.rs b/native/shared/src/string_header.rs index 0eac8fbe..f05a3211 100644 --- a/native/shared/src/string_header.rs +++ b/native/shared/src/string_header.rs @@ -250,7 +250,11 @@ mod tests { let p = alloc_perry_string("abc"); let payload_end = std::mem::size_of::() + 3; for i in 0..TAIL_PAD { - assert_eq!(unsafe { *p.add(payload_end + i) }, 0, "pad byte {i} not zero"); + assert_eq!( + unsafe { *p.add(payload_end + i) }, + 0, + "pad byte {i} not zero" + ); } } diff --git a/native/shared/src/text_renderer.rs b/native/shared/src/text_renderer.rs index 3e943e49..7df9af54 100644 --- a/native/shared/src/text_renderer.rs +++ b/native/shared/src/text_renderer.rs @@ -42,7 +42,9 @@ fn generate_sdf(bitmap: &[u8], w: u32, h: u32, spread: f32) -> Vec { let dx = (sx - x) as f32; let dy = (sy - y) as f32; let dist = (dx * dx + dy * dy).sqrt(); - if dist < min_dist { min_dist = dist; } + if dist < min_dist { + min_dist = dist; + } } } } @@ -142,8 +144,7 @@ impl TextRenderer { } pub fn load_font(&mut self, data: &[u8]) -> usize { - let font = Font::from_bytes(data, FontSettings::default()) - .expect("Failed to load font"); + let font = Font::from_bytes(data, FontSettings::default()).expect("Failed to load font"); self.fonts.push(font); self.fonts.len() - 1 } @@ -202,29 +203,43 @@ impl TextRenderer { } self.atlas_dirty = true; - self.glyph_cache.insert(key, GlyphInfo { - atlas_x: ax, - atlas_y: ay, - width: gw, - height: gh, - advance: metrics.advance_width, - x_offset: metrics.xmin as f32, - y_offset: metrics.ymin as f32, - }); + self.glyph_cache.insert( + key, + GlyphInfo { + atlas_x: ax, + atlas_y: ay, + width: gw, + height: gh, + advance: metrics.advance_width, + x_offset: metrics.xmin as f32, + y_offset: metrics.ymin as f32, + }, + ); &self.glyph_cache[&key] } fn ensure_atlas_uploaded(&mut self, renderer: &mut Renderer) { - if !self.atlas_dirty { return; } + if !self.atlas_dirty { + return; + } match self.atlas_bind_group_idx { None => { - let idx = renderer.register_texture_no_mips(self.atlas_width, self.atlas_height, &self.atlas_data); + let idx = renderer.register_texture_no_mips( + self.atlas_width, + self.atlas_height, + &self.atlas_data, + ); self.atlas_bind_group_idx = Some(idx); } Some(idx) => { - renderer.replace_texture_no_mips(idx, self.atlas_width, self.atlas_height, &self.atlas_data); + renderer.replace_texture_no_mips( + idx, + self.atlas_width, + self.atlas_height, + &self.atlas_data, + ); } } self.atlas_dirty = false; @@ -235,7 +250,11 @@ impl TextRenderer { } pub fn measure_text_ex(&mut self, font_idx: usize, text: &str, size: u32, spacing: f32) -> f64 { - let idx = if font_idx < self.fonts.len() { font_idx } else { 0 }; + let idx = if font_idx < self.fonts.len() { + font_idx + } else { + 0 + }; let mut width = 0.0f32; let mut first = true; for ch in text.chars() { @@ -256,7 +275,10 @@ impl TextRenderer { x: f64, y: f64, size: u32, - r: f64, g: f64, b: f64, a: f64, + r: f64, + g: f64, + b: f64, + a: f64, ) { self.draw_text_ex(renderer, 0, text, x, y, size, 0.0, r, g, b, a); } @@ -270,9 +292,16 @@ impl TextRenderer { y: f64, size: u32, spacing: f32, - r: f64, g: f64, b: f64, a: f64, + r: f64, + g: f64, + b: f64, + a: f64, ) { - let idx = if font_idx < self.fonts.len() { font_idx } else { 0 }; + let idx = if font_idx < self.fonts.len() { + font_idx + } else { + 0 + }; // Round-2 audit F5: glyphs were rasterized at LOGICAL pixel size // and stretched ×dpi by the 2D projection — a soft, bilinear- @@ -331,9 +360,7 @@ impl TextRenderer { // ×dpi stretch then lands the bitmap 1:1 on physical // pixels instead of magnifying a logical-res raster. let gx = cursor_x + glyph.x_offset * inv_dpi; - let gy = y as f32 - - (glyph.y_offset + glyph.height as f32) * inv_dpi - + size as f32; + let gy = y as f32 - (glyph.y_offset + glyph.height as f32) * inv_dpi + size as f32; let gw = glyph.width as f32 * inv_dpi; let gh = glyph.height as f32 * inv_dpi; @@ -410,28 +437,42 @@ impl TextRenderer { } self.sdf_atlas_dirty = true; - self.sdf_glyph_cache.insert(key, GlyphInfo { - atlas_x: ax, - atlas_y: ay, - width: gw, - height: gh, - advance: metrics.advance_width, - x_offset: metrics.xmin as f32, - y_offset: metrics.ymin as f32, - }); + self.sdf_glyph_cache.insert( + key, + GlyphInfo { + atlas_x: ax, + atlas_y: ay, + width: gw, + height: gh, + advance: metrics.advance_width, + x_offset: metrics.xmin as f32, + y_offset: metrics.ymin as f32, + }, + ); &self.sdf_glyph_cache[&key] } fn ensure_sdf_atlas_uploaded(&mut self, renderer: &mut Renderer) { - if !self.sdf_atlas_dirty { return; } + if !self.sdf_atlas_dirty { + return; + } match self.sdf_atlas_bind_group_idx { None => { - let idx = renderer.register_texture(self.atlas_width, self.atlas_height, &self.sdf_atlas_data); + let idx = renderer.register_texture( + self.atlas_width, + self.atlas_height, + &self.sdf_atlas_data, + ); self.sdf_atlas_bind_group_idx = Some(idx); } Some(idx) => { - renderer.update_texture(idx, self.atlas_width, self.atlas_height, &self.sdf_atlas_data); + renderer.update_texture( + idx, + self.atlas_width, + self.atlas_height, + &self.sdf_atlas_data, + ); } } self.sdf_atlas_dirty = false; @@ -447,9 +488,16 @@ impl TextRenderer { y: f64, size: u32, spacing: f32, - r: f64, g: f64, b: f64, a: f64, + r: f64, + g: f64, + b: f64, + a: f64, ) { - let idx = if font_idx < self.fonts.len() { font_idx } else { 0 }; + let idx = if font_idx < self.fonts.len() { + font_idx + } else { + 0 + }; let scale = size as f32 / Self::SDF_BASE_SIZE as f32; for ch in text.chars() { @@ -478,12 +526,15 @@ impl TextRenderer { let mut cursor_x = x as f32; let mut first = true; for ch in text.chars() { - if !first && spacing != 0.0 { cursor_x += spacing * scale; } + if !first && spacing != 0.0 { + cursor_x += spacing * scale; + } first = false; let key = (idx, ch); if let Some(glyph) = self.sdf_glyph_cache.get(&key) { let gx = cursor_x + glyph.x_offset * scale; - let gy = y as f32 - glyph.y_offset * scale - glyph.height as f32 * scale + size as f32; + let gy = + y as f32 - glyph.y_offset * scale - glyph.height as f32 * scale + size as f32; let gw = glyph.width as f32 * scale; let gh = glyph.height as f32 * scale; diff --git a/native/shared/src/textures.rs b/native/shared/src/textures.rs index 9688af1e..3c09756e 100644 --- a/native/shared/src/textures.rs +++ b/native/shared/src/textures.rs @@ -20,7 +20,7 @@ pub struct ImageData { pub struct RenderTextureData { pub width: u32, pub height: u32, - pub texture_handle: f64, // Points to the corresponding TextureData entry. + pub texture_handle: f64, // Points to the corresponding TextureData entry. } pub struct TextureManager { @@ -42,7 +42,9 @@ impl TextureManager { /// is created by the calling FFI function which has access to the Renderer. pub fn load_render_texture(&mut self, width: u32, height: u32) -> f64 { self.render_textures.alloc(RenderTextureData { - width, height, texture_handle: 0.0, + width, + height, + texture_handle: 0.0, }) } @@ -97,7 +99,11 @@ impl TextureManager { let data = img.into_raw(); let bind_group_idx = renderer.register_texture(width, height, &data); - self.textures.alloc(TextureData { bind_group_idx, width, height }) + self.textures.alloc(TextureData { + bind_group_idx, + width, + height, + }) } pub fn unload_texture(&mut self, handle: f64, renderer: &mut Renderer) { @@ -119,14 +125,27 @@ impl TextureManager { let height = img.height(); let data = img.into_raw(); - self.images.alloc(ImageData { data, width, height }) + self.images.alloc(ImageData { + data, + width, + height, + }) } pub fn image_resize(&mut self, handle: f64, new_w: u32, new_h: u32) { if let Some(img_data) = self.images.get_mut(handle) { - let src = image::RgbaImage::from_raw(img_data.width, img_data.height, std::mem::take(&mut img_data.data)); + let src = image::RgbaImage::from_raw( + img_data.width, + img_data.height, + std::mem::take(&mut img_data.data), + ); if let Some(src) = src { - let resized = image::imageops::resize(&src, new_w, new_h, image::imageops::FilterType::Triangle); + let resized = image::imageops::resize( + &src, + new_w, + new_h, + image::imageops::FilterType::Triangle, + ); img_data.width = new_w; img_data.height = new_h; img_data.data = resized.into_raw(); @@ -136,7 +155,11 @@ impl TextureManager { pub fn image_crop(&mut self, handle: f64, x: u32, y: u32, w: u32, h: u32) { if let Some(img_data) = self.images.get_mut(handle) { - let src = image::RgbaImage::from_raw(img_data.width, img_data.height, std::mem::take(&mut img_data.data)); + let src = image::RgbaImage::from_raw( + img_data.width, + img_data.height, + std::mem::take(&mut img_data.data), + ); if let Some(mut src) = src { let cropped = image::imageops::crop(&mut src, x, y, w, h).to_image(); img_data.width = w; @@ -148,7 +171,11 @@ impl TextureManager { pub fn image_flip_h(&mut self, handle: f64) { if let Some(img_data) = self.images.get_mut(handle) { - let src = image::RgbaImage::from_raw(img_data.width, img_data.height, std::mem::take(&mut img_data.data)); + let src = image::RgbaImage::from_raw( + img_data.width, + img_data.height, + std::mem::take(&mut img_data.data), + ); if let Some(src) = src { let flipped = image::imageops::flip_horizontal(&src); img_data.data = flipped.into_raw(); @@ -158,7 +185,11 @@ impl TextureManager { pub fn image_flip_v(&mut self, handle: f64) { if let Some(img_data) = self.images.get_mut(handle) { - let src = image::RgbaImage::from_raw(img_data.width, img_data.height, std::mem::take(&mut img_data.data)); + let src = image::RgbaImage::from_raw( + img_data.width, + img_data.height, + std::mem::take(&mut img_data.data), + ); if let Some(src) = src { let flipped = image::imageops::flip_vertical(&src); img_data.data = flipped.into_raw(); @@ -173,14 +204,22 @@ impl TextureManager { }; let (width, height) = (dds.get_width(), dds.get_height()); if let Some(bind_group_idx) = renderer.register_texture_dds(&dds) { - return self.textures.alloc(TextureData { bind_group_idx, width, height }); + return self.textures.alloc(TextureData { + bind_group_idx, + width, + height, + }); } // No BC support on this adapter (mobile GL): CPU-decode the top // mip and feed the regular RGBA path (which regenerates mips). match image_dds::image_from_dds(&dds, 0) { Ok(rgba) => { let bind_group_idx = renderer.register_texture(width, height, rgba.as_raw()); - self.textures.alloc(TextureData { bind_group_idx, width, height }) + self.textures.alloc(TextureData { + bind_group_idx, + width, + height, + }) } Err(_) => 0.0, } @@ -188,8 +227,13 @@ impl TextureManager { pub fn load_texture_from_image(&mut self, handle: f64, renderer: &mut Renderer) -> f64 { if let Some(img_data) = self.images.get(handle) { - let bind_group_idx = renderer.register_texture(img_data.width, img_data.height, &img_data.data); - self.textures.alloc(TextureData { bind_group_idx, width: img_data.width, height: img_data.height }) + let bind_group_idx = + renderer.register_texture(img_data.width, img_data.height, &img_data.data); + self.textures.alloc(TextureData { + bind_group_idx, + width: img_data.width, + height: img_data.height, + }) } else { 0.0 } diff --git a/native/shared/src/virtual_shadows.rs b/native/shared/src/virtual_shadows.rs new file mode 100644 index 00000000..eca200c7 --- /dev/null +++ b/native/shared/src/virtual_shadows.rs @@ -0,0 +1,1920 @@ +//! Deterministic virtual-shadow page residency for issue #132. +//! +//! This module deliberately contains no renderer policy. It owns the bounded +//! virtual-to-physical mapping, invalidation, and page-table encoding used by +//! the directional VSM path. The existing cascaded shadow map remains the +//! sampling fallback whenever a page is absent, dirty, over budget, or not yet +//! rendered. + +use std::collections::HashMap; +use std::sync::OnceLock; + +pub const VSM_CLIP_LEVELS: u8 = 3; +pub const VSM_VIRTUAL_PAGES_PER_AXIS: u16 = 32; +pub const VSM_PAGE_INTERIOR: u16 = 128; +pub const VSM_PAGE_BORDER: u16 = 2; +pub const VSM_PHYSICAL_PAGE_SIZE: u16 = VSM_PAGE_INTERIOR + VSM_PAGE_BORDER * 2; +pub const VSM_DEFAULT_PHYSICAL_PAGES: u16 = 256; +pub const VSM_MAX_PAGE_RENDER_BUDGET: u16 = 64; +const VSM_DIRECTIONAL_LEVEL_PAGE_CAPS: [usize; VSM_CLIP_LEVELS as usize] = [144, 64, 16]; + +/// Page-table value zero means "sample the conventional shadow fallback". +/// +/// Resident entries store physical page + 1 in the low 16 bits and a +/// saturating residency age in the high 16 bits. The shader can therefore +/// cross-fade a newly rendered VSM page over the CSM result without another +/// texture or buffer. +pub const VSM_PAGE_TABLE_MISSING: u32 = 0; + +#[derive(Copy, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct VirtualShadowPage { + pub light: u16, + pub level: u8, + pub x: u16, + pub y: u16, +} + +impl VirtualShadowPage { + pub fn new(light: u16, level: u8, x: u16, y: u16) -> Option { + (level < VSM_CLIP_LEVELS + && x < VSM_VIRTUAL_PAGES_PER_AXIS + && y < VSM_VIRTUAL_PAGES_PER_AXIS) + .then_some(Self { light, level, x, y }) + } + + fn table_index(self) -> usize { + let axis = VSM_VIRTUAL_PAGES_PER_AXIS as usize; + self.level as usize * axis * axis + self.y as usize * axis + self.x as usize + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct PageRequest { + pub page: VirtualShadowPage, + pub physical_page: u16, + pub needs_render: bool, + pub evicted: Option, +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct VirtualShadowCacheStats { + pub capacity: u16, + pub resident: u16, + pub requested: u32, + pub hits: u32, + pub misses: u32, + pub evictions: u32, + pub denied: u32, + pub dirty: u16, + pub rendered: u32, + pub invalidated: u32, +} + +#[derive(Copy, Clone, Debug)] +struct PhysicalPage { + owner: Option, + last_used_frame: u64, + rendered_frame: u64, + rendered_signature: u64, + dirty: bool, +} + +impl Default for PhysicalPage { + fn default() -> Self { + Self { + owner: None, + last_used_frame: 0, + rendered_frame: 0, + rendered_signature: 0, + dirty: true, + } + } +} + +/// Fixed-budget, deterministic LRU cache. +/// +/// Pages requested earlier in the current frame are protected from eviction. +/// If a frame requests more unique pages than the pool can hold, later +/// requests are denied and sample CSM instead of churning already selected +/// pages or exceeding the configured memory budget. +pub struct VirtualShadowPageCache { + physical: Vec, + mapping: HashMap, + frame: u64, + stats: VirtualShadowCacheStats, +} + +impl VirtualShadowPageCache { + pub fn new(capacity: u16) -> Self { + assert!(capacity > 0, "VSM page cache requires at least one page"); + Self { + physical: vec![PhysicalPage::default(); capacity as usize], + mapping: HashMap::with_capacity(capacity as usize), + frame: 0, + stats: VirtualShadowCacheStats { + capacity, + ..Default::default() + }, + } + } + + pub fn begin_frame(&mut self, frame: u64) { + self.frame = frame.max(1); + self.stats.requested = 0; + self.stats.hits = 0; + self.stats.misses = 0; + self.stats.evictions = 0; + self.stats.denied = 0; + self.stats.rendered = 0; + self.stats.invalidated = 0; + } + + pub fn request( + &mut self, + page: VirtualShadowPage, + content_signature: u64, + ) -> Option { + self.stats.requested = self.stats.requested.saturating_add(1); + if let Some(&physical_page) = self.mapping.get(&page) { + self.stats.hits = self.stats.hits.saturating_add(1); + let slot = &mut self.physical[physical_page as usize]; + slot.last_used_frame = self.frame; + if slot.rendered_signature != content_signature && !slot.dirty { + slot.dirty = true; + self.stats.invalidated = self.stats.invalidated.saturating_add(1); + } + let result = PageRequest { + page, + physical_page, + needs_render: slot.dirty, + evicted: None, + }; + return Some(result); + } + + self.stats.misses = self.stats.misses.saturating_add(1); + let candidate = self + .physical + .iter() + .position(|slot| slot.owner.is_none()) + .or_else(|| { + self.physical + .iter() + .enumerate() + .filter(|(_, slot)| slot.last_used_frame < self.frame) + .min_by_key(|(physical_page, slot)| (slot.last_used_frame, *physical_page)) + .map(|(physical_page, _)| physical_page) + }); + let Some(physical_page) = candidate else { + self.stats.denied = self.stats.denied.saturating_add(1); + return None; + }; + + let evicted = self.physical[physical_page].owner; + if let Some(old_page) = evicted { + self.mapping.remove(&old_page); + self.stats.evictions = self.stats.evictions.saturating_add(1); + } + let physical_page = physical_page as u16; + self.physical[physical_page as usize] = PhysicalPage { + owner: Some(page), + last_used_frame: self.frame, + rendered_frame: 0, + rendered_signature: content_signature, + dirty: true, + }; + self.mapping.insert(page, physical_page); + Some(PageRequest { + page, + physical_page, + needs_render: true, + evicted, + }) + } + + pub fn mark_rendered(&mut self, page: VirtualShadowPage, content_signature: u64) -> bool { + let Some(&physical_page) = self.mapping.get(&page) else { + return false; + }; + let slot = &mut self.physical[physical_page as usize]; + if slot.owner != Some(page) { + return false; + } + slot.rendered_frame = self.frame; + slot.rendered_signature = content_signature; + slot.dirty = false; + self.stats.rendered = self.stats.rendered.saturating_add(1); + true + } + + pub fn finish_requests(&mut self) { + self.refresh_counts(); + } + + pub fn record_stable_requests(&mut self, requested: usize) { + let requested = requested.min(u32::MAX as usize) as u32; + let hits = requested.min(u32::from(self.stats.resident)); + self.stats.requested = requested; + self.stats.hits = hits; + self.stats.misses = requested.saturating_sub(hits); + self.stats.denied = requested.saturating_sub(hits); + } + + pub fn invalidate_light(&mut self, light: u16) { + for slot in &mut self.physical { + if slot.owner.is_some_and(|owner| owner.light == light) && !slot.dirty { + slot.dirty = true; + self.stats.invalidated = self.stats.invalidated.saturating_add(1); + } + } + self.refresh_counts(); + } + + pub fn invalidate_level(&mut self, light: u16, level: u8) { + for slot in &mut self.physical { + if slot + .owner + .is_some_and(|owner| owner.light == light && owner.level == level) + && !slot.dirty + { + slot.dirty = true; + self.stats.invalidated = self.stats.invalidated.saturating_add(1); + } + } + self.refresh_counts(); + } + + pub fn invalidate_all(&mut self) { + for slot in &mut self.physical { + if slot.owner.is_some() && !slot.dirty { + slot.dirty = true; + self.stats.invalidated = self.stats.invalidated.saturating_add(1); + } + } + self.refresh_counts(); + } + + pub fn page_table(&self, light: u16) -> Vec { + let axis = VSM_VIRTUAL_PAGES_PER_AXIS as usize; + let mut table = vec![VSM_PAGE_TABLE_MISSING; VSM_CLIP_LEVELS as usize * axis * axis]; + for (&page, &physical_page) in &self.mapping { + if page.light != light { + continue; + } + let slot = &self.physical[physical_page as usize]; + if slot.dirty || slot.rendered_frame == 0 { + continue; + } + let age = self + .frame + .saturating_sub(slot.rendered_frame) + .saturating_add(1) + // Only the first eight frames are meaningful: the shader + // reaches 100% VSM at age 8. Saturating here makes the page + // table byte-stable afterward, so it needs no steady upload. + .min(8) as u16; + table[page.table_index()] = (physical_page as u32 + 1) | ((age as u32) << 16); + } + table + } + + pub fn stats(&self) -> VirtualShadowCacheStats { + self.stats + } + + pub fn level_counts(&self, light: u16) -> [(u16, u16); VSM_CLIP_LEVELS as usize] { + let mut counts = [(0u16, 0u16); VSM_CLIP_LEVELS as usize]; + for slot in &self.physical { + let Some(owner) = slot.owner else { + continue; + }; + if owner.light != light { + continue; + } + counts[owner.level as usize].0 += 1; + if slot.dirty { + counts[owner.level as usize].1 += 1; + } + } + counts + } + + pub fn debug_virtual_rgb(&self, light: u16, scale: u32) -> (u32, u32, Vec) { + let scale = scale.max(1); + let axis = VSM_VIRTUAL_PAGES_PER_AXIS as u32; + let width = axis * scale; + let height = axis * VSM_CLIP_LEVELS as u32 * scale; + let mut rgb = vec![8u8; (width * height * 3) as usize]; + let level_colors = [[70u8, 210, 110], [70, 150, 255], [190, 100, 255]]; + for (&page, &physical_page) in &self.mapping { + if page.light != light { + continue; + } + let slot = &self.physical[physical_page as usize]; + let color = if slot.dirty || slot.rendered_frame == 0 { + [255, 55, 45] + } else { + level_colors[page.level as usize] + }; + paint_debug_cell( + &mut rgb, + width, + scale, + u32::from(page.x), + u32::from(page.y) + u32::from(page.level) * axis, + color, + ); + } + (width, height, rgb) + } + + pub fn debug_physical_rgb(&self, scale: u32) -> (u32, u32, Vec) { + let scale = scale.max(1); + let columns = 16u32.min(self.physical.len().max(1) as u32); + let rows = (self.physical.len() as u32).div_ceil(columns); + let width = columns * scale; + let height = rows.max(1) * scale; + let mut rgb = vec![8u8; (width * height * 3) as usize]; + let level_colors = [[70u8, 210, 110], [70, 150, 255], [190, 100, 255]]; + for (index, slot) in self.physical.iter().enumerate() { + let Some(owner) = slot.owner else { + continue; + }; + let color = if slot.dirty || slot.rendered_frame == 0 { + [255, 55, 45] + } else { + level_colors[owner.level as usize] + }; + paint_debug_cell( + &mut rgb, + width, + scale, + index as u32 % columns, + index as u32 / columns, + color, + ); + } + (width, height, rgb) + } + + pub fn memory_bytes(&self) -> u64 { + let edge = VSM_PHYSICAL_PAGE_SIZE as u64; + edge * edge * std::mem::size_of::() as u64 * self.physical.len() as u64 + } + + fn refresh_counts(&mut self) { + self.stats.resident = self + .physical + .iter() + .filter(|slot| slot.owner.is_some()) + .count() as u16; + self.stats.dirty = self + .physical + .iter() + .filter(|slot| slot.owner.is_some() && slot.dirty) + .count() as u16; + } +} + +fn paint_debug_cell( + rgb: &mut [u8], + width: u32, + scale: u32, + cell_x: u32, + cell_y: u32, + color: [u8; 3], +) { + for y in 0..scale { + for x in 0..scale { + let pixel_x = cell_x * scale + x; + let pixel_y = cell_y * scale + y; + let offset = ((pixel_y * width + pixel_x) * 3) as usize; + rgb[offset..offset + 3].copy_from_slice(&color); + } + } +} + +/// Runtime policy wrapper for the directional prototype. +/// +/// The cache foundation is intentionally opt-in until physical page rendering +/// and sampling are connected and qualified. With the default environment it +/// performs no demand walk and allocates no GPU memory, so landing the +/// foundation cannot change images, frame time, or residency. +pub struct DirectionalVirtualShadowMap { + requested: bool, + sampling_active: bool, + dynamic_global_fallback: bool, + dynamic_fallback_pages: Vec, + cache: VirtualShadowPageCache, + gpu: Option, + frame: u64, + previous_level_vps: Option<[[[f32; 4]; 4]; VSM_CLIP_LEVELS as usize]>, + previous_content_signatures: Option<[u64; VSM_CLIP_LEVELS as usize]>, + previous_demand_signature: u64, + fallback_demand: Vec, + receiver_demand: Vec, + receiver_bounds_signature: u64, + receiver_demand_level_vps: Option<[[[f32; 4]; 4]; VSM_CLIP_LEVELS as usize]>, + last_demand_count: usize, + receiver_demand_active: bool, + pending: Vec, + uploaded_page_table: Vec, + page_table_may_age_until: u64, + sampling_params_initialized: bool, + render_budget: usize, +} + +impl DirectionalVirtualShadowMap { + pub fn new(device: &wgpu::Device, shadow_uniform_layout: &wgpu::BindGroupLayout) -> Self { + let requested = virtual_shadows_requested(); + let requested_capacity = if requested { + env_u16( + "BLOOM_VSM_PHYSICAL_PAGES", + VSM_DEFAULT_PHYSICAL_PAGES, + 1, + 4096, + ) + } else { + 1 + }; + let capacity = requested_capacity.min( + device + .limits() + .max_texture_array_layers + .min(u16::MAX as u32) as u16, + ); + let page_uniform_bytes = + crate::shadows::SHADOW_UNIFORM_STRIDE as u64 * crate::shadows::SHADOW_MAX_NODES as u64; + let buffer_limited_budget = + (device.limits().max_buffer_size / page_uniform_bytes).min(u16::MAX as u64) as u16; + let max_render_budget = capacity + .min(VSM_MAX_PAGE_RENDER_BUDGET) + .min(buffer_limited_budget.max(1)); + let render_budget = env_u16("BLOOM_VSM_PAGE_BUDGET", 8, 1, max_render_budget).into(); + let gpu = requested.then(|| { + GpuVirtualShadowResources::new(device, shadow_uniform_layout, capacity, render_budget) + }); + let fallback_demand = if requested { + centered_directional_demand(0) + } else { + Vec::new() + }; + Self { + requested, + sampling_active: false, + dynamic_global_fallback: false, + dynamic_fallback_pages: Vec::new(), + cache: VirtualShadowPageCache::new(capacity), + gpu, + frame: 0, + previous_level_vps: None, + previous_content_signatures: None, + previous_demand_signature: 0, + fallback_demand, + receiver_demand: Vec::new(), + receiver_bounds_signature: 0, + receiver_demand_level_vps: None, + last_demand_count: 0, + receiver_demand_active: false, + pending: Vec::with_capacity(render_budget), + uploaded_page_table: Vec::new(), + page_table_may_age_until: 0, + sampling_params_initialized: false, + render_budget, + } + } + + pub fn prepare( + &mut self, + queue: &wgpu::Queue, + level_vps: [[[f32; 4]; 4]; VSM_CLIP_LEVELS as usize], + content_signatures: [u64; VSM_CLIP_LEVELS as usize], + receiver_bounds: Option<&[([f32; 3], [f32; 3])]>, + ) { + self.frame = self.frame.wrapping_add(1).max(1); + self.cache.begin_frame(self.frame); + self.pending.clear(); + if !self.requested { + return; + } + let receiver_bounds_signature = receiver_bounds + .filter(|bounds| !bounds.is_empty()) + .map(receiver_bounds_signature); + if let Some(signature) = receiver_bounds_signature { + if self.receiver_bounds_signature != signature + || self.receiver_demand_level_vps != Some(level_vps) + { + self.receiver_demand = + directional_receiver_demand(level_vps, receiver_bounds.unwrap(), 0); + self.receiver_bounds_signature = signature; + self.receiver_demand_level_vps = Some(level_vps); + } + } else { + self.receiver_demand.clear(); + self.receiver_bounds_signature = 0; + self.receiver_demand_level_vps = None; + } + let (demand, receiver_demand_active) = if self.receiver_demand.is_empty() { + (&self.fallback_demand[..], false) + } else { + (&self.receiver_demand[..], true) + }; + let demand_signature = demand_signature(demand); + self.last_demand_count = demand.len(); + self.receiver_demand_active = receiver_demand_active; + let demand_unchanged = self.previous_level_vps == Some(level_vps) + && self.previous_content_signatures == Some(content_signatures) + && self.previous_demand_signature == demand_signature; + if demand_unchanged && self.cache.stats().dirty == 0 { + self.cache.record_stable_requests(demand.len()); + if self.frame <= self.page_table_may_age_until { + self.upload_page_table_if_changed(queue); + } + return; + } + if let Some(previous) = self.previous_level_vps { + for level in 0..VSM_CLIP_LEVELS as usize { + if previous[level] != level_vps[level] { + self.cache.invalidate_level(0, level as u8); + } + } + } else { + self.cache.invalidate_light(0); + } + self.previous_level_vps = Some(level_vps); + self.previous_content_signatures = Some(content_signatures); + self.previous_demand_signature = demand_signature; + + for &page in demand { + let Some(request) = self + .cache + .request(page, content_signatures[page.level as usize]) + else { + continue; + }; + if request.needs_render && self.pending.len() < self.render_budget { + self.pending.push(request); + } + } + self.cache.finish_requests(); + self.upload_page_table_if_changed(queue); + } + + pub fn pending(&self) -> &[PageRequest] { + &self.pending + } + + pub fn finish_rendered_pages( + &mut self, + queue: &wgpu::Queue, + rendered: &[(VirtualShadowPage, u64)], + ) { + for &(page, signature) in rendered { + self.cache.mark_rendered(page, signature); + } + if !rendered.is_empty() { + self.page_table_may_age_until = self + .page_table_may_age_until + .max(self.frame.saturating_add(7)); + } + self.cache.finish_requests(); + self.upload_page_table_if_changed(queue); + } + + /// Route pages touched by dynamic casters through the live CSM. + /// + /// Static physical pages stay valid: moving a character changes which + /// page-table entries are masked, not cached depth. This avoids stale + /// animated shadows without invalidating or re-rendering unrelated pages. + pub fn set_dynamic_fallback_pages( + &mut self, + queue: &wgpu::Queue, + mut pages: Vec, + ) { + pages.sort_unstable(); + pages.dedup(); + self.dynamic_global_fallback = false; + let mask_changed = pages != self.dynamic_fallback_pages; + self.dynamic_fallback_pages = pages; + let demand = if self.receiver_demand_active { + &self.receiver_demand + } else { + &self.fallback_demand + }; + let all_demand_masked = !demand.is_empty() + && demand + .iter() + .all(|page| self.dynamic_fallback_pages.binary_search(page).is_ok()); + let sampling_active = self.requested && self.gpu.is_some() && !all_demand_masked; + let sampling_changed = sampling_active != self.sampling_active; + if !self.sampling_params_initialized || sampling_active != self.sampling_active { + if let Some(gpu) = self.gpu.as_ref() { + gpu.upload_sampling_params(queue, sampling_active); + } + self.sampling_params_initialized = true; + } + self.sampling_active = sampling_active; + if sampling_active && (mask_changed || sampling_changed) { + self.upload_page_table_if_changed(queue); + } + } + + /// Small receiver footprints cannot retain enough unmasked pages for a + /// dynamic page mask to repay its CPU work and shader indirection. + pub fn dynamic_page_mask_worthwhile(&self) -> bool { + self.requested && self.last_demand_count >= 128 + } + + pub fn set_global_dynamic_fallback(&mut self, queue: &wgpu::Queue, present: bool) { + let state_changed = + present != self.dynamic_global_fallback || !self.dynamic_fallback_pages.is_empty(); + self.dynamic_global_fallback = present; + self.dynamic_fallback_pages.clear(); + let sampling_active = self.requested && self.gpu.is_some() && !present; + let sampling_changed = sampling_active != self.sampling_active; + if !self.sampling_params_initialized || sampling_changed { + if let Some(gpu) = self.gpu.as_ref() { + gpu.upload_sampling_params(queue, sampling_active); + } + self.sampling_params_initialized = true; + } + self.sampling_active = sampling_active; + if sampling_active && (state_changed || sampling_changed) { + self.upload_page_table_if_changed(queue); + } + } + + pub fn requested(&self) -> bool { + self.requested + } + + pub fn physical_page_view(&self, physical_page: u16) -> Option<&wgpu::TextureView> { + self.gpu + .as_ref()? + .physical_page_views + .get(physical_page as usize) + } + + pub fn render_uniform_buffer(&self) -> Option<&wgpu::Buffer> { + self.gpu.as_ref().map(|gpu| &gpu.render_uniform_buffer) + } + + pub fn render_uniform_bind_group(&self) -> Option<&wgpu::BindGroup> { + self.gpu.as_ref().map(|gpu| &gpu.render_uniform_bind_group) + } + + pub fn physical_array_view(&self) -> Option<&wgpu::TextureView> { + self.gpu.as_ref().map(|gpu| &gpu.physical_array_view) + } + + pub fn page_table_view(&self) -> Option<&wgpu::TextureView> { + self.gpu.as_ref().map(|gpu| &gpu.page_table_view) + } + + pub fn sampling_params_buffer(&self) -> Option<&wgpu::Buffer> { + self.gpu.as_ref().map(|gpu| &gpu.sampling_params_buffer) + } + + fn upload_page_table_if_changed(&mut self, queue: &wgpu::Queue) { + if !self.sampling_active { + return; + } + let mut table = self.cache.page_table(0); + apply_dynamic_page_mask(&mut table, &self.dynamic_fallback_pages); + if table == self.uploaded_page_table { + return; + } + if let Some(gpu) = self.gpu.as_ref() { + gpu.upload_page_table(queue, &table); + } + self.uploaded_page_table = table; + } + + pub fn invalidate(&mut self) { + self.cache.invalidate_all(); + self.previous_level_vps = None; + self.previous_content_signatures = None; + self.previous_demand_signature = 0; + self.dynamic_global_fallback = false; + self.dynamic_fallback_pages.clear(); + self.pending.clear(); + self.sampling_active = false; + self.sampling_params_initialized = false; + } + + pub fn debug_images(&self) -> Vec<(&'static str, u32, u32, Vec)> { + if !self.requested { + return Vec::new(); + } + let (virtual_width, virtual_height, virtual_rgb) = self.cache.debug_virtual_rgb(0, 4); + let (physical_width, physical_height, physical_rgb) = self.cache.debug_physical_rgb(4); + vec![ + ( + "virtual-shadow-pages", + virtual_width, + virtual_height, + virtual_rgb, + ), + ( + "virtual-shadow-physical", + physical_width, + physical_height, + physical_rgb, + ), + ] + } + + pub fn report_json(&self) -> String { + let stats = self.cache.stats(); + let levels = self.cache.level_counts(0); + let page_table_bytes = VSM_VIRTUAL_PAGES_PER_AXIS as u64 + * VSM_VIRTUAL_PAGES_PER_AXIS as u64 + * VSM_CLIP_LEVELS as u64 + * std::mem::size_of::() as u64; + let render_staging_bytes = crate::shadows::SHADOW_UNIFORM_STRIDE as u64 + * crate::shadows::SHADOW_MAX_NODES as u64 + * self.render_budget as u64; + let gpu_overhead_bytes = + page_table_bytes + render_staging_bytes + std::mem::size_of::<[u32; 4]>() as u64; + let (physical_capacity, physical_bytes, gpu_overhead_bytes, render_budget) = + if self.requested { + ( + stats.capacity, + self.cache.memory_bytes(), + gpu_overhead_bytes, + self.render_budget, + ) + } else { + (0, 0, 0, 0) + }; + format!( + concat!( + "{{\"requested\":{},\"active\":{},", + "\"fallback\":\"csm\",\"dynamic_fallback\":{},", + "\"dynamic_fallback_mode\":\"{}\",\"dynamic_fallback_pages\":{},", + "\"physical_capacity\":{},\"physical_bytes\":{},", + "\"gpu_overhead_bytes\":{},\"gpu_total_bytes\":{},", + "\"resident\":{},\"dirty\":{},\"requested_pages\":{},", + "\"cache_hits\":{},\"cache_misses\":{},\"evictions\":{},", + "\"denied\":{},\"invalidated\":{},\"rendered\":{},", + "\"pending_render\":{},\"render_budget\":{},", + "\"demand_source\":\"{}\",\"demand_count\":{},", + "\"levels\":[", + "{{\"level\":0,\"resident\":{},\"dirty\":{}}},", + "{{\"level\":1,\"resident\":{},\"dirty\":{}}},", + "{{\"level\":2,\"resident\":{},\"dirty\":{}}}]}}" + ), + self.requested, + self.sampling_active, + self.dynamic_global_fallback || !self.dynamic_fallback_pages.is_empty(), + if self.dynamic_global_fallback { + "whole-frame-csm" + } else if self.dynamic_fallback_pages.is_empty() { + "none" + } else if !self.sampling_active { + "full-demand-csm" + } else { + "per-page-csm" + }, + self.dynamic_fallback_pages.len(), + physical_capacity, + physical_bytes, + gpu_overhead_bytes, + physical_bytes + gpu_overhead_bytes, + stats.resident, + stats.dirty, + stats.requested, + stats.hits, + stats.misses, + stats.evictions, + stats.denied, + stats.invalidated, + stats.rendered, + self.pending.len(), + render_budget, + if !self.requested { + "disabled" + } else if self.receiver_demand_active { + "receiver-bounds" + } else { + "bounded-center-fallback" + }, + self.last_demand_count, + levels[0].0, + levels[0].1, + levels[1].0, + levels[1].1, + levels[2].0, + levels[2].1, + ) + } +} + +struct GpuVirtualShadowResources { + _physical_texture: wgpu::Texture, + physical_array_view: wgpu::TextureView, + physical_page_views: Vec, + page_table_texture: wgpu::Texture, + page_table_view: wgpu::TextureView, + render_uniform_buffer: wgpu::Buffer, + render_uniform_bind_group: wgpu::BindGroup, + sampling_params_buffer: wgpu::Buffer, +} + +impl GpuVirtualShadowResources { + fn new( + device: &wgpu::Device, + shadow_uniform_layout: &wgpu::BindGroupLayout, + physical_pages: u16, + render_budget: usize, + ) -> Self { + let physical_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("vsm_physical_depth_pages"), + size: wgpu::Extent3d { + width: VSM_PHYSICAL_PAGE_SIZE as u32, + height: VSM_PHYSICAL_PAGE_SIZE as u32, + depth_or_array_layers: physical_pages as u32, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth32Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let physical_array_view = physical_texture.create_view(&wgpu::TextureViewDescriptor { + label: Some("vsm_physical_depth_array"), + dimension: Some(wgpu::TextureViewDimension::D2Array), + ..Default::default() + }); + let physical_page_views = (0..physical_pages as u32) + .map(|physical_page| { + physical_texture.create_view(&wgpu::TextureViewDescriptor { + label: Some("vsm_physical_depth_page"), + dimension: Some(wgpu::TextureViewDimension::D2), + base_array_layer: physical_page, + array_layer_count: Some(1), + ..Default::default() + }) + }) + .collect(); + let page_table_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("vsm_directional_page_table"), + size: wgpu::Extent3d { + width: VSM_VIRTUAL_PAGES_PER_AXIS as u32, + height: VSM_VIRTUAL_PAGES_PER_AXIS as u32, + depth_or_array_layers: VSM_CLIP_LEVELS as u32, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R32Uint, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let page_table_view = page_table_texture.create_view(&wgpu::TextureViewDescriptor { + label: Some("vsm_directional_page_table_array"), + dimension: Some(wgpu::TextureViewDimension::D2Array), + ..Default::default() + }); + // Queue writes become visible at submit, not at encode time. VSM page + // matrices therefore cannot share the CSM uniform buffer: doing so + // would replace matrices referenced by already-encoded cascade draws. + let render_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("vsm_render_uniforms"), + size: crate::shadows::SHADOW_UNIFORM_STRIDE as u64 + * crate::shadows::SHADOW_MAX_NODES as u64 + * render_budget as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let render_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("vsm_render_uniform_bg"), + layout: shadow_uniform_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &render_uniform_buffer, + offset: 0, + size: std::num::NonZeroU64::new(std::mem::size_of::< + crate::shadows::ShadowUniforms, + >() as u64), + }), + }], + }); + let sampling_params_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("vsm_sampling_params"), + size: std::mem::size_of::<[u32; 4]>() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + Self { + _physical_texture: physical_texture, + physical_array_view, + physical_page_views, + page_table_texture, + page_table_view, + render_uniform_buffer, + render_uniform_bind_group, + sampling_params_buffer, + } + } + + fn upload_page_table(&self, queue: &wgpu::Queue, table: &[u32]) { + let axis = VSM_VIRTUAL_PAGES_PER_AXIS as usize; + let layers = VSM_CLIP_LEVELS as usize; + debug_assert_eq!(table.len(), axis * axis * layers); + // WebGPU texture copies require 256-byte row alignment. A 32-wide + // R32Uint row is 128 bytes, so stage each logical row into a padded + // 256-byte row before queue upload. + const PADDED_ROW_BYTES: usize = 256; + let row_words = PADDED_ROW_BYTES / std::mem::size_of::(); + let mut padded = vec![0u32; row_words * axis * layers]; + for layer in 0..layers { + for y in 0..axis { + let source = (layer * axis + y) * axis; + let destination = (layer * axis + y) * row_words; + padded[destination..destination + axis] + .copy_from_slice(&table[source..source + axis]); + } + } + queue.write_texture( + self.page_table_texture.as_image_copy(), + bytemuck::cast_slice(&padded), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(PADDED_ROW_BYTES as u32), + rows_per_image: Some(axis as u32), + }, + wgpu::Extent3d { + width: axis as u32, + height: axis as u32, + depth_or_array_layers: layers as u32, + }, + ); + } + + fn upload_sampling_params(&self, queue: &wgpu::Queue, sampling_active: bool) { + let params = [ + u32::from(sampling_active), + VSM_VIRTUAL_PAGES_PER_AXIS as u32, + VSM_PAGE_INTERIOR as u32, + VSM_PAGE_BORDER as u32, + ]; + queue.write_buffer( + &self.sampling_params_buffer, + 0, + bytemuck::cast_slice(¶ms), + ); + } +} + +/// Crop one full cascade VP to a single virtual page, including the physical +/// page's guard texels. Virtual page Y is texture-space (zero at the top), so +/// it is intentionally inverted when converted to WebGPU NDC. +pub fn directional_page_vp(level_vp: [[f32; 4]; 4], page: VirtualShadowPage) -> [[f32; 4]; 4] { + let axis = VSM_VIRTUAL_PAGES_PER_AXIS as f32; + let physical_over_interior = VSM_PHYSICAL_PAGE_SIZE as f32 / VSM_PAGE_INTERIOR as f32; + let half_ndc = physical_over_interior / axis; + let scale = half_ndc.recip(); + let center_x = (f32::from(page.x) + 0.5) * (2.0 / axis) - 1.0; + let center_y = 1.0 - (f32::from(page.y) + 0.5) * (2.0 / axis); + let crop = [ + [scale, 0.0, 0.0, 0.0], + [0.0, scale, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [-center_x * scale, -center_y * scale, 0.0, 1.0], + ]; + crate::renderer::mat4_multiply(crop, level_vp) +} + +const DIRECTIONAL_VSM_SCENE_BINDINGS: &str = r#" +struct DirectionalVsmParams { + enabled: u32, + virtual_pages_per_axis: u32, + page_interior: u32, + page_border: u32, +}; +@group(1) @binding(13) var vsm_page_table: texture_2d_array; +@group(1) @binding(14) var vsm_physical_pages: texture_depth_2d_array; +@group(1) @binding(15) var vsm_params: DirectionalVsmParams; +"#; + +const DIRECTIONAL_VSM_SCENE_HELPER: &str = r#" +fn sample_virtual_shadow( + cascade: i32, + shadow_uv: vec2, + depth_ref: f32, +) -> f32 { + if (vsm_params.enabled == 0u) { + return sample_cascade(cascade, shadow_uv, depth_ref); + } + if (any(shadow_uv < vec2(0.0)) || any(shadow_uv > vec2(1.0))) { + return sample_cascade(cascade, shadow_uv, depth_ref); + } + let axis = vsm_params.virtual_pages_per_axis; + let scaled_uv = shadow_uv * f32(axis); + let page_xy = min(vec2(scaled_uv), vec2(axis - 1u)); + let encoded = textureLoad( + vsm_page_table, + vec2(page_xy), + cascade, + 0, + ).x; + if (encoded == 0u) { + return sample_cascade(cascade, shadow_uv, depth_ref); + } + + let physical_layer = i32((encoded & 0xffffu) - 1u); + let interior = f32(vsm_params.page_interior); + let border = f32(vsm_params.page_border); + let physical_size = interior + 2.0 * border; + let local_uv = clamp( + scaled_uv - vec2(page_xy), + vec2(0.0), + vec2(1.0), + ); + let page_uv = (vec2(border) + local_uv * interior) + / physical_size; + let texel = vec2(1.0 / physical_size); + let offsets = array, 4>( + vec2(-0.5, -0.5), + vec2( 0.5, -0.5), + vec2(-0.5, 0.5), + vec2( 0.5, 0.5), + ); + var virtual_value = 0.0; + for (var i = 0; i < 4; i = i + 1) { + virtual_value += textureSampleCompareLevel( + vsm_physical_pages, + shadow_samp, + page_uv + offsets[i] * texel, + physical_layer, + depth_ref, + ); + } + virtual_value *= 0.25; + let residency_age = f32(encoded >> 16u); + if (residency_age < 8.0) { + return mix( + sample_cascade(cascade, shadow_uv, depth_ref), + virtual_value, + residency_age / 8.0, + ); + } + return virtual_value; +} +"#; + +/// Build the opt-in scene-shader variant. The canonical source remains +/// byte-for-byte unchanged when VSM is disabled, avoiding an extra branch or +/// binding in the default renderer. +pub(crate) fn directional_scene_shader(source: &str) -> String { + let helper_marker = "fn sample_shadow(world_pos: vec3, geo_n: vec3) -> f32 {"; + let helper_offset = source + .find(helper_marker) + .expect("scene shader missing sample_shadow marker"); + let mut output = String::with_capacity( + source.len() + DIRECTIONAL_VSM_SCENE_BINDINGS.len() + DIRECTIONAL_VSM_SCENE_HELPER.len(), + ); + output.push_str(DIRECTIONAL_VSM_SCENE_BINDINGS); + output.push_str(&source[..helper_offset]); + output.push_str(DIRECTIONAL_VSM_SCENE_HELPER); + output.push_str(&source[helper_offset..]); + output = output.replace( + "let shadow_val = sample_cascade(cascade, shadow_uv, depth_ref);", + "let shadow_val = sample_virtual_shadow(cascade, shadow_uv, depth_ref);", + ); + output.replace( + "let next_val = sample_cascade(next_cascade, next_uv, next_depth_ref);", + "let next_val = sample_virtual_shadow(next_cascade, next_uv, next_depth_ref);", + ) +} + +const DIRECTIONAL_VSM_MATERIAL_BINDINGS: &str = r#" +struct DirectionalVsmParams { + enabled: u32, + virtual_pages_per_axis: u32, + page_interior: u32, + page_border: u32, +}; +@group(1) @binding(10) var vsm_page_table: texture_2d_array; +@group(1) @binding(11) var vsm_physical_pages: texture_depth_2d_array; +@group(1) @binding(12) var vsm_params: DirectionalVsmParams; +"#; + +const DIRECTIONAL_VSM_MATERIAL_HELPER: &str = r#" +fn sample_shadow_cascade( + cascade_idx: u32, + world_pos: vec3, +) -> f32 { + if (vsm_params.enabled == 0u) { + return sample_shadow_cascade_csm(cascade_idx, world_pos); + } + let light_clip = view.shadow_cascades[cascade_idx] + * vec4(world_pos, 1.0); + let light_ndc = light_clip.xyz / light_clip.w; + if (abs(light_ndc.x) > 1.0 || abs(light_ndc.y) > 1.0 + || light_ndc.z < 0.0 || light_ndc.z > 1.0) { + return 1.0; + } + let shadow_uv = vec2( + light_ndc.x * 0.5 + 0.5, + 1.0 - (light_ndc.y * 0.5 + 0.5), + ); + let depth_ref = light_ndc.z - 0.001; + let axis = vsm_params.virtual_pages_per_axis; + let scaled_uv = shadow_uv * f32(axis); + let page_xy = min(vec2(scaled_uv), vec2(axis - 1u)); + let encoded = textureLoad( + vsm_page_table, + vec2(page_xy), + i32(cascade_idx), + 0, + ).x; + if (encoded == 0u) { + return sample_shadow_cascade_csm(cascade_idx, world_pos); + } + let physical_layer = i32((encoded & 0xffffu) - 1u); + let interior = f32(vsm_params.page_interior); + let border = f32(vsm_params.page_border); + let physical_size = interior + 2.0 * border; + let local_uv = clamp( + scaled_uv - vec2(page_xy), + vec2(0.0), + vec2(1.0), + ); + let page_uv = (vec2(border) + local_uv * interior) + / physical_size; + let texel = vec2(1.0 / physical_size); + let offsets = array, 4>( + vec2(-0.5, -0.5), + vec2( 0.5, -0.5), + vec2(-0.5, 0.5), + vec2( 0.5, 0.5), + ); + var virtual_value = 0.0; + for (var i = 0; i < 4; i = i + 1) { + virtual_value += textureSampleCompareLevel( + vsm_physical_pages, + shadow_samp, + page_uv + offsets[i] * texel, + physical_layer, + depth_ref, + ); + } + virtual_value *= 0.25; + let residency_age = f32(encoded >> 16u); + if (residency_age < 8.0) { + return mix( + sample_shadow_cascade_csm(cascade_idx, world_pos), + virtual_value, + residency_age / 8.0, + ); + } + return virtual_value; +} +"#; + +/// Add VSM sampling to ABI materials that include the engine shadow helper. +/// Materials that do not receive sun shadows only gain unused declarations +/// in this opt-in shader variant. +pub(crate) fn directional_material_shader(source: String) -> String { + let mut output = String::with_capacity( + source.len() + + DIRECTIONAL_VSM_MATERIAL_BINDINGS.len() + + DIRECTIONAL_VSM_MATERIAL_HELPER.len(), + ); + output.push_str(DIRECTIONAL_VSM_MATERIAL_BINDINGS); + if !source.contains("fn sample_shadow_cascade(") { + output.push_str(&source); + return output; + } + let renamed = source.replacen( + "fn sample_shadow_cascade(", + "fn sample_shadow_cascade_csm(", + 1, + ); + let marker = "fn sample_sun_shadow(world_pos: vec3) -> f32 {"; + let offset = renamed + .find(marker) + .expect("material shadow helper missing sample_sun_shadow"); + output.push_str(&renamed[..offset]); + output.push_str(DIRECTIONAL_VSM_MATERIAL_HELPER); + output.push_str(&renamed[offset..]); + output +} + +pub(crate) fn virtual_shadows_requested() -> bool { + static REQUESTED: OnceLock = OnceLock::new(); + *REQUESTED.get_or_init(|| { + std::env::var("BLOOM_VSM") + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "on" | "true" | "enabled" + ) + }) + .unwrap_or(false) + }) +} + +fn env_u16(name: &str, default: u16, min: u16, max: u16) -> u16 { + std::env::var(name) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(default) + .clamp(min, max) +} + +fn demand_signature(demand: &[VirtualShadowPage]) -> u64 { + const FNV_OFFSET: u64 = 0xcbf29ce484222325; + const FNV_PRIME: u64 = 0x100000001b3; + let mut hash = FNV_OFFSET; + for byte in (demand.len() as u64).to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + for page in demand { + for byte in page.light.to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + for byte in [page.level] { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + for byte in page.x.to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + for byte in page.y.to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + } + hash +} + +fn receiver_bounds_signature(receiver_bounds: &[([f32; 3], [f32; 3])]) -> u64 { + const FNV_OFFSET: u64 = 0xcbf29ce484222325; + const FNV_PRIME: u64 = 0x100000001b3; + let mut hash = FNV_OFFSET; + for byte in (receiver_bounds.len() as u64).to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + for (bmin, bmax) in receiver_bounds { + for value in bmin.iter().chain(bmax.iter()) { + for byte in value.to_bits().to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + } + } + hash +} + +fn projected_directional_page_rect( + level_vp: &[[f32; 4]; 4], + planes: &[[f32; 4]; 6], + bmin: [f32; 3], + bmax: [f32; 3], + guard_pages: i32, +) -> Option<(u16, u16, u16, u16)> { + if bmin[0] > bmax[0] + || bmin + .iter() + .chain(bmax.iter()) + .any(|value| !value.is_finite()) + || crate::scene::aabb_outside_frustum(planes, bmin, bmax) + { + return None; + } + + let mut ndc_min = [f32::INFINITY; 2]; + let mut ndc_max = [f32::NEG_INFINITY; 2]; + for corner in 0..8 { + let world = [ + if corner & 1 == 0 { bmin[0] } else { bmax[0] }, + if corner & 2 == 0 { bmin[1] } else { bmax[1] }, + if corner & 4 == 0 { bmin[2] } else { bmax[2] }, + 1.0, + ]; + let clip = crate::renderer::mat4_mul_vec4(level_vp, &world); + if !clip[3].is_finite() || clip[3].abs() <= f32::EPSILON { + continue; + } + let x = clip[0] / clip[3]; + let y = clip[1] / clip[3]; + if x.is_finite() && y.is_finite() { + ndc_min[0] = ndc_min[0].min(x); + ndc_min[1] = ndc_min[1].min(y); + ndc_max[0] = ndc_max[0].max(x); + ndc_max[1] = ndc_max[1].max(y); + } + } + if !ndc_min[0].is_finite() { + return None; + } + + let uv_min = [ + (ndc_min[0] * 0.5 + 0.5).clamp(0.0, 1.0), + (1.0 - (ndc_max[1] * 0.5 + 0.5)).clamp(0.0, 1.0), + ]; + let uv_max = [ + (ndc_max[0] * 0.5 + 0.5).clamp(0.0, 1.0), + (1.0 - (ndc_min[1] * 0.5 + 0.5)).clamp(0.0, 1.0), + ]; + let axis = i32::from(VSM_VIRTUAL_PAGES_PER_AXIS); + let page_min_x = ((uv_min[0] * axis as f32).floor() as i32 - guard_pages).clamp(0, axis - 1); + let page_min_y = ((uv_min[1] * axis as f32).floor() as i32 - guard_pages).clamp(0, axis - 1); + let page_max_x = ((uv_max[0] * axis as f32).floor() as i32 + guard_pages).clamp(0, axis - 1); + let page_max_y = ((uv_max[1] * axis as f32).floor() as i32 + guard_pages).clamp(0, axis - 1); + Some(( + page_min_x as u16, + page_min_y as u16, + page_max_x as u16, + page_max_y as u16, + )) +} + +fn apply_dynamic_page_mask(table: &mut [u32], pages: &[VirtualShadowPage]) { + for &page in pages { + if page.light == 0 + && page.level < VSM_CLIP_LEVELS + && page.x < VSM_VIRTUAL_PAGES_PER_AXIS + && page.y < VSM_VIRTUAL_PAGES_PER_AXIS + { + if let Some(entry) = table.get_mut(page.table_index()) { + *entry = VSM_PAGE_TABLE_MISSING; + } + } + } +} + +/// Virtual pages whose light-space rays intersect a dynamic caster. +/// +/// These pages sample the live CSM instead of static VSM depth. Two guard +/// pages cover PCF taps, temporal jitter, and conservative AABB projection. +/// An unbounded caster returns the complete virtual address space, preserving +/// the former whole-frame fallback rather than risking a stale shadow. +pub fn directional_dynamic_fallback_pages( + level_vps: [[[f32; 4]; 4]; VSM_CLIP_LEVELS as usize], + dynamic_bounds: &[([f32; 3], [f32; 3])], + light: u16, +) -> Vec { + if dynamic_bounds.is_empty() { + return Vec::new(); + } + let page_count = VSM_VIRTUAL_PAGES_PER_AXIS as usize + * VSM_VIRTUAL_PAGES_PER_AXIS as usize + * VSM_CLIP_LEVELS as usize; + let unbounded = dynamic_bounds.iter().any(|(bmin, bmax)| { + bmin[0] > bmax[0] + || bmin + .iter() + .chain(bmax.iter()) + .any(|value| !value.is_finite()) + }); + let mut marked = vec![unbounded; page_count]; + if !unbounded { + for level in 0..VSM_CLIP_LEVELS as usize { + let planes = crate::scene::extract_frustum_planes(&level_vps[level]); + for &(bmin, bmax) in dynamic_bounds { + let Some((min_x, min_y, max_x, max_y)) = + projected_directional_page_rect(&level_vps[level], &planes, bmin, bmax, 2) + else { + continue; + }; + for y in min_y..=max_y { + for x in min_x..=max_x { + let page = VirtualShadowPage { + light, + level: level as u8, + x, + y, + }; + marked[page.table_index()] = true; + } + } + } + } + } + + let axis = VSM_VIRTUAL_PAGES_PER_AXIS as usize; + marked + .into_iter() + .enumerate() + .filter_map(|(index, is_marked)| { + is_marked.then(|| { + let level = index / (axis * axis); + let within_level = index % (axis * axis); + VirtualShadowPage { + light, + level: level as u8, + x: (within_level % axis) as u16, + y: (within_level / axis) as u16, + } + }) + }) + .collect() +} + +/// Mark directional virtual pages touched by camera-visible receiver bounds. +/// +/// Bounds are projected into each fitted cascade. A one-page guard band keeps +/// PCF taps, temporal jitter, and small camera motion inside resident pages. +/// Coverage count wins over center distance so large shared surfaces (ground, +/// walls) are filled before isolated geometry. Per-level caps keep CPU work, +/// residency, and upload size bounded even when one receiver covers the full +/// clipmap; any omitted page continues to sample the conventional CSM. +pub fn directional_receiver_demand( + level_vps: [[[f32; 4]; 4]; VSM_CLIP_LEVELS as usize], + receiver_bounds: &[([f32; 3], [f32; 3])], + light: u16, +) -> Vec { + let center = i32::from(VSM_VIRTUAL_PAGES_PER_AXIS); + let per_level: [Vec; VSM_CLIP_LEVELS as usize] = + std::array::from_fn(|level| { + let planes = crate::scene::extract_frustum_planes(&level_vps[level]); + let mut coverage: HashMap = HashMap::new(); + for &(bmin, bmax) in receiver_bounds { + let Some((page_min_x, page_min_y, page_max_x, page_max_y)) = + projected_directional_page_rect(&level_vps[level], &planes, bmin, bmax, 1) + else { + continue; + }; + for y in page_min_y..=page_max_y { + for x in page_min_x..=page_max_x { + let page = VirtualShadowPage { + light, + level: level as u8, + x, + y, + }; + coverage + .entry(page) + .and_modify(|count| *count = count.saturating_add(1)) + .or_insert(1); + } + } + } + + let mut ranked: Vec<(VirtualShadowPage, u32)> = coverage.into_iter().collect(); + ranked.sort_unstable_by_key(|(page, count)| { + let dx = (i32::from(page.x) * 2 + 1 - center).unsigned_abs(); + let dy = (i32::from(page.y) * 2 + 1 - center).unsigned_abs(); + ( + std::cmp::Reverse(*count), + dx.max(dy), + dx + dy, + page.y, + page.x, + ) + }); + ranked + .into_iter() + .take(VSM_DIRECTIONAL_LEVEL_PAGE_CAPS[level]) + .map(|(page, _)| page) + .collect() + }); + + // Interleave clip levels so an invalidated near level cannot consume the + // full render budget before mid/far receiver coverage gets a page. + let mut pages = Vec::with_capacity(per_level.iter().map(Vec::len).sum()); + let mut next = [0usize; VSM_CLIP_LEVELS as usize]; + loop { + let mut appended = false; + for level in 0..VSM_CLIP_LEVELS as usize { + if next[level] < per_level[level].len() { + pages.push(per_level[level][next[level]]); + next[level] += 1; + appended = true; + } + } + if !appended { + break; + } + } + pages +} + +/// Deterministic center-first demand used by the directional prototype. +/// +/// The returned footprint is intentionally bounded. Missing outer pages use +/// CSM, and later receiver-driven marking can replace this policy without +/// changing the cache or page-table ABI. +pub fn centered_directional_demand(light: u16) -> Vec { + const HALF_WIDTHS: [u16; VSM_CLIP_LEVELS as usize] = [6, 4, 2]; + let center = VSM_VIRTUAL_PAGES_PER_AXIS / 2; + let mut per_level: [Vec; VSM_CLIP_LEVELS as usize] = + std::array::from_fn(|_| Vec::new()); + for (level, half_width) in HALF_WIDTHS.into_iter().enumerate() { + let min = center - half_width; + let max = center + half_width; + for y in min..max { + for x in min..max { + per_level[level].push( + VirtualShadowPage::new(light, level as u8, x, y) + .expect("centered demand stays in the virtual address space"), + ); + } + } + // Prioritize the pages closest to the clipmap center. Use doubled + // page-center coordinates so the even-sized footprint has four + // equally-near center pages without floating-point ordering. + per_level[level].sort_by_key(|page| { + let dx = (i32::from(page.x) * 2 + 1 - i32::from(center) * 2).unsigned_abs(); + let dy = (i32::from(page.y) * 2 + 1 - i32::from(center) * 2).unsigned_abs(); + (dx.max(dy), dx + dy, page.y, page.x) + }); + } + + // Interleave levels so a frequently-invalidated near clipmap cannot + // consume the whole per-frame render budget and starve mid/far coverage. + let mut pages = Vec::with_capacity(per_level.iter().map(Vec::len).sum()); + let mut next = [0usize; VSM_CLIP_LEVELS as usize]; + loop { + let mut appended = false; + for level in 0..VSM_CLIP_LEVELS as usize { + if next[level] < per_level[level].len() { + pages.push(per_level[level][next[level]]); + next[level] += 1; + appended = true; + } + } + if !appended { + break; + } + } + pages +} + +#[cfg(test)] +mod tests { + use super::*; + + fn transform_ndc(matrix: &[[f32; 4]; 4], point: [f32; 4]) -> [f32; 3] { + let clip = crate::renderer::mat4_mul_vec4(matrix, &point); + [clip[0] / clip[3], clip[1] / clip[3], clip[2] / clip[3]] + } + + fn page(x: u16) -> VirtualShadowPage { + VirtualShadowPage::new(0, 0, x, 0).unwrap() + } + + #[test] + fn invalid_virtual_coordinates_are_rejected() { + assert!(VirtualShadowPage::new(0, VSM_CLIP_LEVELS, 0, 0).is_none()); + assert!(VirtualShadowPage::new(0, 0, VSM_VIRTUAL_PAGES_PER_AXIS, 0).is_none()); + } + + #[test] + fn reuse_is_stable_and_signature_changes_dirty_the_page() { + let mut cache = VirtualShadowPageCache::new(2); + cache.begin_frame(1); + let first = cache.request(page(0), 7).unwrap(); + assert!(first.needs_render); + assert!(cache.mark_rendered(page(0), 7)); + let hit = cache.request(page(0), 7).unwrap(); + assert_eq!(first.physical_page, hit.physical_page); + assert!(!hit.needs_render); + assert!(cache.request(page(0), 8).unwrap().needs_render); + } + + #[test] + fn current_frame_pages_are_never_evicted() { + let mut cache = VirtualShadowPageCache::new(2); + cache.begin_frame(1); + cache.request(page(0), 1).unwrap(); + cache.request(page(1), 1).unwrap(); + assert!(cache.request(page(2), 1).is_none()); + assert_eq!(cache.stats().denied, 1); + } + + #[test] + fn stable_request_accounting_skips_cache_walk_without_hiding_fallbacks() { + let mut cache = VirtualShadowPageCache::new(2); + cache.begin_frame(1); + cache.request(page(0), 1).unwrap(); + cache.request(page(1), 1).unwrap(); + cache.finish_requests(); + cache.begin_frame(2); + cache.record_stable_requests(3); + assert_eq!(cache.stats().requested, 3); + assert_eq!(cache.stats().hits, 2); + assert_eq!(cache.stats().misses, 1); + assert_eq!(cache.stats().denied, 1); + } + + #[test] + fn debug_images_expose_virtual_and_physical_occupancy() { + let mut cache = VirtualShadowPageCache::new(2); + cache.begin_frame(1); + cache.request(page(0), 1).unwrap(); + cache.mark_rendered(page(0), 1); + cache.finish_requests(); + let (virtual_width, virtual_height, virtual_rgb) = cache.debug_virtual_rgb(0, 2); + assert_eq!(virtual_width, u32::from(VSM_VIRTUAL_PAGES_PER_AXIS) * 2); + assert_eq!( + virtual_height, + u32::from(VSM_VIRTUAL_PAGES_PER_AXIS) * u32::from(VSM_CLIP_LEVELS) * 2, + ); + assert_eq!( + virtual_rgb.len(), + (virtual_width * virtual_height * 3) as usize + ); + let (physical_width, physical_height, physical_rgb) = cache.debug_physical_rgb(2); + assert_eq!( + physical_rgb.len(), + (physical_width * physical_height * 3) as usize + ); + assert!(physical_rgb.iter().any(|channel| *channel > 8)); + } + + #[test] + fn lru_eviction_is_deterministic() { + let mut cache = VirtualShadowPageCache::new(2); + cache.begin_frame(1); + cache.request(page(0), 1).unwrap(); + cache.request(page(1), 1).unwrap(); + cache.begin_frame(2); + cache.request(page(1), 1).unwrap(); + let request = cache.request(page(2), 1).unwrap(); + assert_eq!(request.evicted, Some(page(0))); + assert_eq!(request.physical_page, 0); + } + + #[test] + fn dirty_pages_are_missing_until_rendered_and_then_age() { + let mut cache = VirtualShadowPageCache::new(1); + cache.begin_frame(1); + cache.request(page(0), 42).unwrap(); + assert_eq!(cache.page_table(0)[page(0).table_index()], 0); + cache.mark_rendered(page(0), 42); + let first = cache.page_table(0)[page(0).table_index()]; + assert_eq!(first & 0xffff, 1); + assert_eq!(first >> 16, 1); + cache.begin_frame(4); + let aged = cache.page_table(0)[page(0).table_index()]; + assert_eq!(aged >> 16, 4); + cache.begin_frame(100); + let saturated = cache.page_table(0)[page(0).table_index()]; + assert_eq!(saturated >> 16, 8); + cache.invalidate_light(0); + assert_eq!(cache.page_table(0)[page(0).table_index()], 0); + } + + #[test] + fn configured_capacity_is_a_hard_memory_bound() { + let cache = VirtualShadowPageCache::new(8); + let edge = VSM_PHYSICAL_PAGE_SIZE as u64; + assert_eq!(cache.memory_bytes(), edge * edge * 4 * 8); + assert_eq!(cache.stats().capacity, 8); + } + + #[test] + fn centered_demand_fits_default_pool() { + let demand = centered_directional_demand(0); + assert_eq!(demand.len(), 224); + assert!(demand.len() <= VSM_DEFAULT_PHYSICAL_PAGES as usize); + let mut sorted = demand.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), demand.len()); + assert_eq!( + demand[..3] + .iter() + .map(|page| page.level) + .collect::>(), + vec![0, 1, 2], + ); + assert!(demand[..3] + .iter() + .all(|page| (15..=16).contains(&page.x) && (15..=16).contains(&page.y))); + } + + #[test] + fn receiver_demand_is_bounded_unique_and_fair_across_levels() { + let demand = directional_receiver_demand( + [crate::renderer::IDENTITY_MAT4; VSM_CLIP_LEVELS as usize], + &[([-1.0; 3], [1.0; 3])], + 7, + ); + assert_eq!( + demand.len(), + VSM_DIRECTIONAL_LEVEL_PAGE_CAPS + .iter() + .copied() + .sum::() + ); + let mut sorted = demand.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), demand.len()); + assert_eq!( + demand[..3] + .iter() + .map(|page| page.level) + .collect::>(), + vec![0, 1, 2], + ); + assert!(demand.iter().all(|page| page.light == 7)); + } + + #[test] + fn receiver_demand_marks_local_footprint_with_guard_pages() { + let demand = directional_receiver_demand( + [crate::renderer::IDENTITY_MAT4; VSM_CLIP_LEVELS as usize], + &[([-0.02, -0.02, 0.4], [0.02, 0.02, 0.6])], + 0, + ); + assert_eq!(demand.iter().filter(|page| page.level == 0).count(), 16); + assert!(demand + .iter() + .all(|page| { (14..=17).contains(&page.x) && (14..=17).contains(&page.y) })); + } + + #[test] + fn receiver_demand_rejects_bounds_outside_light_volume() { + let demand = directional_receiver_demand( + [crate::renderer::IDENTITY_MAT4; VSM_CLIP_LEVELS as usize], + &[([2.0, 2.0, 2.0], [3.0, 3.0, 3.0])], + 0, + ); + assert!(demand.is_empty()); + } + + #[test] + fn dynamic_fallback_masks_only_guarded_caster_pages() { + let pages = directional_dynamic_fallback_pages( + [crate::renderer::IDENTITY_MAT4; VSM_CLIP_LEVELS as usize], + &[([-0.02, -0.02, 0.4], [0.02, 0.02, 0.6])], + 0, + ); + assert_eq!(pages.len(), 36 * VSM_CLIP_LEVELS as usize); + assert!(pages + .iter() + .all(|page| { (13..=18).contains(&page.x) && (13..=18).contains(&page.y) })); + + let mut table = vec![ + 99; + VSM_VIRTUAL_PAGES_PER_AXIS as usize + * VSM_VIRTUAL_PAGES_PER_AXIS as usize + * VSM_CLIP_LEVELS as usize + ]; + apply_dynamic_page_mask(&mut table, &pages); + assert_eq!( + table.iter().filter(|entry| **entry == 0).count(), + pages.len() + ); + assert_eq!( + table.iter().filter(|entry| **entry == 99).count(), + table.len() - pages.len() + ); + } + + #[test] + fn unbounded_dynamic_caster_preserves_whole_frame_fallback() { + let pages = directional_dynamic_fallback_pages( + [crate::renderer::IDENTITY_MAT4; VSM_CLIP_LEVELS as usize], + &[([1.0, 1.0, 1.0], [-1.0, -1.0, -1.0])], + 0, + ); + assert_eq!( + pages.len(), + VSM_VIRTUAL_PAGES_PER_AXIS as usize + * VSM_VIRTUAL_PAGES_PER_AXIS as usize + * VSM_CLIP_LEVELS as usize + ); + } + + #[test] + fn offscreen_dynamic_caster_does_not_mask_resident_pages() { + let pages = directional_dynamic_fallback_pages( + [crate::renderer::IDENTITY_MAT4; VSM_CLIP_LEVELS as usize], + &[([2.0, 2.0, 2.0], [3.0, 3.0, 3.0])], + 0, + ); + assert!(pages.is_empty()); + } + + #[test] + fn receiver_demand_and_signature_are_deterministic() { + let bounds = [ + ([-0.8, -0.4, 0.2], [-0.2, 0.1, 0.8]), + ([0.1, -0.2, 0.1], [0.7, 0.6, 0.9]), + ]; + let vps = [crate::renderer::IDENTITY_MAT4; VSM_CLIP_LEVELS as usize]; + let first = directional_receiver_demand(vps, &bounds, 0); + let second = directional_receiver_demand(vps, &bounds, 0); + assert_eq!(first, second); + assert_eq!(demand_signature(&first), demand_signature(&second)); + assert_ne!( + demand_signature(&first), + demand_signature(¢ered_directional_demand(0)) + ); + } + + #[test] + fn coordinator_is_inert_without_explicit_request() { + if virtual_shadows_requested() { + return; + } + // Runtime construction requires a device; the non-GPU cache already + // proves inert behavior above. Keep the environment contract here. + let cache = VirtualShadowPageCache::new(1); + assert_eq!(cache.stats().resident, 0); + assert_eq!( + cache + .page_table(0) + .iter() + .filter(|entry| **entry != 0) + .count(), + 0 + ); + } + + #[test] + fn page_crop_maps_interior_edges_to_guard_texels() { + let page = VirtualShadowPage::new(0, 0, 9, 12).unwrap(); + let crop = directional_page_vp(crate::renderer::IDENTITY_MAT4, page); + let axis = VSM_VIRTUAL_PAGES_PER_AXIS as f32; + let left = f32::from(page.x) * (2.0 / axis) - 1.0; + let right = f32::from(page.x + 1) * (2.0 / axis) - 1.0; + let top = 1.0 - f32::from(page.y) * (2.0 / axis); + let bottom = 1.0 - f32::from(page.y + 1) * (2.0 / axis); + let expected = VSM_PAGE_INTERIOR as f32 / VSM_PHYSICAL_PAGE_SIZE as f32; + + let left_ndc = transform_ndc(&crop, [left, 0.5 * (top + bottom), 0.5, 1.0]); + let right_ndc = transform_ndc(&crop, [right, 0.5 * (top + bottom), 0.5, 1.0]); + let top_ndc = transform_ndc(&crop, [0.5 * (left + right), top, 0.5, 1.0]); + let bottom_ndc = transform_ndc(&crop, [0.5 * (left + right), bottom, 0.5, 1.0]); + + assert!((left_ndc[0] + expected).abs() < 1.0e-5); + assert!((right_ndc[0] - expected).abs() < 1.0e-5); + assert!((top_ndc[1] - expected).abs() < 1.0e-5); + assert!((bottom_ndc[1] + expected).abs() < 1.0e-5); + } + + #[test] + fn page_crop_preserves_depth() { + let page = VirtualShadowPage::new(0, 2, 16, 16).unwrap(); + let crop = directional_page_vp(crate::renderer::IDENTITY_MAT4, page); + let transformed = transform_ndc(&crop, [0.03125, -0.03125, 0.37, 1.0]); + assert!((transformed[2] - 0.37).abs() < 1.0e-6); + } + + #[test] + fn scene_shader_variant_injects_bindings_and_both_cascade_samples() { + let source = r#" +let shadow_val = sample_cascade(cascade, shadow_uv, depth_ref); +fn sample_shadow(world_pos: vec3, geo_n: vec3) -> f32 { +let next_val = sample_cascade(next_cascade, next_uv, next_depth_ref); +} +"#; + let variant = directional_scene_shader(source); + assert!(variant.contains("@binding(13) var vsm_page_table")); + assert!(variant.contains("@binding(14) var vsm_physical_pages")); + assert!(variant.contains("@binding(15) var vsm_params")); + assert_eq!(variant.matches("sample_virtual_shadow(").count(), 3); + assert!(!source.contains("vsm_page_table")); + } + + #[test] + fn material_shader_variant_wraps_the_canonical_cascade_sampler() { + let source = r#" +fn sample_shadow_cascade( + cascade_idx: u32, world_pos: vec3, +) -> f32 { + return 1.0; +} +fn sample_sun_shadow(world_pos: vec3) -> f32 { + return sample_shadow_cascade(0u, world_pos); +} +"#; + let variant = directional_material_shader(source.to_owned()); + assert!(variant.contains("@binding(10) var vsm_page_table")); + assert!(variant.contains("fn sample_shadow_cascade_csm(")); + assert_eq!(variant.matches("fn sample_shadow_cascade(").count(), 1); + assert!(variant.contains("sample_shadow_cascade_csm(cascade_idx, world_pos)")); + } + + #[test] + fn material_shadow_variant_parses_through_naga() { + let source = format!( + "{}\n{}", + include_str!("../shaders/material_abi.wgsl"), + include_str!("../shaders/common/shadows.wgsl"), + ); + let variant = directional_material_shader(source); + let result = wgpu::naga::front::wgsl::parse_str(&variant); + if let Err(error) = result.as_ref() { + panic!( + "VSM material shadow variant failed WGSL parsing:\n{}", + error.emit_to_string(&variant), + ); + } + } +} diff --git a/native/shared/tests/device_negotiation.rs b/native/shared/tests/device_negotiation.rs new file mode 100644 index 00000000..4bc3cc51 --- /dev/null +++ b/native/shared/tests/device_negotiation.rs @@ -0,0 +1,45 @@ +//! Production device-request smoke: proves the granted minimum limits can +//! construct the complete renderer, not only a bare wgpu device. + +#[test] +fn negotiated_headless_device_constructs_the_complete_renderer() { + let engine = bloom_shared::attach::attach_headless_engine(wgpu::Backends::PRIMARY, 64, 64); + let mut engine = match engine { + Ok(engine) => engine, + Err(error) if error.contains("no compatible adapter") => { + eprintln!("skip: no native GPU adapter ({error})"); + return; + } + Err(error) => panic!("production renderer device negotiation failed: {error}"), + }; + let report = engine.renderer.quality_adapter_json(); + let json: serde_json::Value = + serde_json::from_str(&report).expect("quality adapter snapshot is valid JSON"); + assert_eq!( + json["device_negotiation"]["required_limits"]["max_color_attachments"], + 4 + ); + assert!( + json["device_negotiation"]["required_limits"]["max_sampled_textures_per_shader_stage"] + .as_u64() + .is_some_and(|value| value >= 19) + ); + + let public_report = engine.renderer.renderer_capability_report_json(); + let public: serde_json::Value = + serde_json::from_str(&public_report).expect("public renderer capability report is valid"); + assert_eq!(public["version"], 1); + assert_eq!(public["availability"], "available"); + assert!(public["adapter"]["renderer_capabilities"]["paths"]["materials"].is_string()); + assert!(public["material_binding"]["selected_tier"].is_string()); + assert!(public["runtime_support"]["path_tracing"].is_boolean()); + assert!(public["runtime_support"]["gpu_driven"]["enabled"].is_boolean()); + + // Headless qualification has no swapchain to configure, but it is still + // uncapped and must retain the requested mode for truthful telemetry. + assert!(engine.renderer.set_present_mode(3)); + assert_eq!(engine.renderer.present_mode_code(), 3); + assert!(!engine.renderer.vsync_active()); + assert!(!engine.renderer.set_present_mode(4)); + assert_eq!(engine.renderer.present_mode_code(), 3); +} diff --git a/native/shared/tests/golden/pt_progressive.png b/native/shared/tests/golden/pt_progressive.png index 8cf9667b..1af5a063 100644 Binary files a/native/shared/tests/golden/pt_progressive.png and b/native/shared/tests/golden/pt_progressive.png differ diff --git a/native/shared/tests/golden/pt_realtime_motion.png b/native/shared/tests/golden/pt_realtime_motion.png index 93ccbbdb..b7a7134a 100644 Binary files a/native/shared/tests/golden/pt_realtime_motion.png and b/native/shared/tests/golden/pt_realtime_motion.png differ diff --git a/native/shared/tests/golden_render.rs b/native/shared/tests/golden_render.rs index fb1c4001..3f66a661 100644 --- a/native/shared/tests/golden_render.rs +++ b/native/shared/tests/golden_render.rs @@ -8,16 +8,35 @@ //! intentional visual change shows up as an explicit golden update in //! the diff. //! -//! - Runs on any machine/CI runner with a GPU adapter (CI: the macos-14 -//! shared-tests job); skips gracefully without one. -//! - TAA is disabled in the scenes (sub-pixel jitter is intentionally -//! non-deterministic across frame counts); a fixed number of warm-up -//! frames settles the temporal passes that remain. -//! - Tolerances absorb GPU-family rasterization differences; goldens are -//! regenerated with BLOOM_UPDATE_GOLDEN=1 `cargo test golden`. +//! - Runs on a non-CPU GPU adapter and skips gracefully without one. +//! - Most scenes disable TAA; fixed warm-up counts settle temporal passes. +//! - Tolerances absorb GPU-family rasterization differences. use bloom_shared::engine::EngineState; +use bloom_shared::models::{ + MaterialAlphaMode, MaterialLayeredPbr, MaterialTextureBinding, MaterialTextureTransform, + MaterialThicknessSource, MaterialTransmission, MeshData, +}; +use bloom_shared::renderer::capabilities::{RendererCapabilities, RendererCapabilityTier}; use bloom_shared::renderer::{Renderer, Vertex3D}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::time::Instant; + +#[path = "golden_render/layered_pbr_motion.rs"] +mod layered_pbr_motion; +#[path = "golden_render/layered_pbr_parity.rs"] +mod layered_pbr_parity; +#[path = "golden_render/lighting_upload.rs"] +mod lighting_upload; +#[path = "golden_render/motion_producer_audit.rs"] +mod motion_producer_audit; +#[path = "golden_render/quality_presets.rs"] +mod quality_presets; +#[path = "golden_render/temporal_history.rs"] +mod temporal_history; +#[path = "golden_render/transparency.rs"] +mod transparency; const W: u32 = 256; const H: u32 = 256; @@ -29,6 +48,449 @@ const MEAN_TOLERANCE: f64 = 2.0; /// single-pixel edge flicker without letting a broken region through. const OUTLIER_FRACTION: f64 = 0.01; +#[derive(Clone, Debug)] +struct AdapterMetadata { + name: String, + backend: String, + device_type: String, + driver: String, + driver_info: String, + supported_features: String, + enabled_features: String, +} + +#[derive(Clone, Debug)] +struct GoldenRunMetadata { + adapter: AdapterMetadata, + seed: u32, + sample_index_start: u32, + camera_frame_start: u32, + jitter_sequence: &'static str, + fault_injection: &'static str, + repeat_index: u32, + repeat_count: u32, + frames: u32, + spp: u32, + render_time_ms: u128, +} + +fn json_escape(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out +} + +fn git_commit() -> String { + std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok() + .filter(|out| out.status.success()) + .and_then(|out| String::from_utf8(out.stdout).ok()) + .map(|s| s.trim().to_owned()) + .unwrap_or_else(|| "unknown".to_owned()) +} + +fn luminance(px: &[u8]) -> f64 { + (0.2126 * px[0] as f64 + 0.7152 * px[1] as f64 + 0.0722 * px[2] as f64) / 255.0 +} + +/// Single-scale luminance SSIM over non-overlapping 8x8 windows. This is +/// intentionally identical in shape to tools/bloom-diff's regression metric. +fn ssim_luminance(reference: &[u8], candidate: &[u8], width: u32, height: u32) -> f64 { + const WINDOW: usize = 8; + const C1: f64 = 0.0001; + const C2: f64 = 0.0009; + let width = width as usize; + let height = height as usize; + if width < WINDOW || height < WINDOW { + return 1.0; + } + let mut total = 0.0; + let mut windows = 0usize; + for y0 in (0..=height - WINDOW).step_by(WINDOW) { + for x0 in (0..=width - WINDOW).step_by(WINDOW) { + let mut mean_r = 0.0; + let mut mean_c = 0.0; + for y in y0..y0 + WINDOW { + for x in x0..x0 + WINDOW { + let i = (y * width + x) * 4; + mean_r += luminance(&reference[i..i + 4]); + mean_c += luminance(&candidate[i..i + 4]); + } + } + let n = (WINDOW * WINDOW) as f64; + mean_r /= n; + mean_c /= n; + let mut var_r = 0.0; + let mut var_c = 0.0; + let mut covariance = 0.0; + for y in y0..y0 + WINDOW { + for x in x0..x0 + WINDOW { + let i = (y * width + x) * 4; + let dr = luminance(&reference[i..i + 4]) - mean_r; + let dc = luminance(&candidate[i..i + 4]) - mean_c; + var_r += dr * dr; + var_c += dc * dc; + covariance += dr * dc; + } + } + var_r /= n; + var_c /= n; + covariance /= n; + total += ((2.0 * mean_r * mean_c + C1) * (2.0 * covariance + C2)) + / ((mean_r * mean_r + mean_c * mean_c + C1) * (var_r + var_c + C2)); + windows += 1; + } + } + total / windows as f64 +} + +#[derive(Clone, Copy, Debug)] +struct DiffMetrics { + mean_rgba: f64, + mean_rgb: f64, + max_diff: u8, + outlier_pixel_fraction: f64, + outlier_channel_fraction: f64, + ssim: f64, +} + +fn calculate_diff_metrics(expected: &[u8], actual: &[u8], width: u32, height: u32) -> DiffMetrics { + assert_eq!(expected.len(), actual.len()); + assert_eq!(actual.len(), width as usize * height as usize * 4); + let mut sum_abs = 0.0; + let mut sum_abs_rgb = 0.0; + let mut outlier_pixels = 0usize; + let mut outlier_channels = 0usize; + let mut max_diff = 0u8; + for (actual, expected) in actual.chunks_exact(4).zip(expected.chunks_exact(4)) { + let mut pixel_max = 0u8; + for channel in 0..4 { + let diff = actual[channel].abs_diff(expected[channel]); + sum_abs += diff as f64; + if channel < 3 { + sum_abs_rgb += diff as f64; + pixel_max = pixel_max.max(diff); + } + if diff > 32 { + outlier_channels += 1; + } + max_diff = max_diff.max(diff); + } + if pixel_max > 32 { + outlier_pixels += 1; + } + } + DiffMetrics { + mean_rgba: sum_abs / actual.len() as f64, + mean_rgb: sum_abs_rgb / (width as f64 * height as f64 * 3.0), + max_diff, + outlier_pixel_fraction: outlier_pixels as f64 / (width as f64 * height as f64), + outlier_channel_fraction: outlier_channels as f64 / actual.len() as f64, + ssim: ssim_luminance(expected, actual, width, height), + } +} + +fn select_outlier_gate(metrics: DiffMetrics, is_pt_oracle: bool) -> (&'static str, f64) { + if is_pt_oracle { + ("pixel", metrics.outlier_pixel_fraction) + } else { + ("channel", metrics.outlier_channel_fraction) + } +} + +#[test] +fn diff_metrics_keep_raster_gate_and_detect_coherent_pt_regions() { + let expected = [0u8, 0, 0, 255, 0, 0, 0, 255]; + let actual = [64u8, 0, 0, 255, 0, 0, 0, 255]; + let metrics = calculate_diff_metrics(&expected, &actual, 2, 1); + assert_eq!(select_outlier_gate(metrics, false), ("channel", 0.125)); + assert_eq!(select_outlier_gate(metrics, true), ("pixel", 0.5)); + assert_eq!(metrics.max_diff, 64); +} + +fn golden_artifact_dir(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/golden-artifacts") + .join(name) +} + +fn write_failure_artifacts( + name: &str, + width: u32, + height: u32, + expected: &[u8], + actual: &[u8], + mean: f64, + mean_rgb: f64, + outlier_pixel_frac: f64, + outlier_channel_frac: f64, + gated_outlier_kind: &str, + gated_outlier_frac: f64, + max_diff: u8, + ssim: f64, + run: Option<&GoldenRunMetadata>, +) -> PathBuf { + let dir = golden_artifact_dir(name); + std::fs::create_dir_all(&dir).expect("create golden failure artifact directory"); + image::save_buffer( + dir.join("expected.png"), + expected, + width, + height, + image::ColorType::Rgba8, + ) + .expect("write expected golden artifact"); + image::save_buffer( + dir.join("actual.png"), + actual, + width, + height, + image::ColorType::Rgba8, + ) + .expect("write actual golden artifact"); + + let mut abs_diff = vec![255u8; actual.len()]; + let mut heatmap = vec![255u8; actual.len()]; + for (pixel, (a, b)) in actual + .chunks_exact(4) + .zip(expected.chunks_exact(4)) + .enumerate() + { + let base = pixel * 4; + let dr = a[0].abs_diff(b[0]); + let dg = a[1].abs_diff(b[1]); + let db = a[2].abs_diff(b[2]); + abs_diff[base..base + 4].copy_from_slice(&[dr, dg, db, 255]); + let m = (dr.max(dg).max(db) as f32 / 255.0 * 16.0).clamp(0.0, 1.0); + heatmap[base] = ((m * 3.0).clamp(0.0, 1.0) * 255.0) as u8; + heatmap[base + 1] = (((m - 0.33) * 3.0).clamp(0.0, 1.0) * 255.0) as u8; + heatmap[base + 2] = (((m - 0.66) * 3.0).clamp(0.0, 1.0) * 255.0) as u8; + } + image::save_buffer( + dir.join("absolute-diff.png"), + &abs_diff, + width, + height, + image::ColorType::Rgba8, + ) + .expect("write absolute-diff golden artifact"); + image::save_buffer( + dir.join("heatmap.png"), + &heatmap, + width, + height, + image::ColorType::Rgba8, + ) + .expect("write heatmap golden artifact"); + + let ( + adapter_json, + seed, + sample_index, + camera_frame, + jitter, + fault, + repeat_index, + repeat_count, + frames, + spp, + render_time_ms, + ) = if let Some(run) = run { + ( + format!( + "{{\"name\":\"{}\",\"backend\":\"{}\",\"device_type\":\"{}\",\"driver\":\"{}\",\"driver_info\":\"{}\",\"supported_features\":\"{}\",\"enabled_features\":\"{}\"}}", + json_escape(&run.adapter.name), + json_escape(&run.adapter.backend), + json_escape(&run.adapter.device_type), + json_escape(&run.adapter.driver), + json_escape(&run.adapter.driver_info), + json_escape(&run.adapter.supported_features), + json_escape(&run.adapter.enabled_features), + ), + run.seed.to_string(), + run.sample_index_start.to_string(), + run.camera_frame_start.to_string(), + format!("\"{}\"", json_escape(run.jitter_sequence)), + format!("\"{}\"", json_escape(run.fault_injection)), + run.repeat_index.to_string(), + run.repeat_count.to_string(), + run.frames.to_string(), + run.spp.to_string(), + run.render_time_ms.to_string(), + ) + } else { + ( + "null".to_owned(), + "null".to_owned(), + "null".to_owned(), + "null".to_owned(), + "null".to_owned(), + "null".to_owned(), + "null".to_owned(), + "null".to_owned(), + "null".to_owned(), + "null".to_owned(), + "null".to_owned(), + ) + }; + let json = format!( + "{{\n \"test\": \"{}\",\n \"git_commit\": \"{}\",\n \"os\": \"{}\",\n \"arch\": \"{}\",\n \"width\": {},\n \"height\": {},\n \"mean_abs_rgba\": {:.9},\n \"mean_abs_rgb\": {:.9},\n \"max_abs\": {},\n \"outlier_pixel_fraction\": {:.9},\n \"outlier_channel_fraction\": {:.9},\n \"gated_outlier_kind\": \"{}\",\n \"gated_outlier_fraction\": {:.9},\n \"ssim_luminance\": {:.9},\n \"seed\": {},\n \"sample_index_start\": {},\n \"camera_frame_start\": {},\n \"jitter_sequence\": {},\n \"fault_injection\": {},\n \"repeat_index\": {},\n \"repeat_count\": {},\n \"frames\": {},\n \"spp\": {},\n \"render_time_ms\": {},\n \"adapter\": {}\n}}\n", + json_escape(name), + json_escape(&git_commit()), + std::env::consts::OS, + std::env::consts::ARCH, + width, + height, + mean, + mean_rgb, + max_diff, + outlier_pixel_frac, + outlier_channel_frac, + json_escape(gated_outlier_kind), + gated_outlier_frac, + ssim, + seed, + sample_index, + camera_frame, + jitter, + fault, + repeat_index, + repeat_count, + frames, + spp, + render_time_ms, + adapter_json, + ); + std::fs::write(dir.join("metrics.json"), json).expect("write golden metrics artifact"); + dir +} + +fn diagnostics_enabled() -> bool { + std::env::var("BLOOM_GOLDEN_DIAGNOSTICS") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) +} + +fn pt_fault_injection() -> &'static str { + match std::env::var("BLOOM_PT_TEST_FAULT").as_deref() { + Ok("brdf-energy") => "brdf-energy", + Ok("reprojection") => "reprojection", + _ => "none", + } +} + +fn pt_golden_repeat_count() -> u32 { + match std::env::var("BLOOM_GOLDEN_REPEAT") { + Ok(value) => value + .parse::() + .ok() + .filter(|count| (1..=10).contains(count)) + .unwrap_or_else(|| { + panic!("BLOOM_GOLDEN_REPEAT must be an integer from 1 through 10, got {value:?}") + }), + Err(_) => 1, + } +} + +fn write_diagnostic_capture(name: &str, stage: &str, width: u32, height: u32, rgba: &[u8]) { + let dir = golden_artifact_dir(name).join("intermediates"); + std::fs::create_dir_all(&dir).expect("create golden diagnostics directory"); + image::save_buffer( + dir.join(format!("{stage}.png")), + rgba, + width, + height, + image::ColorType::Rgba8, + ) + .expect("write golden diagnostic capture"); +} + +fn draw_pt_static_frame(eng: &mut EngineState) { + let r = &mut eng.renderer; + r.set_clear_color(0.05, 0.07, 0.1, 1.0); + r.begin_mode_3d(5.0, 4.0, 7.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 50.0, 0.0); + // The legacy primary-light API and the PT shader both use the vector + // from the shaded point toward the sun. Keep this identical to the CPU + // pt-golden oracle documented below. The setter's colour uses the public + // 0..255 Color convention (unlike add_directional_light's linear 0..1). + r.set_directional_light(0.5, 1.0, 0.3, 255.0, 242.25, 229.5, 1.2); +} + +fn capture_progressive_diagnostics( + eng: &mut EngineState, + final_rgba: &[u8], + query_diagnostics_compiled: bool, +) { + write_diagnostic_capture("pt_progressive", "accumulated-output", W, H, final_rgba); + let mut views = vec![ + (5, "pipeline-solid"), + (1, "depth"), + (2, "normal"), + (3, "albedo"), + (4, "sun-visibility"), + ]; + if query_diagnostics_compiled { + views.push((13, "primary-ray-agreement")); + } + views.push((24, "raw-radiance")); + for (view, stage) in views { + eng.renderer.set_path_tracing_debug_view(view); + let (w, h, rgba) = render(eng, 1, draw_pt_static_frame); + write_diagnostic_capture("pt_progressive", stage, w, h, &rgba); + } + eng.renderer.set_path_tracing_debug_view(0); +} + +fn draw_pt_motion_frame(eng: &mut EngineState, frame: u32) { + let r = &mut eng.renderer; + let a = 0.6 + frame as f32 * 0.009; + r.set_clear_color(0.05, 0.07, 0.1, 1.0); + r.begin_mode_3d( + a.cos() * 8.0, + 4.0, + a.sin() * 8.0, + 0.0, + 0.5, + 0.0, + 0.0, + 1.0, + 0.0, + 50.0, + 0.0, + ); + r.set_directional_light(0.5, 1.0, 0.3, 255.0, 242.25, 229.5, 1.2); +} + +fn capture_realtime_diagnostics(eng: &mut EngineState, final_rgba: &[u8]) { + write_diagnostic_capture("pt_realtime_motion", "denoised-output", W, H, final_rgba); + for (view, stage, frames) in [ + (25, "motion", 2u32), + (24, "raw-radiance", 1), + (20, "history-length", 16), + (21, "variance", 16), + ] { + eng.renderer.set_path_tracing_debug_view(view); + let mut frame = 0u32; + let (w, h, rgba) = render(eng, frames, |eng| { + draw_pt_motion_frame(eng, frame); + frame += 1; + }); + write_diagnostic_capture("pt_realtime_motion", stage, w, h, &rgba); + } + eng.renderer.set_path_tracing_debug_view(0); +} fn try_engine() -> Option { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends: wgpu::Backends::all(), @@ -45,21 +507,28 @@ fn try_engine() -> Option { if adapter.get_info().device_type == wgpu::DeviceType::Cpu { return None; } - let (device, queue) = - pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { - required_limits: adapter.limits(), - ..Default::default() - })) - .ok()?; + let required_features = adapter.features() & wgpu::Features::TIMESTAMP_QUERY; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_features, + required_limits: adapter.limits(), + ..Default::default() + })) + .ok()?; let renderer = Renderer::new_headless(device, queue, W, H); let mut eng = EngineState::new(renderer); - // Deterministic output: no sub-pixel jitter accumulation. + // Deterministic native-resolution output: TAA and resolution are + // independent controls, so the golden harness sets both explicitly. eng.renderer.set_taa_enabled(false); + eng.renderer.set_render_scale(1.0); Some(eng) } /// Render `frames` frames of `draw`, capturing the last one as RGBA. -fn render(eng: &mut EngineState, frames: u32, mut draw: impl FnMut(&mut EngineState)) -> (u32, u32, Vec) { +fn render( + eng: &mut EngineState, + frames: u32, + mut draw: impl FnMut(&mut EngineState), +) -> (u32, u32, Vec) { let mut shot = None; for i in 0..frames { eng.begin_frame(); @@ -88,7 +557,15 @@ fn render(eng: &mut EngineState, frames: u32, mut draw: impl FnMut(&mut EngineSt } fn compare_or_update(name: &str, width: u32, height: u32, rgba: &[u8]) { - compare_or_update_tol(name, width, height, rgba, MEAN_TOLERANCE); + compare_or_update_tol_with_metadata( + name, + width, + height, + rgba, + MEAN_TOLERANCE, + OUTLIER_FRACTION, + None, + ); } /// Like `compare_or_update` but with a per-test mean-difference tolerance. @@ -98,9 +575,35 @@ fn compare_or_update(name: &str, width: u32, height: u32, rgba: &[u8]) { /// backend variance, so raising it for a specific scene cannot let a /// structural regression through. fn compare_or_update_tol(name: &str, width: u32, height: u32, rgba: &[u8], mean_tol: f64) { + compare_or_update_tol_with_metadata( + name, + width, + height, + rgba, + mean_tol, + OUTLIER_FRACTION, + None, + ); +} + +fn compare_or_update_tol_with_metadata( + name: &str, + width: u32, + height: u32, + rgba: &[u8], + mean_tol: f64, + outlier_tol: f64, + run: Option<&GoldenRunMetadata>, +) { let golden_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden"); let path = golden_dir.join(format!("{name}.png")); - let update = std::env::var("BLOOM_UPDATE_GOLDEN").map(|v| v == "1").unwrap_or(false); + let update = std::env::var("BLOOM_UPDATE_GOLDEN") + .map(|v| v == "1") + .unwrap_or(false); + + if update && run.is_some_and(|run| run.fault_injection != "none") { + panic!("refusing to update {name} while a PT fault injection is active"); + } if update || !path.exists() { std::fs::create_dir_all(&golden_dir).unwrap(); @@ -121,29 +624,53 @@ fn compare_or_update_tol(name: &str, width: u32, height: u32, rgba: &[u8], mean_ "golden {name} size mismatch" ); let gold = golden.as_raw(); - let mut sum_abs: f64 = 0.0; - let mut outliers: usize = 0; - let mut max_diff: u8 = 0; - for (a, b) in rgba.iter().zip(gold.iter()) { - let d = a.abs_diff(*b); - sum_abs += d as f64; - if d > 32 { - outliers += 1; - } - max_diff = max_diff.max(d); - } - let mean = sum_abs / rgba.len() as f64; - let outlier_frac = outliers as f64 / rgba.len() as f64; - // On failure, write the actual image next to the golden for diffing. - if mean > mean_tol || outlier_frac > OUTLIER_FRACTION { - let actual = golden_dir.join(format!("{name}.actual.png")); - image::save_buffer(&actual, rgba, width, height, image::ColorType::Rgba8).unwrap(); + let metrics = calculate_diff_metrics(gold, rgba, width, height); + // Preserve the historical channel-based gate for every existing raster + // golden. PT uses the stronger coherent-region detector required by its + // oracle contract; changing unrelated gates would create cross-GPU churn. + let (gated_outlier_kind, gated_outlier_frac) = select_outlier_gate(metrics, run.is_some()); + if metrics.mean_rgba > mean_tol || gated_outlier_frac > outlier_tol { + let artifacts = write_failure_artifacts( + name, + width, + height, + gold, + rgba, + metrics.mean_rgba, + metrics.mean_rgb, + metrics.outlier_pixel_fraction, + metrics.outlier_channel_fraction, + gated_outlier_kind, + gated_outlier_frac, + metrics.max_diff, + metrics.ssim, + run, + ); panic!( - "golden {name} mismatch: mean diff {mean:.3} (tol {mean_tol}), \ - outliers {:.4}% (tol {:.4}%), max {max_diff}. Actual written to {actual:?}; \ - if the change is intentional, regenerate with BLOOM_UPDATE_GOLDEN=1.", - outlier_frac * 100.0, - OUTLIER_FRACTION * 100.0, + "golden {name} mismatch: mean diff {:.3} (tol {mean_tol}), \ + outlier {gated_outlier_kind}s {:.4}% (tol {:.4}%), max {}, SSIM {:.6}. \ + Failure artifacts written to {artifacts:?}; \ + if the change is intentional, regenerate with BLOOM_UPDATE_GOLDEN =1.", + metrics.mean_rgba, + gated_outlier_frac * 100.0, + outlier_tol * 100.0, + metrics.max_diff, + metrics.ssim, + ); + } + if let Some(run) = run { + eprintln!( + "PT metrics {name} repeat {}/{}: mean_rgba={:.6}, mean_rgb={:.6}, \ + outlier_{}={:.6}%, max={}, ssim={:.9}, render_ms={}", + run.repeat_index + 1, + run.repeat_count, + metrics.mean_rgba, + metrics.mean_rgb, + gated_outlier_kind, + gated_outlier_frac * 100.0, + metrics.max_diff, + metrics.ssim, + run.render_time_ms, ); } } @@ -196,37 +723,798 @@ fn golden_lit_primitives_3d() { } #[test] -fn golden_many_point_lights() { +fn gltf_blend_is_fractional_and_not_cutout_or_opaque() { let Some(mut eng) = try_engine() else { eprintln!("skip: no GPU adapter"); return; }; - // 40 colored point lights in a ring over a dark floor — far past the - // old 16-light cap. If the cap regressed, lights 17..40 vanish and - // the right side of the ring goes dark (well past tolerance). - let (w, h, rgba) = render(&mut eng, 6, |eng| { - let r = &mut eng.renderer; - r.set_clear_color(2.0, 2.0, 4.0, 255.0); - r.begin_mode_3d( - 0.0, 9.0, 0.01, // eye: straight above - 0.0, 0.0, 0.0, - 0.0, 1.0, 0.0, - 60.0, 0.0, + let (vertices, indices) = cube_verts(0.9, [1.0, 1.0, 1.0, 1.0]); + let node = eng.scene.create_node(); + eng.scene.update_geometry(node, vertices, indices); + eng.scene.set_material_color(node, 1.0, 0.08, 0.03, 1.0); + + let draw = |eng: &mut EngineState| { + let renderer = &mut eng.renderer; + renderer.set_clear_color(0.02, 0.18, 0.8, 1.0); + renderer.begin_mode_3d(0.0, 0.0, 4.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 45.0, 0.0); + renderer.set_ambient_light(255.0, 255.0, 255.0, 1.0); + }; + + eng.scene.set_visible(node, false); + let (_, _, background) = render(&mut eng, 3, draw); + + eng.scene.set_visible(node, true); + eng.scene + .set_material_gltf_alpha(node, MaterialAlphaMode::Opaque, 0.0, false); + eng.scene.set_material_color(node, 1.0, 0.08, 0.03, 1.0); + let (_, _, opaque) = render(&mut eng, 3, draw); + + eng.scene + .set_material_gltf_alpha(node, MaterialAlphaMode::Blend, 0.0, false); + eng.scene.set_material_color(node, 1.0, 0.08, 0.03, 0.5); + let (_, _, blended) = render(&mut eng, 3, draw); + + let assert_fractional = |label: &str, opaque: &[u8], blended: &[u8]| { + let center = ((H / 2 * W + W / 2) * 4) as usize; + let rgb_distance = |a: &[u8], b: &[u8]| -> u32 { + (0..3) + .map(|channel| a[center + channel].abs_diff(b[center + channel]) as u32) + .sum() + }; + let full_span = rgb_distance(opaque, &background); + let from_background = rgb_distance(blended, &background); + let from_opaque = rgb_distance(blended, opaque); + assert!( + full_span > 48, + "{label}: test scene lacks enough foreground/background contrast" ); - r.draw_plane(0.0, 0.0, 0.0, 14.0, 14.0, 110.0, 110.0, 110.0, 255.0); - for i in 0..40u32 { - let t = i as f32 / 40.0 * std::f32::consts::TAU; - let (sx, sz) = (t.cos() * 4.0, t.sin() * 4.0); - // hue cycles so neighboring lights are distinguishable - let (lr, lg, lb) = ( - 0.5 + 0.5 * (t).cos(), - 0.5 + 0.5 * (t + 2.094).cos(), - 0.5 + 0.5 * (t + 4.189).cos(), - ); - r.add_point_light(sx, 1.2, sz, 3.5, lr, lg, lb, 1.6); + assert!( + from_background > 12 && from_opaque > 12, + "{label}: BLEND collapsed to a binary endpoint: background={:?}, blend={:?}, opaque={:?}", + &background[center..center + 3], + &blended[center..center + 3], + &opaque[center..center + 3], + ); + assert!( + from_background < full_span && from_opaque < full_span, + "{label}: BLEND did not remain between its opaque and background endpoints" + ); + }; + assert_fractional("retained scene", &opaque, &blended); + + // Exercise the imported/cached-model route too. Most drawModel users take + // this path rather than attaching primitives to the retained SceneGraph. + eng.scene.set_visible(node, false); + let mut cached_opaque_vertices = cube_verts(0.9, [1.0, 0.08, 0.03, 1.0]).0; + let cached_indices = cube_verts(0.9, [1.0; 4]).1; + let mut cached_blend_vertices = cached_opaque_vertices.clone(); + for vertex in &mut cached_opaque_vertices { + vertex.color[3] = 1.0; + } + for vertex in &mut cached_blend_vertices { + vertex.color[3] = 0.5; + } + let cached_mesh = |vertices, alpha_mode| MeshData { + vertices, + secondary_tex_coords: None, + indices: cached_indices.clone(), + texture_idx: None, + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.0, + roughness_factor: 1.0, + emissive_factor: [0.0; 3], + alpha_mode, + alpha_cutoff: 0.0, + alpha_coverage_mips: false, + double_sided: false, + transmission: Default::default(), + layered_pbr: Default::default(), + }; + const OPAQUE_HANDLE: u64 = 0xA1FA_0001; + const BLEND_HANDLE: u64 = 0xA1FA_0002; + assert!(eng.renderer.cache_model_if_static( + OPAQUE_HANDLE, + &[cached_mesh( + cached_opaque_vertices, + MaterialAlphaMode::Opaque + )] + )); + assert!(eng.renderer.cache_model_if_static( + BLEND_HANDLE, + &[cached_mesh(cached_blend_vertices, MaterialAlphaMode::Blend)] + )); + let (_, _, cached_opaque) = render(&mut eng, 3, |eng| { + draw(eng); + eng.renderer + .draw_model_cached(OPAQUE_HANDLE, [0.0; 3], 1.0, [1.0, 1.0, 1.0, 1.0]); + }); + let (_, _, cached_blend) = render(&mut eng, 3, |eng| { + draw(eng); + eng.renderer + .draw_model_cached(BLEND_HANDLE, [0.0; 3], 1.0, [1.0, 1.0, 1.0, 1.0]); + }); + assert_fractional("cached model", &cached_opaque, &cached_blend); + assert_eq!( + eng.renderer.active_transparency_composition_mode_code(), + 0, + "auto mode must preserve sorted alpha for a simple imported set" + ); + let simple_graph = eng.renderer.render_graph_json().unwrap(); + assert!( + !simple_graph.contains("transparency-accumulation") + && !simple_graph.contains("transparency-revealage"), + "simple sorted transparency must not allocate weighted targets" + ); + assert!( + !simple_graph.contains("transparency-reactive"), + "TAA-disabled sorted imported BLEND must keep the established topology" + ); + + eng.renderer.set_taa_enabled(true); + let _ = render(&mut eng, 2, |eng| { + draw(eng); + eng.renderer + .draw_model_cached(BLEND_HANDLE, [0.0; 3], 1.0, [1.0; 4]); + }); + assert!( + eng.renderer + .render_graph_json() + .unwrap() + .contains("transparency-reactive"), + "TAA-active sorted imported BLEND must declare temporal coverage" + ); + eng.renderer.set_taa_enabled(false); + let _ = render(&mut eng, 1, |eng| { + draw(eng); + eng.renderer + .draw_model_cached(BLEND_HANDLE, [0.0; 3], 1.0, [1.0; 4]); + }); + assert!( + !eng.renderer + .render_graph_json() + .unwrap() + .contains("transparency-reactive"), + "TAA-disabled imported BLEND must restore the established topology" + ); +} + +#[test] +fn mask_coverage_mips_preserve_subpixel_silhouette_area() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + const TEX_SIZE: u32 = 64; + let mut pixels = Vec::with_capacity((TEX_SIZE * TEX_SIZE * 4) as usize); + for _y in 0..TEX_SIZE { + for x in 0..TEX_SIZE { + pixels.extend_from_slice(if x % 4 == 0 { + &[255, 0, 255, 0] + } else { + &[20, 230, 30, 255] + }); + } + } + let ordinary_texture = eng + .renderer + .register_texture_kind(TEX_SIZE, TEX_SIZE, &pixels, false); + let coverage_texture = eng.renderer.register_texture_kind_with_alpha_coverage( + TEX_SIZE, + TEX_SIZE, + &pixels, + false, + Some(0.5), + ); + + let vertex = |position, uv| Vertex3D { + position, + normal: [0.0, 0.0, 1.0], + color: [1.0; 4], + uv, + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [1.0, 0.0, 0.0, 1.0], + }; + let vertices = vec![ + vertex([-0.13, -0.13, 0.0], [0.0, 1.0]), + vertex([0.13, -0.13, 0.0], [1.0, 1.0]), + vertex([0.13, 0.13, 0.0], [1.0, 0.0]), + vertex([-0.13, 0.13, 0.0], [0.0, 0.0]), + ]; + let indices = vec![0, 1, 2, 0, 2, 3]; + let mask_mesh = |texture_idx, alpha_coverage_mips| MeshData { + vertices: vertices.clone(), + secondary_tex_coords: None, + indices: indices.clone(), + texture_idx: Some(texture_idx), + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.0, + roughness_factor: 1.0, + emissive_factor: [0.0; 3], + alpha_mode: MaterialAlphaMode::Mask, + alpha_cutoff: 0.5, + alpha_coverage_mips, + double_sided: true, + transmission: Default::default(), + layered_pbr: Default::default(), + }; + const ORDINARY_HANDLE: u64 = 0xA1FA_C001; + const COVERAGE_HANDLE: u64 = 0xA1FA_C002; + assert!(eng + .renderer + .cache_model_if_static(ORDINARY_HANDLE, &[mask_mesh(ordinary_texture, false)])); + assert!(eng + .renderer + .cache_model_if_static(COVERAGE_HANDLE, &[mask_mesh(coverage_texture, true)])); + eng.renderer.set_shadows_enabled(true); + + let render_mask = |eng: &mut EngineState, handle| { + render(eng, 2, |eng| { + let renderer = &mut eng.renderer; + renderer.set_clear_color(0.01, 0.01, 0.015, 1.0); + renderer.begin_mode_3d(0.0, 0.0, 4.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 45.0, 0.0); + renderer.set_ambient_light(255.0, 255.0, 255.0, 1.0); + renderer.draw_model_cached(handle, [0.0; 3], 1.0, [1.0; 4]); + }) + .2 + }; + let ordinary = render_mask(&mut eng, ORDINARY_HANDLE); + let coverage = render_mask(&mut eng, COVERAGE_HANDLE); + let visible_pixels = |rgba: &[u8]| { + rgba.chunks_exact(4) + .filter(|pixel| pixel[1] > 35 && pixel[1] > pixel[0].saturating_add(12)) + .count() + }; + let ordinary_visible = visible_pixels(&ordinary); + let coverage_visible = visible_pixels(&coverage); + assert!(ordinary_visible > 100, "negative control did not render"); + assert!( + coverage_visible < ordinary_visible * 9 / 10, + "ordinary averaged alpha did not overfill the minified card: \ + ordinary={ordinary_visible}, coverage={coverage_visible}" + ); + assert!( + coverage_visible * 10 > ordinary_visible * 6, + "coverage-preserving card lost too much authored area: \ + ordinary={ordinary_visible}, coverage={coverage_visible}" + ); +} + +#[test] +fn gltf_transmission_uses_physical_retained_and_cached_paths() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + assert_eq!( + eng.renderer.imported_refraction_mode_code(), + if cfg!(fold_scene_inputs) { 2 } else { 1 } + ); + eng.renderer.set_shadows_enabled(true); + let (glass_vertices, glass_indices) = cube_verts(0.85, [1.0; 4]); + let node = eng.scene.create_node(); + eng.scene + .update_geometry(node, glass_vertices.clone(), glass_indices.clone()); + eng.scene.set_material_pbr(node, 0.08, 0.0); + let transmission = MaterialTransmission { + authored: true, + factor: 1.0, + ior_authored: true, + ior: 1.5, + volume_authored: true, + thickness_factor: 0.8, + attenuation_distance: 0.45, + attenuation_color: [0.08, 0.75, 1.0], + thickness_source: MaterialThicknessSource::Authored, + ..Default::default() + }; + eng.scene.set_material_transmission(node, transmission); + + let draw_background = |eng: &mut EngineState| { + let renderer = &mut eng.renderer; + renderer.set_clear_color(0.04, 0.12, 0.4, 1.0); + renderer.begin_mode_3d(0.0, 0.0, 4.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 45.0, 0.0); + renderer.set_ambient_light(255.0, 255.0, 255.0, 1.0); + renderer.set_directional_light(-0.45, 0.75, 0.35, 255.0, 245.0, 230.0, 1.0); + // Bright opaque target behind the glass makes absorption and snapshot + // routing observable at the center pixel. + renderer.draw_cube(0.0, 0.0, -1.7, 2.2, 2.2, 0.3, 240.0, 230.0, 210.0, 255.0); + }; + + eng.scene.set_visible(node, false); + let (_, _, background) = render(&mut eng, 2, draw_background); + eng.scene.set_visible(node, true); + let (_, _, retained) = render(&mut eng, 2, draw_background); + + let center = ((H / 2 * W + W / 2) * 4) as usize; + assert!( + retained[center] + 12 < background[center], + "retained physical volume did not apply red Beer-Lambert absorption: \ + background={:?}, retained={:?}", + &background[center..center + 3], + &retained[center..center + 3], + ); + let graph = eng + .renderer + .render_graph_json() + .expect("physical transmission rendered a compiled frame plan"); + if !cfg!(fold_scene_inputs) { + assert!( + graph.contains("translucent-scene-color") && graph.contains("translucent-scene-depth"), + "native physical transmission must declare immutable scene snapshots" + ); + } + assert!( + graph.contains("transmitted_shadow_resolve") + && graph.contains("transmitted-shadow-color-0") + && graph.contains("transmitted-shadow-depth-0"), + "shadow-casting physical transmission must declare its lazy \ + transmittance/depth cascade and receiver resolve" + ); + // The cached drawModel route must consume the same contract and shader. + eng.scene.set_visible(node, false); + const TRANSMISSION_HANDLE: u64 = 0x7A11_5001; + assert!(eng.renderer.cache_model_if_static( + TRANSMISSION_HANDLE, + &[MeshData { + vertices: glass_vertices, + secondary_tex_coords: None, + indices: glass_indices, + texture_idx: None, + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.0, + roughness_factor: 0.08, + emissive_factor: [0.0; 3], + alpha_mode: MaterialAlphaMode::Opaque, + alpha_cutoff: 0.0, + alpha_coverage_mips: false, + double_sided: false, + transmission, + layered_pbr: Default::default(), + }] + )); + let (_, _, cached) = render(&mut eng, 2, |eng| { + draw_background(eng); + eng.renderer + .draw_model_cached(TRANSMISSION_HANDLE, [0.0; 3], 1.0, [1.0; 4]); + }); + for channel in 0..3 { + assert!( + retained[center + channel].abs_diff(cached[center + channel]) <= 3, + "retained/cached transmission diverged at channel {channel}: \ + retained={:?}, cached={:?}", + &retained[center..center + 3], + &cached[center..center + 3], + ); + } + + eng.renderer.set_taa_enabled(true); + let _ = render(&mut eng, 2, |eng| { + draw_background(eng); + eng.renderer + .draw_model_cached(TRANSMISSION_HANDLE, [0.0; 3], 1.0, [1.0; 4]); + }); + assert!( + eng.renderer + .render_graph_json() + .unwrap() + .contains("transparency-reactive"), + "TAA-active physical transmission must declare temporal coverage" + ); +} + +#[test] +fn physical_texcoord_1_matches_between_retained_cached_shadow_and_reactive_paths() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + eng.renderer.set_shadows_enabled(true); + const TEX_SIZE: u32 = 8; + let mut pixels = Vec::with_capacity((TEX_SIZE * TEX_SIZE * 4) as usize); + for _y in 0..TEX_SIZE { + for x in 0..TEX_SIZE { + let value = if x < TEX_SIZE / 2 { 0 } else { 255 }; + pixels.extend_from_slice(&[value, value, value, 255]); } + } + let transmission_texture = eng + .renderer + .register_texture_kind(TEX_SIZE, TEX_SIZE, &pixels, false); + let (mut glass_vertices, glass_indices) = cube_verts(0.85, [1.0; 4]); + for vertex in &mut glass_vertices { + // UV0 samples the white half. UV1 samples the black half. A renderer + // that silently aliases TEXCOORD_1 to UV0 therefore matches the + // scalar fallback instead of the authored zero-transmission result. + vertex.uv = [0.8, 0.5]; + } + let secondary_tex_coords = vec![[0.2, 0.5]; glass_vertices.len()]; + let transmission = MaterialTransmission { + authored: true, + factor: 1.0, + texture: Some(MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: Some(transmission_texture), + transform: MaterialTextureTransform { + tex_coord: 1, + ..Default::default() + }, + }), + ior_authored: true, + ior: 1.5, + ..Default::default() + }; + let draw_background = |eng: &mut EngineState| { + let renderer = &mut eng.renderer; + renderer.set_clear_color(0.01, 0.02, 0.08, 1.0); + renderer.begin_mode_3d(0.0, 0.0, 4.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 45.0, 0.0); + renderer.set_ambient_light(255.0, 255.0, 255.0, 0.35); + renderer.set_directional_light(-0.45, 0.75, 0.35, 255.0, 245.0, 230.0, 1.0); + renderer.draw_cube(0.0, 0.0, -1.7, 2.2, 2.2, 0.3, 230.0, 60.0, 25.0, 255.0); + }; + + let retained_node = eng.scene.create_node(); + eng.scene.update_geometry_with_secondary_uv( + retained_node, + glass_vertices.clone(), + Some(secondary_tex_coords.clone()), + glass_indices.clone(), + ); + eng.scene.set_material_pbr(retained_node, 0.55, 0.0); + eng.scene + .set_material_transmission(retained_node, transmission); + let (_, _, retained) = render(&mut eng, 2, draw_background); + + eng.scene.set_visible(retained_node, false); + const UV1_HANDLE: u64 = 0x7A11_5101; + const FALLBACK_HANDLE: u64 = 0x7A11_5102; + let mesh = |secondary_tex_coords| MeshData { + vertices: glass_vertices.clone(), + secondary_tex_coords, + indices: glass_indices.clone(), + texture_idx: None, + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.0, + roughness_factor: 0.55, + emissive_factor: [0.0; 3], + alpha_mode: MaterialAlphaMode::Opaque, + alpha_cutoff: 0.0, + alpha_coverage_mips: false, + double_sided: false, + transmission, + layered_pbr: Default::default(), + }; + assert!(eng + .renderer + .cache_model_if_static(UV1_HANDLE, &[mesh(Some(secondary_tex_coords))])); + assert!(eng + .renderer + .cache_model_if_static(FALLBACK_HANDLE, &[mesh(None)])); + let (_, _, cached) = render(&mut eng, 2, |eng| { + draw_background(eng); + eng.renderer + .draw_model_cached(UV1_HANDLE, [0.0; 3], 1.0, [1.0; 4]); + }); + let (_, _, scalar_fallback) = render(&mut eng, 2, |eng| { + draw_background(eng); + eng.renderer + .draw_model_cached(FALLBACK_HANDLE, [0.0; 3], 1.0, [1.0; 4]); }); - compare_or_update("many_point_lights", w, h, &rgba); + let parity = calculate_diff_metrics(&retained, &cached, W, H); + assert!( + parity.mean_rgb < 0.5, + "retained/cached UV1 physical sampling diverged: {parity:?}" + ); + let authored_effect = calculate_diff_metrics(&cached, &scalar_fallback, W, H); + assert!( + authored_effect.mean_rgb > 1.0, + "TEXCOORD_1 did not select the authored black modulation instead of \ + UV0/scalar fallback: {authored_effect:?}" + ); + let paths = eng.renderer.quality_runtime_paths_json(); + assert!( + paths.contains( + "\"physical_texture_uv\":{\"supported_sets\":[0,1],\ + \"uv1_pipeline_initialized\":true,\"ordinary_vertex_stride_bytes\":96,\ + \"uv1_sidecar_stride_bytes\":8,\"additional_graph_passes\":0,\ + \"additional_image_bytes\":0}" + ), + "UV1 cost/activation telemetry is incomplete: {paths}" + ); + + eng.renderer.set_taa_enabled(true); + let _ = render(&mut eng, 2, |eng| { + draw_background(eng); + eng.renderer + .draw_model_cached(UV1_HANDLE, [0.0; 3], 1.0, [1.0; 4]); + }); + assert!( + eng.renderer + .render_graph_json() + .unwrap() + .contains("transparency-reactive"), + "UV1 physical transmission must retain temporal reactive coverage" + ); +} + +#[test] +fn physical_transmission_casts_a_bounded_colored_directional_shadow() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + eng.renderer.set_shadows_enabled(true); + + let transform = |scale: [f32; 3], translation: [f32; 3]| -> [[f32; 4]; 4] { + [ + [scale[0], 0.0, 0.0, 0.0], + [0.0, scale[1], 0.0, 0.0], + [0.0, 0.0, scale[2], 0.0], + [translation[0], translation[1], translation[2], 1.0], + ] + }; + let (floor_vertices, floor_indices) = cube_verts(0.5, [0.72, 0.72, 0.72, 1.0]); + let floor = eng.scene.create_node(); + eng.scene + .update_geometry(floor, floor_vertices, floor_indices); + eng.scene + .set_transform(floor, transform([7.0, 0.2, 7.0], [0.0, -0.2, 0.0])); + + let (glass_vertices, glass_indices) = cube_verts(0.5, [1.0; 4]); + let glass = eng.scene.create_node(); + eng.scene + .update_geometry(glass, glass_vertices, glass_indices); + eng.scene + .set_transform(glass, transform([2.2, 0.12, 2.2], [0.0, 1.15, 0.0])); + eng.scene.set_material_pbr(glass, 0.12, 0.0); + eng.scene.set_material_transmission( + glass, + MaterialTransmission { + authored: true, + factor: 0.96, + ior_authored: true, + ior: 1.5, + volume_authored: true, + thickness_factor: 0.7, + attenuation_distance: 0.45, + attenuation_color: [0.025, 0.55, 1.0], + thickness_source: MaterialThicknessSource::Authored, + ..Default::default() + }, + ); + + let draw = |eng: &mut EngineState| { + let renderer = &mut eng.renderer; + renderer.set_clear_color(10.0, 14.0, 20.0, 255.0); + renderer.begin_mode_3d(4.2, 4.4, 5.2, 0.0, 0.35, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + renderer.set_ambient_light(205.0, 215.0, 230.0, 0.18); + renderer.set_directional_light(0.0, 1.0, 0.0, 255.0, 248.0, 235.0, 2.2); + }; + + eng.scene.set_cast_shadow(glass, false); + let (_, _, unshadowed) = render(&mut eng, 4, draw); + eng.scene.set_cast_shadow(glass, true); + let (_, _, colored) = render(&mut eng, 4, draw); + + let mut affected = 0usize; + let mut red_loss = 0u64; + let mut green_loss = 0u64; + let mut blue_loss = 0u64; + for (before, after) in unshadowed.chunks_exact(4).zip(colored.chunks_exact(4)) { + let red = before[0].saturating_sub(after[0]); + let green = before[1].saturating_sub(after[1]); + let blue = before[2].saturating_sub(after[2]); + if red > 4 { + affected += 1; + } + red_loss += u64::from(red); + green_loss += u64::from(green); + blue_loss += u64::from(blue); + } + assert!( + affected > 50, + "enabling the glass caster did not produce a bounded receiver region \ + (affected={affected})" + ); + assert!( + red_loss > green_loss && green_loss > blue_loss.saturating_mul(2), + "shadow did not preserve authored cyan transmittance: \ + losses rgb=({red_loss},{green_loss},{blue_loss})" + ); + let graph = eng.renderer.render_graph_json().unwrap(); + assert!( + graph.contains("transmitted_shadow_resolve") + && graph.contains("transmitted-shadow-color-2") + && graph.contains("transmitted-shadow-depth-2") + ); +} + +#[test] +fn physical_transmission_gi_specializations_run_on_hardware_ray_query() { + let _guard = lock_rt_goldens(); + let Some((mut eng, _adapter)) = try_engine_rt().unwrap_or_else(|error| { + panic!("transparent-GI hardware setup failed: {error}"); + }) else { + skip_rt_golden( + "physical_transmission_gi_specializations_run_on_hardware_ray_query", + "adapter does not expose experimental ray query", + ); + return; + }; + eng.renderer.set_ssgi_enabled(true); + eng.renderer.set_ssgi_intensity(4.0); + + let transform = |scale: [f32; 3], translation: [f32; 3]| -> [[f32; 4]; 4] { + [ + [scale[0], 0.0, 0.0, 0.0], + [0.0, scale[1], 0.0, 0.0], + [0.0, 0.0, scale[2], 0.0], + [translation[0], translation[1], translation[2], 1.0], + ] + }; + + let (floor_vertices, floor_indices) = cube_verts(0.5, [0.8, 0.8, 0.8, 1.0]); + let floor = eng.scene.create_node(); + eng.scene + .update_geometry(floor, floor_vertices, floor_indices); + eng.scene + .set_transform(floor, transform([7.0, 0.2, 7.0], [0.0, -0.2, 0.0])); + + // GI-only transport slab: absent from the camera pass, present in cards, + // TLAS and the transparent-GI route. This isolates lazy pipeline + // validation from camera-facing refraction/composition. + let (glass_vertices, glass_indices) = cube_verts(0.5, [0.2, 0.85, 1.0, 1.0]); + let glass = eng.scene.create_node(); + eng.scene + .update_geometry(glass, glass_vertices, glass_indices); + eng.scene + .set_transform(glass, transform([4.0, 0.08, 4.0], [0.0, 1.0, 0.0])); + eng.scene.set_gi_only(glass, true); + let transmission = MaterialTransmission { + authored: true, + factor: 0.96, + ior_authored: true, + ior: 1.5, + volume_authored: true, + thickness_factor: 0.7, + attenuation_distance: 0.5, + attenuation_color: [0.05, 0.7, 1.0], + thickness_source: MaterialThicknessSource::Authored, + ..Default::default() + }; + + let (emitter_vertices, emitter_indices) = cube_verts(0.5, [0.8, 0.9, 1.0, 1.0]); + let emitter = eng.scene.create_node(); + eng.scene + .update_geometry(emitter, emitter_vertices, emitter_indices); + eng.scene + .set_transform(emitter, transform([5.0, 0.1, 5.0], [0.0, 2.1, 0.0])); + eng.scene + .set_material_emissive_factor(emitter, 0.0, 1.0, 1.0); + eng.scene.set_gi_only(emitter, true); + + let draw = |eng: &mut EngineState| { + let renderer = &mut eng.renderer; + renderer.set_clear_color(5.0, 7.0, 10.0, 255.0); + renderer.begin_mode_3d(4.2, 3.5, 4.2, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 52.0, 0.0); + renderer.set_ambient_light(180.0, 195.0, 220.0, 0.02); + renderer.set_directional_light(-0.3, 0.8, 0.4, 255.0, 245.0, 230.0, 0.0); + }; + let (_, _, opaque) = render(&mut eng, 20, draw); + eng.scene.set_material_transmission(glass, transmission); + let (_, _, transmitted) = render(&mut eng, 20, draw); + + assert!( + transmitted + .chunks_exact(4) + .any(|pixel| pixel[0..3] != [0, 0, 0]), + "transparent-GI validation frame was blank" + ); + let mut affected = 0_usize; + let mut delta = [0_i64; 3]; + for (before, after) in opaque.chunks_exact(4).zip(transmitted.chunks_exact(4)) { + let pixel_delta = [ + i64::from(after[0]) - i64::from(before[0]), + i64::from(after[1]) - i64::from(before[1]), + i64::from(after[2]) - i64::from(before[2]), + ]; + if pixel_delta.iter().any(|value| value.abs() > 0) { + affected += 1; + } + for channel in 0..3 { + delta[channel] += pixel_delta[channel]; + } + } + eprintln!("transparent GI affected={affected} delta_rgb={delta:?}"); + assert!( + affected > 50 && delta[1] > 0 && delta[2] > delta[1] && delta[1] + delta[2] > delta[0] * 2, + "GI-only glass did not reveal the green/cyan-weighted emitter behind it: \ + affected={affected}, delta_rgb={delta:?}" + ); + let paths = eng.renderer.quality_runtime_paths_json(); + assert!( + paths.contains("\"ssgi_trace_backend\":\"hw-ray-query\"") + && paths.contains( + "\"transparent_gi\":{\"enabled\":true,\"active\":true,\ + \"representation\":\"one-layer-colored-continuation\"" + ), + "hardware transparent-GI route did not activate: {paths}" + ); +} + +#[test] +fn physical_transmission_gi_specialization_runs_on_software_sdf() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + eng.renderer.set_ssgi_enabled(true); + + let transform = |scale: [f32; 3], translation: [f32; 3]| -> [[f32; 4]; 4] { + [ + [scale[0], 0.0, 0.0, 0.0], + [0.0, scale[1], 0.0, 0.0], + [0.0, 0.0, scale[2], 0.0], + [translation[0], translation[1], translation[2], 1.0], + ] + }; + let (floor_vertices, floor_indices) = cube_verts(0.5, [0.65, 0.65, 0.65, 1.0]); + let floor = eng.scene.create_node(); + eng.scene + .update_geometry(floor, floor_vertices, floor_indices); + eng.scene + .set_transform(floor, transform([6.0, 0.2, 6.0], [0.0, -0.2, 0.0])); + + let (glass_vertices, glass_indices) = cube_verts(0.5, [0.15, 0.8, 1.0, 1.0]); + let glass = eng.scene.create_node(); + eng.scene + .update_geometry(glass, glass_vertices, glass_indices); + eng.scene + .set_transform(glass, transform([3.0, 0.08, 3.0], [0.0, 1.0, 0.0])); + eng.scene.set_gi_only(glass, true); + eng.scene.set_material_transmission( + glass, + MaterialTransmission { + authored: true, + factor: 0.95, + ior_authored: true, + ior: 1.5, + volume_authored: true, + thickness_factor: 0.5, + attenuation_distance: 0.6, + attenuation_color: [0.08, 0.7, 1.0], + thickness_source: MaterialThicknessSource::Authored, + ..Default::default() + }, + ); + + let _ = render(&mut eng, 24, |eng| { + let renderer = &mut eng.renderer; + renderer.set_clear_color(5.0, 7.0, 10.0, 255.0); + renderer.begin_mode_3d(4.0, 3.0, 4.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 52.0, 0.0); + renderer.set_ambient_light(180.0, 195.0, 220.0, 0.2); + renderer.set_directional_light(-0.3, 0.8, 0.4, 255.0, 245.0, 230.0, 1.0); + }); + + let paths = eng.renderer.quality_runtime_paths_json(); + assert!( + paths.contains("\"ssgi_trace_backend\":\"sdf-clipmap\"") + && paths.contains("\"transparent_gi\":{\"enabled\":true,\"active\":true"), + "software transparent-GI route did not activate: {paths}" + ); } /// Froxel-clustering parity gate. The golden for this test is generated @@ -261,32 +1549,37 @@ fn golden_many_point_lights_clustered_scene() { // broken cluster lookup cannot hide. let scale_translate = |sx: f32, sy: f32, sz: f32, x: f32, y: f32, z: f32| -> [[f32; 4]; 4] { let mut m = [[0.0f32; 4]; 4]; - m[0][0] = sx; m[1][1] = sy; m[2][2] = sz; m[3][3] = 1.0; - m[3][0] = x; m[3][1] = y; m[3][2] = z; + m[0][0] = sx; + m[1][1] = sy; + m[2][2] = sz; + m[3][3] = 1.0; + m[3][0] = x; + m[3][1] = y; + m[3][2] = z; m }; let (floor_v, floor_i) = cube_verts(0.5, [0.45, 0.45, 0.45, 1.0]); let floor = eng.scene.create_node(); eng.scene.update_geometry(floor, floor_v, floor_i); - eng.scene.set_transform(floor, scale_translate(14.0, 0.2, 14.0, 0.0, -0.1, 0.0)); + eng.scene + .set_transform(floor, scale_translate(14.0, 0.2, 14.0, 0.0, -0.1, 0.0)); let (cube_v, cube_i) = cube_verts(0.5, [0.8, 0.8, 0.8, 1.0]); for i in 0..6u32 { let t = i as f32 / 6.0 * std::f32::consts::TAU; let node = eng.scene.create_node(); - eng.scene.update_geometry(node, cube_v.clone(), cube_i.clone()); - eng.scene.set_transform(node, scale_translate(1.0, 1.0, 1.0, t.cos() * 2.2, 0.5, t.sin() * 2.2)); + eng.scene + .update_geometry(node, cube_v.clone(), cube_i.clone()); + eng.scene.set_transform( + node, + scale_translate(1.0, 1.0, 1.0, t.cos() * 2.2, 0.5, t.sin() * 2.2), + ); } let (w, h, rgba) = render(&mut eng, 6, |eng| { let r = &mut eng.renderer; r.set_clear_color(2.0, 2.0, 4.0, 255.0); - r.begin_mode_3d( - 6.0, 7.0, 6.0, - 0.0, 0.0, 0.0, - 0.0, 1.0, 0.0, - 60.0, 0.0, - ); + r.begin_mode_3d(6.0, 7.0, 6.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 60.0, 0.0); for i in 0..40u32 { let t = i as f32 / 40.0 * std::f32::consts::TAU; let (sx, sz) = (t.cos() * 4.0, t.sin() * 4.0); @@ -316,12 +1609,30 @@ fn golden_many_point_lights_clustered_scene() { fn cube_verts(half: f32, color: [f32; 4]) -> (Vec, Vec) { let h = half; let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [ - ([0.0, 0.0, -1.0], [[-h,-h,-h],[ h,-h,-h],[ h, h,-h],[-h, h,-h]]), - ([0.0, 0.0, 1.0], [[ h,-h, h],[-h,-h, h],[-h, h, h],[ h, h, h]]), - ([-1.0, 0.0, 0.0], [[-h,-h, h],[-h,-h,-h],[-h, h,-h],[-h, h, h]]), - ([1.0, 0.0, 0.0], [[ h,-h,-h],[ h,-h, h],[ h, h, h],[ h, h,-h]]), - ([0.0, 1.0, 0.0], [[-h, h,-h],[ h, h,-h],[ h, h, h],[-h, h, h]]), - ([0.0, -1.0, 0.0], [[-h,-h, h],[ h,-h, h],[ h,-h,-h],[-h,-h,-h]]), + ( + [0.0, 0.0, -1.0], + [[-h, -h, -h], [h, -h, -h], [h, h, -h], [-h, h, -h]], + ), + ( + [0.0, 0.0, 1.0], + [[h, -h, h], [-h, -h, h], [-h, h, h], [h, h, h]], + ), + ( + [-1.0, 0.0, 0.0], + [[-h, -h, h], [-h, -h, -h], [-h, h, -h], [-h, h, h]], + ), + ( + [1.0, 0.0, 0.0], + [[h, -h, -h], [h, -h, h], [h, h, h], [h, h, -h]], + ), + ( + [0.0, 1.0, 0.0], + [[-h, h, -h], [h, h, -h], [h, h, h], [-h, h, h]], + ), + ( + [0.0, -1.0, 0.0], + [[-h, -h, h], [h, -h, h], [h, -h, -h], [-h, -h, -h]], + ), ]; let mut verts = Vec::new(); let mut idx = Vec::new(); @@ -353,17 +1664,23 @@ fn golden_lod_selection() { let (red_v, red_i) = cube_verts(0.5, [0.9, 0.1, 0.1, 1.0]); let (green_v, green_i) = cube_verts(0.5, [0.1, 0.9, 0.1, 1.0]); - let mut translate = |x: f32, z: f32| -> [[f32; 4]; 4] { + let translate = |x: f32, z: f32| -> [[f32; 4]; 4] { let mut m = [[0.0f32; 4]; 4]; - m[0][0] = 1.0; m[1][1] = 1.0; m[2][2] = 1.0; m[3][3] = 1.0; - m[3][0] = x; m[3][2] = z; + m[0][0] = 1.0; + m[1][1] = 1.0; + m[2][2] = 1.0; + m[3][3] = 1.0; + m[3][0] = x; + m[3][2] = z; m }; // Near node: large on screen → base (red) geometry. let near = eng.scene.create_node(); - eng.scene.update_geometry(near, red_v.clone(), red_i.clone()); - eng.scene.set_lod_geometry(near, 0, green_v.clone(), green_i.clone(), 0.12); + eng.scene + .update_geometry(near, red_v.clone(), red_i.clone()); + eng.scene + .set_lod_geometry(near, 0, green_v.clone(), green_i.clone(), 0.12); eng.scene.set_transform(near, translate(-1.0, 2.0)); // Far node: small on screen → LOD 0 (green) variant. @@ -413,11 +1730,13 @@ fn cooked_bc7_texture_matches_raw() { let (w, _h, frame_raw) = render(&mut eng, 2, |eng| { eng.renderer.set_clear_color(0.0, 0.0, 0.0, 255.0); - eng.renderer.draw_texture(raw_idx, 0.0, 0.0, 255.0, 255.0, 255.0, 255.0); + eng.renderer + .draw_texture(raw_idx, 0.0, 0.0, 255.0, 255.0, 255.0, 255.0); }); let (_, _, frame_cooked) = render(&mut eng, 2, |eng| { eng.renderer.set_clear_color(0.0, 0.0, 0.0, 255.0); - eng.renderer.draw_texture(cooked_idx, 0.0, 0.0, 255.0, 255.0, 255.0, 255.0); + eng.renderer + .draw_texture(cooked_idx, 0.0, 0.0, 255.0, 255.0, 255.0, 255.0); }); // BC7 is lossy but high quality: the two frames must agree closely @@ -443,11 +1762,12 @@ fn golden_lit_primitives_taa() { eprintln!("skip: no GPU adapter"); return; }; - // Same scene as lit_primitives_3d but with TAA ON: pins the TAA - // branch of the post-FX cascade (reprojection, neighborhood clamp, - // Catmull-Rom upscale path) that the TAA-off goldens never touch. + // Same scene as lit_primitives_3d but with half-scale TAA reconstruction: + // pins the TAA branch of the post-FX cascade (reprojection, neighborhood + // clamp, Catmull-Rom upscale path) that the TAA-off goldens never touch. // The Halton jitter sequence is indexed by frame number, so a fixed // frame count renders deterministically. + eng.renderer.set_render_scale(0.5); eng.renderer.set_taa_enabled(true); let (w, h, rgba) = render(&mut eng, 10, |eng| { let r = &mut eng.renderer; @@ -483,7 +1803,26 @@ fn golden_lit_primitives_taa() { // Windows, dxcompiler.dll + dxil.dll must be loadable (cwd or PATH); // without them DX12 is FXC-capped and the tests skip. -fn try_engine_rt() -> Option { +static RT_GOLDEN_LOCK: Mutex<()> = Mutex::new(()); + +struct RtDeviceContext { + device: wgpu::Device, + queue: wgpu::Queue, + adapter: AdapterMetadata, +} + +static RT_DEVICE: OnceLock, String>> = OnceLock::new(); + +fn lock_rt_goldens() -> MutexGuard<'static, ()> { + RT_GOLDEN_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// `Ok(None)` means this machine genuinely has no ray-query adapter and the +/// PT golden is not applicable. A ray-query adapter that fails device creation +/// is an infrastructure/test failure, not a passing skip. +fn create_rt_device_context() -> Result, String> { let mut backend_options = wgpu::BackendOptions::default(); backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::DynamicDxc { dxc_path: String::from("dxcompiler.dll"), @@ -496,26 +1835,118 @@ fn try_engine_rt() -> Option { let rt_mask = wgpu::Features::EXPERIMENTAL_RAY_QUERY; // The default adapter pick may be an FXC-capped DX12 view of a GPU // whose Vulkan view traces fine — enumerate and prefer ray query. - let adapter = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all())) - .into_iter() - .find(|a| { - a.get_info().device_type != wgpu::DeviceType::Cpu - && a.features().contains(rt_mask) - })?; + let mut adapters = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all())); + for adapter in &adapters { + let info = adapter.get_info(); + eprintln!( + "PT adapter candidate: {}({:?}, {:?}), ray_query={}", + info.name, + info.backend, + info.device_type, + adapter.features().contains(rt_mask), + ); + } + let adapter_index = adapters.iter().position(|a| { + a.get_info().device_type != wgpu::DeviceType::Cpu && a.features().contains(rt_mask) + }); + let adapter = if let Some(index) = adapter_index { + Some(adapters.swap_remove(index)) + } else { + // On Metal, enumerate_adapters can transiently return an empty list + // after a headless ray-query process exits even though request_adapter + // still returns the same fully capable device. Enumeration remains the + // first choice (it lets Windows prefer Vulkan/DXC-capable views), but a + // default-adapter fallback prevents a supported GPU from false-skipping. + pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .ok() + .filter(|a| { + a.get_info().device_type != wgpu::DeviceType::Cpu && a.features().contains(rt_mask) + }) + }; + let Some(adapter) = adapter else { + return Ok(None); + }; + let info = adapter.get_info(); + let texture_array_features = wgpu::Features::TEXTURE_BINDING_ARRAY + | wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING; + let enabled_texture_array_features = if adapter.features().contains(texture_array_features) { + texture_array_features + } else { + wgpu::Features::empty() + }; + eprintln!( + "PT golden adapter: {}({:?}, {:?}), texture_arrays={}", + info.name, + info.backend, + info.device_type, + !enabled_texture_array_features.is_empty(), + ); + let supported_features = format!("{:?}", adapter.features()); let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { - required_features: rt_mask, + required_features: rt_mask | enabled_texture_array_features, required_limits: adapter.limits(), experimental_features: unsafe { wgpu::ExperimentalFeatures::enabled() }, ..Default::default() })) - .ok()?; - let renderer = Renderer::new_headless(device, queue, W, H); - let mut eng = EngineState::new(renderer); - eng.renderer.set_taa_enabled(false); - // Auto-exposure adapts over the accumulation window; a fixed - // exposure keeps the golden a pure function of the transport. - eng.renderer.set_manual_exposure(1.0); - Some(eng) + .map_err(|err| { + format!( + "ray-query adapter '{}'({:?}) was found but device creation failed: {err}", + info.name, info.backend + ) + })?; + let metadata = AdapterMetadata { + name: info.name.clone(), + backend: format!("{:?}", info.backend), + device_type: format!("{:?}", info.device_type), + driver: info.driver.clone(), + driver_info: info.driver_info.clone(), + supported_features, + enabled_features: format!("{:?}", device.features()), + }; + Ok(Some(RtDeviceContext { + device, + queue, + adapter: metadata, + })) +} + +fn try_engine_rt() -> Result, String> { + if !RendererCapabilities::forced_path_allowed(RendererCapabilityTier::HighEnd) { + return Ok(None); + } + match RT_DEVICE.get_or_init(create_rt_device_context) { + Ok(Some(context)) => { + // Reuse one device while giving each golden fresh renderer/history state. + let renderer = + Renderer::new_headless(context.device.clone(), context.queue.clone(), W, H); + let mut eng = EngineState::new(renderer); + eng.renderer.set_taa_enabled(false); + eng.renderer.set_render_scale(1.0); + // Auto-exposure adapts over the accumulation window; a fixed + // exposure keeps the golden a pure function of the transport. + eng.renderer.set_manual_exposure(1.0); + Ok(Some((eng, context.adapter.clone()))) + } + Ok(None) => Ok(None), + Err(err) => Err(err.clone()), + } +} + +fn skip_rt_golden(name: &str, reason: &str) { + let message = format!( + "{{\"test\":\"{}\",\"status\":\"skipped\",\"reason\":\"{}\",\"os\":\"{}\",\"arch\":\"{}\"}}", + json_escape(name), + json_escape(reason), + std::env::consts::OS, + std::env::consts::ARCH, + ); + let required = std::env::var("BLOOM_REQUIRE_RAY_QUERY") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + if required { + panic!("ray query is required on this test runner: {message}"); + } + eprintln!("{message}"); } /// Shared PT test scene: floor slab + a ring of cubes as SCENE NODES so @@ -524,14 +1955,20 @@ fn try_engine_rt() -> Option { fn build_pt_scene(eng: &mut EngineState) { let scale_translate = |sx: f32, sy: f32, sz: f32, x: f32, y: f32, z: f32| -> [[f32; 4]; 4] { let mut m = [[0.0f32; 4]; 4]; - m[0][0] = sx; m[1][1] = sy; m[2][2] = sz; m[3][3] = 1.0; - m[3][0] = x; m[3][1] = y; m[3][2] = z; + m[0][0] = sx; + m[1][1] = sy; + m[2][2] = sz; + m[3][3] = 1.0; + m[3][0] = x; + m[3][1] = y; + m[3][2] = z; m }; let (floor_v, floor_i) = cube_verts(0.5, [0.55, 0.5, 0.45, 1.0]); let floor = eng.scene.create_node(); eng.scene.update_geometry(floor, floor_v, floor_i); - eng.scene.set_transform(floor, scale_translate(16.0, 0.2, 16.0, 0.0, -0.1, 0.0)); + eng.scene + .set_transform(floor, scale_translate(16.0, 0.2, 16.0, 0.0, -0.1, 0.0)); let colors: [[f32; 4]; 3] = [ [0.85, 0.2, 0.15, 1.0], [0.2, 0.65, 0.9, 1.0], @@ -544,63 +1981,265 @@ fn build_pt_scene(eng: &mut EngineState) { eng.scene.update_geometry(node, cv, ci); eng.scene.set_transform( node, - scale_translate(1.0, 1.0 + (i % 2) as f32, 1.0, t.cos() * 2.4, 0.5, t.sin() * 2.4), + scale_translate( + 1.0, + 1.0 + (i % 2) as f32, + 1.0, + t.cos() * 2.4, + 0.5, + t.sin() * 2.4, + ), + ); + } +} + +fn run_pt_progressive(repeat_count: u32, capture_diagnostics: bool) { + for repeat_index in 0..repeat_count { + let (mut eng, adapter) = match try_engine_rt() { + Ok(Some(pair)) => pair, + Ok(None) => { + skip_rt_golden("pt_progressive", "no-non-cpu-ray-query-adapter"); + return; + } + Err(err) => panic!("{err}"), + }; + build_pt_scene(&mut eng); + eng.renderer.set_path_tracing(1); + eng.renderer.set_path_tracing_debug_view(0); + eng.renderer.set_path_tracing_seed(0); + eng.renderer.reset_path_tracing_history(0); + // Static camera: progressive accumulates 96 samples — converged + // enough at 256x256 that the residual noise sits well under the + // tolerance while transport regressions (wrong BRDF energy, + // broken NEE, sky double-count) land far above it. + let render_started = Instant::now(); + let (w, h, rgba) = render(&mut eng, 300, draw_pt_static_frame); + let render_time_ms = render_started.elapsed().as_millis(); + let spp = eng.renderer.path_tracing_sample_count(); + if repeat_index == 0 && capture_diagnostics { + capture_progressive_diagnostics(&mut eng, &rgba, diagnostics_enabled()); + } + // Accumulated stochastic content: same seed sequence every run on + // one GPU; cross-GPU fp differences get a little extra headroom. + let run = GoldenRunMetadata { + adapter, + seed: 0, + sample_index_start: 0, + camera_frame_start: 0, + jitter_sequence: "disabled", + fault_injection: pt_fault_injection(), + repeat_index, + repeat_count, + frames: 300, + spp, + render_time_ms, + }; + compare_or_update_tol_with_metadata( + "pt_progressive", + w, + h, + &rgba, + 4.0, + OUTLIER_FRACTION, + Some(&run), + ); + eprintln!( + "PT golden pt_progressive repeat {}/{} passed in {} ms", + repeat_index + 1, + repeat_count, + render_time_ms, ); } } #[test] fn golden_pt_progressive() { - let Some(mut eng) = try_engine_rt() else { - eprintln!("skip: no ray-query adapter (or DXC unavailable)"); - return; - }; - build_pt_scene(&mut eng); - eng.renderer.set_path_tracing(1); - // Static camera: progressive accumulates 96 samples — converged - // enough at 256x256 that the residual noise sits well under the - // tolerance while transport regressions (wrong BRDF energy, - // broken NEE, sky double-count) land far above it. - let (w, h, rgba) = render(&mut eng, 300, |eng| { - let r = &mut eng.renderer; - r.set_clear_color(0.05, 0.07, 0.1, 1.0); - r.begin_mode_3d(5.0, 4.0, 7.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 50.0, 0.0); - r.add_directional_light(-0.5, -1.0, -0.3, 1.0, 0.95, 0.9, 1.2); - }); - // Accumulated stochastic content: same seed sequence every run on - // one GPU; cross-GPU fp differences get a little extra headroom. - compare_or_update_tol("pt_progressive", w, h, &rgba, 4.0); + let _rt_guard = lock_rt_goldens(); + run_pt_progressive(pt_golden_repeat_count(), diagnostics_enabled()); +} + +fn run_pt_realtime_motion(repeat_count: u32, capture_diagnostics: bool) { + for repeat_index in 0..repeat_count { + let (mut eng, adapter) = match try_engine_rt() { + Ok(Some(pair)) => pair, + Ok(None) => { + skip_rt_golden("pt_realtime_motion", "no-non-cpu-ray-query-adapter"); + return; + } + Err(err) => panic!("{err}"), + }; + build_pt_scene(&mut eng); + eng.renderer.set_path_tracing(2); + eng.renderer.set_path_tracing_debug_view(0); + eng.renderer.set_path_tracing_seed(0); + eng.renderer.reset_path_tracing_history(0); + // The camera orbits ~0.5 deg/frame: every frame reprojects real + // motion through the SVGF history. A reprojection regression (the + // prev_vp-transpose class) rejects all history, the denoiser gets + // 1-spp input with zero variance signal, and the image fills with + // speckle — far past any tolerance here. + let mut frame = 0u32; + let render_started = Instant::now(); + let (w, h, rgba) = render(&mut eng, 48, move |eng| { + draw_pt_motion_frame(eng, frame); + frame += 1; + }); + let render_time_ms = render_started.elapsed().as_millis(); + if repeat_index == 0 && capture_diagnostics { + capture_realtime_diagnostics(&mut eng, &rgba); + } + // Denoised 1-spp under motion: noisier baseline than the converged + // progressive golden, hence the wider mean gate. The outlier gate + // (broken-region detector) stays at the global strict value. + let run = GoldenRunMetadata { + adapter, + seed: 0, + sample_index_start: 0, + camera_frame_start: 0, + jitter_sequence: "disabled", + fault_injection: pt_fault_injection(), + repeat_index, + repeat_count, + frames: 48, + spp: 1, + render_time_ms, + }; + compare_or_update_tol_with_metadata( + "pt_realtime_motion", + w, + h, + &rgba, + 6.0, + OUTLIER_FRACTION, + Some(&run), + ); + eprintln!( + "PT golden pt_realtime_motion repeat {}/{} passed in {} ms", + repeat_index + 1, + repeat_count, + render_time_ms, + ); + } } #[test] fn golden_pt_realtime_motion() { - let Some(mut eng) = try_engine_rt() else { - eprintln!("skip: no ray-query adapter (or DXC unavailable)"); - return; + let _rt_guard = lock_rt_goldens(); + run_pt_realtime_motion(pt_golden_repeat_count(), diagnostics_enabled()); +} + +/// Fast first-divergence probe for the production (query-stripped) shader. +/// It distinguishes a corrupt shadow query from later bounce/accumulation +/// stages without paying for a 300-frame convergence run. +#[test] +#[ignore = "requires a non-CPU ray-query adapter"] +fn diagnose_pt_production_queries() { + let _rt_guard = lock_rt_goldens(); + let (mut eng, adapter) = match try_engine_rt() { + Ok(Some(pair)) => pair, + Ok(None) => panic!("PT query diagnosis requires a non-CPU ray-query adapter"), + Err(error) => panic!("{error}"), }; + eprintln!( + "PT query diagnosis adapter: {} ({}, {})", + adapter.name, adapter.backend, adapter.device_type, + ); build_pt_scene(&mut eng); - eng.renderer.set_path_tracing(2); - // The camera orbits ~0.5 deg/frame: every frame reprojects real - // motion through the SVGF history. A reprojection regression (the - // prev_vp-transpose class) rejects all history, the denoiser gets - // 1-spp input with zero variance signal, and the image fills with - // speckle — far past any tolerance here. - let mut frame = 0u32; - let (w, h, rgba) = render(&mut eng, 48, move |eng| { - let r = &mut eng.renderer; - let a = 0.6 + frame as f32 * 0.009; - frame += 1; - r.set_clear_color(0.05, 0.07, 0.1, 1.0); - r.begin_mode_3d( - a.cos() * 8.0, 4.0, a.sin() * 8.0, - 0.0, 0.5, 0.0, - 0.0, 1.0, 0.0, - 50.0, 0.0, + eng.renderer.set_path_tracing(1); + eng.renderer.set_path_tracing_seed(0); + eng.renderer.set_path_tracing_debug_view(5); + // First frame commits scene geometry/TLAS; the second proves the storage + // write path before any ray query participates. + let (w, h, solid) = render(&mut eng, 2, draw_pt_static_frame); + write_diagnostic_capture("pt_query_probe", "pipeline-solid", w, h, &solid); + for (view, stage) in [(4, "sun-visibility"), (24, "raw-radiance")] { + eng.renderer.set_path_tracing_debug_view(view); + let (w, h, rgba) = render(&mut eng, 1, draw_pt_static_frame); + write_diagnostic_capture("pt_query_probe", stage, w, h, &rgba); + } +} + +fn panic_message(payload: Box) -> String { + match payload.downcast::() { + Ok(message) => *message, + Err(payload) => match payload.downcast::<&'static str>() { + Ok(message) => (*message).to_owned(), + Err(_) => "non-string panic payload".to_owned(), + }, + } +} + +fn expect_pt_negative_control( + fault: &'static str, + expected_mismatch: &'static str, + run: impl FnOnce(), +) { + let previous = std::env::var_os("BLOOM_PT_TEST_FAULT"); + // This helper is called only by the exact, ignored qualification test. + // That command selects one test, so no sibling test can observe the + // temporary process environment. Restoring it before returning also makes + // unwind handling deterministic. + unsafe { std::env::set_var("BLOOM_PT_TEST_FAULT", fault) }; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(run)); + match previous { + Some(value) => unsafe { std::env::set_var("BLOOM_PT_TEST_FAULT", value) }, + None => unsafe { std::env::remove_var("BLOOM_PT_TEST_FAULT") }, + } + let payload = match result { + Err(payload) => payload, + Ok(()) => panic!("PT negative control {fault:?} unexpectedly passed"), + }; + let message = panic_message(payload); + assert!( + message.contains(expected_mismatch), + "PT negative control {fault:?} failed for the wrong reason: {message}", + ); + eprintln!("PT negative control {fault:?} failed as expected"); +} + +/// End-to-end hardware qualification for issue #127. It deliberately keeps +/// one wgpu device alive across the same-adapter stability runs and both fault +/// controls; separate cargo invocations can exhaust headless Metal devices +/// before the negative controls execute. +/// +/// Run exactly this test on each Metal and DX12/Vulkan hardware runner: +/// `cargo test --release --test golden_render qualify_pt_oracle_hardware -- --ignored --exact --nocapture` +#[test] +#[ignore = "requires a non-CPU ray-query adapter and several minutes"] +fn qualify_pt_oracle_hardware() { + let _rt_guard = lock_rt_goldens(); + assert!( + !std::env::var("BLOOM_UPDATE_GOLDEN").is_ok_and(|value| value == "1"), + "hardware qualification never updates checked-in goldens", + ); + match try_engine_rt() { + Ok(Some((_engine, adapter))) => eprintln!( + "PT hardware qualification adapter: {} ({}, {})", + adapter.name, adapter.backend, adapter.device_type, + ), + Ok(None) => panic!("PT hardware qualification requires a non-CPU ray-query adapter"), + Err(error) => panic!("{error}"), + } + + // Catch normal mismatches long enough to capture both modes while this + // device remains alive. They are re-raised after both artifact sets exist. + let progressive = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + run_pt_progressive(3, true); + })); + let realtime = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + run_pt_realtime_motion(3, true); + })); + if progressive.is_err() || realtime.is_err() { + let progressive = progressive.err().map(panic_message); + let realtime = realtime.err().map(panic_message); + panic!( + "PT hardware qualification normal runs failed; progressive={progressive:?}; realtime={realtime:?}" ); - r.add_directional_light(-0.5, -1.0, -0.3, 1.0, 0.95, 0.9, 1.2); + } + expect_pt_negative_control("brdf-energy", "golden pt_progressive mismatch", || { + run_pt_progressive(1, false); + }); + expect_pt_negative_control("reprojection", "golden pt_realtime_motion mismatch", || { + run_pt_realtime_motion(1, false); }); - // Denoised 1-spp under motion: noisier baseline than the converged - // progressive golden, hence the wider mean gate. The outlier gate - // (broken-region detector) stays at the global strict value. - compare_or_update_tol("pt_realtime_motion", w, h, &rgba, 6.0); } diff --git a/native/shared/tests/golden_render/layered_pbr_motion.rs b/native/shared/tests/golden_render/layered_pbr_motion.rs new file mode 100644 index 00000000..e6a1fdf9 --- /dev/null +++ b/native/shared/tests/golden_render/layered_pbr_motion.rs @@ -0,0 +1,200 @@ +use super::*; + +const NORMAL_MAP_SIZE: u32 = 64; +const MOTION_FRAMES: usize = 16; + +fn normal_map_texels(filtered_variance: bool) -> Vec { + let mut texels = Vec::with_capacity((NORMAL_MAP_SIZE * NORMAL_MAP_SIZE * 4) as usize); + for y in 0..NORMAL_MAP_SIZE { + for x in 0..NORMAL_MAP_SIZE { + if filtered_variance { + // Unit normals approximately (+/-0.8, 0, 0.6), arranged in + // 2x2 islands so camera motion crosses several authored + // directions per pixel before the vector/variance mips filter + // them. + let positive = ((x / 2) + (y / 2)) & 1 == 0; + texels.extend_from_slice(if positive { + &[230, 128, 204, 255] + } else { + &[26, 128, 204, 255] + }); + } else { + texels.extend_from_slice(&[128, 128, 255, 255]); + } + } + } + texels +} + +fn textured_quad() -> (Vec, Vec) { + let positions = [ + [-2.0, -2.0, 0.0], + [2.0, -2.0, 0.0], + [2.0, 2.0, 0.0], + [-2.0, 2.0, 0.0], + ]; + let uvs = [[0.0, 0.0], [24.0, 0.0], [24.0, 24.0], [0.0, 24.0]]; + let vertices = positions + .into_iter() + .zip(uvs) + .map(|(position, uv)| Vertex3D { + position, + normal: [0.0, 0.0, 1.0], + color: [0.32, 0.34, 0.38, 1.0], + uv, + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [1.0, 0.0, 0.0, 1.0], + }) + .collect(); + (vertices, vec![0, 1, 2, 0, 2, 3]) +} + +fn render_motion_sequence(filtered_variance: bool) -> Option>> { + let mut eng = try_engine()?; + temporal_history::configure_taa_motion_corpus(&mut eng.renderer); + + let texels = normal_map_texels(filtered_variance); + let normal_texture = + eng.renderer + .register_texture_kind(NORMAL_MAP_SIZE, NORMAL_MAP_SIZE, &texels, true); + let (vertices, indices) = textured_quad(); + let node = eng.scene.create_node(); + eng.scene.update_geometry(node, vertices, indices); + eng.scene.set_material_pbr(node, 0.62, 0.0); + eng.scene.set_material_layered_pbr( + node, + MaterialLayeredPbr { + clearcoat_authored: true, + clearcoat_factor: 1.0, + clearcoat_roughness_factor: 0.04, + clearcoat_normal_scale: 1.0, + clearcoat_normal_texture: Some(MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: Some(normal_texture), + transform: Default::default(), + }), + ..Default::default() + }, + ); + + let draw_pose = |eng: &mut EngineState, camera_x: f32| { + let renderer = &mut eng.renderer; + renderer.set_clear_color(0.012, 0.016, 0.025, 1.0); + renderer.begin_mode_3d(camera_x, 0.18, 5.2, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 45.0, 0.0); + renderer.set_ambient_light(28.0, 32.0, 42.0, 0.18); + renderer.set_directional_light(-0.55, 0.35, 0.76, 255.0, 246.0, 230.0, 4.0); + }; + + eng.renderer.reset_temporal_history(); + for _ in 0..8 { + eng.begin_frame(); + draw_pose(&mut eng, -0.06); + eng.end_frame(); + } + + let mut frames = Vec::with_capacity(MOTION_FRAMES); + for frame in 0..MOTION_FRAMES { + let camera_x = -0.06 + frame as f32 * (0.12 / (MOTION_FRAMES - 1) as f32); + frames.push(render(&mut eng, 1, |eng| draw_pose(eng, camera_x)).2); + } + Some(frames) +} + +#[derive(Clone, Copy, Debug)] +struct ResidualMetrics { + mean_rgb: f64, + outlier_fraction: f64, +} + +fn normal_response_residual( + previous_mapped: &[u8], + mapped: &[u8], + previous_flat: &[u8], + flat: &[u8], +) -> ResidualMetrics { + let mut sum = 0u64; + let mut samples = 0u64; + let mut outliers = 0u64; + // Ignore the outer frame where clear colour dominates. This region + // remains large enough to include both the coat highlight and moving + // geometry edges on every qualified backend. + for y in 36..H - 36 { + for x in 36..W - 36 { + let pixel = ((y * W + x) * 4) as usize; + for channel in 0..3 { + let previous_response = i16::from(previous_mapped[pixel + channel]) + - i16::from(previous_flat[pixel + channel]); + let response = + i16::from(mapped[pixel + channel]) - i16::from(flat[pixel + channel]); + let delta = response.abs_diff(previous_response); + sum += u64::from(delta); + samples += 1; + outliers += u64::from(delta > 16); + } + } + } + ResidualMetrics { + mean_rgb: sum as f64 / samples as f64, + outlier_fraction: outliers as f64 / samples as f64, + } +} + +#[test] +fn clearcoat_normal_minification_bounds_motion_sparkle() { + let Some(mapped) = render_motion_sequence(true) else { + eprintln!("skip: no GPU adapter"); + return; + }; + let flat = render_motion_sequence(false).expect("same GPU adapter remains available"); + + let motion = calculate_diff_metrics(&flat[0], flat.last().unwrap(), W, H); + let response = mapped + .iter() + .zip(&flat) + .map(|(mapped, flat)| calculate_diff_metrics(flat, mapped, W, H).mean_rgb) + .sum::() + / MOTION_FRAMES as f64; + let residuals = (1..MOTION_FRAMES) + .map(|frame| { + normal_response_residual( + &mapped[frame - 1], + &mapped[frame], + &flat[frame - 1], + &flat[frame], + ) + }) + .collect::>(); + let max_residual_mean = residuals + .iter() + .map(|metrics| metrics.mean_rgb) + .fold(0.0f64, f64::max); + let max_residual_outliers = residuals + .iter() + .map(|metrics| metrics.outlier_fraction) + .fold(0.0f64, f64::max); + + eprintln!( + "layered-normal-motion motion_mean={:.6} response_mean={response:.6} \ + max_residual_mean={max_residual_mean:.6} max_residual_outliers={:.4}%", + motion.mean_rgb, + max_residual_outliers * 100.0, + ); + assert!( + motion.mean_rgb >= 0.05, + "normal-minification corpus did not exercise visible camera motion: {motion:?}" + ); + assert!( + response >= 0.1, + "variance-bearing clearcoat normals did not produce a visible filtered response" + ); + assert!( + max_residual_mean <= 1.5, + "minified clearcoat-normal response flickered under motion: {residuals:?}" + ); + assert!( + max_residual_outliers <= 0.02, + "coherent clearcoat-normal sparkle exceeded 2% of sampled channels: {residuals:?}" + ); +} diff --git a/native/shared/tests/golden_render/layered_pbr_parity.rs b/native/shared/tests/golden_render/layered_pbr_parity.rs new file mode 100644 index 00000000..7b0d1487 --- /dev/null +++ b/native/shared/tests/golden_render/layered_pbr_parity.rs @@ -0,0 +1,346 @@ +use super::*; + +const ANGULAR_REFERENCE: &str = + include_str!("../../../../tools/bloom-reference/reference/layered-pbr-angular-v1.json"); +const VIEW_SAMPLE: usize = 1; +const LIGHT_SAMPLE: usize = 2; +const PT_ACCUMULATION_FRAMES: u32 = 64; + +#[derive(Clone, Copy)] +struct ParityScenario { + label: &'static str, + base_color: [f32; 3], + metallic: f32, + roughness: f32, + layered: MaterialLayeredPbr, + view_n_dot: f32, + view_azimuth: f32, + light_n_dot: f32, + light_azimuth: f32, + reference_brdf_cos: [f64; 3], +} + +fn vec3(value: &serde_json::Value, field: &str) -> [f32; 3] { + let values = value[field] + .as_array() + .unwrap_or_else(|| panic!("angular reference field {field} is not an array")); + [ + values[0].as_f64().unwrap() as f32, + values[1].as_f64().unwrap() as f32, + values[2].as_f64().unwrap() as f32, + ] +} + +fn angular_scenarios() -> Vec { + let report: serde_json::Value = + serde_json::from_str(ANGULAR_REFERENCE).expect("checked angular reference is valid JSON"); + [ + "base", + "specular-ior", + "clearcoat", + "sheen", + "anisotropy", + "iridescence", + "combined", + ] + .into_iter() + .map(|label| { + let id = format!("{label}-v{VIEW_SAMPLE}-l{LIGHT_SAMPLE}"); + let sample = report["samples"] + .as_array() + .unwrap() + .iter() + .find(|sample| sample["id"].as_str() == Some(&id)) + .unwrap_or_else(|| panic!("angular reference is missing {id}")); + let material = &sample["material"]; + let specular_authored = matches!(label, "specular-ior" | "combined"); + let clearcoat_authored = matches!(label, "clearcoat" | "combined"); + let sheen_authored = matches!(label, "sheen" | "combined"); + let anisotropy_authored = matches!(label, "anisotropy" | "combined"); + let iridescence_authored = matches!(label, "iridescence" | "combined"); + ParityScenario { + label, + base_color: vec3(material, "base_color"), + metallic: material["metallic"].as_f64().unwrap() as f32, + roughness: material["perceptual_roughness"].as_f64().unwrap() as f32, + layered: MaterialLayeredPbr { + clearcoat_authored, + clearcoat_factor: material["clearcoat_factor"].as_f64().unwrap() as f32, + clearcoat_roughness_factor: material["clearcoat_perceptual_roughness"] + .as_f64() + .unwrap() as f32, + specular_authored, + specular_factor: material["specular_factor"].as_f64().unwrap() as f32, + specular_color_factor: vec3(material, "specular_color"), + ior_authored: specular_authored, + ior: material["ior"].as_f64().unwrap() as f32, + sheen_authored, + sheen_color_factor: vec3(material, "sheen_color"), + sheen_roughness_factor: material["sheen_perceptual_roughness"].as_f64().unwrap() + as f32, + anisotropy_authored, + anisotropy_strength: material["anisotropy_strength"].as_f64().unwrap() as f32, + anisotropy_rotation: material["anisotropy_rotation"].as_f64().unwrap() as f32, + iridescence_authored, + iridescence_factor: material["iridescence_factor"].as_f64().unwrap() as f32, + iridescence_ior: material["iridescence_ior"].as_f64().unwrap() as f32, + iridescence_thickness_minimum: material["iridescence_thickness_nm"] + .as_f64() + .unwrap() as f32, + iridescence_thickness_maximum: material["iridescence_thickness_nm"] + .as_f64() + .unwrap() as f32, + ..Default::default() + }, + view_n_dot: sample["n_dot_v"].as_f64().unwrap() as f32, + view_azimuth: sample["view_azimuth_radians"].as_f64().unwrap() as f32, + light_n_dot: sample["n_dot_l"].as_f64().unwrap() as f32, + light_azimuth: sample["light_azimuth_radians"].as_f64().unwrap() as f32, + reference_brdf_cos: { + let values = sample["direct_brdf_cos"].as_array().unwrap(); + [ + values[0].as_f64().unwrap(), + values[1].as_f64().unwrap(), + values[2].as_f64().unwrap(), + ] + }, + } + }) + .collect() +} + +fn comparison_quad() -> (Vec, Vec) { + let vertices = [ + [-2.2, -2.2, 0.0], + [2.2, -2.2, 0.0], + [2.2, 2.2, 0.0], + [-2.2, 2.2, 0.0], + ] + .into_iter() + .zip([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]) + .map(|(position, uv)| Vertex3D { + position, + normal: [0.0, 0.0, 1.0], + color: [1.0; 4], + uv, + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [1.0, 0.0, 0.0, 1.0], + }) + .collect(); + (vertices, vec![0, 1, 2, 0, 2, 3]) +} + +fn direction(n_dot: f32, azimuth: f32) -> [f32; 3] { + let sin_theta = (1.0 - n_dot * n_dot).sqrt(); + [sin_theta * azimuth.cos(), sin_theta * azimuth.sin(), n_dot] +} + +fn render_forward_and_path( + scenario: ParityScenario, +) -> Result, Vec, String)>, String> { + let Some((mut eng, _adapter)) = try_engine_rt()? else { + return Ok(None); + }; + eng.renderer.set_taa_enabled(false); + eng.renderer.set_render_scale(1.0); + eng.renderer.set_ssao_enabled(false); + eng.renderer.set_ssr_enabled(false); + eng.renderer.set_ssgi_enabled(false); + eng.renderer.set_bloom_enabled(false); + eng.renderer.set_auto_exposure(false); + eng.renderer.set_manual_exposure(1.0); + eng.renderer.set_motion_blur_enabled(false); + eng.renderer.set_shadows_enabled(false); + eng.renderer.set_env_intensity(0.0); + + let (vertices, indices) = comparison_quad(); + let node = eng.scene.create_node(); + eng.scene.update_geometry(node, vertices, indices); + eng.scene.set_material_color( + node, + scenario.base_color[0], + scenario.base_color[1], + scenario.base_color[2], + 1.0, + ); + eng.scene + .set_material_pbr(node, scenario.roughness, scenario.metallic); + eng.scene.set_material_layered_pbr(node, scenario.layered); + + let view = direction(scenario.view_n_dot, scenario.view_azimuth); + let light = direction(scenario.light_n_dot, scenario.light_azimuth); + let draw = |eng: &mut EngineState| { + let renderer = &mut eng.renderer; + renderer.set_clear_color(0.0, 0.0, 0.0, 1.0); + renderer.begin_mode_3d( + view[0] * 5.0, + view[1] * 5.0, + view[2] * 5.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 42.0, + 0.0, + ); + renderer.set_ambient_light(255.0, 255.0, 255.0, 0.0); + renderer.set_directional_light( + f64::from(light[0]), + f64::from(light[1]), + f64::from(light[2]), + 255.0, + 255.0, + 255.0, + 1.0, + ); + }; + + let forward = render(&mut eng, 3, draw).2; + eng.renderer.set_path_tracing(1); + eng.renderer.set_path_tracing_seed(0); + eng.renderer.reset_path_tracing_history(0); + let path = render(&mut eng, PT_ACCUMULATION_FRAMES, draw).2; + Ok(Some(( + forward, + path, + eng.renderer.quality_runtime_paths_json(), + ))) +} + +fn center_mean_rgb(image: &[u8]) -> [f64; 3] { + let mut sum = [0.0; 3]; + let mut count = 0.0; + for y in H / 2 - 16..H / 2 + 16 { + for x in W / 2 - 16..W / 2 + 16 { + let pixel = ((y * W + x) * 4) as usize; + for channel in 0..3 { + sum[channel] += f64::from(image[pixel + channel]); + } + count += 1.0; + } + } + sum.map(|value| value / count) +} + +fn subtract(value: [f64; 3], base: [f64; 3]) -> [f64; 3] { + [value[0] - base[0], value[1] - base[1], value[2] - base[2]] +} + +fn length(value: [f64; 3]) -> f64 { + (value[0] * value[0] + value[1] * value[1] + value[2] * value[2]).sqrt() +} + +fn cosine_similarity(a: [f64; 3], b: [f64; 3]) -> f64 { + let denominator = length(a) * length(b); + if denominator <= 1.0e-12 { + 1.0 + } else { + (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) / denominator + } +} + +fn mean_absolute_rgb(a: [f64; 3], b: [f64; 3]) -> f64 { + ((a[0] - b[0]).abs() + (a[1] - b[1]).abs() + (a[2] - b[2]).abs()) / 3.0 +} + +fn display_luminance(value: [f64; 3]) -> f64 { + 0.2126 * value[0] + 0.7152 * value[1] + 0.0722 * value[2] +} + +#[test] +fn layered_forward_and_path_responses_track_the_angular_reference() { + let _guard = lock_rt_goldens(); + let scenarios = angular_scenarios(); + let mut captures = Vec::with_capacity(scenarios.len()); + for scenario in scenarios.iter().copied() { + let Some((forward, path, runtime_paths)) = render_forward_and_path(scenario) + .unwrap_or_else(|error| panic!("{} parity scene failed: {error}", scenario.label)) + else { + skip_rt_golden( + "layered_forward_and_path_responses_track_the_angular_reference", + "no-non-cpu-ray-query-adapter", + ); + return; + }; + captures.push(( + scenario, + center_mean_rgb(&forward), + center_mean_rgb(&path), + runtime_paths, + )); + } + + let (_, base_forward, base_path, base_runtime) = &captures[0]; + assert!(base_runtime.contains("\"path_tracing_specialization_initialized\":false")); + eprintln!( + "layered-parity base forward={base_forward:?} path={base_path:?}, frames={PT_ACCUMULATION_FRAMES}" + ); + for (scenario, forward, path, runtime_paths) in &captures { + let display_mae = mean_absolute_rgb(*forward, *path); + let display_cosine = cosine_similarity(*forward, *path); + let forward_luminance = display_luminance(*forward); + let path_luminance = display_luminance(*path); + let luminance_relative_error = (forward_luminance - path_luminance).abs() + / forward_luminance.max(path_luminance).max(1.0); + assert!( + display_mae <= 24.0 && display_cosine >= 0.96 && luminance_relative_error <= 0.30, + "{} forward/path direct-light display agreement exceeded the approved model \ + tolerance: mae={display_mae:.4}, cosine={display_cosine:.6}, \ + luma_relative={luminance_relative_error:.4}, \ + forward={forward:?}, path={path:?}", + scenario.label, + ); + if scenario.label == "base" { + continue; + } + let reference_response = subtract( + scenario.reference_brdf_cos, + captures[0].0.reference_brdf_cos, + ); + let forward_response = subtract(*forward, *base_forward); + let path_response = subtract(*path, *base_path); + let reference_path_cosine = cosine_similarity(reference_response, path_response); + eprintln!( + "layered-parity {} reference={reference_response:?} \ + forward={forward_response:?} path={path_response:?} \ + display_mae={display_mae:.4} display_cosine={display_cosine:.6} \ + luma_relative={luminance_relative_error:.4} \ + reference_path_cosine={reference_path_cosine:.6}", + scenario.label, + ); + assert!( + runtime_paths.contains("\"path_tracing_specialization_initialized\":true"), + "{} did not select layered path transport", + scenario.label, + ); + assert!( + length(forward_response) >= 0.01 && length(path_response) >= 0.01, + "{} did not produce a measurable response in both renderers", + scenario.label, + ); + if length(reference_response) >= 0.01 { + assert!( + reference_path_cosine >= 0.85, + "{} path-traced lobe response diverged from the linear angular reference: \ + reference={reference_response:?}, path={path_response:?}, \ + cosine={reference_path_cosine:.6}", + scenario.label, + ); + } else { + let maximum_display_response = forward_response + .into_iter() + .chain(path_response) + .map(f64::abs) + .fold(0.0f64, f64::max); + assert!( + maximum_display_response <= 12.0, + "{} sub-threshold reference response exceeded the bounded display allowance: \ + {maximum_display_response:.4}", + scenario.label, + ); + } + } +} diff --git a/native/shared/tests/golden_render/lighting_upload.rs b/native/shared/tests/golden_render/lighting_upload.rs new file mode 100644 index 00000000..90ba9e2c --- /dev/null +++ b/native/shared/tests/golden_render/lighting_upload.rs @@ -0,0 +1,436 @@ +use super::*; + +#[derive(Debug, Eq, PartialEq)] +struct LiveGpuObjects { + buffers: isize, + textures: isize, + texture_views: isize, + bind_groups: isize, + bind_group_layouts: isize, + render_pipelines: isize, + compute_pipelines: isize, + pipeline_layouts: isize, + samplers: isize, + command_encoders: isize, + shader_modules: isize, + query_sets: isize, + fences: isize, + buffer_memory: isize, + texture_memory: isize, + acceleration_structure_memory: isize, + memory_allocations: isize, +} + +fn live_gpu_objects(device: &wgpu::Device) -> LiveGpuObjects { + let counters = device.get_internal_counters(); + let hal = counters.hal; + LiveGpuObjects { + buffers: hal.buffers.read(), + textures: hal.textures.read(), + texture_views: hal.texture_views.read(), + bind_groups: hal.bind_groups.read(), + bind_group_layouts: hal.bind_group_layouts.read(), + render_pipelines: hal.render_pipelines.read(), + compute_pipelines: hal.compute_pipelines.read(), + pipeline_layouts: hal.pipeline_layouts.read(), + samplers: hal.samplers.read(), + command_encoders: hal.command_encoders.read(), + shader_modules: hal.shader_modules.read(), + query_sets: hal.query_sets.read(), + fences: hal.fences.read(), + buffer_memory: hal.buffer_memory.read(), + texture_memory: hal.texture_memory.read(), + acceleration_structure_memory: hal.acceleration_structure_memory.read(), + memory_allocations: hal.memory_allocations.read(), + } +} + +fn wait_for_gpu(device: &wgpu::Device) { + let _ = device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); +} + +#[test] +fn static_ultra_scene_has_stable_renderer_owned_memory_for_1000_frames() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + eng.renderer.apply_quality_preset(4); + + let draw = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(2.0, 2.0, 4.0, 255.0); + r.begin_mode_3d( + 0.0, 8.0, 7.0, // eye + 0.0, 0.0, 0.0, // target + 0.0, 1.0, 0.0, 55.0, 0.0, + ); + r.draw_plane(0.0, 0.0, 0.0, 14.0, 14.0, 110.0, 110.0, 110.0, 255.0); + r.draw_cube(0.0, 0.8, 0.0, 1.6, 1.6, 1.6, 210.0, 105.0, 35.0, 255.0); + for i in 0..40u32 { + let t = i as f32 / 40.0 * std::f32::consts::TAU; + r.add_point_light( + t.cos() * 4.0, + 1.2, + t.sin() * 4.0, + 3.5, + 0.5 + 0.5 * t.cos(), + 0.5 + 0.5 * (t + 2.094).cos(), + 0.5 + 0.5 * (t + 4.189).cos(), + 1.6, + ); + } + }; + let run_frames = |eng: &mut EngineState, count: u32| { + for _ in 0..count { + eng.begin_frame(); + draw(eng); + eng.end_frame(); + } + }; + + // Settle temporal histories, rotating bind-group caches, queue-owned + // staging allocations, and the three-frame headless submission window. + run_frames(&mut eng, 16); + wait_for_gpu(&eng.renderer.device); + let before = live_gpu_objects(&eng.renderer.device); + assert!( + before.buffers > 0 && before.textures > 0, + "wgpu test counters are disabled; this would be a vacuous memory gate: {before:?}" + ); + let paths_before: serde_json::Value = + serde_json::from_str(&eng.renderer.quality_runtime_paths_json()) + .expect("pre-run runtime paths are valid JSON"); + let cpu_capacity_before = eng.renderer.quality_frame_cpu_capacity_bytes(); + + run_frames(&mut eng, 1_000); + wait_for_gpu(&eng.renderer.device); + let after = live_gpu_objects(&eng.renderer.device); + let paths_after: serde_json::Value = + serde_json::from_str(&eng.renderer.quality_runtime_paths_json()) + .expect("post-run runtime paths are valid JSON"); + let cpu_capacity_after = eng.renderer.quality_frame_cpu_capacity_bytes(); + + assert_eq!( + after, before, + "renderer-owned live GPU objects or backend-reported bytes grew over 1,000 static frames" + ); + assert_eq!( + cpu_capacity_after, cpu_capacity_before, + "renderer-owned growable frame-container capacity changed over 1,000 static frames" + ); + assert_eq!( + paths_after["render_graph"]["cached_plan_count"], + paths_before["render_graph"]["cached_plan_count"], + "render-graph plan cache grew after warm-up" + ); + assert_eq!( + paths_after["render_graph"]["physical_transient_slots"], + paths_before["render_graph"]["physical_transient_slots"], + "compiled transient pool grew after warm-up" + ); + let steady = &paths_after["steady_state_resources"]; + assert_eq!(steady["graph_compiles"].as_u64(), Some(0)); + assert_eq!(steady["pipeline_creations"]["first_use"].as_u64(), Some(0)); + assert_eq!( + steady["transient_physical_creations"]["textures"].as_u64(), + Some(0) + ); + assert_eq!( + steady["transient_physical_creations"]["buffers"].as_u64(), + Some(0) + ); + assert_eq!(steady["bind_group_creations"]["total"].as_u64(), Some(0)); + eprintln!( + "1,000-frame renderer memory stable: {before:?}; frame_cpu_capacity_bytes={cpu_capacity_before}; graph_plans={}; transient_slots={}", + paths_before["render_graph"]["cached_plan_count"], + paths_before["render_graph"]["physical_transient_slots"], + ); +} + +#[test] +fn golden_many_point_lights() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + // 40 colored point lights in a ring over a dark floor — far past the + // old 16-light cap. If the cap regressed, lights 17..40 vanish and + // the right side of the ring goes dark (well past tolerance). + let (w, h, rgba) = render(&mut eng, 6, |eng| { + let r = &mut eng.renderer; + r.set_clear_color(2.0, 2.0, 4.0, 255.0); + r.begin_mode_3d( + 0.0, 9.0, 0.01, // eye: straight above + 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 60.0, 0.0, + ); + r.draw_plane(0.0, 0.0, 0.0, 14.0, 14.0, 110.0, 110.0, 110.0, 255.0); + for i in 0..40u32 { + let t = i as f32 / 40.0 * std::f32::consts::TAU; + let (sx, sz) = (t.cos() * 4.0, t.sin() * 4.0); + // Hue cycles so neighboring lights are distinguishable. + let (lr, lg, lb) = ( + 0.5 + 0.5 * t.cos(), + 0.5 + 0.5 * (t + 2.094).cos(), + 0.5 + 0.5 * (t + 4.189).cos(), + ); + r.add_point_light(sx, 1.2, sz, 3.5, lr, lg, lb, 1.6); + } + }); + compare_or_update("many_point_lights", w, h, &rgba); + + // The sixth frame is unchanged apart from per-frame/view values. Forty + // repeated light setters must not enqueue forty copies of the full block. + let paths: serde_json::Value = serde_json::from_str(&eng.renderer.quality_runtime_paths_json()) + .expect("runtime path telemetry is valid JSON"); + let uploads = &paths["steady_state_uploads"]["lighting"]; + let writes = uploads["write_count"].as_u64().expect("write count"); + let bytes = uploads["byte_count"].as_u64().expect("byte count"); + let full_bytes = uploads["full_buffer_bytes"].as_u64().expect("buffer size"); + assert!( + writes <= 3 && bytes <= 512 && bytes < full_bytes, + "steady point-light frame uploaded too much lighting data: {uploads}" + ); + + let bind_groups = &paths["steady_state_resources"]["bind_group_creations"]; + let total = bind_groups["total"].as_u64().expect("bind-group total"); + let sites = bind_groups["sites"] + .as_object() + .expect("named bind-group creation sites"); + let named_total: u64 = sites + .values() + .map(|count| count.as_u64().expect("site count")) + .sum(); + assert_eq!( + total, named_total, + "bind-group total must match named sites" + ); + assert_eq!( + sites["scene_compose"].as_u64(), + Some(0), + "steady scene compose must reuse its SSR-source-specific bind group" + ); + assert_eq!( + sites["final_composite"].as_u64(), + Some(0), + "steady final composite must reuse its source/exposure-specific bind group" + ); + assert_eq!( + sites["ssr_temporal"].as_u64(), + Some(0), + "steady SSR temporal must reuse its previous-history-specific bind group" + ); + assert_eq!( + sites["taa"].as_u64(), + Some(0), + "steady ordinary TAA must reuse its previous-history-specific bind group" + ); + let resources = &paths["steady_state_resources"]; + assert_eq!( + resources["graph_compiles"].as_u64(), + Some(0), + "stable topology must not compile after warm-up" + ); + assert_eq!( + resources["pipeline_creations"]["first_use"].as_u64(), + Some(0), + "warmed frame must not create a first-use pipeline" + ); + assert_eq!( + resources["command_encoder_creations"]["total"].as_u64(), + Some(1), + "steady rendering must use one submission encoder" + ); + assert_eq!( + resources["transient_physical_creations"]["textures"].as_u64(), + Some(0), + "stable graph must not allocate physical textures after warm-up" + ); + assert_eq!( + resources["transient_physical_creations"]["buffers"].as_u64(), + Some(0), + "stable graph must not allocate physical buffers after warm-up" + ); +} + +#[test] +fn steady_half_resolution_upscale_reuses_its_bind_group() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + eng.renderer.set_render_scale(0.5); + eng.renderer.set_taa_enabled(false); + let (_, _, rgba) = render(&mut eng, 4, |eng| { + let r = &mut eng.renderer; + r.set_clear_color(8.0, 12.0, 20.0, 255.0); + r.begin_mode_3d(3.0, 2.5, 5.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.draw_cube(0.0, 0.75, 0.0, 1.5, 1.5, 1.5, 220.0, 90.0, 35.0, 255.0); + }); + assert!( + rgba.chunks_exact(4) + .any(|pixel| pixel[0] != 8 || pixel[1] != 12 || pixel[2] != 20), + "half-resolution upscale frame did not render scene geometry" + ); + let paths: serde_json::Value = serde_json::from_str(&eng.renderer.quality_runtime_paths_json()) + .expect("upscale telemetry is valid JSON"); + assert_eq!( + paths["steady_state_resources"]["bind_group_creations"]["sites"]["upscale"].as_u64(), + Some(0), + "warmed upscale path must reuse its persistent bind group" + ); +} + +#[test] +fn steady_depth_of_field_reuses_its_history_specific_bind_group() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + eng.renderer.set_taa_enabled(true); + eng.renderer.set_dof_enabled(true); + eng.renderer.set_dof_focus_distance(4.0); + eng.renderer.set_dof_aperture(0.04); + let (_, _, rgba) = render(&mut eng, 5, |eng| { + let r = &mut eng.renderer; + r.set_clear_color(8.0, 12.0, 20.0, 255.0); + r.begin_mode_3d(3.0, 2.5, 5.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.draw_cube(0.0, 0.75, 0.0, 1.5, 1.5, 1.5, 220.0, 90.0, 35.0, 255.0); + }); + assert!( + rgba.chunks_exact(4) + .any(|pixel| pixel[0] != 8 || pixel[1] != 12 || pixel[2] != 20), + "depth-of-field frame did not render scene geometry" + ); + let paths: serde_json::Value = serde_json::from_str(&eng.renderer.quality_runtime_paths_json()) + .expect("depth-of-field telemetry is valid JSON"); + assert_eq!( + paths["steady_state_resources"]["bind_group_creations"]["sites"]["depth_of_field"].as_u64(), + Some(0), + "warmed depth of field must reuse its TAA-history-specific bind group" + ); +} + +#[test] +fn steady_optional_postfx_chain_reuses_every_source_specific_bind_group() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + let r = &mut eng.renderer; + r.set_taa_enabled(true); + r.set_dof_enabled(true); + r.set_dof_focus_distance(4.0); + r.set_dof_aperture(0.04); + r.set_motion_blur_enabled(true); + r.set_motion_blur_strength(0.75); + r.set_sss_enabled(true); + r.set_sss_strength(0.4); + r.set_cas_strength(0.35); + r.set_auto_exposure(true); + + let (_, _, rgba) = render(&mut eng, 5, |eng| { + let r = &mut eng.renderer; + r.set_clear_color(8.0, 12.0, 20.0, 255.0); + r.begin_mode_3d(3.0, 2.5, 5.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.draw_cube(0.0, 0.75, 0.0, 1.5, 1.5, 1.5, 220.0, 90.0, 35.0, 255.0); + }); + assert!( + rgba.chunks_exact(4) + .any(|pixel| pixel[0] != 8 || pixel[1] != 12 || pixel[2] != 20), + "optional post-FX frame did not render scene geometry" + ); + let paths: serde_json::Value = serde_json::from_str(&eng.renderer.quality_runtime_paths_json()) + .expect("optional post-FX telemetry is valid JSON"); + let sites = &paths["steady_state_resources"]["bind_group_creations"]["sites"]; + for site in [ + "depth_of_field", + "motion_blur", + "subsurface_scattering", + "contrast_adaptive_sharpen", + "auto_exposure", + ] { + assert_eq!( + sites[site].as_u64(), + Some(0), + "warmed optional post-FX site {site} must reuse its source-specific bind group" + ); + } +} + +#[test] +fn steady_custom_post_pass_stack_reuses_parity_specific_bind_groups() { + const COPY_PASS: &str = r#" +@fragment +fn fs_main(@location(0) uv: vec2) -> @location(0) vec4 { + return textureSample(scene_color_tex, scene_color_samp, uv); +} +"#; + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + let draw = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(8.0, 12.0, 20.0, 255.0); + r.begin_mode_3d(3.0, 2.5, 5.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.draw_cube(0.0, 0.75, 0.0, 1.5, 1.5, 1.5, 220.0, 90.0, 35.0, 255.0); + }; + eng.begin_frame(); + eng.renderer + .add_post_pass(COPY_PASS) + .expect("first copy post pass compiles"); + eng.renderer + .add_post_pass(COPY_PASS) + .expect("second copy post pass compiles"); + draw(&mut eng); + eng.end_frame(); + let first_use: serde_json::Value = + serde_json::from_str(&eng.renderer.quality_runtime_paths_json()) + .expect("first-use pipeline telemetry is valid JSON"); + assert_eq!( + first_use["steady_state_resources"]["pipeline_creations"]["first_use"].as_u64(), + Some(2), + "two post-pass pipeline compilations must be measured in their creation frame" + ); + let (_, _, rgba) = render(&mut eng, 4, draw); + assert!( + rgba.chunks_exact(4) + .any(|pixel| pixel[0] != 8 || pixel[1] != 12 || pixel[2] != 20), + "custom post-pass stack did not preserve scene geometry" + ); + let paths: serde_json::Value = serde_json::from_str(&eng.renderer.quality_runtime_paths_json()) + .expect("custom post-pass telemetry is valid JSON"); + assert_eq!( + paths["steady_state_resources"]["bind_group_creations"]["sites"]["custom_post_pass"] + .as_u64(), + Some(0), + "warmed custom post-pass stack must reuse parity-specific bind groups" + ); + assert_eq!( + paths["steady_state_resources"]["pipeline_creations"]["first_use"].as_u64(), + Some(0), + "warmed custom post-pass stack must not recreate pipelines" + ); + + eng.renderer.resize(320, 192, 320, 192); + let _ = render(&mut eng, 3, draw); + let resized_paths: serde_json::Value = + serde_json::from_str(&eng.renderer.quality_runtime_paths_json()) + .expect("resized custom post-pass telemetry is valid JSON"); + assert_eq!( + resized_paths["steady_state_resources"]["bind_group_creations"]["sites"] + ["custom_post_pass"] + .as_u64(), + Some(0), + "post-pass bindings must rebuild after resize then return to zero churn" + ); + assert_eq!( + resized_paths["steady_state_resources"]["pipeline_creations"]["first_use"].as_u64(), + Some(0), + "resize must retain custom pipelines" + ); +} diff --git a/native/shared/tests/golden_render/motion_producer_audit.rs b/native/shared/tests/golden_render/motion_producer_audit.rs new file mode 100644 index 00000000..fe328a83 --- /dev/null +++ b/native/shared/tests/golden_render/motion_producer_audit.rs @@ -0,0 +1,371 @@ +use super::*; + +fn quad_vertices(half_extent: f32) -> Vec { + let vertex = |position| Vertex3D { + position, + normal: [0.0, 0.0, 1.0], + color: [1.0; 4], + uv: [0.0; 2], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [1.0, 0.0, 0.0, 1.0], + }; + vec![ + vertex([-half_extent, -half_extent, 0.0]), + vertex([half_extent, -half_extent, 0.0]), + vertex([half_extent, half_extent, 0.0]), + vertex([-half_extent, half_extent, 0.0]), + ] +} + +fn untextured_mesh(vertices: Vec, alpha_mode: MaterialAlphaMode) -> MeshData { + MeshData { + vertices, + secondary_tex_coords: None, + indices: vec![0, 1, 2, 0, 2, 3], + texture_idx: None, + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.0, + roughness_factor: 1.0, + emissive_factor: [0.0; 3], + alpha_mode, + alpha_cutoff: 0.5, + alpha_coverage_mips: false, + double_sided: true, + transmission: Default::default(), + layered_pbr: Default::default(), + } +} + +#[test] +fn procedural_foliage_wind_reconstructs_previous_deformation_velocity() { + const HANDLE: u64 = 0x7AA5_3011; + + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + temporal_history::configure_taa_motion_corpus(&mut eng.renderer); + + let vertex = |position| Vertex3D { + position, + normal: [0.0, 0.0, 1.0], + color: [0.08, 0.72, 0.12, 1.0], + uv: [0.0; 2], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [1.0, 0.0, 0.0, 1.0], + }; + assert!(eng.renderer.cache_model_if_static( + HANDLE, + &[untextured_mesh( + vec![ + vertex([-0.45, 0.0, 0.0]), + vertex([0.45, 0.0, 0.0]), + vertex([0.45, 4.0, 0.0]), + vertex([-0.45, 4.0, 0.0]), + ], + MaterialAlphaMode::Opaque, + )] + )); + eng.renderer.set_wind(1.0, 0.25, 0.8, 1.0); + eng.renderer.set_model_foliage_wind(HANDLE, 1.0); + + let capture = |eng: &mut EngineState| { + std::thread::sleep(std::time::Duration::from_millis(20)); + render(eng, 1, |eng| { + let r = &mut eng.renderer; + r.set_clear_color(7.0, 10.0, 20.0, 255.0); + r.begin_mode_3d(0.0, 2.1, 7.0, 0.0, 2.0, 0.0, 0.0, 1.0, 0.0, 46.0, 0.0); + r.draw_model_cached(HANDLE, [0.0; 3], 1.0, [1.0; 4]); + }) + .2 + }; + for _ in 0..4 { + capture(&mut eng); + } + let directory = std::env::temp_dir().join(format!("bloom-foliage-wind-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + capture(&mut eng); + + let motion = image::open(directory.join("taa-motion.png")) + .expect("foliage capture did not emit the TAA velocity map") + .to_rgb8(); + let moving_pixels = motion.pixels().filter(|pixel| pixel[2] > 8).count(); + eprintln!("temporal-corpus foliage-wind moving_pixels={moving_pixels}"); + assert!( + moving_pixels >= 100, + "procedural foliage wrote no meaningful prior-deformation velocity" + ); + + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept foliage-wind diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } +} + +#[test] +fn static_decal_zero_velocity_opt_out_rejects_spawn_and_expiry() { + const HANDLE: u64 = 0x7AA5_D011; + const DECAL_SHADER: &str = r#" +#include "material_abi.wgsl" + +struct DecalInput { + @location(0) position: vec3, + @location(1) normal: vec3, + @location(2) color: vec4, + @location(3) uv: vec2, + @location(4) joints: vec4, + @location(5) weights: vec4, + @location(6) tangent: vec4, + @location(7) instance_pos: vec3, + @location(8) instance_rot_y: f32, + @location(9) instance_scale: f32, + @location(10) instance_tint: vec4, +}; + +struct VsOut { + @builtin(position) clip_position: vec4, + @location(0) tint: vec4, +}; + +@vertex +fn vs_main(in: DecalInput) -> VsOut { + var out: VsOut; + let world = in.position * in.instance_scale + in.instance_pos; + out.clip_position = view.view_proj * vec4(world, 1.0); + out.tint = in.color * in.instance_tint; + return out; +} + +@fragment +fn fs_main(in: VsOut) -> OpaqueOut { + if (in.tint.a < 0.5) { discard; } + var out: OpaqueOut; + out.hdr = vec4(in.tint.rgb, 1.0); + out.material = vec2(0.0, 1.0); + out.velocity = vec2(0.0); + out.albedo = vec4(in.tint.rgb, 1.0); + return out; +} +"#; + + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + temporal_history::configure_taa_motion_corpus(&mut eng.renderer); + assert!(eng.renderer.cache_model_if_static( + HANDLE, + &[untextured_mesh(quad_vertices(0.7), MaterialAlphaMode::Mask)] + )); + let material = eng + .renderer + .compile_material_instanced_bucket(DECAL_SHADER, 1, false) + .expect("static decal opt-out material compiles"); + let instance_buffer = eng + .renderer + .create_instance_buffer(&[0.0, 1.1, 0.0, 0.0, 1.0, 0.05, 0.02, 0.01, 1.0], 1); + + let capture = |eng: &mut EngineState, visible: bool| { + render(eng, 1, |eng| { + let r = &mut eng.renderer; + r.set_clear_color(7.0, 10.0, 20.0, 255.0); + r.begin_mode_3d(0.0, 2.0, 6.5, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.draw_cube(0.0, 1.1, -0.8, 5.0, 3.2, 0.3, 35.0, 155.0, 220.0, 255.0); + if visible { + r.submit_material_draw_instanced(material, HANDLE, 0, instance_buffer, 1); + } + }) + .2 + }; + let mut run_transition = |from_visible, to_visible, label| { + eng.renderer.reset_temporal_history(); + for _ in 0..8 { + capture(&mut eng, from_visible); + } + let old_pose = capture(&mut eng, from_visible); + let mut frames = Vec::new(); + for _ in 0..24 { + frames.push(capture(&mut eng, to_visible)); + } + temporal_history::evaluate_motion_recovery(label, &old_pose, &frames); + }; + + run_transition(false, true, "static-decal-spawn-optout"); + run_transition(true, false, "static-decal-expiry-optout"); + eng.renderer.destroy_instance_buffer(instance_buffer); +} + +#[test] +fn procedural_cloud_zero_velocity_opt_out_rejects_field_changes() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + temporal_history::configure_taa_motion_corpus(&mut eng.renderer); + eng.renderer.set_procedural_sky(true, 1.0, 1.0, 0.1); + eng.renderer.set_sun_direction(0.35, 0.82, 0.28, 1.0); + eng.renderer.set_cloud_shadows(0.45, 180.0, 0.006, 0.0); + + let capture = |eng: &mut EngineState| { + render(eng, 1, |eng| { + eng.renderer + .begin_mode_3d(0.0, 2.0, 0.0, 0.0, 2.1, -1.0, 0.0, 1.0, 0.0, 70.0, 0.0); + }) + .2 + }; + eng.renderer.reset_temporal_history(); + for _ in 0..8 { + capture(&mut eng); + } + let old_field = capture(&mut eng); + eng.renderer.set_cloud_shadows(0.45, 310.0, 0.014, 0.0); + let mut frames = Vec::new(); + for _ in 0..24 { + frames.push(capture(&mut eng)); + } + temporal_history::evaluate_motion_recovery( + "procedural-cloud-field-optout", + &old_field, + &frames, + ); +} + +#[test] +fn unkeyed_editor_skin_pose_writes_velocity_and_bounds_trails() { + fn palette(x: f32, bend: f32, facing: f32) -> [[[f32; 4]; 4]; 2] { + let (sin, cos) = bend.sin_cos(); + let mut matrices = [ + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, cos, sin, 0.0], + [0.0, -sin, cos, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + ]; + let (rot_sin, rot_cos) = facing.sin_cos(); + for matrix in &mut matrices { + for column in matrix.iter_mut() { + let old_x = column[0]; + let old_z = column[2]; + column[0] = rot_cos * old_x + rot_sin * old_z; + column[2] = -rot_sin * old_x + rot_cos * old_z; + } + matrix[3][0] += x; + matrix[3][1] += 1.0; + } + matrices + } + + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + temporal_history::configure_taa_motion_corpus(&mut eng.renderer); + let (mut vertices, indices) = cube_verts(0.9, [0.9, 0.08, 0.025, 1.0]); + for vertex in &mut vertices { + vertex.joints = if vertex.position[1] > 0.0 { + [1.0, 0.0, 0.0, 0.0] + } else { + [0.0; 4] + }; + vertex.weights = [1.0, 0.0, 0.0, 0.0]; + } + + let draw_scene = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(7.0, 10.0, 20.0, 255.0); + r.begin_mode_3d(0.0, 2.2, 6.5, 0.0, 0.8, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.add_directional_light(-0.4, -1.0, -0.25, 1.0, 0.95, 0.88, 2.2); + r.draw_plane(0.0, 0.0, 0.0, 12.0, 12.0, 30.0, 38.0, 52.0, 255.0); + r.draw_cube(0.0, 1.1, -1.8, 5.0, 3.2, 0.35, 30.0, 170.0, 235.0, 255.0); + }; + let capture = |eng: &mut EngineState, x, bend, facing| { + render(eng, 1, |eng| { + draw_scene(eng); + eng.renderer.set_joint_matrices(&palette(x, bend, facing)); + eng.renderer + .draw_model_mesh(&vertices, &indices, [0.0; 3], 1.0); + }) + .2 + }; + eng.renderer.reset_temporal_history(); + for _ in 0..8 { + capture(&mut eng, -1.5, -0.55, -0.45); + } + let old_pose = capture(&mut eng, -1.5, -0.55, -0.45); + let directory = + std::env::temp_dir().join(format!("bloom-unkeyed-skin-motion-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + let mut frames = Vec::new(); + for _ in 0..24 { + frames.push(capture(&mut eng, 1.5, 0.75, 0.6)); + } + let motion = image::open(directory.join("taa-motion.png")) + .expect("unkeyed skin capture did not emit the TAA velocity map") + .to_rgb8(); + let moving_pixels = motion.pixels().filter(|pixel| pixel[2] > 8).count(); + eprintln!("temporal-corpus unkeyed-skin moving_pixels={moving_pixels}"); + assert!( + moving_pixels >= 250, + "unkeyed skin pose wrote no meaningful velocity" + ); + temporal_history::evaluate_motion_recovery("unkeyed-skin", &old_pose, &frames); + + // A missing frame breaks slot identity. Reappearance must seed from the + // current pose instead of inheriting the last visible pose's velocity. + render(&mut eng, 1, |eng| draw_scene(eng)); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + capture(&mut eng, -0.5, -0.2, 0.1); + let gap_motion = image::open(directory.join("taa-motion.png")) + .expect("unkeyed skin gap capture did not emit the TAA velocity map") + .to_rgb8(); + let gap_moving_pixels = gap_motion.pixels().filter(|pixel| pixel[2] > 8).count(); + eprintln!("temporal-corpus unkeyed-skin gap_moving_pixels={gap_moving_pixels}"); + assert!( + gap_moving_pixels <= 16, + "unkeyed skin reappearance inherited stale pre-gap velocity" + ); + + let paths: serde_json::Value = + serde_json::from_str(&eng.renderer.quality_runtime_paths_json()).unwrap(); + assert_eq!( + paths["temporal_history"]["unkeyed_skin_motion_entries"].as_u64(), + Some(1) + ); + assert_eq!( + paths["temporal_history"]["unkeyed_skin_motion_gpu_bytes"].as_u64(), + Some(0) + ); + assert_eq!( + paths["temporal_history"]["unkeyed_skin_motion_passes"].as_u64(), + Some(0) + ); + let cpu_capacity = paths["temporal_history"]["unkeyed_skin_motion_cpu_capacity_bytes"] + .as_u64() + .unwrap(); + eprintln!("temporal-corpus unkeyed-skin cpu_capacity_bytes={cpu_capacity}"); + assert!( + cpu_capacity <= 1024, + "one two-joint unkeyed skin retained excessive CPU history" + ); + + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept unkeyed skin diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } +} diff --git a/native/shared/tests/golden_render/quality_presets.rs b/native/shared/tests/golden_render/quality_presets.rs new file mode 100644 index 00000000..117f29b8 --- /dev/null +++ b/native/shared/tests/golden_render/quality_presets.rs @@ -0,0 +1,103 @@ +use super::*; + +fn luma(byte_pixel: &[u8]) -> i32 { + (i32::from(byte_pixel[0]) * 54 + i32::from(byte_pixel[1]) * 183 + i32::from(byte_pixel[2]) * 19) + / 256 +} + +/// Mean absolute 4-neighbor Laplacian. Unlike total edge contrast, this rises +/// when the same visible edge is resolved over fewer output pixels. +fn detail_energy(rgba: &[u8]) -> f64 { + let mut total = 0u64; + let mut samples = 0u64; + for y in 1..H - 1 { + for x in 1..W - 1 { + let pixel = |x: u32, y: u32| { + let index = ((y * W + x) * 4) as usize; + luma(&rgba[index..index + 4]) + }; + let center = pixel(x, y); + let laplacian = + center * 4 - pixel(x - 1, y) - pixel(x + 1, y) - pixel(x, y - 1) - pixel(x, y + 1); + total += u64::from(laplacian.unsigned_abs()); + samples += 1; + } + } + total as f64 / samples as f64 +} + +fn configure_reconstruction_scene(renderer: &mut Renderer) { + renderer.set_ssao_enabled(false); + renderer.set_ssr_enabled(false); + renderer.set_ssgi_enabled(false); + renderer.set_bloom_enabled(false); + renderer.set_motion_blur_enabled(false); + renderer.set_sss_enabled(false); + renderer.set_shadows_enabled(false); +} + +fn capture_preset(eng: &mut EngineState, preset: u32, legacy_scale: Option) -> Vec { + eng.renderer.apply_quality_preset(preset); + if let Some(scale) = legacy_scale { + eng.renderer.set_render_scale(scale); + } + configure_reconstruction_scene(&mut eng.renderer); + eng.renderer.reset_temporal_history(); + render(eng, 24, |eng| { + let r = &mut eng.renderer; + r.set_clear_color(5.0, 7.0, 12.0, 255.0); + r.begin_mode_3d(4.4, 3.5, 6.0, 0.0, 0.45, 0.0, 0.0, 1.0, 0.0, 51.0, 0.0); + r.set_ambient_light(255.0, 255.0, 255.0, 0.82); + r.draw_grid(48, 0.16); + for column in -8..=8 { + let bright = column & 1 == 0; + r.draw_cube( + f64::from(column) * 0.19, + 0.55, + -0.35 + f64::from(column & 3) * 0.08, + 0.075, + 1.10, + 0.075, + if bright { 238.0 } else { 35.0 }, + if bright { 226.0 } else { 55.0 }, + if bright { 205.0 } else { 85.0 }, + 255.0, + ); + } + }) + .2 +} + +#[test] +fn default_and_ultra_presets_resolve_more_detail_than_legacy_half_scale() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + + let legacy_half = capture_preset(&mut eng, 2, Some(0.5)); + let default_medium = capture_preset(&mut eng, 2, None); + let ultra = capture_preset(&mut eng, 4, None); + let legacy_energy = detail_energy(&legacy_half); + let default_energy = detail_energy(&default_medium); + let ultra_energy = detail_energy(&ultra); + let legacy_to_native = calculate_diff_metrics(&ultra, &legacy_half, W, H); + let default_to_native = calculate_diff_metrics(&ultra, &default_medium, W, H); + eprintln!( + "quality-preset detail_energy legacy_half={legacy_energy:.4} \ + default_075={default_energy:.4} ultra_native={ultra_energy:.4} \ + legacy_native_mean={:.4} default_native_mean={:.4}", + legacy_to_native.mean_rgb, default_to_native.mean_rgb, + ); + + assert!( + default_energy >= legacy_energy * 1.02, + "0.75 default did not resolve meaningfully more detail than legacy 0.5: \ + {default_energy:.4} vs {legacy_energy:.4}" + ); + assert!( + default_to_native.mean_rgb < legacy_to_native.mean_rgb * 0.90, + "0.75 default was not materially closer to native Ultra than legacy 0.5: \ + default={default_to_native:?}, legacy={legacy_to_native:?}" + ); +} diff --git a/native/shared/tests/golden_render/refractive_quality.rs b/native/shared/tests/golden_render/refractive_quality.rs new file mode 100644 index 00000000..e9171c93 --- /dev/null +++ b/native/shared/tests/golden_render/refractive_quality.rs @@ -0,0 +1,190 @@ +use super::super::*; + +fn glass_transform(x: f32, angle: f32) -> [[f32; 4]; 4] { + let (sin, cos) = angle.sin_cos(); + [ + [cos, 0.0, -sin, 0.0], + [0.0, 1.0, 0.0, 0.0], + [sin, 0.0, cos, 0.0], + [x, 1.0, 0.0, 1.0], + ] +} + +fn evaluate_reactive_motion_recovery(label: &str, old_pose: &[u8], frames: &[Vec]) { + assert_eq!( + frames.len(), + 24, + "reactive recovery expects one and a half 16-sample jitter cycles" + ); + let stable = average_rgba(&frames[8..]); + let movement = calculate_diff_metrics(old_pose, &stable, W, H); + // Fully reactive transmission intentionally consumes the current refracted + // result immediately. Compare equal Halton phases one 16-frame cycle apart + // so legitimate subpixel coverage changes are not misclassified as trails. + let recovery = frames[..8] + .iter() + .zip(&frames[16..]) + .map(|(frame, phase_match)| calculate_diff_metrics(phase_match, frame, W, H)) + .collect::>(); + let severe = frames[..8] + .iter() + .zip(&frames[16..]) + .map(|(frame, phase_match)| severe_pixel_fraction(phase_match, frame)) + .collect::>(); + let trail_frames = severe + .iter() + .enumerate() + .find(|(index, _)| severe[*index..].iter().all(|fraction| *fraction <= 0.005)) + .map(|(index, _)| index) + .unwrap_or(severe.len()); + let stable_mean = frames[8..] + .iter() + .map(|frame| calculate_diff_metrics(&stable, frame, W, H).mean_rgb) + .sum::() + / (frames.len() - 8) as f64; + eprintln!( + "temporal-corpus {label} movement_mean={:.4} initial_phase_mean={:.4} \ + frame4_phase_mean={:.4} frame4_phase_outliers={:.4}% \ + trail_frames={trail_frames} stable_flicker={stable_mean:.4}", + movement.mean_rgb, + recovery[0].mean_rgb, + recovery[4].mean_rgb, + recovery[4].outlier_pixel_fraction * 100.0, + ); + assert!( + movement.mean_rgb >= 1.0 && movement.outlier_pixel_fraction >= 0.01, + "{label} negative control did not produce visible object motion" + ); + assert!( + trail_frames <= 4, + "{label} left severe motion trails beyond four frames" + ); + assert!( + recovery[4].outlier_pixel_fraction <= 0.02, + "{label} coherent trail covered over 2% after four frames" + ); + assert!( + stable_mean <= 2.0, + "{label} did not settle to a stable jitter-cycle estimate" + ); +} + +#[test] +fn moving_physical_refraction_writes_velocity_reactive_coverage_and_no_trail() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + let r = &mut eng.renderer; + r.set_taa_enabled(true); + r.set_render_scale(1.0); + r.set_ssao_enabled(false); + r.set_ssr_enabled(false); + r.set_ssgi_enabled(false); + r.set_bloom_enabled(false); + r.set_auto_exposure(false); + r.set_motion_blur_enabled(false); + r.set_shadows_enabled(false); + r.set_transparency_composition_mode(0); + + let (vertices, indices) = cube_verts(0.8, [1.0; 4]); + let glass = eng.scene.create_node(); + eng.scene.update_geometry(glass, vertices, indices); + eng.scene.set_material_pbr(glass, 0.08, 0.0); + eng.scene.set_material_transmission( + glass, + MaterialTransmission { + authored: true, + factor: 1.0, + ior_authored: true, + ior: 1.52, + volume_authored: true, + thickness_factor: 0.9, + attenuation_distance: 0.7, + attenuation_color: [0.08, 0.75, 1.0], + thickness_source: MaterialThicknessSource::Authored, + ..Default::default() + }, + ); + + let draw_scene = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(7.0, 10.0, 22.0, 255.0); + r.begin_mode_3d(0.0, 2.0, 6.2, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.add_directional_light(-0.4, -1.0, -0.25, 1.0, 0.9, 0.75, 1.8); + r.draw_plane(0.0, 0.0, 0.0, 12.0, 12.0, 35.0, 42.0, 58.0, 255.0); + for y in 0..3 { + for x in -3..=3 { + let alternate = (x + y) & 1 == 0; + r.draw_cube( + x as f64 * 0.72, + 0.42 + y as f64 * 0.72, + -1.8, + 0.62, + 0.62, + 0.3, + if alternate { 235.0 } else { 30.0 }, + if alternate { 55.0 } else { 175.0 }, + if alternate { 25.0 } else { 235.0 }, + 255.0, + ); + } + } + }; + let capture = |eng: &mut EngineState| render(eng, 1, draw_scene).2; + + eng.scene + .set_transform(glass, glass_transform(-1.45, -0.45)); + eng.renderer.reset_temporal_history(); + for _ in 0..8 { + capture(&mut eng); + } + let old_pose = capture(&mut eng); + + eng.scene.set_transform(glass, glass_transform(1.45, 0.55)); + let directory = + std::env::temp_dir().join(format!("bloom-refraction-motion-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + let mut frames = Vec::new(); + for _ in 0..24 { + frames.push(capture(&mut eng)); + } + + let motion = image::open(directory.join("taa-motion.png")) + .expect("refractive motion capture did not emit the TAA velocity map") + .to_rgb8(); + let moving_pixels = motion.pixels().filter(|pixel| pixel[2] > 8).count(); + let reasons = image::open(directory.join("taa-rejection-reason.png")) + .expect("refractive motion capture did not emit TAA rejection reasons") + .to_rgb8(); + let reactive_pixels = reasons + .pixels() + .filter(|pixel| pixel[0] < 40 && pixel[1] > 180 && pixel[2] > 210) + .count(); + eprintln!( + "temporal-corpus refractive moving_pixels={moving_pixels} \ + reactive_pixels={reactive_pixels}" + ); + assert!( + moving_pixels >= 250, + "moving refractive geometry wrote no meaningful velocity" + ); + assert!( + reactive_pixels >= 250, + "moving physical refraction wrote no reactive TAA coverage" + ); + evaluate_reactive_motion_recovery("physical-refraction", &old_pose, &frames); + assert!( + eng.renderer + .quality_runtime_paths_json() + .contains("\"temporal_reactive\":{\"enabled\":true,\"active\":true"), + "physical refraction did not keep the reactive composition path active" + ); + + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept refractive motion diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } +} diff --git a/native/shared/tests/golden_render/ssgi_quality.rs b/native/shared/tests/golden_render/ssgi_quality.rs new file mode 100644 index 00000000..d96ebac9 --- /dev/null +++ b/native/shared/tests/golden_render/ssgi_quality.rs @@ -0,0 +1,407 @@ +use super::super::*; + +#[test] +fn wsrc_bake_double_wraps_all_octahedral_corners() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + let r = &mut eng.renderer; + r.set_taa_enabled(false); + r.set_ssao_enabled(false); + r.set_ssr_enabled(false); + r.set_ssgi_enabled(true); + r.set_bloom_enabled(false); + r.set_auto_exposure(false); + r.set_shadows_enabled(false); + + let capture = |eng: &mut EngineState| { + eng.begin_frame(); + let r = &mut eng.renderer; + r.set_clear_color(6.0, 8.0, 15.0, 255.0); + r.begin_mode_3d(4.0, 3.0, 6.0, 0.0, 0.6, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.set_ambient_light(15.0, 18.0, 28.0, 0.2); + r.add_directional_light(-0.5, -1.0, -0.3, 1.0, 0.85, 0.7, 1.8); + r.draw_cube(0.0, -0.1, 0.0, 12.0, 0.2, 12.0, 90.0, 96.0, 107.0, 255.0); + eng.end_frame(); + }; + + // Profile the fixed-resolution bake independently of its normal + // once-per-cascade amortization. This is test-only state manipulation. + eng.profiler.set_enabled(true); + for _ in 0..120 { + eng.renderer.wsrc_built = [false; 3]; + capture(&mut eng); + } + let bake_gpu_us = eng + .profiler + .snapshot() + .into_iter() + .find_map(|(label, _, gpu)| (label == "wsrc_bake_pass").then_some(gpu?)) + .unwrap_or(0.0); + eng.profiler.set_enabled(false); + + // The loop above repeatedly baked cascade zero. Let the established + // amortizer finish cascades one and two before reading the atlas. + capture(&mut eng); + capture(&mut eng); + assert_eq!(eng.renderer.wsrc_built, [true; 3]); + + let shader = eng + .renderer + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("wsrc_corner_readback_shader"), + source: wgpu::ShaderSource::Wgsl( + " +@group(0) @binding(0) var atlas: texture_3d; +@group(0) @binding(1) var atlas_sampler: sampler; +@group(0) @binding(2) var samples: array, 16>; + +@compute @workgroup_size(1, 1, 1) +fn cs_main(@builtin(global_invocation_id) gid: vec3) { + if (gid.x >= 8u) { return; } + let coords = array, 8>( + vec2(0, 0), vec2(8, 8), + vec2(0, 9), vec2(8, 1), + vec2(9, 0), vec2(1, 8), + vec2(9, 9), vec2(1, 1), + ); + samples[gid.x] = textureLoad(atlas, vec3(coords[gid.x], 0), 0); + if (gid.x < 4u) { + let bases = array, 4>( + vec2(0, 0), vec2(8, 0), + vec2(0, 8), vec2(8, 8), + ); + let sample_texels = array, 4>( + vec2(1.0, 1.0), vec2(9.0, 1.0), + vec2(1.0, 9.0), vec2(9.0, 9.0), + ); + let base = bases[gid.x]; + let filtered = textureSampleLevel( + atlas, + atlas_sampler, + vec3(sample_texels[gid.x] / 160.0, 0.5 / 48.0), + 0.0, + ); + let expected = ( + textureLoad(atlas, vec3(base, 0), 0) + + textureLoad(atlas, vec3(base + vec2(1, 0), 0), 0) + + textureLoad(atlas, vec3(base + vec2(0, 1), 0), 0) + + textureLoad(atlas, vec3(base + vec2(1, 1), 0), 0) + ) * 0.25; + samples[8u + gid.x * 2u] = filtered; + samples[9u + gid.x * 2u] = expected; + } +} +" + .into(), + ), + }); + let pipeline = eng + .renderer + .device + .create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("wsrc_corner_readback_pipeline"), + layout: None, + module: &shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }); + let output = eng.renderer.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("wsrc_corner_samples"), + size: 16 * 16, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let staging = eng.renderer.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("wsrc_corner_staging"), + size: 16 * 16, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let bind_group = eng + .renderer + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("wsrc_corner_readback_bind_group"), + layout: &pipeline.get_bind_group_layout(0), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&eng.renderer.wsrc_atlas_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&eng.renderer.wsrc_atlas_sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: output.as_entire_binding(), + }, + ], + }); + let mut encoder = eng + .renderer + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("wsrc_corner_readback_encoder"), + }); + { + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor::default()); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.dispatch_workgroups(8, 1, 1); + } + encoder.copy_buffer_to_buffer(&output, 0, &staging, 0, 16 * 16); + eng.renderer.queue.submit(std::iter::once(encoder.finish())); + + let slice = staging.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + let _ = tx.send(result); + }); + eng.renderer + .device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }) + .expect("WSRC corner readback poll failed"); + rx.recv() + .expect("WSRC corner map sender dropped") + .expect("WSRC corner map failed"); + let mapped = slice.get_mapped_range(); + let values = bytemuck::cast_slice::(&mapped); + for pair in 0..4 { + let corner = &values[pair * 8..pair * 8 + 4]; + let wrapped = &values[pair * 8 + 4..pair * 8 + 8]; + assert!(corner.iter().all(|value| value.is_finite())); + assert_eq!( + corner, wrapped, + "WSRC padded corner pair {pair} did not double-wrap" + ); + } + for corner in 0..4 { + let filtered_start = (8 + corner * 2) * 4; + let expected_start = filtered_start + 4; + for channel in 0..4 { + let filtered = values[filtered_start + channel]; + let expected = values[expected_start + channel]; + assert!( + (filtered - expected).abs() <= 0.0001, + "WSRC corner {corner} channel {channel} filter weight mismatch: \ + filtered={filtered}, expected={expected}" + ); + } + } + eprintln!("wsrc-corner-wrap bake_gpu_us={bake_gpu_us:.3}"); + drop(mapped); + staging.unmap(); +} + +#[test] +fn ssgi_hiz_immediate_scene_produces_finite_indirect_radiance() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + let r = &mut eng.renderer; + if std::env::var_os("BLOOM_SSGI_PROFILE_HD").is_some() { + r.resize(1280, 720, 1280, 720); + } + r.set_taa_enabled(false); + r.set_ssao_enabled(false); + r.set_ssr_enabled(false); + r.set_ssgi_enabled(true); + r.set_bloom_enabled(false); + r.set_auto_exposure(false); + r.set_shadows_enabled(false); + + let draw = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(6.0, 8.0, 15.0, 255.0); + r.begin_mode_3d(4.0, 3.0, 6.0, 0.0, 0.6, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.set_ambient_light(15.0, 18.0, 28.0, 0.2); + r.add_directional_light(-0.5, -1.0, -0.3, 1.0, 0.85, 0.7, 1.8); + r.draw_cube(0.0, -0.1, 0.0, 12.0, 0.2, 12.0, 90.0, 96.0, 107.0, 255.0); + r.draw_cube(0.0, 2.0, -3.0, 8.0, 4.0, 0.2, 230.0, 166.0, 31.0, 255.0); + r.draw_cube(-1.1, 1.0, 0.0, 1.8, 2.0, 1.8, 230.0, 45.0, 25.0, 255.0); + r.draw_sphere(1.1, 0.9, -0.8, 0.9, 30.0, 110.0, 240.0, 255.0); + }; + let capture = |eng: &mut EngineState| { + eng.begin_frame(); + draw(eng); + eng.end_frame(); + }; + + eng.renderer.reset_temporal_history(); + for _ in 0..24 { + capture(&mut eng); + } + eng.profiler.set_enabled(true); + for _ in 0..120 { + capture(&mut eng); + } + let probe_gpu_us = eng + .profiler + .snapshot() + .into_iter() + .filter_map(|(label, _, gpu)| label.starts_with("probe_").then_some((label, gpu?))) + .collect::>(); + let probe_total_gpu_us = probe_gpu_us.iter().map(|(_, gpu)| gpu).sum::(); + eng.profiler.set_enabled(false); + let directory = std::env::temp_dir().join(format!("bloom-ssgi-hiz-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + capture(&mut eng); + + let confidence = image::open(directory.join("ssgi-temporal-confidence.png")) + .expect("Hi-Z SSGI capture did not emit temporal confidence") + .to_rgb8(); + let current = confidence.pixels().filter(|pixel| pixel[1] > 0).count(); + let retained = confidence.pixels().filter(|pixel| pixel[2] > 16).count(); + let metrics: serde_json::Value = serde_json::from_slice( + &std::fs::read(directory.join("ssgi.metrics.json")) + .expect("Hi-Z SSGI capture did not emit resolved HDR metrics"), + ) + .unwrap(); + let non_finite = metrics["non_finite_pixels"].as_u64().unwrap(); + let max_luminance = metrics["max_luminance"].as_f64().unwrap(); + let paths = eng.renderer.quality_runtime_paths_json(); + eprintln!( + "hiz-corpus ssgi current={current} retained={retained} non_finite={non_finite} \ + max_luma={max_luminance:.6} probe_total_gpu_us={probe_total_gpu_us:.3} \ + probe_gpu_us={probe_gpu_us:?} paths={paths}" + ); + assert!( + paths.contains("\"ssgi_trace_backend\":\"hiz-screen\""), + "immediate-only SSGI scene escaped the Hi-Z backend: {paths}" + ); + assert_eq!(non_finite, 0, "Hi-Z SSGI emitted non-finite radiance"); + assert!( + current >= 100 && retained >= 100 && max_luminance > 0.0001, + "Hi-Z SSGI produced no usable indirect radiance: current={current}, \ + retained={retained}, max_luma={max_luminance:.6}" + ); + + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept Hi-Z SSGI diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } +} + +#[test] +fn ssgi_capture_exposes_probe_history_without_normal_frame_resources() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + let r = &mut eng.renderer; + r.set_taa_enabled(false); + r.set_ssao_enabled(false); + r.set_ssr_enabled(false); + r.set_ssgi_enabled(true); + r.set_bloom_enabled(false); + r.set_auto_exposure(false); + r.set_shadows_enabled(false); + super::transformed_box( + &mut eng, + [0.0, -0.1, 0.0], + [12.0, 0.2, 12.0], + [0.35, 0.38, 0.42, 1.0], + 0.8, + 0.0, + [0.0; 3], + ); + super::transformed_box( + &mut eng, + [0.0, 2.0, -3.0], + [8.0, 4.0, 0.2], + [0.9, 0.65, 0.12, 1.0], + 0.7, + 0.0, + [2.5, 1.2, 0.15], + ); + + let draw = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(6.0, 8.0, 15.0, 255.0); + r.begin_mode_3d(4.0, 3.0, 6.0, 0.0, 0.6, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.set_ambient_light(15.0, 18.0, 28.0, 0.2); + r.add_directional_light(-0.5, -1.0, -0.3, 1.0, 0.85, 0.7, 1.8); + r.draw_cube(-1.1, 1.0, 0.0, 1.8, 2.0, 1.8, 230.0, 45.0, 25.0, 255.0); + r.draw_sphere(1.1, 0.9, -0.8, 0.9, 30.0, 110.0, 240.0, 255.0); + }; + let capture = |eng: &mut EngineState| { + eng.begin_frame(); + draw(eng); + eng.end_frame(); + }; + + eng.renderer.reset_temporal_history(); + for _ in 0..24 { + capture(&mut eng); + } + let directory = + std::env::temp_dir().join(format!("bloom-ssgi-diagnostics-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + capture(&mut eng); + + let reasons = image::open(directory.join("ssgi-rejection-reason.png")) + .expect("SSGI capture did not emit probe-domain temporal reasons") + .to_rgb8(); + let accepted = reasons + .pixels() + .filter(|pixel| pixel[0] < 40 && pixel[1] > 140 && pixel[2] < 60) + .count(); + let refreshed = reasons + .pixels() + .filter(|pixel| pixel[0] > 220 && pixel[1] < 40 && pixel[2] > 160) + .count(); + let confidence = image::open(directory.join("ssgi-temporal-confidence.png")) + .expect("SSGI capture did not emit probe-domain temporal confidence") + .to_rgb8(); + let retained = confidence.pixels().filter(|pixel| pixel[2] > 16).count(); + let current = confidence.pixels().filter(|pixel| pixel[1] > 0).count(); + let metrics: serde_json::Value = serde_json::from_slice( + &std::fs::read(directory.join("ssgi.metrics.json")) + .expect("SSGI capture did not emit resolved HDR metrics"), + ) + .unwrap(); + let non_finite = metrics["non_finite_pixels"].as_u64().unwrap(); + let max_luminance = metrics["max_luminance"].as_f64().unwrap(); + eprintln!( + "temporal-corpus ssgi-probes accepted={accepted} refreshed={refreshed} current={current} \ + retained={retained} non_finite={non_finite} max_luma={max_luminance:.4} total={}", + reasons.width() * reasons.height() + ); + assert!( + accepted >= 100 && current >= 100 && retained >= 100, + "settled SSGI probes exposed no current radiance or retained history" + ); + assert_eq!(non_finite, 0, "SSGI resolve emitted non-finite radiance"); + assert!( + max_luminance > 0.0001, + "SSGI probe resolve produced no indirect radiance" + ); + assert_eq!( + reasons.dimensions(), + confidence.dimensions(), + "SSGI reason/confidence atlases describe different probe domains" + ); + + let paths = eng.renderer.quality_runtime_paths_json(); + assert!(paths.contains("\"ray_scene_preparation\":\"ssgi\"")); + assert!(paths.contains("\"ssgi_diagnostic_persistent_bytes\":0")); + assert!(paths.contains("\"ssgi_diagnostic_capture_passes\":1")); + assert!(paths.contains("\"ssgi_diagnostic_resources_live\":false")); + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept SSGI diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } +} diff --git a/native/shared/tests/golden_render/ssr_quality.rs b/native/shared/tests/golden_render/ssr_quality.rs new file mode 100644 index 00000000..df618b8b --- /dev/null +++ b/native/shared/tests/golden_render/ssr_quality.rs @@ -0,0 +1,262 @@ +use super::*; + +#[path = "refractive_quality.rs"] +mod refractive_quality; +#[path = "ssgi_quality.rs"] +mod ssgi_quality; + +fn transformed_box( + eng: &mut EngineState, + position: [f32; 3], + size: [f32; 3], + color: [f32; 4], + roughness: f32, + metalness: f32, + emissive: [f32; 3], +) { + let (vertices, indices) = cube_verts(0.5, color); + let node = eng.scene.create_node(); + eng.scene.update_geometry(node, vertices, indices); + eng.scene.set_transform( + node, + [ + [size[0], 0.0, 0.0, 0.0], + [0.0, size[1], 0.0, 0.0], + [0.0, 0.0, size[2], 0.0], + [position[0], position[1], position[2], 1.0], + ], + ); + eng.scene.set_material_pbr(node, roughness, metalness); + eng.scene + .set_material_color(node, color[0], color[1], color[2], color[3]); + eng.scene + .set_material_emissive_factor(node, emissive[0], emissive[1], emissive[2]); +} + +fn luma(pixel: &image::Rgb) -> f64 { + 0.2126 * f64::from(pixel[0]) + 0.7152 * f64::from(pixel[1]) + 0.0722 * f64::from(pixel[2]) +} + +fn isolated_hot_pixels(image: &image::RgbImage) -> usize { + let mut isolated = 0; + for y in 1..image.height() - 1 { + for x in 1..image.width() - 1 { + let center = luma(image.get_pixel(x, y)); + if center < 180.0 { + continue; + } + let mut dark_neighbors = 0; + for oy in -1..=1 { + for ox in -1..=1 { + if ox == 0 && oy == 0 { + continue; + } + let neighbor = + luma(image.get_pixel(x.wrapping_add_signed(ox), y.wrapping_add_signed(oy))); + if neighbor + 70.0 < center { + dark_neighbors += 1; + } + } + } + isolated += usize::from(dark_neighbors >= 6); + } + } + isolated +} + +#[test] +fn dark_interior_ssr_rejects_fireflies_and_preserves_smooth_reflections() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + let r = &mut eng.renderer; + r.set_taa_enabled(true); + r.set_render_scale(1.0); + r.set_ssao_enabled(false); + r.set_ssgi_enabled(false); + r.set_ssr_enabled(true); + r.set_ssr_strength(1.0); + r.set_bloom_enabled(false); + r.set_auto_exposure(false); + r.set_motion_blur_enabled(false); + r.set_shadows_enabled(false); + + let concrete = [0.075, 0.085, 0.105, 1.0]; + transformed_box( + &mut eng, + [0.0, -0.05, 0.0], + [8.0, 0.1, 10.0], + concrete, + 0.68, + 0.0, + [0.0; 3], + ); + transformed_box( + &mut eng, + [0.0, 3.05, 0.0], + [8.0, 0.1, 10.0], + concrete, + 0.68, + 0.0, + [0.0; 3], + ); + transformed_box( + &mut eng, + [0.0, 1.5, -4.0], + [8.0, 3.0, 0.1], + concrete, + 0.68, + 0.0, + [0.0; 3], + ); + for x in [-4.0, 4.0] { + transformed_box( + &mut eng, + [x, 1.5, 0.0], + [0.1, 3.0, 10.0], + concrete, + 0.68, + 0.0, + [0.0; 3], + ); + } + transformed_box( + &mut eng, + [0.0, 1.65, -3.9], + [2.2, 1.5, 0.08], + [1.0, 0.92, 0.72, 1.0], + 0.25, + 0.0, + [2000.0, 1600.0, 900.0], + ); + // On-screen bright geometry above the floor is the traced-hit negative + // control. The back-wall opening alone can legitimately miss because SSR + // cannot reflect content that leaves the viewport. + transformed_box( + &mut eng, + [-0.6, 1.2, -1.3], + [3.2, 2.4, 0.8], + [1.0, 0.32, 0.08, 1.0], + 0.2, + 0.0, + [80.0, 6.0, 0.5], + ); + transformed_box( + &mut eng, + [0.6, 0.02, -0.8], + [4.8, 0.08, 4.0], + [0.32, 0.36, 0.42, 1.0], + 0.08, + 1.0, + [0.0; 3], + ); + + let draw = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(1.0, 1.0, 2.0, 255.0); + r.begin_mode_3d(0.0, 2.2, 4.5, 0.0, 0.6, -0.8, 0.0, 1.0, 0.0, 55.0, 0.0); + r.set_ambient_light(10.0, 12.0, 18.0, 0.08); + r.add_directional_light(-0.3, -1.0, -0.2, 0.3, 0.36, 0.5, 0.18); + }; + let capture = |eng: &mut EngineState| render(eng, 1, draw).2; + + eng.renderer.reset_temporal_history(); + for _ in 0..24 { + capture(&mut eng); + } + let directory = std::env::temp_dir().join(format!("bloom-ssr-interior-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + let ssr_enabled = capture(&mut eng); + let ssr = image::open(directory.join("ssr.png")) + .expect("SSR qualification capture did not emit its logical graph resource") + .to_rgb8(); + let active = ssr.pixels().filter(|pixel| luma(pixel) > 10.0).count(); + let hot = ssr.pixels().filter(|pixel| luma(pixel) > 220.0).count(); + let isolated = isolated_hot_pixels(&ssr); + let metrics: serde_json::Value = serde_json::from_slice( + &std::fs::read(directory.join("ssr.metrics.json")) + .expect("SSR qualification capture did not emit raw HDR metrics"), + ) + .unwrap(); + let max_luminance = metrics["max_luminance"].as_f64().unwrap(); + let p999_luminance = metrics["p999_luminance"].as_f64().unwrap(); + let raw_isolated = metrics["isolated_local_outliers"].as_u64().unwrap(); + let non_finite = metrics["non_finite_pixels"].as_u64().unwrap(); + let hit_alpha_pixels = metrics["hit_alpha_pixels"].as_u64().unwrap(); + let raw_metrics: serde_json::Value = serde_json::from_slice( + &std::fs::read(directory.join("ssr-raw.metrics.json")) + .expect("SSR qualification capture did not emit raw-march HDR metrics"), + ) + .unwrap(); + let raw_hit_alpha_pixels = raw_metrics["hit_alpha_pixels"].as_u64().unwrap(); + let raw_march_max = raw_metrics["max_luminance"].as_f64().unwrap(); + let rejection = image::open(directory.join("ssr-rejection-reason.png")) + .expect("SSR qualification capture did not emit temporal rejection reasons") + .to_rgb8(); + let accepted_history = rejection + .pixels() + .filter(|pixel| pixel[0] < 40 && pixel[1] > 140 && pixel[2] < 60) + .count(); + let neighborhood_clamps = rejection + .pixels() + .filter(|pixel| pixel[0] > 220 && pixel[1] > 150 && pixel[2] < 40) + .count(); + let confidence = image::open(directory.join("ssr-temporal-confidence.png")) + .expect("SSR qualification capture did not emit temporal confidence") + .to_rgb8(); + let retained_history = confidence.pixels().filter(|pixel| pixel[2] > 200).count(); + eprintln!( + "temporal-corpus dark-interior-ssr active={active} hot={hot} isolated={isolated} \ + raw_max={max_luminance:.4} raw_p999={p999_luminance:.4} \ + raw_isolated={raw_isolated} hit_alpha={hit_alpha_pixels} \ + march_max={raw_march_max:.4} march_hits={raw_hit_alpha_pixels} \ + non_finite={non_finite} accepted={accepted_history} \ + clamps={neighborhood_clamps} retained={retained_history}" + ); + assert!( + active >= 100, + "negative control produced no SSR reflections" + ); + assert_eq!(non_finite, 0, "SSR history retained NaN/Inf radiance"); + assert!( + max_luminance <= 8.01 && raw_march_max <= 8.01, + "SSR radiance exceeded the documented firefly bound" + ); + assert_eq!( + raw_isolated, 0, + "SSR history contains isolated high-radiance fireflies" + ); + assert!( + accepted_history >= 100 && retained_history >= 100, + "SSR diagnostics did not expose retained temporal history" + ); + assert!( + neighborhood_clamps > 0, + "bright-interior scene did not exercise SSR neighborhood clamping" + ); + + eng.renderer.set_ssr_enabled(false); + eng.renderer.reset_temporal_history(); + for _ in 0..16 { + capture(&mut eng); + } + let ssr_disabled = capture(&mut eng); + let reflection_delta = calculate_diff_metrics(&ssr_disabled, &ssr_enabled, W, H).mean_rgb; + eprintln!("temporal-corpus dark-interior-ssr reflection_delta={reflection_delta:.4}"); + assert!( + reflection_delta >= 0.05, + "smooth metal negative control did not retain a visible SSR contribution" + ); + let paths = eng.renderer.quality_runtime_paths_json(); + assert!(paths.contains("\"ssr_diagnostic_persistent_bytes\":0")); + assert!(paths.contains("\"ssr_diagnostic_capture_passes\":1")); + assert!(paths.contains("\"ssr_diagnostic_resources_live\":false")); + + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept dark-interior SSR diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } +} diff --git a/native/shared/tests/golden_render/temporal_history.rs b/native/shared/tests/golden_render/temporal_history.rs new file mode 100644 index 00000000..23b99e85 --- /dev/null +++ b/native/shared/tests/golden_render/temporal_history.rs @@ -0,0 +1,1970 @@ +use super::*; + +#[path = "ssr_quality.rs"] +mod ssr_quality; + +fn severe_pixel_fraction(reference: &[u8], candidate: &[u8]) -> f64 { + let severe = reference + .chunks_exact(4) + .zip(candidate.chunks_exact(4)) + .filter(|(a, b)| (0..3).any(|channel| a[channel].abs_diff(b[channel]) > 64)) + .count(); + severe as f64 / (reference.len() / 4) as f64 +} + +fn average_rgba(frames: &[Vec]) -> Vec { + assert!(!frames.is_empty()); + let mut sum = vec![0u32; frames[0].len()]; + for frame in frames { + for (sum, value) in sum.iter_mut().zip(frame) { + *sum += u32::from(*value); + } + } + let count = frames.len() as u32; + sum.into_iter() + .map(|sum| ((sum + count / 2) / count) as u8) + .collect() +} + +pub(super) fn evaluate_motion_recovery(label: &str, old_pose: &[u8], frames: &[Vec]) { + let stable = average_rgba(&frames[8..]); + let movement = calculate_diff_metrics(old_pose, &stable, W, H); + let recovery = frames[..8] + .iter() + .map(|frame| calculate_diff_metrics(&stable, frame, W, H)) + .collect::>(); + let severe = frames[..8] + .iter() + .map(|frame| severe_pixel_fraction(&stable, frame)) + .collect::>(); + let trail_frames = severe + .iter() + .enumerate() + .find(|(index, _)| severe[*index..].iter().all(|fraction| *fraction <= 0.005)) + .map(|(index, _)| index) + .unwrap_or(severe.len()); + let stable_mean = frames[8..] + .iter() + .map(|frame| calculate_diff_metrics(&stable, frame, W, H).mean_rgb) + .sum::() + / (frames.len() - 8) as f64; + eprintln!( + "temporal-corpus {label} movement_mean={:.4} initial_mean={:.4} frame4_mean={:.4} \ + frame4_outliers={:.4}% trail_frames={trail_frames} \ + stable_flicker={stable_mean:.4}", + movement.mean_rgb, + recovery[0].mean_rgb, + recovery[4].mean_rgb, + recovery[4].outlier_pixel_fraction * 100.0, + ); + assert!( + trail_frames <= 4, + "{label} left severe motion trails beyond four frames" + ); + assert!( + movement.mean_rgb >= 1.0 && movement.outlier_pixel_fraction >= 0.01, + "{label} negative control did not produce visible object motion" + ); + assert!( + recovery[4].outlier_pixel_fraction <= 0.02, + "{label} coherent trail covered over 2% after four frames" + ); + assert!( + stable_mean <= 2.0, + "{label} did not settle to a stable jitter-cycle estimate" + ); +} + +pub(super) fn configure_taa_motion_corpus(renderer: &mut Renderer) { + renderer.set_taa_enabled(true); + renderer.set_render_scale(1.0); + renderer.set_ssao_enabled(false); + renderer.set_ssr_enabled(false); + renderer.set_ssgi_enabled(false); + renderer.set_bloom_enabled(false); + renderer.set_auto_exposure(false); + renderer.set_motion_blur_enabled(false); + renderer.set_shadows_enabled(false); +} + +#[test] +fn ssr_history_lifetime_is_independent_from_the_taa_frame_counter() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + eng.renderer.set_taa_enabled(true); + + let draw_frame = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(13.0, 18.0, 26.0, 255.0); + r.begin_mode_3d(4.0, 3.0, 6.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 45.0, 0.0); + r.add_directional_light(-0.5, -1.0, -0.3, 1.0, 0.95, 0.9, 1.2); + r.draw_plane(0.0, 0.0, 0.0, 10.0, 10.0, 120.0, 120.0, 125.0, 255.0); + r.draw_sphere(0.0, 0.75, 0.0, 0.75, 220.0, 228.0, 240.0, 255.0); + }; + let advance = |eng: &mut EngineState, frames: u32| { + for _ in 0..frames { + eng.begin_frame(); + draw_frame(eng); + eng.end_frame(); + } + }; + + advance(&mut eng, 3); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"temporal_history\":{\"ssr_valid\":true")); + + eng.renderer.set_ssr_enabled(false); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"temporal_history\":{\"ssr_valid\":false")); + advance(&mut eng, 2); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"temporal_history\":{\"ssr_valid\":false")); + + eng.renderer.set_ssr_enabled(true); + advance(&mut eng, 1); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"temporal_history\":{\"ssr_valid\":true")); + + eng.renderer.set_ssr_strength(0.75); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"temporal_history\":{\"ssr_valid\":false")); + advance(&mut eng, 1); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"temporal_history\":{\"ssr_valid\":true")); + + eng.renderer.set_path_tracing(1); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"temporal_history\":{\"ssr_valid\":false")); + eng.renderer.set_path_tracing(0); + eng.renderer.set_render_scale(0.75); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"temporal_history\":{\"ssr_valid\":false")); +} + +#[test] +fn ssgi_probe_history_tracks_only_frames_that_write_it() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + eng.renderer.set_taa_enabled(false); + let draw_frame = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(13.0, 18.0, 26.0, 255.0); + r.begin_mode_3d(4.0, 3.0, 6.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 45.0, 0.0); + r.add_directional_light(-0.5, -1.0, -0.3, 1.0, 0.95, 0.9, 1.2); + r.draw_plane(0.0, 0.0, 0.0, 10.0, 10.0, 120.0, 120.0, 125.0, 255.0); + r.draw_sphere(0.0, 0.75, 0.0, 0.75, 220.0, 228.0, 240.0, 255.0); + }; + let advance = |eng: &mut EngineState, frames: u32| { + for _ in 0..frames { + eng.begin_frame(); + draw_frame(eng); + eng.end_frame(); + } + }; + + advance(&mut eng, 1); + let paths = eng.renderer.quality_runtime_paths_json(); + assert!(paths.contains("\"ssgi_probe_valid\":true")); + + eng.renderer.set_ssgi_enabled(false); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"ssgi_probe_valid\":false,\"ssgi_probe_index\":0")); + advance(&mut eng, 2); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"ssgi_probe_valid\":false,\"ssgi_probe_index\":0")); + + eng.renderer.set_ssgi_enabled(true); + advance(&mut eng, 1); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"ssgi_probe_valid\":true")); + + eng.renderer.set_ssgi_intensity(0.75); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"ssgi_probe_valid\":false,\"ssgi_probe_index\":0")); + advance(&mut eng, 1); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"ssgi_probe_valid\":true")); + + eng.renderer.set_ssgi_radius(12.0); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"ssgi_probe_valid\":false,\"ssgi_probe_index\":0")); + eng.renderer.set_path_tracing(1); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"ssgi_probe_valid\":false,\"ssgi_probe_index\":0")); + eng.renderer.set_path_tracing(0); + eng.renderer.set_render_scale(0.75); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"ssgi_probe_valid\":false,\"ssgi_probe_index\":0")); +} + +#[test] +fn taa_history_lifetime_is_explicit_across_toggles_and_resize() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + eng.renderer.set_taa_enabled(true); + let draw_frame = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(13.0, 18.0, 26.0, 255.0); + r.begin_mode_3d(4.0, 3.0, 6.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 45.0, 0.0); + r.draw_plane(0.0, 0.0, 0.0, 10.0, 10.0, 120.0, 120.0, 125.0, 255.0); + }; + let advance = |eng: &mut EngineState, frames: u32| { + for _ in 0..frames { + eng.begin_frame(); + draw_frame(eng); + eng.end_frame(); + } + }; + + advance(&mut eng, 1); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"taa_valid\":true")); + + if eng.renderer.pt_supported() { + eng.renderer.set_path_tracing(2); + advance(&mut eng, 1); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"taa_valid\":true,\"taa_index\":1,\"taa_pt_owned\":true")); + eng.renderer.set_path_tracing(0); + advance(&mut eng, 1); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"taa_pt_owned\":false")); + } + + eng.renderer.set_taa_enabled(false); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"taa_valid\":false,\"taa_index\":0")); + advance(&mut eng, 2); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"taa_valid\":false,\"taa_index\":0")); + + eng.renderer.set_taa_enabled(true); + advance(&mut eng, 1); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"taa_valid\":true")); + + eng.renderer.set_render_scale(0.75); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"taa_valid\":false,\"taa_index\":0")); + advance(&mut eng, 1); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"taa_valid\":true")); +} + +#[test] +fn exposure_history_seeds_each_enable_epoch_without_advancing_while_off() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + let draw_frame = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(13.0, 18.0, 26.0, 255.0); + r.begin_mode_3d(4.0, 3.0, 6.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 45.0, 0.0); + r.draw_plane(0.0, 0.0, 0.0, 10.0, 10.0, 120.0, 120.0, 125.0, 255.0); + }; + let advance = |eng: &mut EngineState, frames: u32| { + for _ in 0..frames { + eng.begin_frame(); + draw_frame(eng); + eng.end_frame(); + } + }; + + eng.renderer.set_auto_exposure(true); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"exposure_valid\":false,\"exposure_index\":0")); + advance(&mut eng, 1); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"exposure_valid\":true,\"exposure_index\":1")); + + eng.renderer.set_auto_exposure(false); + advance(&mut eng, 2); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"exposure_valid\":false,\"exposure_index\":0")); + + eng.renderer.set_auto_exposure_rate(0.0); + eng.renderer.set_auto_exposure(true); + advance(&mut eng, 1); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"exposure_valid\":true,\"exposure_index\":1")); +} + +#[test] +fn path_tracing_mode_transitions_reset_incompatible_history() { + let _rt_guard = lock_rt_goldens(); + let (mut eng, _) = match try_engine_rt() { + Ok(Some(pair)) => pair, + Ok(None) => { + skip_rt_golden("pt_history_lifetime", "no-non-cpu-ray-query-adapter"); + return; + } + Err(err) => panic!("{err}"), + }; + build_pt_scene(&mut eng); + + eng.renderer.set_path_tracing(2); + let _ = render(&mut eng, 1, draw_pt_static_frame); + assert!(eng.renderer.path_tracing_sample_count() > 0); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"ray_scene_preparation\":\"ssgi+pt\"")); + + eng.renderer.set_path_tracing(1); + assert_eq!(eng.renderer.path_tracing_sample_count(), 0); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"pt_samples\":0,\"pt_index\":0")); + + eng.renderer.set_path_tracing(0); + assert_eq!(eng.renderer.path_tracing_sample_count(), 0); +} + +#[test] +fn realtime_path_tracing_capture_exposes_svgf_history_without_normal_frame_resources() { + let _rt_guard = lock_rt_goldens(); + let (mut eng, _) = match try_engine_rt() { + Ok(Some(pair)) => pair, + Ok(None) => { + skip_rt_golden("pt_temporal_capture", "no-non-cpu-ray-query-adapter"); + return; + } + Err(err) => panic!("{err}"), + }; + build_pt_scene(&mut eng); + let r = &mut eng.renderer; + r.set_taa_enabled(false); + r.set_ssao_enabled(false); + r.set_ssr_enabled(false); + r.set_ssgi_enabled(false); + r.set_bloom_enabled(false); + r.set_auto_exposure(false); + r.set_path_tracing(2); + r.set_path_tracing_debug_view(0); + r.set_path_tracing_seed(0); + r.reset_path_tracing_history(0); + + let mut frame = 0u32; + let _ = render(&mut eng, 24, |eng| { + draw_pt_motion_frame(eng, frame); + frame += 1; + }); + let samples_before_capture = eng.renderer.path_tracing_sample_count(); + assert!( + samples_before_capture >= 8, + "realtime PT reached only {samples_before_capture} history frames before capture" + ); + let normal_paths = eng.renderer.quality_runtime_paths_json(); + assert!(normal_paths.contains("\"ray_scene_preparation\":\"pt\"")); + assert!(normal_paths.contains("\"pt_diagnostic_persistent_bytes\":0")); + assert!(normal_paths.contains("\"pt_diagnostic_resources_live\":false")); + + let directory = + std::env::temp_dir().join(format!("bloom-pt-diagnostics-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + eng.begin_frame(); + draw_pt_motion_frame(&mut eng, frame); + eng.end_frame(); + assert!( + eng.renderer.path_tracing_sample_count() > samples_before_capture, + "qualification frame did not execute the realtime PT pass" + ); + + let reasons = image::open(directory.join("pt-rejection-reason.png")) + .expect("PT capture did not emit temporal rejection reasons") + .to_rgb8(); + let accepted = reasons + .pixels() + .filter(|pixel| { + (pixel[0] < 40 && pixel[1] > 140 && pixel[2] < 60) || (pixel[0] < 40 && pixel[2] > 200) + }) + .count(); + let motion = image::open(directory.join("pt-motion.png")) + .expect("PT capture did not emit motion vectors") + .to_rgb8(); + let reprojection = image::open(directory.join("pt-reprojected-uv.png")) + .expect("PT capture did not emit reprojected UVs") + .to_rgb8(); + let valid_reprojection = reprojection.pixels().filter(|pixel| pixel[2] > 200).count(); + let confidence = image::open(directory.join("pt-temporal-confidence.png")) + .expect("PT capture did not emit temporal confidence") + .to_rgb8(); + let accumulated = confidence + .pixels() + .filter(|pixel| pixel[1] > 16 && pixel[2] > 16) + .count(); + let metrics: serde_json::Value = serde_json::from_slice( + &std::fs::read(directory.join("hdr-scene.metrics.json")) + .expect("PT capture did not emit raw HDR metrics"), + ) + .unwrap(); + let non_finite = metrics["non_finite_pixels"].as_u64().unwrap(); + let max_luminance = metrics["max_luminance"].as_f64().unwrap(); + eprintln!( + "temporal-corpus pt-svgf accepted={accepted} valid_reprojection={valid_reprojection} \ + accumulated={accumulated} non_finite={non_finite} max_luma={max_luminance:.4} total={}", + reasons.width() * reasons.height() + ); + assert!( + accepted >= 100 && valid_reprojection >= 100 && accumulated >= 100, + "settled realtime PT exposed no accepted, reprojected, accumulated history" + ); + assert_eq!(non_finite, 0, "realtime PT emitted non-finite HDR radiance"); + assert!(max_luminance > 0.0001, "realtime PT produced no radiance"); + assert_eq!(reasons.dimensions(), motion.dimensions()); + assert_eq!(reasons.dimensions(), reprojection.dimensions()); + assert_eq!(reasons.dimensions(), confidence.dimensions()); + + let paths = eng.renderer.quality_runtime_paths_json(); + assert!(paths.contains("\"pt_diagnostic_persistent_bytes\":0")); + assert!(paths.contains("\"pt_diagnostic_capture_passes\":1")); + assert!(paths.contains("\"pt_diagnostic_resources_live\":false")); + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept PT diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } +} + +#[test] +fn realtime_path_tracing_rigid_motion_bounds_trails_and_keeps_history() { + fn transform(x: f32, angle: f32) -> [[f32; 4]; 4] { + let (sin, cos) = angle.sin_cos(); + [ + [cos, 0.0, -sin, 0.0], + [0.0, 1.4, 0.0, 0.0], + [sin, 0.0, cos, 0.0], + [x, 1.0, -0.4, 1.0], + ] + } + + let _rt_guard = lock_rt_goldens(); + let (mut eng, _) = match try_engine_rt() { + Ok(Some(pair)) => pair, + Ok(None) => { + skip_rt_golden("pt_rigid_motion", "no-non-cpu-ray-query-adapter"); + return; + } + Err(err) => panic!("{err}"), + }; + build_pt_scene(&mut eng); + let (vertices, indices) = cube_verts(0.7, [0.95, 0.06, 0.02, 1.0]); + let node = eng.scene.create_node(); + eng.scene.update_geometry(node, vertices, indices); + eng.scene.set_material_pbr(node, 0.15, 0.3); + eng.scene.set_material_color(node, 0.95, 0.06, 0.02, 1.0); + eng.scene.set_transform(node, transform(-2.0, -0.65)); + + let r = &mut eng.renderer; + r.set_taa_enabled(false); + r.set_ssao_enabled(false); + r.set_ssr_enabled(false); + r.set_ssgi_enabled(false); + r.set_bloom_enabled(false); + r.set_auto_exposure(false); + r.set_path_tracing(2); + r.set_path_tracing_debug_view(0); + r.set_path_tracing_seed(0); + r.reset_path_tracing_history(0); + let _ = render(&mut eng, 24, draw_pt_static_frame); + let old_pose = render(&mut eng, 1, draw_pt_static_frame).2; + + eng.scene.set_transform(node, transform(2.0, 0.8)); + let directory = + std::env::temp_dir().join(format!("bloom-pt-rigid-motion-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + let mut frames = Vec::new(); + for _ in 0..24 { + frames.push(render(&mut eng, 1, draw_pt_static_frame).2); + } + evaluate_motion_recovery("pt-rigid", &old_pose, &frames); + + let motion = image::open(directory.join("pt-motion.png")) + .expect("moving PT capture did not emit motion vectors") + .to_rgb8(); + let moving = motion.pixels().filter(|pixel| pixel[2] > 16).count(); + let reasons = image::open(directory.join("pt-rejection-reason.png")) + .expect("moving PT capture did not emit rejection reasons") + .to_rgb8(); + let mut motion_history = 0usize; + let mut motion_rejected = 0usize; + let mut motion_flip = 0usize; + for (motion, reason) in motion.pixels().zip(reasons.pixels()) { + if motion[2] <= 16 { + continue; + } + motion_history += + usize::from(reason[0] < 40 && reason[1] > 40 && reason[1] < 100 && reason[2] > 220); + motion_rejected += + usize::from(reason[0] > 220 && reason[1] < 40 && (reason[2] > 160 || reason[2] < 40)); + motion_flip += usize::from(reason[0] < 40 && reason[1] > 200 && reason[2] > 220); + } + let classified_motion = motion_history + motion_rejected + motion_flip; + eprintln!( + "temporal-corpus pt-rigid moving={moving} retained={motion_history} \ + rejected={motion_rejected} footprint_flip={motion_flip} total={}", + motion.width() * motion.height() + ); + assert!(moving >= 100, "rigid PT motion wrote no velocity coverage"); + assert!( + motion_history >= 25, + "overlapping rigid PT motion retained no reprojected history" + ); + assert!( + classified_motion * 10 >= moving * 9, + "moving PT texels were neither retained nor explicitly rejected" + ); + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept PT rigid-motion diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } +} + +#[test] +fn realtime_path_tracing_lighting_changes_converge_without_reset_or_lag() { + fn evaluate_lighting(label: &str, previous: &[u8], frames: &[Vec]) { + let stable = average_rgba(&frames[12..]); + let change = calculate_diff_metrics(previous, &stable, W, H); + let recovery = frames[..13] + .iter() + .map(|frame| calculate_diff_metrics(&stable, frame, W, H)) + .collect::>(); + let stable_flicker = frames[12..] + .iter() + .map(|frame| calculate_diff_metrics(&stable, frame, W, H).mean_rgb) + .sum::() + / (frames.len() - 12) as f64; + eprintln!( + "temporal-corpus {label} change_mean={:.4} initial_mean={:.4} \ + frame4_mean={:.4} frame8_mean={:.4} frame12_outliers={:.4}% \ + stable_flicker={stable_flicker:.4}", + change.mean_rgb, + recovery[0].mean_rgb, + recovery[4].mean_rgb, + recovery[8].mean_rgb, + recovery[12].outlier_pixel_fraction * 100.0, + ); + assert!( + change.mean_rgb >= 1.0 && change.outlier_pixel_fraction >= 0.01, + "{label} negative control did not produce a visible lighting change" + ); + assert!( + recovery[8].mean_rgb <= recovery[0].mean_rgb * 0.65 + 0.25, + "{label} retained stale lighting beyond eight frames" + ); + assert!( + recovery[12].outlier_pixel_fraction <= 0.02, + "{label} retained coherent stale lighting after twelve frames" + ); + assert!( + stable_flicker <= 2.0, + "{label} did not settle to a stable stochastic estimate" + ); + } + + let _rt_guard = lock_rt_goldens(); + let (mut eng, _) = match try_engine_rt() { + Ok(Some(pair)) => pair, + Ok(None) => { + skip_rt_golden("pt_lighting_change", "no-non-cpu-ray-query-adapter"); + return; + } + Err(err) => panic!("{err}"), + }; + build_pt_scene(&mut eng); + let r = &mut eng.renderer; + r.set_taa_enabled(false); + r.set_ssao_enabled(false); + r.set_ssr_enabled(false); + r.set_ssgi_enabled(false); + r.set_bloom_enabled(false); + r.set_auto_exposure(false); + r.set_path_tracing(2); + r.set_path_tracing_debug_view(0); + r.set_path_tracing_seed(0); + r.reset_path_tracing_history(0); + let draw = |eng: &mut EngineState, bright: bool| { + draw_pt_static_frame(eng); + eng.renderer.set_directional_light( + 0.5, + 1.0, + 0.3, + 255.0, + 242.25, + 229.5, + if bright { 2.4 } else { 0.15 }, + ); + }; + let capture_state = + |eng: &mut EngineState, bright: bool| render(eng, 1, |eng| draw(eng, bright)).2; + + let _ = render(&mut eng, 24, |eng| draw(eng, false)); + let dark = capture_state(&mut eng, false); + let before_bright = eng.renderer.path_tracing_sample_count(); + let mut bright_frames = Vec::new(); + for _ in 0..24 { + bright_frames.push(capture_state(&mut eng, true)); + } + assert_eq!( + eng.renderer.path_tracing_sample_count(), + before_bright + 24, + "lighting change reset realtime PT history" + ); + evaluate_lighting("pt-light-on", &dark, &bright_frames); + + let bright = average_rgba(&bright_frames[12..]); + let before_dark = eng.renderer.path_tracing_sample_count(); + let mut dark_frames = Vec::new(); + for _ in 0..24 { + dark_frames.push(capture_state(&mut eng, false)); + } + assert_eq!( + eng.renderer.path_tracing_sample_count(), + before_dark + 24, + "lighting removal reset realtime PT history" + ); + evaluate_lighting("pt-light-off", &bright, &dark_frames); +} + +#[test] +fn realtime_path_tracing_resets_are_byte_exact_fresh_seeds() { + let _rt_guard = lock_rt_goldens(); + let (mut eng, _) = match try_engine_rt() { + Ok(Some(pair)) => pair, + Ok(None) => { + skip_rt_golden("pt_reset_seed", "no-non-cpu-ray-query-adapter"); + return; + } + Err(err) => panic!("{err}"), + }; + build_pt_scene(&mut eng); + let r = &mut eng.renderer; + r.set_taa_enabled(false); + r.set_ssao_enabled(false); + r.set_ssr_enabled(false); + r.set_ssgi_enabled(false); + r.set_bloom_enabled(false); + r.set_auto_exposure(false); + r.set_path_tracing(2); + r.set_path_tracing_debug_view(0); + r.set_path_tracing_seed(0); + let draw = |eng: &mut EngineState, camera: [f32; 3]| { + let r = &mut eng.renderer; + r.set_clear_color(0.05, 0.07, 0.1, 1.0); + r.begin_mode_3d( + camera[0], camera[1], camera[2], 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 50.0, 0.0, + ); + r.set_directional_light(0.5, 1.0, 0.3, 255.0, 242.25, 229.5, 1.2); + }; + let capture = + |eng: &mut EngineState, camera: [f32; 3]| render(eng, 1, |eng| draw(eng, camera)).2; + let camera_a = [-5.5, 3.2, 5.0]; + let camera_b = [5.0, 4.0, 7.0]; + + // Drain shared card/TLAS warm-up before establishing the seed oracle. + let _ = render(&mut eng, 8, |eng| draw(eng, camera_b)); + eng.renderer.reset_temporal_history(); + let fresh_b = capture(&mut eng, camera_b); + assert_eq!(eng.renderer.path_tracing_sample_count(), 1); + + let _ = render(&mut eng, 16, |eng| draw(eng, camera_a)); + eng.renderer.reset_temporal_history(); + let directory = + std::env::temp_dir().join(format!("bloom-pt-reset-seed-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + let cut_b = capture(&mut eng, camera_b); + let cut_metrics = calculate_diff_metrics(&fresh_b, &cut_b, W, H); + assert_eq!( + cut_metrics.max_diff, 0, + "explicit PT reset retained pixels from the prior camera" + ); + assert_eq!(eng.renderer.path_tracing_sample_count(), 1); + let reasons = image::open(directory.join("pt-rejection-reason.png")) + .expect("PT reset capture did not emit rejection reasons") + .to_rgb8(); + let non_seed = reasons + .pixels() + .filter(|pixel| pixel[0].abs_diff(pixel[1]) > 2 || pixel[1].abs_diff(pixel[2]) > 2) + .count(); + assert_eq!( + non_seed, 0, + "fresh PT history was not entirely classified as seed/sky" + ); + + let _ = render(&mut eng, 16, |eng| draw(eng, camera_a)); + eng.renderer.set_path_tracing(0); + let _ = capture(&mut eng, camera_a); + eng.renderer.set_path_tracing(2); + let toggled_b = capture(&mut eng, camera_b); + let toggle_metrics = calculate_diff_metrics(&fresh_b, &toggled_b, W, H); + eprintln!( + "temporal-corpus pt-reset cut_max={} toggle_max={} non_seed={non_seed}", + cut_metrics.max_diff, toggle_metrics.max_diff, + ); + assert_eq!( + toggle_metrics.max_diff, 0, + "PT off/on transition retained pixels from the prior ownership epoch" + ); + assert_eq!(eng.renderer.path_tracing_sample_count(), 1); + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept PT reset diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } +} + +#[test] +fn common_camera_cut_reset_invalidates_every_temporal_owner() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + eng.renderer.set_taa_enabled(true); + eng.renderer.set_ssao_enabled(true); + eng.renderer.set_ssr_enabled(true); + eng.renderer.set_ssgi_enabled(true); + eng.renderer.set_auto_exposure(true); + + let draw_frame = |eng: &mut EngineState, fov: f32| { + let r = &mut eng.renderer; + r.set_clear_color(13.0, 18.0, 26.0, 255.0); + r.begin_mode_3d(4.0, 3.0, 6.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, fov, 0.0); + r.draw_plane(0.0, 0.0, 0.0, 10.0, 10.0, 120.0, 120.0, 125.0, 255.0); + }; + for _ in 0..2 { + eng.begin_frame(); + draw_frame(&mut eng, 45.0); + eng.end_frame(); + } + let before = eng.renderer.quality_runtime_paths_json(); + assert!(before.contains("\"ssr_valid\":true")); + assert!(before.contains("\"ssgi_probe_valid\":true")); + assert!(before.contains("\"taa_valid\":true")); + assert!(before.contains("\"exposure_valid\":true")); + + eng.renderer.reset_temporal_history(); + let reset = eng.renderer.quality_runtime_paths_json(); + assert!(reset.contains("\"ssr_valid\":false")); + assert!(reset.contains("\"ssgi_probe_valid\":false")); + assert!(reset.contains("\"taa_valid\":false")); + assert!(reset.contains("\"exposure_valid\":false")); + assert!(reset.contains("\"pt_samples\":0,\"pt_index\":0")); + assert!(reset.contains("\"ssao_frames\":0,\"ssao_index\":0")); + assert!(reset.contains("\"camera_cut_pending\":true,\"camera_cut_active\":false")); + + eng.begin_frame(); + draw_frame(&mut eng, 70.0); + eng.end_frame(); + let after = eng.renderer.quality_runtime_paths_json(); + assert!(after.contains("\"camera_cut_pending\":false,\"camera_cut_active\":true")); + assert!(after.contains("\"taa_valid\":true")); + assert!(after.contains("\"ssr_valid\":true")); + assert!(after.contains("\"ssgi_probe_valid\":true")); + assert!(after.contains("\"exposure_valid\":true")); +} + +#[test] +fn taa_capture_emits_per_pixel_diagnostics_without_retaining_resources() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + eng.renderer.set_taa_enabled(true); + eng.renderer.set_ssao_enabled(false); + eng.renderer.set_ssr_enabled(false); + eng.renderer.set_ssgi_enabled(false); + eng.renderer.set_bloom_enabled(false); + eng.renderer.set_shadows_enabled(false); + let (reactive_vertices, reactive_indices) = cube_verts(0.7, [0.1, 0.8, 1.0, 0.95]); + let reactive_node = eng.scene.create_node(); + eng.scene + .update_geometry(reactive_node, reactive_vertices, reactive_indices); + eng.scene.set_transform( + reactive_node, + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 1.25, 0.0, 1.0], + ], + ); + eng.scene + .set_material_gltf_alpha(reactive_node, MaterialAlphaMode::Blend, 0.0, false); + eng.scene + .set_material_color(reactive_node, 0.1, 0.8, 1.0, 0.95); + let draw_frame = |eng: &mut EngineState, camera_x: f32| { + let r = &mut eng.renderer; + r.set_clear_color(13.0, 18.0, 26.0, 255.0); + r.begin_mode_3d( + 4.0 + camera_x, + 3.0, + 6.0, + camera_x, + 0.5, + 0.0, + 0.0, + 1.0, + 0.0, + 45.0, + 0.0, + ); + r.draw_plane(0.0, 0.0, 0.0, 10.0, 10.0, 120.0, 120.0, 125.0, 255.0); + r.draw_sphere(0.0, 0.75, 0.0, 0.75, 220.0, 228.0, 240.0, 255.0); + }; + for _ in 0..6 { + eng.begin_frame(); + draw_frame(&mut eng, 0.0); + eng.end_frame(); + } + + let directory = + std::env::temp_dir().join(format!("bloom-taa-diagnostics-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + eng.begin_frame(); + draw_frame(&mut eng, 0.25); + eng.end_frame(); + + for name in [ + "taa-rejection-reason", + "taa-motion", + "taa-reprojected-uv", + "taa-temporal-confidence", + ] { + let path = directory.join(format!("{name}.png")); + let bytes = std::fs::read(&path) + .unwrap_or_else(|error| panic!("missing diagnostic {path:?}: {error}")); + assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n")); + assert!( + bytes.len() > 64, + "diagnostic {path:?} is unexpectedly empty" + ); + if name == "taa-rejection-reason" { + let pixels = image::open(&path).unwrap().to_rgb8(); + let first = pixels.get_pixel(0, 0); + assert!( + pixels.pixels().any(|pixel| pixel != first), + "rejection map must distinguish at least two per-pixel outcomes" + ); + let palette = [ + [64u8, 64, 64], + [255, 13, 5], + [0, 230, 255], + [255, 0, 204], + [255, 191, 0], + [13, 64, 255], + [13, 166, 26], + ]; + let mut counts = [0usize; 7]; + for pixel in pixels.pixels() { + let nearest = palette + .iter() + .enumerate() + .min_by_key(|(_, color)| { + (0..3) + .map(|channel| { + let delta = i32::from(pixel[channel]) - i32::from(color[channel]); + delta * delta + }) + .sum::() + }) + .unwrap() + .0; + counts[nearest] += 1; + } + let pixel_count = u64::from(pixels.width()) * u64::from(pixels.height()); + let non_accepted = 1.0 - counts[6] as f64 / pixel_count as f64; + eprintln!( + "temporal-corpus rejection_ratio={:.4}% reasons={counts:?}", + non_accepted * 100.0 + ); + assert!( + counts[1] > 0, + "motion capture must expose off-screen history" + ); + assert!( + counts[2] > 0, + "transparent coverage must appear as a reactive rejection" + ); + assert!( + counts[4] > 0, + "motion capture must exercise history clamping" + ); + } + } + assert!(eng.renderer.pending_quality_capture_dir.is_none()); + let paths = eng.renderer.quality_runtime_paths_json(); + assert!(paths.contains("\"diagnostic_persistent_bytes\":0")); + assert!(paths.contains("\"diagnostic_capture_passes\":1")); + assert!(paths.contains("\"diagnostic_resources_live\":false")); + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept TAA diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } +} + +#[test] +fn camera_motion_sequence_bounds_ghosting_flicker_and_cut_residue() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + configure_taa_motion_corpus(&mut eng.renderer); + + let draw_pose = |eng: &mut EngineState, angle: f32, fov: f32| { + let radius = 7.2; + let r = &mut eng.renderer; + r.set_clear_color(8.0, 10.0, 18.0, 255.0); + r.begin_mode_3d( + angle.sin() * radius, + 2.6, + angle.cos() * radius, + 0.0, + 0.7, + 0.0, + 0.0, + 1.0, + 0.0, + fov, + 0.0, + ); + r.add_directional_light(-0.4, -1.0, -0.2, 1.0, 0.95, 0.88, 2.0); + r.add_point_light(0.0, 2.5, -1.5, 8.0, 1.0, 0.15, 0.05, 7.0); + r.draw_plane(0.0, 0.0, 0.0, 14.0, 14.0, 35.0, 42.0, 55.0, 255.0); + r.draw_cube(-1.5, 0.9, 0.2, 1.8, 1.8, 1.8, 240.0, 42.0, 35.0, 255.0); + r.draw_cube(1.2, 1.5, -1.2, 1.2, 3.0, 1.2, 25.0, 210.0, 245.0, 255.0); + r.draw_sphere(0.3, 0.8, 1.5, 0.8, 245.0, 220.0, 35.0, 255.0); + }; + let advance = |eng: &mut EngineState, frames: u32, angle: f32, fov: f32| { + for _ in 0..frames { + eng.begin_frame(); + draw_pose(eng, angle, fov); + eng.end_frame(); + } + }; + let capture = |eng: &mut EngineState, angle: f32, fov: f32| { + render(eng, 1, |eng| draw_pose(eng, angle, fov)).2 + }; + + let old_angle = -0.55; + let new_angle = 0.65; + eng.renderer.reset_temporal_history(); + let fresh_new_pose = capture(&mut eng, new_angle, 58.0); + advance(&mut eng, 8, old_angle, 42.0); + eng.renderer.reset_temporal_history(); + let cut_new_pose = capture(&mut eng, new_angle, 58.0); + let cut_metrics = calculate_diff_metrics(&fresh_new_pose, &cut_new_pose, W, H); + assert_eq!( + cut_metrics.max_diff, 0, + "an explicit camera cut retained pixels from the prior camera" + ); + + advance(&mut eng, 8, old_angle, 42.0); + let mut fast_rotation = Vec::new(); + for _ in 0..24 { + fast_rotation.push(capture(&mut eng, new_angle, 42.0)); + } + let stable_reference = average_rgba(&fast_rotation[8..]); + let convergence = fast_rotation[..8] + .iter() + .map(|frame| calculate_diff_metrics(&stable_reference, frame, W, H)) + .collect::>(); + let severe_trails = fast_rotation[..8] + .iter() + .map(|frame| severe_pixel_fraction(&stable_reference, frame)) + .collect::>(); + for (index, metrics) in convergence.iter().enumerate() { + eprintln!( + "temporal-corpus fast-rotation frame={index} mean_rgb={:.4} \ + outliers={:.4}% severe_trail={:.4}% ssim={:.6}", + metrics.mean_rgb, + metrics.outlier_pixel_fraction * 100.0, + severe_trails[index] * 100.0, + metrics.ssim, + ); + } + assert!( + convergence[4].mean_rgb <= convergence[0].mean_rgb * 0.6 + 0.25, + "fast-rotation history did not converge within four recovery frames" + ); + assert!( + convergence[4].outlier_pixel_fraction <= 0.02, + "fast-rotation ghost trail exceeded 2% of pixels after four frames" + ); + let ghost_trail_frames = severe_trails + .iter() + .enumerate() + .find(|(index, _)| { + severe_trails[*index..] + .iter() + .all(|fraction| *fraction <= 0.005) + }) + .map(|(index, _)| index) + .unwrap_or(severe_trails.len()); + eprintln!("temporal-corpus ghost_trail_frames={ghost_trail_frames}"); + assert!( + ghost_trail_frames <= 4, + "severe fast-rotation trail persisted beyond four frames" + ); + let stable_metrics = fast_rotation[8..] + .iter() + .map(|frame| calculate_diff_metrics(&stable_reference, frame, W, H)) + .collect::>(); + let stable_mean = stable_metrics + .iter() + .map(|metrics| metrics.mean_rgb) + .sum::() + / stable_metrics.len() as f64; + eprintln!("temporal-corpus stable-reference mean_flicker={stable_mean:.4}"); + assert!( + stable_mean <= 2.0, + "settled TAA jitter cycle did not converge to a stable estimate" + ); + + eng.renderer.reset_temporal_history(); + advance(&mut eng, 8, 0.0, 50.0); + let mut slow_pan = Vec::new(); + for frame in 0..8 { + slow_pan.push(capture(&mut eng, frame as f32 * 0.0025, 50.0)); + } + let slow_deltas = slow_pan + .windows(2) + .map(|pair| calculate_diff_metrics(&pair[0], &pair[1], W, H)) + .collect::>(); + let mean_flicker = slow_deltas + .iter() + .map(|metrics| metrics.mean_rgb) + .sum::() + / slow_deltas.len() as f64; + let max_outliers = slow_deltas + .iter() + .map(|metrics| metrics.outlier_pixel_fraction) + .fold(0.0f64, f64::max); + eprintln!( + "temporal-corpus slow-pan mean_flicker={mean_flicker:.4} \ + max_outliers={:.4}%", + max_outliers * 100.0, + ); + assert!( + mean_flicker <= 4.0, + "slow-pan temporal flicker is unbounded" + ); + assert!( + max_outliers <= 0.03, + "slow-pan coherent flicker exceeded 3% of pixels" + ); +} + +#[test] +fn retained_rigid_and_reactive_motion_sequences_bound_trails() { + fn transform(x: f32, angle: f32) -> [[f32; 4]; 4] { + let (sin, cos) = angle.sin_cos(); + [ + [cos, 0.0, -sin, 0.0], + [0.0, 1.0, 0.0, 0.0], + [sin, 0.0, cos, 0.0], + [x, 1.0, 0.0, 1.0], + ] + } + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + configure_taa_motion_corpus(&mut eng.renderer); + eng.renderer.set_transparency_composition_mode(0); + + let (vertices, indices) = cube_verts(0.9, [0.95, 0.08, 0.04, 1.0]); + let node = eng.scene.create_node(); + eng.scene.update_geometry(node, vertices, indices); + eng.scene.set_material_pbr(node, 0.2, 0.35); + eng.scene.set_material_color(node, 0.95, 0.08, 0.04, 1.0); + + let draw_scene = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(7.0, 10.0, 20.0, 255.0); + r.begin_mode_3d(0.0, 2.2, 6.5, 0.0, 0.8, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.add_directional_light(-0.4, -1.0, -0.25, 1.0, 0.95, 0.88, 2.2); + r.draw_plane(0.0, 0.0, 0.0, 12.0, 12.0, 30.0, 38.0, 52.0, 255.0); + r.draw_cube(0.0, 1.1, -1.8, 5.0, 3.2, 0.35, 30.0, 170.0, 235.0, 255.0); + }; + let advance = |eng: &mut EngineState, frames: u32| { + for _ in 0..frames { + eng.begin_frame(); + draw_scene(eng); + eng.end_frame(); + } + }; + let capture = |eng: &mut EngineState| render(eng, 1, draw_scene).2; + let run_motion = |eng: &mut EngineState, node: f64| { + eng.scene.set_transform(node, transform(-1.6, -0.7)); + eng.renderer.reset_temporal_history(); + advance(eng, 8); + let old_pose = capture(eng); + eng.scene.set_transform(node, transform(1.6, 0.9)); + let mut frames = Vec::new(); + for _ in 0..24 { + frames.push(capture(eng)); + } + (old_pose, frames) + }; + + let (opaque_old, opaque) = run_motion(&mut eng, node); + evaluate_motion_recovery("rigid-opaque", &opaque_old, &opaque); + + eng.scene + .set_material_gltf_alpha(node, MaterialAlphaMode::Blend, 0.0, false); + eng.scene.set_material_color(node, 0.1, 0.8, 1.0, 0.95); + let (reactive_old, reactive) = run_motion(&mut eng, node); + evaluate_motion_recovery("rigid-reactive", &reactive_old, &reactive); + assert!( + eng.renderer + .quality_runtime_paths_json() + .contains("\"temporal_reactive\":{\"enabled\":true,\"active\":true"), + "transparent retained motion did not select reactive TAA coverage" + ); + let paths: serde_json::Value = serde_json::from_str(&eng.renderer.quality_runtime_paths_json()) + .expect("reactive motion telemetry is valid JSON"); + assert_eq!( + paths["steady_state_resources"]["bind_group_creations"]["sites"]["taa_reactive"].as_u64(), + Some(0), + "warmed reactive TAA must reuse its plan/generation/history-specific bind group" + ); + assert_eq!( + paths["steady_state_resources"]["graph_compiles"].as_u64(), + Some(0), + "reactive topology must not recompile after warm-up" + ); + assert_eq!( + paths["steady_state_resources"]["transient_physical_creations"]["textures"].as_u64(), + Some(0), + "reactive graph textures must be reused after warm-up" + ); + + eng.renderer.resize(320, 192, 320, 192); + advance(&mut eng, 3); + eng.renderer.resize(W, H, W, H); + advance(&mut eng, 4); + let resized_paths: serde_json::Value = + serde_json::from_str(&eng.renderer.quality_runtime_paths_json()) + .expect("resized reactive motion telemetry is valid JSON"); + assert_eq!( + resized_paths["steady_state_resources"]["bind_group_creations"]["sites"]["taa_reactive"] + .as_u64(), + Some(0), + "reactive TAA must rebuild for resize generation then return to zero churn" + ); + assert_eq!( + resized_paths["steady_state_resources"]["graph_compiles"].as_u64(), + Some(0), + "settled resize generation must return to cached topology" + ); + assert_eq!( + resized_paths["steady_state_resources"]["transient_physical_creations"]["textures"] + .as_u64(), + Some(0), + "settled resize generation must reuse graph textures" + ); +} + +#[test] +fn immediate_primitive_motion_writes_velocity_and_bounds_trails() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + configure_taa_motion_corpus(&mut eng.renderer); + + let draw_pose = |eng: &mut EngineState, moving_x: f64| { + let r = &mut eng.renderer; + r.set_clear_color(7.0, 10.0, 20.0, 255.0); + r.begin_mode_3d(0.0, 2.2, 6.5, 0.0, 0.8, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.add_directional_light(-0.4, -1.0, -0.25, 1.0, 0.95, 0.88, 2.2); + r.draw_plane(0.0, 0.0, 0.0, 12.0, 12.0, 30.0, 38.0, 52.0, 255.0); + r.draw_cube(0.0, 1.1, -1.8, 5.0, 3.2, 0.35, 30.0, 170.0, 235.0, 255.0); + r.draw_cube( + moving_x, 0.9, 0.35, 1.35, 1.8, 1.35, 245.0, 45.0, 25.0, 255.0, + ); + }; + let capture_pose = |eng: &mut EngineState, x| render(eng, 1, |eng| draw_pose(eng, x)).2; + + eng.renderer.reset_temporal_history(); + for _ in 0..8 { + capture_pose(&mut eng, -1.6); + } + let old_pose = capture_pose(&mut eng, -1.6); + let directory = + std::env::temp_dir().join(format!("bloom-immediate-motion-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + let mut frames = Vec::new(); + for _ in 0..24 { + frames.push(capture_pose(&mut eng, 1.6)); + } + + let motion = image::open(directory.join("taa-motion.png")) + .expect("immediate primitive motion did not emit the TAA velocity map") + .to_rgb8(); + let moving_pixels = motion.pixels().filter(|pixel| pixel[2] > 8).count(); + eprintln!("temporal-corpus immediate moving_pixels={moving_pixels}"); + assert!( + moving_pixels >= 300, + "moving immediate cube wrote no meaningful velocity" + ); + evaluate_motion_recovery("immediate-cube", &old_pose, &frames); + + let paths: serde_json::Value = + serde_json::from_str(&eng.renderer.quality_runtime_paths_json()).unwrap(); + assert_eq!( + paths["temporal_history"]["immediate_motion_entries"].as_u64(), + Some(3) + ); + assert_eq!( + paths["temporal_history"]["immediate_motion_gpu_bytes"].as_u64(), + Some(0) + ); + assert_eq!( + paths["temporal_history"]["immediate_motion_passes"].as_u64(), + Some(0) + ); + let cpu_capacity = paths["temporal_history"]["immediate_motion_cpu_capacity_bytes"] + .as_u64() + .unwrap(); + eprintln!("temporal-corpus immediate history_cpu_capacity={cpu_capacity} bytes"); + assert!( + cpu_capacity <= 4096, + "three immediate primitives retained excessive history: {cpu_capacity} bytes" + ); + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept immediate motion diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } +} + +#[test] +fn instanced_particle_reactive_opt_in_bounds_trails_without_taxing_opt_out() { + const HANDLE: u64 = 0x7AA5_C011; + const PARTICLE_SHADER_PREFIX: &str = r#" +#include "material_abi.wgsl" + +struct ParticleInput { + @location(0) position: vec3, + @location(1) normal: vec3, + @location(2) color: vec4, + @location(3) uv: vec2, + @location(4) joints: vec4, + @location(5) weights: vec4, + @location(6) tangent: vec4, + @location(7) instance_pos: vec3, + @location(8) instance_rot_y: f32, + @location(9) instance_scale: f32, + @location(10) instance_tint: vec4, +}; + +struct VsOut { + @builtin(position) clip_position: vec4, + @location(0) tint: vec4, +}; + +@vertex +fn vs_main(in: ParticleInput) -> VsOut { + var out: VsOut; + let world = in.position * in.instance_scale + in.instance_pos; + out.clip_position = view.view_proj * vec4(world, 1.0); + out.tint = in.color * in.instance_tint; + return out; +} + +fn particle_color(in: VsOut) -> vec4 { + return vec4(in.tint.rgb * 3.0, in.tint.a); +} + +@fragment +fn fs_main(in: VsOut) -> TranslucentOut { + var out: TranslucentOut; + out.hdr = particle_color(in); + return out; +} +"#; + const PARTICLE_REACTIVE_SUFFIX: &str = r#" + +@fragment +fn fs_reactive(in: VsOut) -> ReactiveTranslucentOut { + var out: ReactiveTranslucentOut; + out.hdr = particle_color(in); + out.reactive = in.tint.a; + return out; +} +"#; + + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + configure_taa_motion_corpus(&mut eng.renderer); + eng.renderer.set_transparency_composition_mode(0); + + let vertex = |position| Vertex3D { + position, + normal: [0.0, 0.0, 1.0], + color: [1.0, 0.13, 0.025, 1.0], + uv: [0.0; 2], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [1.0, 0.0, 0.0, 1.0], + }; + assert!(eng.renderer.cache_model_if_static( + HANDLE, + &[MeshData { + vertices: vec![ + vertex([-0.55, -0.55, 0.0]), + vertex([0.55, -0.55, 0.0]), + vertex([0.55, 0.55, 0.0]), + vertex([-0.55, 0.55, 0.0]), + ], + secondary_tex_coords: None, + indices: vec![0, 1, 2, 0, 2, 3], + texture_idx: None, + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.0, + roughness_factor: 1.0, + emissive_factor: [0.0; 3], + alpha_mode: MaterialAlphaMode::Blend, + alpha_cutoff: 0.0, + alpha_coverage_mips: false, + double_sided: true, + transmission: Default::default(), + layered_pbr: Default::default(), + }] + )); + + let ordinary_material = eng + .renderer + .compile_material_instanced_bucket(PARTICLE_SHADER_PREFIX, 2, false) + .expect("ordinary instanced particle material compiles"); + let reactive_source = format!("{PARTICLE_SHADER_PREFIX}{PARTICLE_REACTIVE_SUFFIX}"); + let reactive_material = eng + .renderer + .compile_material_instanced_bucket(&reactive_source, 2, false) + .expect("reactive instanced particle material compiles"); + let old_buffer = eng + .renderer + .create_instance_buffer(&[-1.35, 1.15, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0], 1); + let new_buffer = eng + .renderer + .create_instance_buffer(&[1.35, 1.15, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0], 1); + + let draw_scene = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(7.0, 10.0, 20.0, 255.0); + r.begin_mode_3d(0.0, 2.0, 6.5, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.draw_plane(0.0, 0.0, 0.0, 12.0, 12.0, 30.0, 38.0, 52.0, 255.0); + r.draw_cube(0.0, 1.2, -1.7, 5.0, 3.2, 0.3, 30.0, 150.0, 220.0, 255.0); + }; + let capture_particle = |eng: &mut EngineState, material, instance_buffer| -> Vec { + render(eng, 1, |eng| { + draw_scene(eng); + eng.renderer + .submit_material_draw_instanced(material, HANDLE, 0, instance_buffer, 1); + }) + .2 + }; + let mut run_sequence = |material, label: &str, capture_reactive: bool| { + eng.renderer.reset_temporal_history(); + for _ in 0..8 { + capture_particle(&mut eng, material, old_buffer); + } + let old_pose = capture_particle(&mut eng, material, old_buffer); + let directory = std::env::temp_dir().join(format!("bloom-{label}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + if capture_reactive { + eng.renderer.pending_quality_capture_dir = + Some(directory.to_string_lossy().into_owned()); + } + let mut frames = Vec::new(); + for _ in 0..24 { + frames.push(capture_particle(&mut eng, material, new_buffer)); + } + evaluate_motion_recovery(label, &old_pose, &frames); + let paths: serde_json::Value = + serde_json::from_str(&eng.renderer.quality_runtime_paths_json()).unwrap(); + assert_eq!( + paths["temporal_reactive"]["active"].as_bool(), + Some(capture_reactive), + "{label} selected the wrong temporal-reactive topology" + ); + if capture_reactive { + let reasons = image::open(directory.join("taa-rejection-reason.png")) + .expect("reactive particle capture did not emit rejection reasons") + .to_rgb8(); + let reactive_pixels = reasons + .pixels() + .filter(|pixel| { + i32::from(pixel[0]).pow(2) + + (i32::from(pixel[1]) - 230).pow(2) + + (i32::from(pixel[2]) - 255).pow(2) + < 80_i32.pow(2) + }) + .count(); + eprintln!("temporal-corpus {label} reactive_pixels={reactive_pixels}"); + assert!( + reactive_pixels >= 100, + "authored particle coverage did not reach TAA rejection" + ); + } + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept {label} diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } + }; + + run_sequence(reactive_material, "reactive-particle", true); + run_sequence(ordinary_material, "ordinary-particle-control", false); + eng.renderer.destroy_instance_buffer(old_buffer); + eng.renderer.destroy_instance_buffer(new_buffer); +} + +#[test] +fn legacy_skinned_motion_uses_staged_previous_palette_and_bounds_trails() { + const PALETTE_KEY: u64 = 0x7AA5_2001; + const IDENTITY: [[f32; 4]; 4] = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + + fn palette(bend: f32) -> [[[f32; 4]; 4]; 2] { + let (sin, cos) = bend.sin_cos(); + [ + IDENTITY, + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, cos, sin, 0.0], + [0.0, -sin, cos, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + ] + } + + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + configure_taa_motion_corpus(&mut eng.renderer); + + let (mut vertices, indices) = cube_verts(0.9, [0.95, 0.12, 0.035, 1.0]); + for vertex in &mut vertices { + let upper = vertex.position[1] > 0.0; + vertex.joints = if upper { + [1.0, 0.0, 0.0, 0.0] + } else { + [0.0; 4] + }; + vertex.weights = [1.0, 0.0, 0.0, 0.0]; + } + + let draw_scene = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(7.0, 10.0, 20.0, 255.0); + r.begin_mode_3d(0.0, 2.2, 6.5, 0.0, 0.8, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.add_directional_light(-0.4, -1.0, -0.25, 1.0, 0.95, 0.88, 2.2); + r.draw_plane(0.0, 0.0, 0.0, 12.0, 12.0, 30.0, 38.0, 52.0, 255.0); + r.draw_cube(0.0, 1.1, -1.8, 5.0, 3.2, 0.35, 30.0, 170.0, 235.0, 255.0); + }; + let capture_pose = |eng: &mut EngineState, x: f32, bend: f32, facing: f32| { + render(eng, 1, |eng| { + draw_scene(eng); + let (rot_sin, rot_cos) = facing.sin_cos(); + eng.renderer.set_joint_matrices_scaled( + PALETTE_KEY, + &palette(bend), + 1.0, + [x, 1.0, 0.0], + rot_sin, + rot_cos, + ); + eng.renderer + .draw_model_mesh(&vertices, &indices, [0.0; 3], 1.0); + }) + .2 + }; + + eng.renderer.reset_temporal_history(); + for _ in 0..8 { + capture_pose(&mut eng, -1.5, -0.55, -0.45); + } + let old_pose = capture_pose(&mut eng, -1.5, -0.55, -0.45); + let directory = + std::env::temp_dir().join(format!("bloom-legacy-skin-motion-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + let mut frames = Vec::new(); + for _ in 0..24 { + frames.push(capture_pose(&mut eng, 1.5, 0.75, 0.6)); + } + + let motion = image::open(directory.join("taa-motion.png")) + .expect("legacy skinned capture did not emit the TAA velocity map") + .to_rgb8(); + let moving_pixels = motion.pixels().filter(|pixel| pixel[2] > 8).count(); + eprintln!("temporal-corpus legacy-skinned moving_pixels={moving_pixels}"); + assert!( + moving_pixels >= 250, + "legacy skinned pose wrote no meaningful velocity" + ); + evaluate_motion_recovery("legacy-skinned", &old_pose, &frames); + + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept legacy-skinned diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } +} + +#[test] +fn cached_skinned_motion_sequence_bounds_animation_trails() { + const HANDLE: u64 = 0x7AA5_0001; + const PALETTE_KEY: u64 = 0x7AA5_1001; + const IDENTITY: [[f32; 4]; 4] = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + + fn palette(bend: f32) -> [[[f32; 4]; 4]; 2] { + let (sin, cos) = bend.sin_cos(); + [ + IDENTITY, + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, cos, sin, 0.0], + [0.0, -sin, cos, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + ] + } + + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + configure_taa_motion_corpus(&mut eng.renderer); + + let (mut vertices, indices) = cube_verts(0.9, [0.95, 0.12, 0.035, 1.0]); + for vertex in &mut vertices { + let upper = vertex.position[1] > 0.0; + vertex.joints = if upper { + [1.0, 0.0, 0.0, 0.0] + } else { + [0.0; 4] + }; + vertex.weights = [1.0, 0.0, 0.0, 0.0]; + } + assert!(eng.renderer.cache_model_if_static( + HANDLE, + &[MeshData { + vertices, + secondary_tex_coords: None, + indices, + texture_idx: None, + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.15, + roughness_factor: 0.32, + emissive_factor: [0.0; 3], + alpha_mode: MaterialAlphaMode::Opaque, + alpha_cutoff: 0.0, + alpha_coverage_mips: false, + double_sided: false, + transmission: Default::default(), + layered_pbr: Default::default(), + }] + )); + assert!( + eng.renderer.is_model_skinned(HANDLE), + "weighted temporal test mesh did not select the cached skinned path" + ); + + let draw_scene = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(7.0, 10.0, 20.0, 255.0); + r.begin_mode_3d(0.0, 2.2, 6.5, 0.0, 0.8, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.add_directional_light(-0.4, -1.0, -0.25, 1.0, 0.95, 0.88, 2.2); + r.draw_plane(0.0, 0.0, 0.0, 12.0, 12.0, 30.0, 38.0, 52.0, 255.0); + r.draw_cube(0.0, 1.1, -1.8, 5.0, 3.2, 0.35, 30.0, 170.0, 235.0, 255.0); + }; + let draw_pose = |eng: &mut EngineState, x: f32, bend: f32, facing: f32| { + draw_scene(eng); + let (rot_sin, rot_cos) = facing.sin_cos(); + eng.renderer.set_joint_matrices_scaled( + PALETTE_KEY, + &palette(bend), + 1.0, + [x, 1.0, 0.0], + rot_sin, + rot_cos, + ); + eng.renderer + .draw_model_cached_skinned(HANDLE, [0.0; 3], 1.0, [1.0; 4]); + }; + let capture_pose = |eng: &mut EngineState, x: f32, bend: f32, facing: f32| { + render(eng, 1, |eng| draw_pose(eng, x, bend, facing)).2 + }; + + eng.renderer.reset_temporal_history(); + for _ in 0..8 { + capture_pose(&mut eng, -1.5, -0.55, -0.45); + } + let old_pose = capture_pose(&mut eng, -1.5, -0.55, -0.45); + let mut frames = Vec::new(); + for _ in 0..24 { + frames.push(capture_pose(&mut eng, 1.5, 0.75, 0.6)); + } + evaluate_motion_recovery("cached-skinned", &old_pose, &frames); +} + +#[test] +fn cached_alpha_tested_card_motion_writes_velocity_and_bounds_trails() { + const HANDLE: u64 = 0x7AA5_F011; + const TEX_SIZE: u32 = 64; + + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + configure_taa_motion_corpus(&mut eng.renderer); + + let mut pixels = Vec::with_capacity((TEX_SIZE * TEX_SIZE * 4) as usize); + for y in 0..TEX_SIZE { + for x in 0..TEX_SIZE { + let dx = (x as f32 + 0.5) / TEX_SIZE as f32 * 2.0 - 1.0; + let dy = (y as f32 + 0.5) / TEX_SIZE as f32 * 2.0 - 1.0; + let ellipse = dx * dx / 0.82f32.powi(2) + dy * dy / 0.96f32.powi(2) < 1.0; + let serrated_edge = ((y / 4 + x / 7) & 1) == 0 || dx.abs() < 0.68; + let vein_gap = (x as i32 - TEX_SIZE as i32 / 2).abs() == 5 && y % 9 < 6; + let opaque = ellipse && serrated_edge && !vein_gap; + pixels.extend_from_slice(if opaque { + &[30, 205, 55, 255] + } else { + &[0, 0, 0, 0] + }); + } + } + let texture = eng.renderer.register_texture_kind_with_alpha_coverage( + TEX_SIZE, + TEX_SIZE, + &pixels, + false, + Some(0.5), + ); + let vertex = |position, uv| Vertex3D { + position, + normal: [0.0, 0.0, 1.0], + color: [1.0; 4], + uv, + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [1.0, 0.0, 0.0, 1.0], + }; + let vertices = vec![ + vertex([-1.0, 0.0, 0.0], [0.0, 1.0]), + vertex([1.0, 0.0, 0.0], [1.0, 1.0]), + vertex([1.0, 2.8, 0.0], [1.0, 0.0]), + vertex([-1.0, 2.8, 0.0], [0.0, 0.0]), + ]; + assert!(eng.renderer.cache_model_if_static( + HANDLE, + &[MeshData { + vertices, + secondary_tex_coords: None, + indices: vec![0, 1, 2, 0, 2, 3], + texture_idx: Some(texture), + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.0, + roughness_factor: 0.72, + emissive_factor: [0.0; 3], + alpha_mode: MaterialAlphaMode::Mask, + alpha_cutoff: 0.5, + alpha_coverage_mips: true, + double_sided: true, + transmission: Default::default(), + layered_pbr: Default::default(), + }] + )); + + let draw_scene = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(8.0, 12.0, 25.0, 255.0); + r.begin_mode_3d(0.0, 2.2, 6.5, 0.0, 1.3, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.add_directional_light(-0.35, -1.0, -0.2, 0.9, 1.0, 0.8, 2.0); + r.draw_plane(0.0, 0.0, 0.0, 12.0, 12.0, 38.0, 45.0, 55.0, 255.0); + r.draw_cube(0.0, 1.5, -1.1, 6.0, 3.4, 0.25, 35.0, 80.0, 125.0, 255.0); + }; + let capture_pose = |eng: &mut EngineState, x: f32, angle: f32| { + render(eng, 1, |eng| { + draw_scene(eng); + eng.renderer + .draw_model_cached_rotated(HANDLE, [x, 0.0, 0.0], 1.0, angle, [1.0; 4]); + }) + .2 + }; + + eng.renderer.reset_temporal_history(); + for _ in 0..8 { + capture_pose(&mut eng, -1.35, -0.35); + } + let old_pose = capture_pose(&mut eng, -1.35, -0.35); + let directory = + std::env::temp_dir().join(format!("bloom-foliage-motion-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + let mut frames = Vec::new(); + for _ in 0..24 { + frames.push(capture_pose(&mut eng, 1.35, 0.5)); + } + + let motion = image::open(directory.join("taa-motion.png")) + .expect("alpha-tested motion capture did not emit the TAA velocity map") + .to_rgb8(); + let moving_pixels = motion.pixels().filter(|pixel| pixel[2] > 8).count(); + eprintln!("temporal-corpus alpha-tested moving_pixels={moving_pixels}"); + assert!( + moving_pixels >= 250, + "cached alpha-tested object motion wrote no meaningful velocity" + ); + evaluate_motion_recovery("alpha-tested-card", &old_pose, &frames); + + let paths = eng.renderer.quality_runtime_paths_json(); + assert!(paths.contains("\"cached_model_motion_entries\":1")); + assert!(paths.contains("\"cached_model_motion_gpu_bytes\":0")); + assert!(paths.contains("\"cached_model_motion_passes\":0")); + let paths: serde_json::Value = serde_json::from_str(&paths).unwrap(); + let cpu_capacity = paths["temporal_history"]["cached_model_motion_cpu_capacity_bytes"] + .as_u64() + .unwrap(); + eprintln!("temporal-corpus cached-model history_cpu_capacity={cpu_capacity} bytes"); + assert!( + cpu_capacity <= 1024, + "one cached instance retained excessive transform history: {cpu_capacity} bytes" + ); + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept alpha-tested motion diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } +} + +#[test] +fn emissive_light_switches_converge_without_radiance_trails() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + configure_taa_motion_corpus(&mut eng.renderer); + + let (vertices, indices) = cube_verts(0.65, [0.18, 0.035, 0.01, 1.0]); + let node = eng.scene.create_node(); + eng.scene.update_geometry(node, vertices, indices); + eng.scene.set_transform( + node, + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 1.0, 0.0, 1.0], + ], + ); + eng.scene.set_material_pbr(node, 0.24, 0.0); + + let draw_state = |eng: &mut EngineState, enabled: bool| { + eng.scene.set_material_emissive_factor( + node, + if enabled { 7.0 } else { 0.0 }, + if enabled { 0.8 } else { 0.0 }, + if enabled { 0.08 } else { 0.0 }, + ); + let r = &mut eng.renderer; + r.set_clear_color(5.0, 7.0, 14.0, 255.0); + r.begin_mode_3d(4.2, 2.8, 6.2, 0.0, 0.8, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.add_directional_light(-0.4, -1.0, -0.25, 0.55, 0.65, 0.9, 0.55); + if enabled { + r.add_point_light(0.0, 1.25, 0.2, 4.5, 1.0, 0.11, 0.02, 8.0); + } + r.draw_plane(0.0, 0.0, 0.0, 12.0, 12.0, 30.0, 38.0, 52.0, 255.0); + r.draw_cube(-1.4, 0.65, 0.0, 0.8, 1.3, 0.8, 105.0, 115.0, 135.0, 255.0); + r.draw_cube(1.4, 0.65, 0.0, 0.8, 1.3, 0.8, 105.0, 115.0, 135.0, 255.0); + }; + let capture_state = + |eng: &mut EngineState, enabled| render(eng, 1, |eng| draw_state(eng, enabled)).2; + let run_switch = |eng: &mut EngineState, from: bool, to: bool| { + eng.renderer.reset_temporal_history(); + for _ in 0..8 { + capture_state(eng, from); + } + let old_state = capture_state(eng, from); + let mut frames = Vec::new(); + for _ in 0..24 { + frames.push(capture_state(eng, to)); + } + (old_state, frames) + }; + + let (off_state, on_frames) = run_switch(&mut eng, false, true); + evaluate_motion_recovery("emissive-on", &off_state, &on_frames); + let (on_state, off_frames) = run_switch(&mut eng, true, false); + evaluate_motion_recovery("emissive-off", &on_state, &off_frames); +} + +#[test] +fn render_scale_and_resize_steps_seed_without_prior_frame_residue() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + configure_taa_motion_corpus(&mut eng.renderer); + + let draw_scene = |eng: &mut EngineState| { + let r = &mut eng.renderer; + r.set_clear_color(8.0, 12.0, 24.0, 255.0); + r.begin_mode_3d(4.0, 2.8, 6.0, 0.0, 0.8, 0.0, 0.0, 1.0, 0.0, 49.0, 0.0); + r.add_directional_light(-0.5, -1.0, -0.3, 1.0, 0.9, 0.75, 2.0); + r.draw_plane(0.0, 0.0, 0.0, 12.0, 12.0, 35.0, 42.0, 58.0, 255.0); + for x in -2..=2 { + r.draw_cube( + x as f64 * 0.75, + 0.45, + (x & 1) as f64 * 0.45, + 0.42, + 0.9, + 0.42, + 210.0, + 55.0 + (x + 2) as f64 * 35.0, + 35.0, + 255.0, + ); + } + }; + let advance = |eng: &mut EngineState, frames: u32| { + for _ in 0..frames { + eng.begin_frame(); + draw_scene(eng); + eng.end_frame(); + } + }; + let capture = |eng: &mut EngineState| render(eng, 1, draw_scene); + + eng.renderer.set_render_scale(0.5); + eng.renderer.reset_temporal_history(); + let (_, _, fresh_half_scale) = capture(&mut eng); + eng.renderer.set_render_scale(1.0); + advance(&mut eng, 8); + eng.renderer.set_render_scale(0.5); + let (_, _, stepped_half_scale) = capture(&mut eng); + let scale_metrics = calculate_diff_metrics(&fresh_half_scale, &stepped_half_scale, W, H); + assert_eq!( + scale_metrics.max_diff, 0, + "render-scale step blended pixels from the incompatible full-scale history" + ); + + eng.renderer.set_render_scale(1.0); + eng.renderer.reset_temporal_history(); + let (_, _, fresh_native_size) = capture(&mut eng); + eng.renderer.resize(320, 192, 320, 192); + advance(&mut eng, 3); + eng.renderer.resize(W, H, W, H); + let (width, height, returned_native_size) = capture(&mut eng); + assert_eq!((width, height), (W, H)); + let resize_metrics = calculate_diff_metrics(&fresh_native_size, &returned_native_size, W, H); + eprintln!("temporal-corpus resize metrics={resize_metrics:?}"); + assert!( + resize_metrics.mean_rgb <= 0.5 + && resize_metrics.outlier_pixel_fraction == 0.0 + && resize_metrics.max_diff <= 32, + "window resize restored a coherent image from destroyed prior-size history: \ + {resize_metrics:?}" + ); + eprintln!( + "temporal-corpus scale_step_max={} resize_step_max={}", + scale_metrics.max_diff, resize_metrics.max_diff, + ); +} diff --git a/native/shared/tests/golden_render/transparency.rs b/native/shared/tests/golden_render/transparency.rs new file mode 100644 index 00000000..9c11ea7e --- /dev/null +++ b/native/shared/tests/golden_render/transparency.rs @@ -0,0 +1,1976 @@ +use super::*; + +#[test] +fn layered_pbr_runs_on_opaque_sorted_reactive_and_weighted_paths() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + // This test compares submission paths, not GI history. SceneGraph and + // cached-model visibility changes legitimately select different SSGI + // tracing backends, so remove that unrelated temporal signal. + eng.renderer.set_ssgi_enabled(false); + let (vertices, indices) = cube_verts(0.85, [0.82, 0.18, 0.05, 1.0]); + let layered = MaterialLayeredPbr { + clearcoat_authored: true, + clearcoat_factor: 0.9, + clearcoat_roughness_factor: 0.14, + specular_authored: true, + specular_factor: 0.7, + ior_authored: true, + ior: 1.5, + sheen_authored: true, + sheen_color_factor: [0.24, 0.055, 0.018], + sheen_roughness_factor: 0.42, + anisotropy_authored: true, + anisotropy_strength: 0.68, + anisotropy_rotation: 0.37, + ..Default::default() + }; + let node = eng.scene.create_node(); + eng.scene + .update_geometry(node, vertices.clone(), indices.clone()); + eng.scene.set_material_pbr(node, 0.35, 0.0); + eng.scene.set_material_layered_pbr(node, layered); + + let draw_scene = |eng: &mut EngineState| { + let renderer = &mut eng.renderer; + renderer.set_clear_color(0.02, 0.025, 0.04, 1.0); + renderer.begin_mode_3d(0.0, 0.0, 4.2, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 45.0, 0.0); + renderer.set_ambient_light(45.0, 50.0, 60.0, 0.35); + renderer.set_directional_light(-0.4, 0.75, 0.5, 255.0, 245.0, 225.0, 2.2); + }; + let (_, _, retained_opaque) = render(&mut eng, 2, draw_scene); + + // The cached-model route has separate material/geometry selection logic; + // require it to remain visually equivalent to the retained route. + eng.scene.set_visible(node, false); + const HANDLE: u64 = 0x1340_0001; + assert!(eng.renderer.cache_model_if_static( + HANDLE, + &[MeshData { + vertices, + secondary_tex_coords: None, + indices, + texture_idx: None, + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.0, + roughness_factor: 0.35, + emissive_factor: [0.0; 3], + alpha_mode: MaterialAlphaMode::Opaque, + alpha_cutoff: 0.0, + alpha_coverage_mips: false, + double_sided: false, + transmission: Default::default(), + layered_pbr: layered, + }] + )); + let (_, _, cached_opaque) = render(&mut eng, 2, |eng| { + draw_scene(eng); + eng.renderer + .draw_model_cached(HANDLE, [0.0; 3], 1.0, [1.0; 4]); + }); + let parity = calculate_diff_metrics(&retained_opaque, &cached_opaque, W, H); + assert!( + parity.mean_rgb <= 0.5, + "retained/cached layered-PBR paths diverged: {parity:?}" + ); + + eng.scene.set_visible(node, true); + eng.scene + .set_material_gltf_alpha(node, MaterialAlphaMode::Blend, 0.0, false); + eng.scene.set_material_color(node, 1.0, 1.0, 1.0, 0.62); + eng.renderer.set_transparency_composition_mode(0); + eng.renderer.set_taa_enabled(false); + let (_, _, sorted) = render(&mut eng, 2, draw_scene); + assert_eq!(eng.renderer.active_transparency_composition_mode_code(), 0); + + eng.renderer.set_taa_enabled(true); + let (_, _, reactive) = render(&mut eng, 3, draw_scene); + assert_eq!(eng.renderer.active_transparency_composition_mode_code(), 0); + + eng.renderer.set_transparency_composition_mode(2); + let (_, _, weighted) = render(&mut eng, 3, draw_scene); + assert_eq!(eng.renderer.active_transparency_composition_mode_code(), 1); + + let center = ((H / 2 * W + W / 2) * 4) as usize; + for (label, image) in [ + ("sorted", sorted), + ("reactive", reactive), + ("weighted", weighted), + ] { + let center_rgb = &image[center..center + 3]; + let corner_rgb = &image[..3]; + let contrast = center_rgb + .iter() + .zip(corner_rgb) + .map(|(center, corner)| center.abs_diff(*corner) as u32) + .sum::(); + assert!( + contrast >= 12, + "{label} layered transparency did not contribute at the center: \ + center={center_rgb:?}, corner={corner_rgb:?}" + ); + } + + // Transmission owns a dedicated scene-snapshot composition pass. Verify + // that all current layered lobes remain active there for both submission + // APIs and for the TAA-reactive attachment variant. + eng.renderer.set_transparency_composition_mode(0); + eng.renderer.set_taa_enabled(true); + eng.scene + .set_material_gltf_alpha(node, MaterialAlphaMode::Opaque, 0.0, false); + eng.scene.set_material_color(node, 1.0, 1.0, 1.0, 1.0); + let transmission = MaterialTransmission { + authored: true, + factor: 0.82, + ior_authored: true, + ior: 1.5, + ..Default::default() + }; + eng.scene.set_material_transmission(node, transmission); + let draw_refractive_background = |eng: &mut EngineState| { + draw_scene(eng); + eng.renderer + .draw_cube(0.0, 0.0, -1.7, 2.0, 2.0, 0.25, 40.0, 150.0, 235.0, 255.0); + }; + let (_, _, retained_refractive) = render(&mut eng, 3, draw_refractive_background); + + eng.scene.set_visible(node, false); + let (refractive_vertices, refractive_indices) = cube_verts(0.85, [0.82, 0.18, 0.05, 1.0]); + const REFRACTIVE_HANDLE: u64 = 0x1340_0002; + assert!(eng.renderer.cache_model_if_static( + REFRACTIVE_HANDLE, + &[MeshData { + vertices: refractive_vertices, + secondary_tex_coords: None, + indices: refractive_indices, + texture_idx: None, + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.0, + roughness_factor: 0.35, + emissive_factor: [0.0; 3], + alpha_mode: MaterialAlphaMode::Opaque, + alpha_cutoff: 0.0, + alpha_coverage_mips: false, + double_sided: false, + transmission, + layered_pbr: layered, + }] + )); + let (_, _, cached_refractive) = render(&mut eng, 3, |eng| { + draw_refractive_background(eng); + eng.renderer + .draw_model_cached(REFRACTIVE_HANDLE, [0.0; 3], 1.0, [1.0; 4]); + }); + let refractive_parity = calculate_diff_metrics(&retained_refractive, &cached_refractive, W, H); + assert!( + refractive_parity.mean_rgb <= 1.0 + && refractive_parity.outlier_pixel_fraction <= 0.01 + && refractive_parity.ssim >= 0.98, + "retained/cached layered refraction diverged: {refractive_parity:?}" + ); + + let white_layer_texture = eng + .renderer + .register_texture_kind(2, 2, &[255; 2 * 2 * 4], false); + let anisotropy_texels = [255, 128, 255, 255].repeat(4); + let neutral_anisotropy_texture = + eng.renderer + .register_texture_kind(2, 2, &anisotropy_texels, false); + let mut layered_uv1 = layered; + layered_uv1.clearcoat_texture = Some(MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: Some(white_layer_texture), + transform: MaterialTextureTransform { + tex_coord: 1, + ..Default::default() + }, + }); + layered_uv1.sheen_color_texture = Some(MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: Some(white_layer_texture), + transform: MaterialTextureTransform { + tex_coord: 1, + ..Default::default() + }, + }); + layered_uv1.sheen_roughness_texture = Some(MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: Some(white_layer_texture), + transform: MaterialTextureTransform { + tex_coord: 1, + ..Default::default() + }, + }); + layered_uv1.anisotropy_texture = Some(MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: Some(neutral_anisotropy_texture), + transform: MaterialTextureTransform { + tex_coord: 1, + ..Default::default() + }, + }); + let (uv1_vertices, uv1_indices) = cube_verts(0.85, [0.82, 0.18, 0.05, 1.0]); + let uv1 = vec![[0.5, 0.5]; uv1_vertices.len()]; + const REFRACTIVE_UV1_HANDLE: u64 = 0x1340_0003; + assert!(eng.renderer.cache_model_if_static( + REFRACTIVE_UV1_HANDLE, + &[MeshData { + vertices: uv1_vertices, + secondary_tex_coords: Some(uv1), + indices: uv1_indices, + texture_idx: None, + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.0, + roughness_factor: 0.35, + emissive_factor: [0.0; 3], + alpha_mode: MaterialAlphaMode::Opaque, + alpha_cutoff: 0.0, + alpha_coverage_mips: false, + double_sided: false, + transmission, + layered_pbr: layered_uv1, + }] + )); + let (_, _, cached_refractive_uv1) = render(&mut eng, 3, |eng| { + draw_refractive_background(eng); + eng.renderer + .draw_model_cached(REFRACTIVE_UV1_HANDLE, [0.0; 3], 1.0, [1.0; 4]); + }); + let uv1_parity = calculate_diff_metrics(&cached_refractive, &cached_refractive_uv1, W, H); + assert!( + uv1_parity.mean_rgb <= 1.25 + && uv1_parity.outlier_pixel_fraction <= 0.015 + && uv1_parity.ssim >= 0.97, + "white UV1 layered refraction lost scalar-path image coherence: {uv1_parity:?}" + ); +} + +#[test] +fn anisotropy_follows_explicit_mirrored_tangent_handedness() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + eng.renderer.set_ssgi_enabled(false); + eng.renderer.set_taa_enabled(false); + + let tangented_cube = |handedness: f32| { + let (mut vertices, indices) = cube_verts(0.9, [0.72, 0.72, 0.72, 1.0]); + for face in vertices.chunks_exact_mut(4) { + let normal = face[0].normal; + let tangent = if normal[0].abs() > 0.5 { + [0.0, 0.0, 1.0] + } else { + [1.0, 0.0, 0.0] + }; + for (vertex, uv) in + face.iter_mut() + .zip([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]) + { + vertex.uv = uv; + vertex.tangent = [tangent[0], tangent[1], tangent[2], handedness]; + } + } + (vertices, indices) + }; + let material = |rotation: f32| MaterialLayeredPbr { + anisotropy_authored: true, + anisotropy_strength: 0.92, + anisotropy_rotation: rotation, + ..Default::default() + }; + let draw_scene = |eng: &mut EngineState| { + let renderer = &mut eng.renderer; + renderer.set_clear_color(0.015, 0.02, 0.03, 1.0); + renderer.begin_mode_3d(0.0, 0.2, 4.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 42.0, 0.0); + renderer.set_ambient_light(38.0, 44.0, 55.0, 0.22); + renderer.set_directional_light(-0.7, 0.55, 0.28, 255.0, 244.0, 222.0, 3.4); + }; + + let node = eng.scene.create_node(); + let (positive_vertices, indices) = tangented_cube(1.0); + eng.scene + .update_geometry(node, positive_vertices, indices.clone()); + eng.scene.set_material_color(node, 0.72, 0.72, 0.72, 1.0); + eng.scene.set_material_pbr(node, 0.24, 1.0); + const ROTATION: f32 = 0.61; + eng.scene.set_material_layered_pbr(node, material(ROTATION)); + let (_, _, positive) = render(&mut eng, 2, draw_scene); + + // Mirroring the UV frame flips tangent.w. Negating the authored + // counter-clockwise rotation must recover the same world-space + // anisotropy axis. + let (mirrored_vertices, _) = tangented_cube(-1.0); + eng.scene + .update_geometry(node, mirrored_vertices, indices.clone()); + eng.scene + .set_material_layered_pbr(node, material(-ROTATION)); + let (_, _, mirrored_compensated) = render(&mut eng, 2, draw_scene); + let mirrored_parity = calculate_diff_metrics(&positive, &mirrored_compensated, W, H); + assert!( + mirrored_parity.mean_rgb <= 0.5 + && mirrored_parity.outlier_pixel_fraction <= 0.005 + && mirrored_parity.ssim >= 0.995, + "mirrored tangent handedness changed the world anisotropy axis: {mirrored_parity:?}" + ); + + // Negative control: keeping the rotation sign while flipping tangent.w + // rotates the world-space axis the other way and must be visible. + eng.scene.set_material_layered_pbr(node, material(ROTATION)); + let (_, _, mirrored_uncompensated) = render(&mut eng, 2, draw_scene); + let negative_control = calculate_diff_metrics(&positive, &mirrored_uncompensated, W, H); + assert!( + negative_control.mean_rgb >= 0.05 && negative_control.max_diff >= 1, + "anisotropy tangent negative control was not orientation-sensitive: \ + {negative_control:?}" + ); +} + +#[test] +fn anisotropy_follows_negative_model_scale_handedness() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + eng.renderer.set_ssgi_enabled(false); + eng.renderer.set_taa_enabled(false); + + // Duplicate each triangle with opposite winding. A negative-determinant + // model transform reverses raster winding, so this keeps the test focused + // on the tangent frame rather than the renderer's single-sided cull policy. + let vertices = [ + [-1.0, -1.0, 0.0], + [1.0, -1.0, 0.0], + [1.0, 1.0, 0.0], + [-1.0, 1.0, 0.0], + ] + .into_iter() + .zip([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]) + .map(|(position, uv)| Vertex3D { + position, + normal: [0.0, 0.0, 1.0], + color: [0.72, 0.72, 0.72, 1.0], + uv, + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [1.0, 0.0, 0.0, 1.0], + }) + .collect::>(); + let indices = vec![0, 1, 2, 0, 2, 3, 0, 2, 1, 0, 3, 2]; + let material = |rotation: f32| MaterialLayeredPbr { + anisotropy_authored: true, + anisotropy_strength: 0.92, + anisotropy_rotation: rotation, + ..Default::default() + }; + let transform = |scale_x: f32| { + [ + [scale_x, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + }; + let draw_scene = |eng: &mut EngineState| { + let renderer = &mut eng.renderer; + renderer.set_clear_color(0.015, 0.02, 0.03, 1.0); + renderer.begin_mode_3d(0.0, 0.0, 3.4, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 42.0, 0.0); + renderer.set_ambient_light(32.0, 38.0, 48.0, 0.18); + renderer.set_directional_light(-0.7, 0.55, 0.45, 255.0, 244.0, 222.0, 3.8); + }; + + let node = eng.scene.create_node(); + eng.scene.update_geometry(node, vertices, indices); + eng.scene.set_material_pbr(node, 0.18, 1.0); + const ROTATION: f32 = 0.58; + eng.scene.set_material_layered_pbr(node, material(ROTATION)); + let (_, _, positive) = render(&mut eng, 2, draw_scene); + + // Reflecting the model's X axis flips tangent.xyz and the model + // determinant. Negating the authored rotation recovers the same + // unoriented world-space anisotropy axis only when tangent.w also + // incorporates that determinant sign. + eng.scene.set_transform(node, transform(-1.0)); + eng.scene + .set_material_layered_pbr(node, material(-ROTATION)); + let (_, _, mirrored_compensated) = render(&mut eng, 2, draw_scene); + let mirrored_parity = calculate_diff_metrics(&positive, &mirrored_compensated, W, H); + assert!( + mirrored_parity.mean_rgb <= 0.5 + && mirrored_parity.outlier_pixel_fraction <= 0.005 + && mirrored_parity.ssim >= 0.995, + "negative model scale changed the compensated anisotropy axis: {mirrored_parity:?}" + ); + + // Negative control: without the corresponding rotation sign change the + // mirrored tangent frame must produce a visibly different highlight. + eng.scene.set_material_layered_pbr(node, material(ROTATION)); + let (_, _, mirrored_uncompensated) = render(&mut eng, 2, draw_scene); + let negative_control = calculate_diff_metrics(&positive, &mirrored_uncompensated, W, H); + assert!( + negative_control.mean_rgb >= 0.05 && negative_control.max_diff >= 1, + "negative-scale anisotropy control was not orientation-sensitive: \ + {negative_control:?}" + ); +} + +#[test] +fn weighted_transparency_is_order_independent_for_intersecting_imported_blend() { + fn render_order( + mode: u32, + reverse: bool, + custom_material_z: Option, + taa_enabled: bool, + ) -> Option<(Vec, u32, String)> { + let mut eng = try_engine()?; + eng.renderer.set_transparency_composition_mode(mode); + eng.renderer.set_taa_enabled(taa_enabled); + + let (retained_vertices, retained_indices) = cube_verts(0.95, [0.08, 0.9, 0.16, 0.35]); + let retained = eng.scene.create_node(); + eng.scene + .update_geometry(retained, retained_vertices, retained_indices); + eng.scene + .set_material_gltf_alpha(retained, MaterialAlphaMode::Blend, 0.0, false); + + let (red_vertices, indices) = cube_verts(0.8, [1.0, 0.04, 0.02, 0.55]); + let (blue_vertices, _) = cube_verts(0.8, [0.02, 0.12, 1.0, 0.62]); + let blend_mesh = |vertices| MeshData { + vertices, + secondary_tex_coords: None, + indices: indices.clone(), + texture_idx: None, + normal_texture_idx: None, + metallic_roughness_texture_idx: None, + emissive_texture_idx: None, + occlusion_texture_idx: None, + metallic_factor: 0.0, + roughness_factor: 1.0, + emissive_factor: [0.0; 3], + alpha_mode: MaterialAlphaMode::Blend, + alpha_cutoff: 0.0, + alpha_coverage_mips: false, + double_sided: false, + transmission: Default::default(), + layered_pbr: Default::default(), + }; + const RED_HANDLE: u64 = 0x0170_0001; + const BLUE_HANDLE: u64 = 0x0170_0002; + assert!(eng + .renderer + .cache_model_if_static(RED_HANDLE, &[blend_mesh(red_vertices)])); + assert!(eng + .renderer + .cache_model_if_static(BLUE_HANDLE, &[blend_mesh(blue_vertices)])); + let custom_material = custom_material_z + .map(|_| { + eng.renderer.compile_material_with_options( + r#" +#include "material_abi.wgsl" + +struct VsOut { + @builtin(position) clip_position: vec4, +}; + +@vertex +fn vs_main(in: VertexInput) -> VsOut { + var out: VsOut; + out.clip_position = draw.mvp * vec4(in.position, 1.0); + return out; +} + +@fragment +fn fs_main(_in: VsOut) -> TranslucentOut { + var out: TranslucentOut; + out.hdr = vec4(0.04, 0.8, 0.12, 0.18); + return out; +} +"#, + bloom_shared::renderer::material_pipeline::FragmentProfile::Translucent, + bloom_shared::renderer::material_pipeline::Bucket::Transparent, + false, + false, + ) + }) + .transpose() + .expect("custom sorted transparent material compiles alongside imported OIT"); + + let (_, _, rgba) = render(&mut eng, 2, |eng| { + let renderer = &mut eng.renderer; + renderer.set_clear_color(0.03, 0.035, 0.05, 1.0); + renderer.begin_mode_3d(0.0, 0.0, 4.5, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 45.0, 0.0); + renderer.set_ambient_light(255.0, 255.0, 255.0, 1.0); + let draw = |renderer: &mut Renderer, handle| { + renderer.draw_model_cached(handle, [0.0; 3], 1.0, [1.0; 4]); + }; + if reverse { + draw(renderer, BLUE_HANDLE); + draw(renderer, RED_HANDLE); + } else { + draw(renderer, RED_HANDLE); + draw(renderer, BLUE_HANDLE); + } + // Custom materials retain their sorted pass after the imported OIT + // resolve. Any attachment/layout leakage between the routes turns + // this into a wgpu validation failure or an AB/BA mismatch. + if let (Some(custom_material), Some(custom_material_z)) = + (custom_material, custom_material_z) + { + renderer.submit_material_draw( + custom_material, + RED_HANDLE, + 0, + [0.0, 0.0, custom_material_z], + 0.72, + [1.0; 4], + ); + } + }); + Some(( + rgba, + eng.renderer.active_transparency_composition_mode_code(), + eng.renderer.render_graph_json().unwrap(), + )) + } + + let Some((sorted_ab, sorted_mode, _)) = render_order(0, false, None, false) else { + eprintln!("skip: no GPU adapter"); + return; + }; + let (sorted_ba, _, _) = + render_order(0, true, None, false).expect("same adapter remains available"); + assert_eq!(sorted_mode, 0); + let sorted_diff = calculate_diff_metrics(&sorted_ab, &sorted_ba, W, H); + assert!( + sorted_diff.mean_rgb > 0.25, + "negative control was not order-sensitive: {sorted_diff:?}" + ); + + let (weighted_ab, weighted_mode, weighted_graph) = + render_order(2, false, None, false).expect("same adapter remains available"); + let (weighted_ba, weighted_mode_reversed, _) = + render_order(2, true, None, false).expect("same adapter remains available"); + assert_eq!(weighted_mode, 1); + assert_eq!(weighted_mode_reversed, 1); + assert!( + weighted_graph.contains("transparency-accumulation") + && weighted_graph.contains("transparency-revealage"), + "weighted route must declare both graph-owned transient targets" + ); + let weighted_diff = calculate_diff_metrics(&weighted_ab, &weighted_ba, W, H); + assert!( + weighted_diff.mean_rgb <= 0.02 && weighted_diff.max_diff <= 2, + "weighted composition changed with submission order: {weighted_diff:?}" + ); + + let (weighted_with_custom, custom_mode, _) = + render_order(2, false, Some(-0.05), false).expect("same adapter remains available"); + assert_eq!(custom_mode, 1); + let custom_diff = calculate_diff_metrics(&weighted_ab, &weighted_with_custom, W, H); + assert!( + custom_diff.mean_rgb > 0.05, + "custom sorted material did not render after imported OIT: {custom_diff:?}" + ); + + let (_, reactive_mode, reactive_graph) = + render_order(2, false, Some(-0.05), true).expect("same adapter remains available"); + assert_eq!(reactive_mode, 1); + assert!( + reactive_graph.contains("transparency-accumulation") + && reactive_graph.contains("transparency-revealage") + && reactive_graph.contains("transparency-reactive"), + "TAA-active OIT plus custom translucency must retain all graph contracts" + ); + + // Conventional imported and custom translucency must respond to their + // cross-list depth relationship. Before the shared dispatcher, the custom + // list always rendered after the complete imported list, so moving this + // constant-color custom cube from behind to in front left the fully + // overlapped center pixel unchanged. TAA-on also exercises the lazy + // two-attachment-compatible custom sibling. + let (sorted_custom_far, far_mode, far_graph) = + render_order(0, false, Some(-0.6), true).expect("same adapter remains available"); + let (sorted_custom_near, near_mode, near_graph) = + render_order(0, false, Some(0.6), true).expect("same adapter remains available"); + assert_eq!((far_mode, near_mode), (0, 0)); + assert!( + far_graph.contains("transparency-reactive") + && near_graph.contains("transparency-reactive") + && !far_graph.contains("transparency-accumulation") + && !near_graph.contains("transparency-accumulation"), + "mixed sorted translucency must retain the reactive target without enabling OIT" + ); + let center = (((H / 2) * W + W / 2) * 4) as usize; + let center_delta = (0..3) + .map(|channel| { + sorted_custom_far[center + channel].abs_diff(sorted_custom_near[center + channel]) + as u32 + }) + .sum::(); + assert!( + center_delta >= 8, + "custom/imported global depth order did not affect the overlapped pixel: \ + far={:?}, near={:?}, delta={center_delta}", + &sorted_custom_far[center..center + 4], + &sorted_custom_near[center..center + 4], + ); +} + +#[test] +fn layered_path_tracing_scalar_lobes_are_isolated_and_energy_bounded() { + fn render_variant_with_layered_textures( + mut layered: Option, + specular_textures: Option<( + &[u8], + &[u8], + MaterialTextureTransform, + MaterialTextureTransform, + )>, + clearcoat_textures: Option<( + &[u8], + &[u8], + MaterialTextureTransform, + MaterialTextureTransform, + )>, + clearcoat_normal_texture: Option<(&[u8], MaterialTextureTransform)>, + sheen_textures: Option<( + &[u8], + &[u8], + MaterialTextureTransform, + MaterialTextureTransform, + )>, + iridescence_textures: Option<( + &[u8], + &[u8], + MaterialTextureTransform, + MaterialTextureTransform, + )>, + anisotropy_texture: Option<(&[u8], MaterialTextureTransform)>, + ) -> Result, String)>, String> { + let Some((mut eng, _adapter)) = try_engine_rt()? else { + return Ok(None); + }; + if let (Some(material), Some((factor, color, factor_transform, color_transform))) = + (layered.as_mut(), specular_textures) + { + let factor_index = eng.renderer.register_texture_kind(2, 2, factor, false); + let color_index = eng.renderer.register_texture_kind(2, 2, color, false); + material.specular_texture = Some(MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: Some(factor_index), + transform: factor_transform, + }); + material.specular_color_texture = Some(MaterialTextureBinding { + source_texture_index: 1, + source_image_index: 1, + runtime_texture_idx: Some(color_index), + transform: color_transform, + }); + } + if let (Some(material), Some((factor, roughness, factor_transform, roughness_transform))) = + (layered.as_mut(), clearcoat_textures) + { + let factor_index = eng.renderer.register_texture_kind(2, 2, factor, false); + let roughness_index = eng.renderer.register_texture_kind(2, 2, roughness, false); + material.clearcoat_texture = Some(MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: Some(factor_index), + transform: factor_transform, + }); + material.clearcoat_roughness_texture = Some(MaterialTextureBinding { + source_texture_index: 1, + source_image_index: 1, + runtime_texture_idx: Some(roughness_index), + transform: roughness_transform, + }); + } + if let (Some(material), Some((normal, transform))) = + (layered.as_mut(), clearcoat_normal_texture) + { + let normal_index = eng.renderer.register_texture_kind(2, 2, normal, true); + material.clearcoat_normal_texture = Some(MaterialTextureBinding { + source_texture_index: 2, + source_image_index: 2, + runtime_texture_idx: Some(normal_index), + transform, + }); + } + if let (Some(material), Some((color, roughness, color_transform, roughness_transform))) = + (layered.as_mut(), sheen_textures) + { + let color_index = eng.renderer.register_texture_kind(2, 2, color, false); + let roughness_index = eng.renderer.register_texture_kind(2, 2, roughness, false); + material.sheen_color_texture = Some(MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: Some(color_index), + transform: color_transform, + }); + material.sheen_roughness_texture = Some(MaterialTextureBinding { + source_texture_index: 1, + source_image_index: 1, + runtime_texture_idx: Some(roughness_index), + transform: roughness_transform, + }); + } + if let (Some(material), Some((factor, thickness, factor_transform, thickness_transform))) = + (layered.as_mut(), iridescence_textures) + { + let factor_index = eng.renderer.register_texture_kind(2, 2, factor, false); + let thickness_index = eng.renderer.register_texture_kind(2, 2, thickness, false); + material.iridescence_texture = Some(MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: Some(factor_index), + transform: factor_transform, + }); + material.iridescence_thickness_texture = Some(MaterialTextureBinding { + source_texture_index: 1, + source_image_index: 1, + runtime_texture_idx: Some(thickness_index), + transform: thickness_transform, + }); + } + if let (Some(material), Some((texture, transform))) = (layered.as_mut(), anisotropy_texture) + { + let texture_index = eng.renderer.register_texture_kind(2, 2, texture, false); + material.anisotropy_texture = Some(MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: Some(texture_index), + transform, + }); + } + build_pt_scene(&mut eng); + // Replace the material target with the same cube carrying an explicit + // per-face tangent. The base PT shader ignores this attribute, while + // scalar anisotropy must recover it from the geometry megabuffer at + // both the primary and bounce intersections. + let (mut vertices, indices) = cube_verts(0.5, [0.85, 0.2, 0.15, 1.0]); + let face_uvs = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; + for (index, vertex) in vertices.iter_mut().enumerate() { + vertex.uv = face_uvs[index % face_uvs.len()]; + vertex.tangent = if vertex.normal[0].abs() > 0.5 { + [0.0, 0.0, 1.0, 1.0] + } else { + [1.0, 0.0, 0.0, 1.0] + }; + } + let node = eng + .scene + .nodes + .iter() + .nth(1) + .map(|(handle, _)| handle) + .expect("PT scene has a test object"); + let uses_uv1 = layered.is_some_and(|material| { + material + .specular_texture + .is_some_and(|binding| binding.transform.tex_coord == 1) + || material + .specular_color_texture + .is_some_and(|binding| binding.transform.tex_coord == 1) + || material + .clearcoat_texture + .is_some_and(|binding| binding.transform.tex_coord == 1) + || material + .clearcoat_roughness_texture + .is_some_and(|binding| binding.transform.tex_coord == 1) + || material + .clearcoat_normal_texture + .is_some_and(|binding| binding.transform.tex_coord == 1) + || material + .sheen_color_texture + .is_some_and(|binding| binding.transform.tex_coord == 1) + || material + .sheen_roughness_texture + .is_some_and(|binding| binding.transform.tex_coord == 1) + || material + .iridescence_texture + .is_some_and(|binding| binding.transform.tex_coord == 1) + || material + .iridescence_thickness_texture + .is_some_and(|binding| binding.transform.tex_coord == 1) + || material + .anisotropy_texture + .is_some_and(|binding| binding.transform.tex_coord == 1) + }); + let secondary_uvs = uses_uv1.then(|| { + vertices + .iter() + .map(|vertex| [1.0 - vertex.uv[1], vertex.uv[0]]) + .collect() + }); + eng.scene + .update_geometry_with_secondary_uv(node, vertices, secondary_uvs, indices); + if let Some(material) = layered { + eng.scene.set_material_layered_pbr(node, material); + } + eng.renderer.set_path_tracing(1); + eng.renderer.set_path_tracing_seed(0); + eng.renderer.reset_path_tracing_history(0); + let (_, _, rgba) = render(&mut eng, 12, draw_pt_static_frame); + Ok(Some((rgba, eng.renderer.quality_runtime_paths_json()))) + } + + fn render_variant_with_specular_textures( + layered: Option, + specular_textures: Option<( + &[u8], + &[u8], + MaterialTextureTransform, + MaterialTextureTransform, + )>, + ) -> Result, String)>, String> { + render_variant_with_layered_textures( + layered, + specular_textures, + None, + None, + None, + None, + None, + ) + } + + fn render_variant_with_clearcoat_textures( + layered: Option, + clearcoat_textures: Option<( + &[u8], + &[u8], + MaterialTextureTransform, + MaterialTextureTransform, + )>, + ) -> Result, String)>, String> { + render_variant_with_layered_textures( + layered, + None, + clearcoat_textures, + None, + None, + None, + None, + ) + } + + fn render_variant_with_clearcoat_normal( + layered: Option, + clearcoat_normal: Option<(&[u8], MaterialTextureTransform)>, + ) -> Result, String)>, String> { + render_variant_with_layered_textures( + layered, + None, + None, + clearcoat_normal, + None, + None, + None, + ) + } + + fn render_variant_with_sheen_textures( + layered: Option, + sheen_textures: Option<( + &[u8], + &[u8], + MaterialTextureTransform, + MaterialTextureTransform, + )>, + ) -> Result, String)>, String> { + render_variant_with_layered_textures(layered, None, None, None, sheen_textures, None, None) + } + + fn render_variant_with_iridescence_textures( + layered: Option, + iridescence_textures: Option<( + &[u8], + &[u8], + MaterialTextureTransform, + MaterialTextureTransform, + )>, + ) -> Result, String)>, String> { + render_variant_with_layered_textures( + layered, + None, + None, + None, + None, + iridescence_textures, + None, + ) + } + + fn render_variant_with_anisotropy_texture( + layered: Option, + anisotropy_texture: Option<(&[u8], MaterialTextureTransform)>, + ) -> Result, String)>, String> { + render_variant_with_layered_textures( + layered, + None, + None, + None, + None, + None, + anisotropy_texture, + ) + } + + fn render_variant( + layered: Option, + ) -> Result, String)>, String> { + render_variant_with_layered_textures(layered, None, None, None, None, None, None) + } + + fn mean_display_luminance(rgba: &[u8]) -> f64 { + rgba.chunks_exact(4) + .map(|pixel| { + 0.2126 * f64::from(pixel[0]) + + 0.7152 * f64::from(pixel[1]) + + 0.0722 * f64::from(pixel[2]) + }) + .sum::() + / (rgba.len() / 4) as f64 + } + + fn assert_transport_response(label: &str, base: &[u8], transported: &[u8]) { + let response = calculate_diff_metrics(base, transported, W, H); + let changed_pixels = base + .chunks_exact(4) + .zip(transported.chunks_exact(4)) + .filter(|(base, transported)| { + (0..3) + .map(|channel| base[channel].abs_diff(transported[channel]) as u32) + .sum::() + >= 3 + }) + .count(); + let changed_fraction = changed_pixels as f64 / (W * H) as f64; + assert!( + response.mean_rgb >= 0.05 && changed_fraction >= 0.002, + "{label} did not produce a visible transport response: \ + metrics={response:?}, changed_fraction={changed_fraction:.6}" + ); + + // A layered interface redistributes energy; it must not behave as a + // second unattenuated BRDF. This display-space guard complements the + // CPU white-furnace oracle and catches catastrophic GPU energy gain + // without over-constraining a legitimate sharp highlight. + let base_luminance = mean_display_luminance(base); + let transported_luminance = mean_display_luminance(transported); + assert!( + transported_luminance <= base_luminance * 1.10 + 0.25, + "{label} created unbounded display energy: \ + base={base_luminance:.4}, transported={transported_luminance:.4}" + ); + } + + let _guard = lock_rt_goldens(); + let Some((base, base_paths)) = render_variant(None).expect("base PT variant initializes") + else { + skip_rt_golden( + "layered_path_tracing_scalar_lobes_are_isolated_and_energy_bounded", + "no-non-cpu-ray-query-adapter", + ); + return; + }; + let (neutral, neutral_paths) = render_variant(Some(MaterialLayeredPbr { + iridescence_authored: true, + iridescence_factor: 0.65, + iridescence_thickness_minimum: 120.0, + iridescence_thickness_maximum: 360.0, + iridescence_texture: Some(MaterialTextureBinding { + source_texture_index: 0, + source_image_index: 0, + runtime_texture_idx: None, + transform: Default::default(), + }), + ..Default::default() + })) + .expect("neutral layered PT variant initializes") + .expect("same ray-query adapter remains available"); + let (clearcoat, clearcoat_paths) = render_variant(Some(MaterialLayeredPbr { + clearcoat_authored: true, + clearcoat_factor: 0.85, + clearcoat_roughness_factor: 0.2, + ..Default::default() + })) + .expect("clearcoat PT variant initializes") + .expect("same ray-query adapter remains available"); + let (specular_ior, specular_ior_paths) = render_variant(Some(MaterialLayeredPbr { + specular_authored: true, + specular_factor: 0.8, + specular_color_factor: [1.2, 0.6, 0.3], + ior_authored: true, + ior: 2.0, + ..Default::default() + })) + .expect("specular/IOR PT variant initializes") + .expect("same ray-query adapter remains available"); + let white_specular_texels = [255u8; 2 * 2 * 4]; + let directional_specular_factor = [ + 255, 255, 255, 24, 255, 255, 255, 255, 255, 255, 255, 24, 255, 255, 255, 255, + ]; + let directional_clearcoat_channels = [ + 24, 255, 255, 255, 255, 24, 255, 255, 24, 255, 255, 255, 255, 24, 255, 255, + ]; + let textured_clearcoat_material = MaterialLayeredPbr { + clearcoat_authored: true, + clearcoat_factor: 0.85, + clearcoat_roughness_factor: 0.2, + ..Default::default() + }; + let (textured_clearcoat_white, textured_clearcoat_white_paths) = + render_variant_with_clearcoat_textures( + Some(textured_clearcoat_material), + Some(( + &white_specular_texels, + &white_specular_texels, + Default::default(), + Default::default(), + )), + ) + .expect("neutral textured clearcoat PT variant initializes") + .expect("same ray-query adapter remains available"); + let (textured_clearcoat, textured_clearcoat_paths) = render_variant_with_clearcoat_textures( + Some(textured_clearcoat_material), + Some(( + &directional_clearcoat_channels, + &directional_clearcoat_channels, + Default::default(), + Default::default(), + )), + ) + .expect("textured clearcoat PT variant initializes") + .expect("same ray-query adapter remains available"); + let (textured_clearcoat_rotated, textured_clearcoat_rotated_paths) = + render_variant_with_clearcoat_textures( + Some(textured_clearcoat_material), + Some(( + &directional_clearcoat_channels, + &directional_clearcoat_channels, + MaterialTextureTransform { + rotation: std::f32::consts::FRAC_PI_2, + ..Default::default() + }, + Default::default(), + )), + ) + .expect("rotated textured clearcoat PT variant initializes") + .expect("same ray-query adapter remains available"); + let (textured_clearcoat_uv1, textured_clearcoat_uv1_paths) = + render_variant_with_clearcoat_textures( + Some(textured_clearcoat_material), + Some(( + &directional_clearcoat_channels, + &directional_clearcoat_channels, + MaterialTextureTransform { + tex_coord: 1, + ..Default::default() + }, + MaterialTextureTransform { + tex_coord: 1, + ..Default::default() + }, + )), + ) + .expect("UV1 textured clearcoat PT variant initializes") + .expect("same ray-query adapter remains available"); + let flat_clearcoat_normals = [ + 128, 128, 255, 255, 128, 128, 255, 255, 128, 128, 255, 255, 128, 128, 255, 255, + ]; + let directional_clearcoat_normals = [ + 230, 128, 220, 255, 128, 230, 220, 255, 26, 128, 220, 255, 128, 26, 220, 255, + ]; + let (flat_clearcoat_normal, flat_clearcoat_normal_paths) = + render_variant_with_clearcoat_normal( + Some(MaterialLayeredPbr { + clearcoat_authored: true, + clearcoat_factor: 0.85, + clearcoat_roughness_factor: 0.2, + clearcoat_normal_scale: 0.0, + ..Default::default() + }), + Some((&flat_clearcoat_normals, Default::default())), + ) + .expect("flat clearcoat normal PT variant initializes") + .expect("same ray-query adapter remains available"); + let clearcoat_normal_material = MaterialLayeredPbr { + clearcoat_authored: true, + clearcoat_factor: 0.85, + clearcoat_roughness_factor: 0.2, + clearcoat_normal_scale: 0.75, + ..Default::default() + }; + let (textured_clearcoat_normal, textured_clearcoat_normal_paths) = + render_variant_with_clearcoat_normal( + Some(clearcoat_normal_material), + Some((&directional_clearcoat_normals, Default::default())), + ) + .expect("textured clearcoat normal PT variant initializes") + .expect("same ray-query adapter remains available"); + let (rotated_clearcoat_normal, rotated_clearcoat_normal_paths) = + render_variant_with_clearcoat_normal( + Some(clearcoat_normal_material), + Some(( + &directional_clearcoat_normals, + MaterialTextureTransform { + rotation: std::f32::consts::FRAC_PI_2, + ..Default::default() + }, + )), + ) + .expect("rotated clearcoat normal PT variant initializes") + .expect("same ray-query adapter remains available"); + let (uv1_clearcoat_normal, uv1_clearcoat_normal_paths) = render_variant_with_clearcoat_normal( + Some(clearcoat_normal_material), + Some(( + &directional_clearcoat_normals, + MaterialTextureTransform { + tex_coord: 1, + ..Default::default() + }, + )), + ) + .expect("UV1 clearcoat normal PT variant initializes") + .expect("same ray-query adapter remains available"); + let textured_specular_material = MaterialLayeredPbr { + specular_authored: true, + specular_factor: 0.8, + specular_color_factor: [1.2, 0.6, 0.3], + ior_authored: true, + ior: 2.0, + ..Default::default() + }; + let (textured_specular_white, textured_specular_white_paths) = + render_variant_with_specular_textures( + Some(textured_specular_material), + Some(( + &white_specular_texels, + &white_specular_texels, + Default::default(), + Default::default(), + )), + ) + .expect("neutral textured specular/IOR PT variant initializes") + .expect("same ray-query adapter remains available"); + let (textured_specular, textured_specular_paths) = render_variant_with_specular_textures( + Some(textured_specular_material), + Some(( + &directional_specular_factor, + &white_specular_texels, + Default::default(), + Default::default(), + )), + ) + .expect("textured specular/IOR PT variant initializes") + .expect("same ray-query adapter remains available"); + let (textured_specular_rotated, textured_specular_rotated_paths) = + render_variant_with_specular_textures( + Some(textured_specular_material), + Some(( + &directional_specular_factor, + &white_specular_texels, + MaterialTextureTransform { + rotation: std::f32::consts::FRAC_PI_2, + ..Default::default() + }, + Default::default(), + )), + ) + .expect("rotated textured specular/IOR PT variant initializes") + .expect("same ray-query adapter remains available"); + let (textured_specular_uv1, textured_specular_uv1_paths) = + render_variant_with_specular_textures( + Some(textured_specular_material), + Some(( + &directional_specular_factor, + &white_specular_texels, + MaterialTextureTransform { + tex_coord: 1, + ..Default::default() + }, + MaterialTextureTransform { + tex_coord: 1, + ..Default::default() + }, + )), + ) + .expect("UV1 textured specular/IOR PT variant initializes") + .expect("same ray-query adapter remains available"); + let (sheen, sheen_paths) = render_variant(Some(MaterialLayeredPbr { + sheen_authored: true, + sheen_color_factor: [0.45, 0.12, 0.04], + sheen_roughness_factor: 0.4, + ..Default::default() + })) + .expect("sheen PT variant initializes") + .expect("same ray-query adapter remains available"); + let directional_sheen_channels = [ + 255, 64, 32, 255, 32, 255, 64, 32, 255, 64, 32, 255, 32, 255, 64, 32, + ]; + let textured_sheen_material = MaterialLayeredPbr { + sheen_authored: true, + sheen_color_factor: [0.45, 0.12, 0.04], + sheen_roughness_factor: 0.4, + ..Default::default() + }; + let (textured_sheen_white, textured_sheen_white_paths) = render_variant_with_sheen_textures( + Some(textured_sheen_material), + Some(( + &white_specular_texels, + &white_specular_texels, + Default::default(), + Default::default(), + )), + ) + .expect("neutral textured sheen PT variant initializes") + .expect("same ray-query adapter remains available"); + let (textured_sheen, textured_sheen_paths) = render_variant_with_sheen_textures( + Some(textured_sheen_material), + Some(( + &directional_sheen_channels, + &directional_sheen_channels, + Default::default(), + Default::default(), + )), + ) + .expect("textured sheen PT variant initializes") + .expect("same ray-query adapter remains available"); + let (textured_sheen_rotated, textured_sheen_rotated_paths) = + render_variant_with_sheen_textures( + Some(textured_sheen_material), + Some(( + &directional_sheen_channels, + &directional_sheen_channels, + MaterialTextureTransform { + rotation: std::f32::consts::FRAC_PI_2, + ..Default::default() + }, + Default::default(), + )), + ) + .expect("rotated textured sheen PT variant initializes") + .expect("same ray-query adapter remains available"); + let (textured_sheen_uv1, textured_sheen_uv1_paths) = render_variant_with_sheen_textures( + Some(textured_sheen_material), + Some(( + &directional_sheen_channels, + &directional_sheen_channels, + MaterialTextureTransform { + tex_coord: 1, + ..Default::default() + }, + MaterialTextureTransform { + tex_coord: 1, + ..Default::default() + }, + )), + ) + .expect("UV1 textured sheen PT variant initializes") + .expect("same ray-query adapter remains available"); + let (anisotropy, anisotropy_paths) = render_variant(Some(MaterialLayeredPbr { + anisotropy_authored: true, + anisotropy_strength: 1.0, + anisotropy_rotation: 0.3, + ..Default::default() + })) + .expect("anisotropy PT variant initializes") + .expect("same ray-query adapter remains available"); + let (anisotropy_rotated, anisotropy_rotated_paths) = render_variant(Some(MaterialLayeredPbr { + anisotropy_authored: true, + anisotropy_strength: 1.0, + anisotropy_rotation: 0.3 + std::f32::consts::FRAC_PI_2, + ..Default::default() + })) + .expect("rotated anisotropy PT variant initializes") + .expect("same ray-query adapter remains available"); + let neutral_anisotropy_channels = [ + 255, 128, 255, 255, 255, 128, 255, 255, 255, 128, 255, 255, 255, 128, 255, 255, + ]; + let directional_anisotropy_channels = [ + 255, 128, 255, 255, 128, 255, 255, 255, 128, 255, 255, 255, 128, 255, 255, 255, + ]; + let textured_anisotropy_material = MaterialLayeredPbr { + anisotropy_authored: true, + anisotropy_strength: 1.0, + anisotropy_rotation: 0.3, + ..Default::default() + }; + let (textured_anisotropy_neutral, textured_anisotropy_neutral_paths) = + render_variant_with_anisotropy_texture( + Some(textured_anisotropy_material), + Some((&neutral_anisotropy_channels, Default::default())), + ) + .expect("neutral textured anisotropy PT variant initializes") + .expect("same ray-query adapter remains available"); + let (textured_anisotropy, textured_anisotropy_paths) = render_variant_with_anisotropy_texture( + Some(textured_anisotropy_material), + Some((&directional_anisotropy_channels, Default::default())), + ) + .expect("textured anisotropy PT variant initializes") + .expect("same ray-query adapter remains available"); + let (textured_anisotropy_rotated, textured_anisotropy_rotated_paths) = + render_variant_with_anisotropy_texture( + Some(textured_anisotropy_material), + Some(( + &directional_anisotropy_channels, + MaterialTextureTransform { + rotation: std::f32::consts::FRAC_PI_2, + ..Default::default() + }, + )), + ) + .expect("rotated textured anisotropy PT variant initializes") + .expect("same ray-query adapter remains available"); + let (textured_anisotropy_uv1, textured_anisotropy_uv1_paths) = + render_variant_with_anisotropy_texture( + Some(textured_anisotropy_material), + Some(( + &directional_anisotropy_channels, + MaterialTextureTransform { + tex_coord: 1, + ..Default::default() + }, + )), + ) + .expect("UV1 textured anisotropy PT variant initializes") + .expect("same ray-query adapter remains available"); + let (iridescence, iridescence_paths) = render_variant(Some(MaterialLayeredPbr { + iridescence_authored: true, + iridescence_factor: 1.0, + iridescence_ior: 1.3, + iridescence_thickness_minimum: 100.0, + iridescence_thickness_maximum: 180.0, + ..Default::default() + })) + .expect("iridescence PT variant initializes") + .expect("same ray-query adapter remains available"); + let directional_iridescence_channels = [ + 24, 255, 255, 255, 255, 24, 255, 255, 24, 255, 255, 255, 255, 24, 255, 255, + ]; + let textured_iridescence_material = MaterialLayeredPbr { + iridescence_authored: true, + iridescence_factor: 1.0, + iridescence_ior: 1.3, + iridescence_thickness_minimum: 100.0, + iridescence_thickness_maximum: 180.0, + ..Default::default() + }; + let (textured_iridescence_white, textured_iridescence_white_paths) = + render_variant_with_iridescence_textures( + Some(textured_iridescence_material), + Some(( + &white_specular_texels, + &white_specular_texels, + Default::default(), + Default::default(), + )), + ) + .expect("neutral textured iridescence PT variant initializes") + .expect("same ray-query adapter remains available"); + let (textured_iridescence, textured_iridescence_paths) = + render_variant_with_iridescence_textures( + Some(textured_iridescence_material), + Some(( + &directional_iridescence_channels, + &directional_iridescence_channels, + Default::default(), + Default::default(), + )), + ) + .expect("textured iridescence PT variant initializes") + .expect("same ray-query adapter remains available"); + let (textured_iridescence_rotated, textured_iridescence_rotated_paths) = + render_variant_with_iridescence_textures( + Some(textured_iridescence_material), + Some(( + &directional_iridescence_channels, + &directional_iridescence_channels, + MaterialTextureTransform { + rotation: std::f32::consts::FRAC_PI_2, + ..Default::default() + }, + Default::default(), + )), + ) + .expect("rotated textured iridescence PT variant initializes") + .expect("same ray-query adapter remains available"); + let (textured_iridescence_uv1, textured_iridescence_uv1_paths) = + render_variant_with_iridescence_textures( + Some(textured_iridescence_material), + Some(( + &directional_iridescence_channels, + &directional_iridescence_channels, + MaterialTextureTransform { + tex_coord: 1, + ..Default::default() + }, + MaterialTextureTransform { + tex_coord: 1, + ..Default::default() + }, + )), + ) + .expect("UV1 textured iridescence PT variant initializes") + .expect("same ray-query adapter remains available"); + let (iridescence_thick, iridescence_thick_paths) = render_variant(Some(MaterialLayeredPbr { + iridescence_authored: true, + iridescence_factor: 1.0, + iridescence_ior: 1.3, + iridescence_thickness_minimum: 100.0, + iridescence_thickness_maximum: 520.0, + ..Default::default() + })) + .expect("thick iridescence PT variant initializes") + .expect("same ray-query adapter remains available"); + let (combined, combined_paths) = render_variant(Some(MaterialLayeredPbr { + clearcoat_authored: true, + clearcoat_factor: 0.7, + clearcoat_roughness_factor: 0.16, + specular_authored: true, + specular_factor: 0.8, + specular_color_factor: [1.2, 0.6, 0.3], + ior_authored: true, + ior: 2.0, + sheen_authored: true, + sheen_color_factor: [0.35, 0.1, 0.04], + sheen_roughness_factor: 0.4, + anisotropy_authored: true, + anisotropy_strength: 0.6, + anisotropy_rotation: 0.3, + iridescence_authored: true, + iridescence_factor: 0.8, + iridescence_ior: 1.3, + iridescence_thickness_minimum: 100.0, + iridescence_thickness_maximum: 360.0, + ..Default::default() + })) + .expect("combined clearcoat/specular PT variant initializes") + .expect("same ray-query adapter remains available"); + + assert!( + base == neutral, + "an unqualified layered lobe changed PT output before its transport landed" + ); + assert!(base_paths.contains("\"path_tracing_specialization_initialized\":false")); + assert!(base_paths.contains("\"path_tracing_sheen_specialization_initialized\":false")); + assert!(base_paths.contains("\"path_tracing_iridescence_specialization_initialized\":false")); + assert!(base_paths.contains("\"path_tracing_active_instance_count\":0")); + assert!(base_paths.contains("\"path_tracing_sidecar_allocated_bytes\":0")); + assert!( + base_paths.contains("\"path_tracing_clearcoat_texture_specialization_initialized\":false") + ); + assert!(base_paths.contains("\"path_tracing_clearcoat_texture_sidecar_allocated_bytes\":0")); + assert!( + base_paths.contains("\"path_tracing_clearcoat_normal_specialization_initialized\":false") + ); + assert!(base_paths.contains("\"path_tracing_clearcoat_normal_sidecar_allocated_bytes\":0")); + assert!(base_paths.contains("\"path_tracing_sheen_texture_specialization_initialized\":false")); + assert!(base_paths.contains("\"path_tracing_sheen_texture_sidecar_allocated_bytes\":0")); + assert!(base_paths + .contains("\"path_tracing_iridescence_texture_specialization_initialized\":false")); + assert!(base_paths.contains("\"path_tracing_iridescence_texture_sidecar_allocated_bytes\":0")); + assert!( + base_paths.contains("\"path_tracing_anisotropy_texture_specialization_initialized\":false") + ); + assert!(base_paths.contains("\"path_tracing_anisotropy_texture_sidecar_allocated_bytes\":0")); + assert!(neutral_paths.contains("\"path_tracing_specialization_initialized\":false")); + assert!(neutral_paths.contains("\"path_tracing_iridescence_specialization_initialized\":false")); + assert!(neutral_paths.contains("\"path_tracing_active_instance_count\":1")); + assert!(neutral_paths.contains("\"path_tracing_sidecar_allocated_bytes\":0")); + assert!(clearcoat_paths.contains("\"path_tracing_specialization_initialized\":true")); + assert!(clearcoat_paths.contains("\"path_tracing_sheen_specialization_initialized\":false")); + assert!( + clearcoat_paths.contains("\"path_tracing_anisotropy_specialization_initialized\":false") + ); + assert!( + clearcoat_paths.contains("\"path_tracing_iridescence_specialization_initialized\":false") + ); + assert!(clearcoat_paths.contains("\"sheen_lut_initialized\":false")); + assert!(clearcoat_paths.contains("\"path_tracing_active_instance_count\":1")); + assert!(clearcoat_paths + .contains("\"path_tracing_clearcoat_normal_specialization_initialized\":false")); + assert!(clearcoat_paths.contains("\"path_tracing_clearcoat_normal_sidecar_allocated_bytes\":0")); + let textured_clearcoat_supported = textured_clearcoat_white_paths + .contains("\"path_tracing_clearcoat_texture_specialization_initialized\":true"); + if textured_clearcoat_supported { + assert_eq!( + clearcoat, textured_clearcoat_white, + "neutral UV0 clearcoat textures changed scalar path-traced transport" + ); + assert!(textured_clearcoat_paths + .contains("\"path_tracing_clearcoat_texture_specialization_initialized\":true")); + assert!(textured_clearcoat_rotated_paths + .contains("\"path_tracing_clearcoat_texture_specialization_initialized\":true")); + assert!(textured_clearcoat_paths + .contains("\"path_tracing_texture_specialization_initialized\":false")); + assert!( + textured_clearcoat_paths.contains("\"path_tracing_texture_sidecar_allocated_bytes\":0") + ); + assert!(textured_clearcoat_paths + .contains("\"path_tracing_sheen_texture_specialization_initialized\":false")); + assert!(textured_clearcoat_paths + .contains("\"path_tracing_sheen_texture_sidecar_allocated_bytes\":0")); + assert!(textured_clearcoat_paths + .contains("\"path_tracing_clearcoat_texture_sidecar_record_bytes\":64")); + assert!(!textured_clearcoat_paths + .contains("\"path_tracing_clearcoat_texture_sidecar_allocated_bytes\":0")); + assert!(textured_clearcoat_paths + .contains("\"path_tracing_clearcoat_normal_sidecar_allocated_bytes\":0")); + assert!(textured_clearcoat_uv1_paths + .contains("\"path_tracing_uv1_specialization_initialized\":true")); + assert!(!textured_clearcoat_uv1_paths + .contains("\"path_tracing_uv1_sidecar_allocated_bytes\":0")); + } else { + assert_eq!( + base, textured_clearcoat_white, + "an adapter without PT texture arrays must preserve clearcoat fallback" + ); + assert_eq!( + base, textured_clearcoat_uv1, + "an adapter without PT texture arrays must not partially enable clearcoat UV1" + ); + assert!(textured_clearcoat_white_paths + .contains("\"path_tracing_clearcoat_texture_sidecar_allocated_bytes\":0")); + assert!( + textured_clearcoat_uv1_paths.contains("\"path_tracing_uv1_sidecar_allocated_bytes\":0") + ); + } + let clearcoat_normal_supported = flat_clearcoat_normal_paths + .contains("\"path_tracing_clearcoat_normal_specialization_initialized\":true"); + if clearcoat_normal_supported { + assert_eq!( + clearcoat, flat_clearcoat_normal, + "a flat zero-scale coat normal changed scalar path-traced transport" + ); + assert!(textured_clearcoat_normal_paths + .contains("\"path_tracing_clearcoat_normal_specialization_initialized\":true")); + assert!(rotated_clearcoat_normal_paths + .contains("\"path_tracing_clearcoat_normal_specialization_initialized\":true")); + assert!(textured_clearcoat_normal_paths + .contains("\"path_tracing_clearcoat_normal_sidecar_record_bytes\":48")); + assert!(!textured_clearcoat_normal_paths + .contains("\"path_tracing_clearcoat_normal_sidecar_allocated_bytes\":0")); + assert!(textured_clearcoat_normal_paths + .contains("\"path_tracing_clearcoat_texture_sidecar_allocated_bytes\":0")); + assert!(uv1_clearcoat_normal_paths + .contains("\"path_tracing_uv1_specialization_initialized\":true")); + assert!( + !uv1_clearcoat_normal_paths.contains("\"path_tracing_uv1_sidecar_allocated_bytes\":0") + ); + } else { + assert_eq!( + clearcoat, flat_clearcoat_normal, + "an adapter without PT texture arrays must preserve scalar clearcoat fallback" + ); + assert_eq!( + clearcoat, textured_clearcoat_normal, + "an adapter without PT texture arrays must ignore only the coat normal texture" + ); + assert_eq!( + clearcoat, uv1_clearcoat_normal, + "an adapter without PT texture arrays must not partially enable coat-normal UV1" + ); + assert!(flat_clearcoat_normal_paths + .contains("\"path_tracing_clearcoat_normal_sidecar_allocated_bytes\":0")); + assert!( + uv1_clearcoat_normal_paths.contains("\"path_tracing_uv1_sidecar_allocated_bytes\":0") + ); + } + assert!(specular_ior_paths.contains("\"path_tracing_specialization_initialized\":true")); + assert!(specular_ior_paths.contains("\"path_tracing_sheen_specialization_initialized\":false")); + assert!( + specular_ior_paths.contains("\"path_tracing_anisotropy_specialization_initialized\":false") + ); + assert!(specular_ior_paths + .contains("\"path_tracing_iridescence_specialization_initialized\":false")); + assert!(specular_ior_paths.contains("\"sheen_lut_initialized\":false")); + assert!(specular_ior_paths.contains("\"path_tracing_active_instance_count\":1")); + let textured_specular_supported = textured_specular_white_paths + .contains("\"path_tracing_texture_specialization_initialized\":true"); + if textured_specular_supported { + assert_eq!( + specular_ior, textured_specular_white, + "neutral UV0 specular textures changed scalar path-traced transport" + ); + assert!(textured_specular_paths + .contains("\"path_tracing_texture_specialization_initialized\":true")); + assert!(textured_specular_rotated_paths + .contains("\"path_tracing_texture_specialization_initialized\":true")); + assert!( + textured_specular_paths.contains("\"path_tracing_texture_sidecar_record_bytes\":64") + ); + assert!( + !textured_specular_paths.contains("\"path_tracing_texture_sidecar_allocated_bytes\":0") + ); + assert!(textured_specular_paths + .contains("\"path_tracing_clearcoat_texture_specialization_initialized\":false")); + assert!(textured_specular_paths + .contains("\"path_tracing_clearcoat_texture_sidecar_allocated_bytes\":0")); + assert!(textured_specular_paths + .contains("\"path_tracing_sheen_texture_specialization_initialized\":false")); + assert!(textured_specular_paths + .contains("\"path_tracing_sheen_texture_sidecar_allocated_bytes\":0")); + } else { + assert_eq!( + base, textured_specular_white, + "an adapter without PT texture arrays must preserve the exact base path" + ); + assert!(textured_specular_white_paths + .contains("\"path_tracing_texture_sidecar_allocated_bytes\":0")); + } + if textured_specular_supported { + assert!(textured_specular_uv1_paths + .contains("\"path_tracing_texture_specialization_initialized\":true")); + assert!(textured_specular_uv1_paths + .contains("\"path_tracing_uv1_specialization_initialized\":true")); + assert!( + !textured_specular_uv1_paths.contains("\"path_tracing_uv1_sidecar_allocated_bytes\":0") + ); + } else { + assert_eq!( + base, textured_specular_uv1, + "an adapter without PT texture arrays must not partially enable UV1" + ); + assert!(textured_specular_uv1_paths + .contains("\"path_tracing_uv1_specialization_initialized\":false")); + assert!( + textured_specular_uv1_paths.contains("\"path_tracing_uv1_sidecar_allocated_bytes\":0") + ); + } + assert!(sheen_paths.contains("\"path_tracing_specialization_initialized\":true")); + assert!(sheen_paths.contains("\"path_tracing_sheen_specialization_initialized\":true")); + assert!(sheen_paths.contains("\"path_tracing_anisotropy_specialization_initialized\":false")); + assert!(sheen_paths.contains("\"path_tracing_iridescence_specialization_initialized\":false")); + assert!(sheen_paths.contains("\"sheen_lut_initialized\":true")); + assert!(sheen_paths.contains("\"path_tracing_active_instance_count\":1")); + let textured_sheen_supported = textured_sheen_white_paths + .contains("\"path_tracing_sheen_texture_specialization_initialized\":true"); + if textured_sheen_supported { + assert_eq!( + sheen, textured_sheen_white, + "neutral UV0 sheen textures changed scalar path-traced transport" + ); + assert!(textured_sheen_paths + .contains("\"path_tracing_sheen_texture_specialization_initialized\":true")); + assert!( + textured_sheen_paths.contains("\"path_tracing_sheen_specialization_initialized\":true") + ); + assert!(textured_sheen_rotated_paths + .contains("\"path_tracing_sheen_texture_specialization_initialized\":true")); + assert!(textured_sheen_paths.contains("\"sheen_lut_initialized\":true")); + assert!(textured_sheen_paths.contains("\"path_tracing_texture_sidecar_allocated_bytes\":0")); + assert!(textured_sheen_paths + .contains("\"path_tracing_clearcoat_texture_sidecar_allocated_bytes\":0")); + assert!( + textured_sheen_paths.contains("\"path_tracing_sheen_texture_sidecar_record_bytes\":64") + ); + assert!(!textured_sheen_paths + .contains("\"path_tracing_sheen_texture_sidecar_allocated_bytes\":0")); + assert!(textured_sheen_uv1_paths + .contains("\"path_tracing_uv1_specialization_initialized\":true")); + assert!( + !textured_sheen_uv1_paths.contains("\"path_tracing_uv1_sidecar_allocated_bytes\":0") + ); + } else { + assert_eq!( + base, textured_sheen_white, + "an adapter without PT texture arrays must preserve sheen fallback" + ); + assert_eq!( + base, textured_sheen_uv1, + "an adapter without PT texture arrays must not partially enable sheen UV1" + ); + assert!(textured_sheen_white_paths + .contains("\"path_tracing_sheen_texture_sidecar_allocated_bytes\":0")); + assert!(textured_sheen_uv1_paths.contains("\"path_tracing_uv1_sidecar_allocated_bytes\":0")); + } + assert!(anisotropy_paths.contains("\"path_tracing_specialization_initialized\":true")); + assert!(anisotropy_paths.contains("\"path_tracing_sheen_specialization_initialized\":false")); + assert!( + anisotropy_paths.contains("\"path_tracing_anisotropy_specialization_initialized\":true") + ); + assert!( + anisotropy_paths.contains("\"path_tracing_iridescence_specialization_initialized\":false") + ); + assert!(anisotropy_paths.contains("\"sheen_lut_initialized\":false")); + assert!(anisotropy_paths.contains("\"path_tracing_active_instance_count\":1")); + assert!(anisotropy_rotated_paths.contains("\"path_tracing_specialization_initialized\":true")); + assert!(anisotropy_rotated_paths + .contains("\"path_tracing_anisotropy_specialization_initialized\":true")); + assert!(anisotropy_rotated_paths.contains("\"sheen_lut_initialized\":false")); + let textured_anisotropy_supported = textured_anisotropy_neutral_paths + .contains("\"path_tracing_anisotropy_texture_specialization_initialized\":true"); + if textured_anisotropy_supported { + // KHR_materials_anisotropy stores a centered direction in UNORM + // RG. Eight-bit 128 decodes to 0.50196 rather than exact 0.5, so the + // closest representable +X control uses an image-tolerance contract + // instead of the byte-exact neutral contract used by scalar channels. + let neutral_parity = + calculate_diff_metrics(&anisotropy, &textured_anisotropy_neutral, W, H); + assert!( + neutral_parity.mean_rgb <= 0.5 && neutral_parity.ssim >= 0.995, + "nearest representable neutral anisotropy texture drifted from scalar transport: \ + {neutral_parity:?}" + ); + assert!(textured_anisotropy_paths + .contains("\"path_tracing_anisotropy_texture_specialization_initialized\":true")); + assert!(textured_anisotropy_paths + .contains("\"path_tracing_anisotropy_specialization_initialized\":true")); + assert!(textured_anisotropy_rotated_paths + .contains("\"path_tracing_anisotropy_texture_specialization_initialized\":true")); + assert!(textured_anisotropy_paths + .contains("\"path_tracing_texture_sidecar_allocated_bytes\":0")); + assert!(textured_anisotropy_paths + .contains("\"path_tracing_clearcoat_texture_sidecar_allocated_bytes\":0")); + assert!(textured_anisotropy_paths + .contains("\"path_tracing_sheen_texture_sidecar_allocated_bytes\":0")); + assert!(textured_anisotropy_paths + .contains("\"path_tracing_iridescence_texture_sidecar_allocated_bytes\":0")); + assert!(textured_anisotropy_paths + .contains("\"path_tracing_anisotropy_texture_sidecar_record_bytes\":64")); + assert!(!textured_anisotropy_paths + .contains("\"path_tracing_anisotropy_texture_sidecar_allocated_bytes\":0")); + assert!(textured_anisotropy_uv1_paths + .contains("\"path_tracing_uv1_specialization_initialized\":true")); + assert!(!textured_anisotropy_uv1_paths + .contains("\"path_tracing_uv1_sidecar_allocated_bytes\":0")); + } else { + assert_eq!( + base, textured_anisotropy_neutral, + "an adapter without PT texture arrays must preserve anisotropy fallback" + ); + assert_eq!( + base, textured_anisotropy_uv1, + "an adapter without PT texture arrays must not partially enable anisotropy UV1" + ); + assert!(textured_anisotropy_neutral_paths + .contains("\"path_tracing_anisotropy_texture_sidecar_allocated_bytes\":0")); + assert!(textured_anisotropy_uv1_paths + .contains("\"path_tracing_uv1_sidecar_allocated_bytes\":0")); + } + assert!(iridescence_paths.contains("\"path_tracing_specialization_initialized\":true")); + assert!(iridescence_paths.contains("\"path_tracing_sheen_specialization_initialized\":false")); + assert!( + iridescence_paths.contains("\"path_tracing_anisotropy_specialization_initialized\":false") + ); + assert!( + iridescence_paths.contains("\"path_tracing_iridescence_specialization_initialized\":true") + ); + assert!(iridescence_paths.contains("\"sheen_lut_initialized\":false")); + assert!(iridescence_thick_paths + .contains("\"path_tracing_iridescence_specialization_initialized\":true")); + let textured_iridescence_supported = textured_iridescence_white_paths + .contains("\"path_tracing_iridescence_texture_specialization_initialized\":true"); + if textured_iridescence_supported { + assert_eq!( + iridescence, textured_iridescence_white, + "neutral UV0 iridescence textures changed scalar path-traced transport" + ); + assert!(textured_iridescence_paths + .contains("\"path_tracing_iridescence_texture_specialization_initialized\":true")); + assert!(textured_iridescence_paths + .contains("\"path_tracing_iridescence_specialization_initialized\":true")); + assert!(textured_iridescence_rotated_paths + .contains("\"path_tracing_iridescence_texture_specialization_initialized\":true")); + assert!(textured_iridescence_paths + .contains("\"path_tracing_texture_sidecar_allocated_bytes\":0")); + assert!(textured_iridescence_paths + .contains("\"path_tracing_clearcoat_texture_sidecar_allocated_bytes\":0")); + assert!(textured_iridescence_paths + .contains("\"path_tracing_sheen_texture_sidecar_allocated_bytes\":0")); + assert!(textured_iridescence_paths + .contains("\"path_tracing_iridescence_texture_sidecar_record_bytes\":64")); + assert!(!textured_iridescence_paths + .contains("\"path_tracing_iridescence_texture_sidecar_allocated_bytes\":0")); + assert!(textured_iridescence_uv1_paths + .contains("\"path_tracing_uv1_specialization_initialized\":true")); + assert!(!textured_iridescence_uv1_paths + .contains("\"path_tracing_uv1_sidecar_allocated_bytes\":0")); + } else { + assert_eq!( + base, textured_iridescence_white, + "an adapter without PT texture arrays must preserve iridescence fallback" + ); + assert_eq!( + base, textured_iridescence_uv1, + "an adapter without PT texture arrays must not partially enable iridescence UV1" + ); + assert!(textured_iridescence_white_paths + .contains("\"path_tracing_iridescence_texture_sidecar_allocated_bytes\":0")); + assert!(textured_iridescence_uv1_paths + .contains("\"path_tracing_uv1_sidecar_allocated_bytes\":0")); + } + assert!(combined_paths.contains("\"path_tracing_specialization_initialized\":true")); + assert!(combined_paths.contains("\"path_tracing_sheen_specialization_initialized\":true")); + assert!(combined_paths.contains("\"path_tracing_anisotropy_specialization_initialized\":true")); + assert!(combined_paths.contains("\"path_tracing_iridescence_specialization_initialized\":true")); + assert!(combined_paths.contains("\"path_tracing_active_instance_count\":1")); + + assert_transport_response("clearcoat", &base, &clearcoat); + if textured_clearcoat_supported { + assert_transport_response( + "UV0 textured clearcoat", + &textured_clearcoat_white, + &textured_clearcoat, + ); + let transform_response = + calculate_diff_metrics(&textured_clearcoat, &textured_clearcoat_rotated, W, H); + assert!( + transform_response.mean_rgb >= 0.02, + "clearcoat texture UV rotation did not turn the path-traced response: \ + {transform_response:?}" + ); + let uv_set_response = + calculate_diff_metrics(&textured_clearcoat, &textured_clearcoat_uv1, W, H); + assert!( + uv_set_response.mean_rgb >= 0.02, + "clearcoat UV1 did not select retained secondary coordinates: {uv_set_response:?}" + ); + } + if clearcoat_normal_supported { + let normal_response = + calculate_diff_metrics(&flat_clearcoat_normal, &textured_clearcoat_normal, W, H); + assert_transport_response( + "textured clearcoat normal", + &flat_clearcoat_normal, + &textured_clearcoat_normal, + ); + let transform_response = + calculate_diff_metrics(&textured_clearcoat_normal, &rotated_clearcoat_normal, W, H); + assert!( + transform_response.mean_rgb >= 0.02, + "clearcoat normal UV rotation did not turn the path-traced response: \ + {transform_response:?}" + ); + let uv_set_response = + calculate_diff_metrics(&textured_clearcoat_normal, &uv1_clearcoat_normal, W, H); + eprintln!( + "clearcoat-normal PT qualification: normal={normal_response:?}, \ + rotation={transform_response:?}, uv1={uv_set_response:?}, \ + flat_luma={:.6}, mapped_luma={:.6}", + mean_display_luminance(&flat_clearcoat_normal), + mean_display_luminance(&textured_clearcoat_normal), + ); + assert!( + uv_set_response.mean_rgb >= 0.02, + "clearcoat normal UV1 did not select retained secondary coordinates: \ + {uv_set_response:?}" + ); + } + assert_transport_response("specular/IOR", &base, &specular_ior); + if textured_specular_supported { + assert_transport_response( + "UV0 textured specular/IOR", + &textured_specular_white, + &textured_specular, + ); + let transform_response = + calculate_diff_metrics(&textured_specular, &textured_specular_rotated, W, H); + assert!( + transform_response.mean_rgb >= 0.02, + "specular texture UV rotation did not turn the path-traced response: \ + {transform_response:?}" + ); + assert_transport_response( + "UV0 rotated textured specular/IOR", + &base, + &textured_specular_rotated, + ); + assert_transport_response("UV1 textured specular/IOR", &base, &textured_specular_uv1); + let uv_set_response = + calculate_diff_metrics(&textured_specular, &textured_specular_uv1, W, H); + assert!( + uv_set_response.mean_rgb >= 0.02, + "UV1 did not select the retained secondary coordinates: {uv_set_response:?}" + ); + } + assert_transport_response("sheen", &base, &sheen); + if textured_sheen_supported { + assert_transport_response("UV0 textured sheen", &textured_sheen_white, &textured_sheen); + let transform_response = + calculate_diff_metrics(&textured_sheen, &textured_sheen_rotated, W, H); + assert!( + transform_response.mean_rgb >= 0.02, + "sheen texture UV rotation did not turn the path-traced response: \ + {transform_response:?}" + ); + let uv_set_response = calculate_diff_metrics(&textured_sheen, &textured_sheen_uv1, W, H); + assert!( + uv_set_response.mean_rgb >= 0.02, + "sheen UV1 did not select retained secondary coordinates: {uv_set_response:?}" + ); + } + assert_transport_response("anisotropy", &base, &anisotropy); + assert_transport_response("rotated anisotropy", &base, &anisotropy_rotated); + let rotation_response = calculate_diff_metrics(&anisotropy, &anisotropy_rotated, W, H); + assert!( + rotation_response.mean_rgb >= 0.02, + "anisotropy rotation did not turn the path-traced highlight: {rotation_response:?}" + ); + if textured_anisotropy_supported { + assert_transport_response( + "UV0 textured anisotropy", + &textured_anisotropy_neutral, + &textured_anisotropy, + ); + let transform_response = + calculate_diff_metrics(&textured_anisotropy, &textured_anisotropy_rotated, W, H); + assert!( + transform_response.mean_rgb >= 0.02, + "anisotropy texture UV rotation did not turn the path-traced response: \ + {transform_response:?}" + ); + let uv_set_response = + calculate_diff_metrics(&textured_anisotropy, &textured_anisotropy_uv1, W, H); + assert!( + uv_set_response.mean_rgb >= 0.02, + "anisotropy UV1 did not select retained secondary coordinates: {uv_set_response:?}" + ); + } + assert_transport_response("iridescence", &base, &iridescence); + if textured_iridescence_supported { + assert_transport_response( + "UV0 textured iridescence", + &textured_iridescence_white, + &textured_iridescence, + ); + let transform_response = + calculate_diff_metrics(&textured_iridescence, &textured_iridescence_rotated, W, H); + assert!( + transform_response.mean_rgb >= 0.02, + "iridescence texture UV rotation did not turn the path-traced response: \ + {transform_response:?}" + ); + let uv_set_response = + calculate_diff_metrics(&textured_iridescence, &textured_iridescence_uv1, W, H); + assert!( + uv_set_response.mean_rgb >= 0.02, + "iridescence UV1 did not select retained secondary coordinates: {uv_set_response:?}" + ); + } + assert_transport_response("thick iridescence", &base, &iridescence_thick); + let thickness_response = calculate_diff_metrics(&iridescence, &iridescence_thick, W, H); + assert!( + thickness_response.mean_rgb >= 0.02, + "thin-film thickness did not shift the path-traced spectrum: {thickness_response:?}" + ); + assert_transport_response("combined scalar lobes", &base, &combined); +} diff --git a/native/shared/tests/render_targets.rs b/native/shared/tests/render_targets.rs index f1f79711..b7df435e 100644 --- a/native/shared/tests/render_targets.rs +++ b/native/shared/tests/render_targets.rs @@ -5,9 +5,9 @@ //! depth/HDR/G-buffer targets. Those two must agree from the very first //! frame. //! -//! The golden suite cannot cover this: its harness calls -//! `set_taa_enabled(false)` immediately after construction, which -//! internally resizes and so always repairs the invariant before +//! The golden suite cannot cover this: its harness explicitly selects native +//! render scale immediately after construction, which resizes and repairs the +//! invariant before //! anything renders. A real host does not — every `attach_engine` //! platform (macOS/iOS/tvOS/Linux/Android) boots and then only resizes //! when the OS-reported window size *changes*, which is false on frame @@ -16,9 +16,9 @@ //! dropped out, and the depth-snapshot copy failed validation outright //! whenever a scene-reading translucent material was in view. +use bloom_shared::profiler::Profiler; use bloom_shared::renderer::Renderer; use bloom_shared::scene::SceneGraph; -use bloom_shared::profiler::Profiler; fn try_renderer() -> Option { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { @@ -65,16 +65,60 @@ fn render_targets_track_render_extent_through_changes() { }; r.set_render_scale(1.0); - assert_eq!(r.render_target_extent(), r.render_extent(), "after set_render_scale(1.0)"); + assert_eq!( + r.render_target_extent(), + r.render_extent(), + "after set_render_scale(1.0)" + ); r.set_render_scale(0.5); - assert_eq!(r.render_target_extent(), r.render_extent(), "after set_render_scale(0.5)"); + assert_eq!( + r.render_target_extent(), + r.render_extent(), + "after set_render_scale(0.5)" + ); r.resize(640, 480, 640, 480); assert_eq!(r.render_target_extent(), r.render_extent(), "after resize"); r.set_taa_enabled(false); - assert_eq!(r.render_target_extent(), r.render_extent(), "after set_taa_enabled(false)"); + assert_eq!( + r.render_target_extent(), + r.render_extent(), + "after set_taa_enabled(false)" + ); + assert_eq!( + r.render_scale(), + 0.5, + "TAA toggles must not silently change render resolution" + ); +} + +#[test] +fn quality_presets_own_resolution_reconstruction_and_sharpening() { + let Some(mut r) = try_renderer() else { + eprintln!("no GPU adapter — skipping"); + return; + }; + + let expected = [ + (0, (128, 128), false, 0, 0.0), + (1, (172, 172), false, 1, 0.25), + (2, (192, 192), true, 1, 0.40), + (3, (218, 218), true, 1, 0.45), + (4, (256, 256), true, 1, 0.50), + ]; + for (preset, extent, taa, upscale, sharpen) in expected { + r.apply_quality_preset(preset); + assert_eq!(r.render_extent(), extent, "preset {preset} extent"); + assert_eq!(r.taa_enabled, taa, "preset {preset} TAA"); + assert_eq!(r.upscale_mode, upscale, "preset {preset} upscale"); + assert_eq!( + r.sharpen_strength, sharpen, + "preset {preset} composite sharpen" + ); + assert_eq!(r.cas_strength, 0.0, "preset {preset} extra CAS pass"); + } } /// The deferred frame path must honor the render-target override — the @@ -105,7 +149,11 @@ fn deferred_frame_writes_render_target_override() { .create_view(&wgpu::TextureViewDescriptor::default()); let depth_tex = r.device.create_texture(&wgpu::TextureDescriptor { label: Some("test_rt_depth"), - size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -131,7 +179,9 @@ fn deferred_frame_writes_render_target_override() { r.end_texture_mode(); // Read the RT back and look at the center pixel. - let tex = r.get_texture_ref(tex_idx).expect("render texture registered"); + let tex = r + .get_texture_ref(tex_idx) + .expect("render texture registered"); let bpr = (W * 4 + 255) & !255; let buf = r.device.create_buffer(&wgpu::BufferDescriptor { label: Some("test_rt_readback"), @@ -157,7 +207,11 @@ fn deferred_frame_writes_render_target_override() { rows_per_image: Some(H), }, }, - wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, ); r.queue.submit(std::iter::once(enc.finish())); @@ -166,12 +220,20 @@ fn deferred_frame_writes_render_target_override() { slice.map_async(wgpu::MapMode::Read, move |res| { let _ = tx.send(res); }); - let _ = r.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + let _ = r.device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); rx.recv().expect("map callback").expect("map ok"); let data = slice.get_mapped_range(); let center = ((H / 2) * bpr + (W / 2) * 4) as usize; - let px = [data[center], data[center + 1], data[center + 2], data[center + 3]]; + let px = [ + data[center], + data[center + 1], + data[center + 2], + data[center + 3], + ]; drop(data); // Channel order differs by surface format (RGBA vs BGRA) and the clear diff --git a/native/tvos/Cargo.lock b/native/tvos/Cargo.lock index 74cdb47f..99e0ee2b 100644 --- a/native/tvos/Cargo.lock +++ b/native/tvos/Cargo.lock @@ -100,8 +100,11 @@ dependencies = [ "image_dds", "lewton", "libc", + "log", "minimp3", "raw-window-handle", + "serde_json", + "web-sys", "wgpu", ] diff --git a/native/tvos/src/lib.rs b/native/tvos/src/lib.rs index f1977bda..8509c869 100644 --- a/native/tvos/src/lib.rs +++ b/native/tvos/src/lib.rs @@ -742,59 +742,11 @@ unsafe extern "C" fn scene_will_connect( Err(_) => panic!("[bloom-tvos] No GPU adapter found"), }; - // Ticket 007b: HW ray-query on RT-capable tvOS hardware (A13+). - let supported = adapter.features(); - let force_sw_gi = std::env::var("BLOOM_FORCE_SW_GI") - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false); - let rt_mask = wgpu::Features::EXPERIMENTAL_RAY_QUERY; - let mut required_features = wgpu::Features::empty(); - // Ticket 011: request TIMESTAMP_QUERY when supported so the profiler - // can record GPU timings. Optional — profiler falls back to CPU-only - // when the adapter doesn't grant it. - if supported.contains(wgpu::Features::TIMESTAMP_QUERY) { - required_features |= wgpu::Features::TIMESTAMP_QUERY; - } - // Cooked BC7 textures (bloom-cook) upload compressed when the - // adapter has BC support; without it they CPU-decode at load. - if supported.contains(wgpu::Features::TEXTURE_COMPRESSION_BC) { - required_features |= wgpu::Features::TEXTURE_COMPRESSION_BC; - } - if !force_sw_gi && supported.contains(rt_mask) { - required_features |= rt_mask; - } - let experimental_features = if required_features.intersects(rt_mask) { - unsafe { wgpu::ExperimentalFeatures::enabled() } - } else { - wgpu::ExperimentalFeatures::disabled() - }; - // Base the requested limits on what the adapter actually advertises so we - // never ask for more than the backend can grant. The tvOS/iOS *simulators* - // cap several limits below wgpu's desktop default() — notably - // max_inter_stage_shader_variables (15 vs 16) — which makes request_device - // fail with LimitsExceeded. On real Apple TV hardware adapter.limits() - // meets or exceeds default(), so behaviour there is unchanged. - let adapter_limits = adapter.limits(); - let mut required_limits = wgpu::Limits::default(); - required_limits.max_inter_stage_shader_variables = required_limits - .max_inter_stage_shader_variables - .min(adapter_limits.max_inter_stage_shader_variables); - if required_features.intersects(rt_mask) { - required_limits = required_limits - .using_minimum_supported_acceleration_structure_values(); - } - let (device, queue) = match pollster_block_on(adapter.request_device( - &wgpu::DeviceDescriptor { - label: Some("bloom_device"), - required_features, - required_limits, - experimental_features, - ..Default::default() - }, - )) { - Ok(dq) => dq, - Err(e) => panic!("[bloom-tvos] Failed to create device: {e}"), - }; + let negotiated = request_renderer_device(&adapter); + let negotiation_report = negotiated.report.report_json(); + eprintln!("[bloom-tvos] renderer device negotiation = {negotiation_report}"); + let device = negotiated.device; + let queue = negotiated.queue; let surface_caps = surface.get_capabilities(&adapter); // Use non-sRGB format to match game's sRGB color space (colors are specified as sRGB 0-255) @@ -814,7 +766,8 @@ unsafe extern "C" fn scene_will_connect( }; surface.configure(&device, &surface_config); - let renderer = Renderer::new(device, queue, surface, surface_config, pixel_width, pixel_height); + let mut renderer = Renderer::new(device, queue, surface, surface_config, pixel_width, pixel_height); + renderer.set_device_negotiation_report(negotiation_report); let _ = ENGINE.set(EngineState::new(renderer)); std::io::Write::write_all(&mut std::io::stderr(), b"[bloom-tvos] ENGINE created on main thread\n").ok(); } @@ -1035,56 +988,11 @@ unsafe extern "C" fn deferred_init(_ctx: *mut c_void) { ..Default::default() })).expect("[bloom-tvos] No GPU adapter found"); - // Ticket 007b: HW ray-query on RT-capable tvOS hardware (A13+). - let supported = adapter.features(); - let force_sw_gi = std::env::var("BLOOM_FORCE_SW_GI") - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false); - let rt_mask = wgpu::Features::EXPERIMENTAL_RAY_QUERY; - let mut required_features = wgpu::Features::empty(); - // Ticket 011: request TIMESTAMP_QUERY when supported so the profiler - // can record GPU timings. Optional — profiler falls back to CPU-only - // when the adapter doesn't grant it. - if supported.contains(wgpu::Features::TIMESTAMP_QUERY) { - required_features |= wgpu::Features::TIMESTAMP_QUERY; - } - // Cooked BC7 textures (bloom-cook) upload compressed when the - // adapter has BC support; without it they CPU-decode at load. - if supported.contains(wgpu::Features::TEXTURE_COMPRESSION_BC) { - required_features |= wgpu::Features::TEXTURE_COMPRESSION_BC; - } - if !force_sw_gi && supported.contains(rt_mask) { - required_features |= rt_mask; - } - let experimental_features = if required_features.intersects(rt_mask) { - unsafe { wgpu::ExperimentalFeatures::enabled() } - } else { - wgpu::ExperimentalFeatures::disabled() - }; - // Base the requested limits on what the adapter actually advertises so we - // never ask for more than the backend can grant. The tvOS/iOS *simulators* - // cap several limits below wgpu's desktop default() — notably - // max_inter_stage_shader_variables (15 vs 16) — which makes request_device - // fail with LimitsExceeded. On real Apple TV hardware adapter.limits() - // meets or exceeds default(), so behaviour there is unchanged. - let adapter_limits = adapter.limits(); - let mut required_limits = wgpu::Limits::default(); - required_limits.max_inter_stage_shader_variables = required_limits - .max_inter_stage_shader_variables - .min(adapter_limits.max_inter_stage_shader_variables); - if required_features.intersects(rt_mask) { - required_limits = required_limits - .using_minimum_supported_acceleration_structure_values(); - } - let (device, queue) = pollster_block_on(adapter.request_device( - &wgpu::DeviceDescriptor { - label: Some("bloom_device"), - required_features, - required_limits, - experimental_features, - ..Default::default() - }, - )).expect("[bloom-tvos] Failed to create device"); + let negotiated = request_renderer_device(&adapter); + let negotiation_report = negotiated.report.report_json(); + eprintln!("[bloom-tvos] renderer device negotiation = {negotiation_report}"); + let device = negotiated.device; + let queue = negotiated.queue; let surface_caps = surface.get_capabilities(&adapter); let format = surface_caps.formats.iter() @@ -1103,7 +1011,8 @@ unsafe extern "C" fn deferred_init(_ctx: *mut c_void) { }; surface.configure(&device, &surface_config); - let renderer = Renderer::new(device, queue, surface, surface_config, pixel_width, pixel_height); + let mut renderer = Renderer::new(device, queue, surface, surface_config, pixel_width, pixel_height); + renderer.set_device_negotiation_report(negotiation_report); let _ = ENGINE.set(EngineState::new(renderer)); let _ = std::fs::write("/tmp/bloom_tvos_debug.txt", format!("ENGINE created\nformat={:?}\nsize={}x{}\n", format, pixel_width, pixel_height)); } @@ -1298,6 +1207,25 @@ fn pollster_block_on(future: F) -> F::Output { } } +fn request_renderer_device( + adapter: &wgpu::Adapter, +) -> bloom_shared::renderer::device_negotiation::NegotiatedDevice { + let force_sw_gi = std::env::var("BLOOM_FORCE_SW_GI") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + pollster_block_on( + bloom_shared::renderer::device_negotiation::request_device_with_fallback( + adapter, + bloom_shared::renderer::device_negotiation::DeviceRequestOptions { + allow_ray_query: !force_sw_gi, + profile: + bloom_shared::renderer::device_negotiation::DeviceRequestProfile::NativeFull, + }, + ), + ) + .unwrap_or_else(|error| panic!("[bloom-tvos] Failed to create renderer device: {error}")) +} + // ============================================================ // GCController — Siri Remote and game controller monitoring // ============================================================ diff --git a/native/visionos/Cargo.lock b/native/visionos/Cargo.lock index 7d977ad9..2992ced3 100644 --- a/native/visionos/Cargo.lock +++ b/native/visionos/Cargo.lock @@ -100,8 +100,11 @@ dependencies = [ "image_dds", "lewton", "libc", + "log", "minimp3", "raw-window-handle", + "serde_json", + "web-sys", "wgpu", ] diff --git a/native/visionos/src/lib.rs b/native/visionos/src/lib.rs index 01db4563..c57a3c6f 100644 --- a/native/visionos/src/lib.rs +++ b/native/visionos/src/lib.rs @@ -742,59 +742,11 @@ unsafe extern "C" fn scene_will_connect( Err(_) => panic!("[bloom-visionos] No GPU adapter found"), }; - // Ticket 007b: HW ray-query on RT-capable tvOS hardware (A13+). - let supported = adapter.features(); - let force_sw_gi = std::env::var("BLOOM_FORCE_SW_GI") - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false); - let rt_mask = wgpu::Features::EXPERIMENTAL_RAY_QUERY; - let mut required_features = wgpu::Features::empty(); - // Ticket 011: request TIMESTAMP_QUERY when supported so the profiler - // can record GPU timings. Optional — profiler falls back to CPU-only - // when the adapter doesn't grant it. - if supported.contains(wgpu::Features::TIMESTAMP_QUERY) { - required_features |= wgpu::Features::TIMESTAMP_QUERY; - } - // Cooked BC7 textures (bloom-cook) upload compressed when the - // adapter has BC support; without it they CPU-decode at load. - if supported.contains(wgpu::Features::TEXTURE_COMPRESSION_BC) { - required_features |= wgpu::Features::TEXTURE_COMPRESSION_BC; - } - if !force_sw_gi && supported.contains(rt_mask) { - required_features |= rt_mask; - } - let experimental_features = if required_features.intersects(rt_mask) { - unsafe { wgpu::ExperimentalFeatures::enabled() } - } else { - wgpu::ExperimentalFeatures::disabled() - }; - // Base the requested limits on what the adapter actually advertises so we - // never ask for more than the backend can grant. The tvOS/iOS *simulators* - // cap several limits below wgpu's desktop default() — notably - // max_inter_stage_shader_variables (15 vs 16) — which makes request_device - // fail with LimitsExceeded. On real Apple TV hardware adapter.limits() - // meets or exceeds default(), so behaviour there is unchanged. - let adapter_limits = adapter.limits(); - let mut required_limits = wgpu::Limits::default(); - required_limits.max_inter_stage_shader_variables = required_limits - .max_inter_stage_shader_variables - .min(adapter_limits.max_inter_stage_shader_variables); - if required_features.intersects(rt_mask) { - required_limits = required_limits - .using_minimum_supported_acceleration_structure_values(); - } - let (device, queue) = match pollster_block_on(adapter.request_device( - &wgpu::DeviceDescriptor { - label: Some("bloom_device"), - required_features, - required_limits, - experimental_features, - ..Default::default() - }, - )) { - Ok(dq) => dq, - Err(e) => panic!("[bloom-visionos] Failed to create device: {e}"), - }; + let negotiated = request_renderer_device(&adapter); + let negotiation_report = negotiated.report.report_json(); + eprintln!("[bloom-visionos] renderer device negotiation = {negotiation_report}"); + let device = negotiated.device; + let queue = negotiated.queue; let surface_caps = surface.get_capabilities(&adapter); // Use non-sRGB format to match game's sRGB color space (colors are specified as sRGB 0-255) @@ -814,7 +766,8 @@ unsafe extern "C" fn scene_will_connect( }; surface.configure(&device, &surface_config); - let renderer = Renderer::new(device, queue, surface, surface_config, pixel_width, pixel_height); + let mut renderer = Renderer::new(device, queue, surface, surface_config, pixel_width, pixel_height); + renderer.set_device_negotiation_report(negotiation_report); let _ = ENGINE.set(EngineState::new(renderer)); std::io::Write::write_all(&mut std::io::stderr(), b"[bloom-visionos] ENGINE created on main thread\n").ok(); } @@ -1035,56 +988,11 @@ unsafe extern "C" fn deferred_init(_ctx: *mut c_void) { ..Default::default() })).expect("[bloom-visionos] No GPU adapter found"); - // Ticket 007b: HW ray-query on RT-capable tvOS hardware (A13+). - let supported = adapter.features(); - let force_sw_gi = std::env::var("BLOOM_FORCE_SW_GI") - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false); - let rt_mask = wgpu::Features::EXPERIMENTAL_RAY_QUERY; - let mut required_features = wgpu::Features::empty(); - // Ticket 011: request TIMESTAMP_QUERY when supported so the profiler - // can record GPU timings. Optional — profiler falls back to CPU-only - // when the adapter doesn't grant it. - if supported.contains(wgpu::Features::TIMESTAMP_QUERY) { - required_features |= wgpu::Features::TIMESTAMP_QUERY; - } - // Cooked BC7 textures (bloom-cook) upload compressed when the - // adapter has BC support; without it they CPU-decode at load. - if supported.contains(wgpu::Features::TEXTURE_COMPRESSION_BC) { - required_features |= wgpu::Features::TEXTURE_COMPRESSION_BC; - } - if !force_sw_gi && supported.contains(rt_mask) { - required_features |= rt_mask; - } - let experimental_features = if required_features.intersects(rt_mask) { - unsafe { wgpu::ExperimentalFeatures::enabled() } - } else { - wgpu::ExperimentalFeatures::disabled() - }; - // Base the requested limits on what the adapter actually advertises so we - // never ask for more than the backend can grant. The tvOS/iOS *simulators* - // cap several limits below wgpu's desktop default() — notably - // max_inter_stage_shader_variables (15 vs 16) — which makes request_device - // fail with LimitsExceeded. On real Apple TV hardware adapter.limits() - // meets or exceeds default(), so behaviour there is unchanged. - let adapter_limits = adapter.limits(); - let mut required_limits = wgpu::Limits::default(); - required_limits.max_inter_stage_shader_variables = required_limits - .max_inter_stage_shader_variables - .min(adapter_limits.max_inter_stage_shader_variables); - if required_features.intersects(rt_mask) { - required_limits = required_limits - .using_minimum_supported_acceleration_structure_values(); - } - let (device, queue) = pollster_block_on(adapter.request_device( - &wgpu::DeviceDescriptor { - label: Some("bloom_device"), - required_features, - required_limits, - experimental_features, - ..Default::default() - }, - )).expect("[bloom-visionos] Failed to create device"); + let negotiated = request_renderer_device(&adapter); + let negotiation_report = negotiated.report.report_json(); + eprintln!("[bloom-visionos] renderer device negotiation = {negotiation_report}"); + let device = negotiated.device; + let queue = negotiated.queue; let surface_caps = surface.get_capabilities(&adapter); let format = surface_caps.formats.iter() @@ -1103,7 +1011,8 @@ unsafe extern "C" fn deferred_init(_ctx: *mut c_void) { }; surface.configure(&device, &surface_config); - let renderer = Renderer::new(device, queue, surface, surface_config, pixel_width, pixel_height); + let mut renderer = Renderer::new(device, queue, surface, surface_config, pixel_width, pixel_height); + renderer.set_device_negotiation_report(negotiation_report); let _ = ENGINE.set(EngineState::new(renderer)); let _ = std::fs::write("/tmp/bloom_tvos_debug.txt", format!("ENGINE created\nformat={:?}\nsize={}x{}\n", format, pixel_width, pixel_height)); } @@ -1298,6 +1207,25 @@ fn pollster_block_on(future: F) -> F::Output { } } +fn request_renderer_device( + adapter: &wgpu::Adapter, +) -> bloom_shared::renderer::device_negotiation::NegotiatedDevice { + let force_sw_gi = std::env::var("BLOOM_FORCE_SW_GI") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + pollster_block_on( + bloom_shared::renderer::device_negotiation::request_device_with_fallback( + adapter, + bloom_shared::renderer::device_negotiation::DeviceRequestOptions { + allow_ray_query: !force_sw_gi, + profile: + bloom_shared::renderer::device_negotiation::DeviceRequestProfile::NativeFull, + }, + ), + ) + .unwrap_or_else(|error| panic!("[bloom-visionos] Failed to create renderer device: {error}")) +} + // ============================================================ // GCController — Siri Remote and game controller monitoring // ============================================================ diff --git a/native/watchos/gen_stubs.js b/native/watchos/gen_stubs.js index 48f9d3dc..c518934b 100644 --- a/native/watchos/gen_stubs.js +++ b/native/watchos/gen_stubs.js @@ -66,6 +66,8 @@ const OVERRIDES = new Set([ 'bloom_enable_postfx', 'bloom_disable_postfx', 'bloom_set_vignette', 'bloom_set_chromatic_aberration', 'bloom_set_film_grain', 'bloom_set_manual_exposure', 'bloom_set_auto_exposure', 'bloom_set_sun_shafts', + // Explicit renderer-unavailable capability responses. + 'bloom_get_material_binding_capabilities', 'bloom_get_renderer_capabilities', ]); const rustType = (p) => { diff --git a/native/watchos/src/ffi_stubs.rs b/native/watchos/src/ffi_stubs.rs index 42edc472..82dc1a64 100644 --- a/native/watchos/src/ffi_stubs.rs +++ b/native/watchos/src/ffi_stubs.rs @@ -15,7 +15,17 @@ } #[no_mangle] pub extern "C" fn bloom_take_screenshot(_p0: i64) { } -#[no_mangle] pub extern "C" fn bloom_set_env_clear_from_hdr(_p0: i64) { +#[no_mangle] pub extern "C" fn bloom_capture_frame_to_png(_p0: i64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_capture_debug_intermediates(_p0: i64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_capture_frame_ready() -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_set_env_clear_from_hdr(_p0: i64) -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_is_key_repeated(_p0: f64) -> f64 { 0.0 @@ -103,7 +113,8 @@ #[no_mangle] pub extern "C" fn bloom_create_instance_buffer_scratch(_p0: f64) -> f64 { 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_material_params_scratch(_p0: f64, _p1: f64) { +#[no_mangle] pub extern "C" fn bloom_set_material_params_scratch(_p0: f64, _p1: f64) -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_compile_material_from_file(_p0: i64, _p1: f64) -> f64 { 0.0 @@ -147,7 +158,8 @@ #[no_mangle] pub extern "C" fn bloom_create_planar_reflection(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64) -> f64 { 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_material_reflection_probe(_p0: f64, _p1: f64) { +#[no_mangle] pub extern "C" fn bloom_set_material_reflection_probe(_p0: f64, _p1: f64) -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_create_texture_array(_p0: i64, _p1: f64, _p2: f64, _p3: f64, _p4: f64) -> f64 { 0.0 @@ -158,23 +170,29 @@ #[no_mangle] pub extern "C" fn bloom_create_texture_array_scratch(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64) -> f64 { 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_material_texture_array(_p0: f64, _p1: f64, _p2: f64) { +#[no_mangle] pub extern "C" fn bloom_set_material_texture_array(_p0: f64, _p1: f64, _p2: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_material_shading_model(_p0: f64, _p1: f64) { +#[no_mangle] pub extern "C" fn bloom_set_material_shading_model(_p0: f64, _p1: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_material_probe_visible(_p0: f64, _p1: f64) { +#[no_mangle] pub extern "C" fn bloom_set_material_probe_visible(_p0: f64, _p1: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_material_foliage(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64) { +#[no_mangle] pub extern "C" fn bloom_set_material_foliage(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64) -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_set_post_pass(_p0: i64) -> f64 { 0.0 } -#[no_mangle] pub extern "C" fn bloom_clear_post_pass() { +#[no_mangle] pub extern "C" fn bloom_clear_post_pass() -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_add_post_pass(_p0: i64) -> f64 { 0.0 } -#[no_mangle] pub extern "C" fn bloom_clear_all_post_passes() { +#[no_mangle] pub extern "C" fn bloom_clear_all_post_passes() -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_draw_material(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64, _p7: f64, _p8: f64, _p9: f64, _p10: f64) { } @@ -273,34 +291,65 @@ #[no_mangle] pub extern "C" fn bloom_create_mesh_scratch(_p0: f64, _p1: f64) -> f64 { 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_joint_test(_p0: f64, _p1: f64) { +#[no_mangle] pub extern "C" fn bloom_set_joint_test(_p0: f64, _p1: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_set_ambient_light(_p0: f64, _p1: f64, _p2: f64, _p3: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_set_directional_light(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_set_procedural_sky(_p0: f64, _p1: f64, _p2: f64, _p3: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_set_sun_direction(_p0: f64, _p1: f64, _p2: f64, _p3: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_ambient_light(_p0: f64, _p1: f64, _p2: f64, _p3: f64) { +#[no_mangle] pub extern "C" fn bloom_set_fog(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_directional_light(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64) { +#[no_mangle] pub extern "C" fn bloom_set_sharpen_strength(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_procedural_sky(_p0: f64, _p1: f64, _p2: f64, _p3: f64) { +#[no_mangle] pub extern "C" fn bloom_set_present_mode(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_sun_direction(_p0: f64, _p1: f64, _p2: f64, _p3: f64) { +#[no_mangle] pub extern "C" fn bloom_get_present_mode() -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_fog(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64) { +#[no_mangle] pub extern "C" fn bloom_get_imported_refraction_mode() -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_sharpen_strength(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_transparency_composition_mode(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_present_mode(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_get_transparency_composition_mode() -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_taa_enabled(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_get_active_transparency_composition_mode() -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_occlusion_culling(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_material_binding_tier_override(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_render_scale(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_taa_enabled(_p0: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_set_occlusion_culling(_p0: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_set_render_scale(_p0: f64) -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_get_render_scale() -> f64 { 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_upscale_mode(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_upscale_mode(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_cas_strength(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_cas_strength(_p0: f64) -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_get_physical_width() -> f64 { 0.0 @@ -308,66 +357,101 @@ #[no_mangle] pub extern "C" fn bloom_get_physical_height() -> f64 { 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_auto_resolution(_p0: f64, _p1: f64) { +#[no_mangle] pub extern "C" fn bloom_set_auto_resolution(_p0: f64, _p1: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_env_intensity(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_env_intensity(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_ssgi_enabled(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_ssgi_enabled(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_path_tracing(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_path_tracing(_p0: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_reset_temporal_history() -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_path_tracing_supported() -> f64 { 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_ssgi_intensity(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_ssgi_intensity(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_ssgi_radius(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_ssgi_radius(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_dof(_p0: f64, _p1: f64, _p2: f64) { +#[no_mangle] pub extern "C" fn bloom_set_dof(_p0: f64, _p1: f64, _p2: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_quality_preset(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_quality_preset(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_shadows_enabled(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_shadows_enabled(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_shadows_always_fresh(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_shadows_always_fresh(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_bloom_enabled(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_bloom_enabled(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_bloom_intensity(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_bloom_intensity(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_tonemap(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_tonemap(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_auto_exposure_key(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_auto_exposure_key(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_auto_exposure_rate(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_auto_exposure_rate(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_ssao_enabled(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_ssao_enabled(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_ssao_intensity(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_ssao_intensity(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_ssao_radius(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_ssao_radius(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_wind(_p0: f64, _p1: f64, _p2: f64, _p3: f64) { +#[no_mangle] pub extern "C" fn bloom_set_wind(_p0: f64, _p1: f64, _p2: f64, _p3: f64) -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_launch_process(_p0: i64, _p1: i64, _p2: i64) -> f64 { 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_output_scale(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_command_line_arg_count() -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_command_line_arg(_p0: f64) -> i64 { + 0 +} +#[no_mangle] pub extern "C" fn bloom_set_output_scale(_p0: f64) -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_get_output_scale() -> f64 { 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_model_foliage_wind(_p0: f64, _p1: f64) { +#[no_mangle] pub extern "C" fn bloom_set_model_foliage_wind(_p0: f64, _p1: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_foliage_shadow_motion(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_foliage_shadow_motion(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_cloud_shadows(_p0: f64, _p1: f64, _p2: f64, _p3: f64) { +#[no_mangle] pub extern "C" fn bloom_set_cloud_shadows(_p0: f64, _p1: f64, _p2: f64, _p3: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_ssr_enabled(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_ssr_enabled(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_motion_blur_enabled(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_motion_blur_enabled(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_set_sss_enabled(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_set_sss_enabled(_p0: f64) -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_set_profiler_enabled(_p0: f64) { } @@ -377,6 +461,9 @@ #[no_mangle] pub extern "C" fn bloom_get_profiler_frame_gpu_us() -> f64 { 0.0 } +#[no_mangle] pub extern "C" fn bloom_write_quality_telemetry(_p0: i64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64) -> f64 { + 0.0 +} #[no_mangle] pub extern "C" fn bloom_print_profiler_summary() { } #[no_mangle] pub extern "C" fn bloom_get_model_mesh_count(_p0: f64) -> f64 { @@ -450,17 +537,28 @@ } #[no_mangle] pub extern "C" fn bloom_unregister_frame_callback(_p0: f64) { } -#[no_mangle] pub extern "C" fn bloom_scene_set_gi_only(_p0: f64, _p1: f64) { +#[no_mangle] pub extern "C" fn bloom_scene_set_gi_only(_p0: f64, _p1: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_scene_set_trs(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64) { +#[no_mangle] pub extern "C" fn bloom_scene_set_trs(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_scene_set_transform16(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64, _p7: f64, _p8: f64, _p9: f64, _p10: f64, _p11: f64, _p12: f64, _p13: f64, _p14: f64, _p15: f64, _p16: f64) { +#[no_mangle] pub extern "C" fn bloom_scene_set_transform16(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64, _p7: f64, _p8: f64, _p9: f64, _p10: f64, _p11: f64, _p12: f64, _p13: f64, _p14: f64, _p15: f64, _p16: f64) -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_scene_update_geometry_scratch(_p0: f64, _p1: f64, _p2: f64) { } -#[no_mangle] pub extern "C" fn bloom_scene_set_lod(_p0: f64, _p1: f64, _p2: i64, _p3: f64, _p4: i64, _p5: f64, _p6: f64) { +#[no_mangle] pub extern "C" fn bloom_scene_set_lod(_p0: f64, _p1: f64, _p2: i64, _p3: f64, _p4: i64, _p5: f64, _p6: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_scene_attach_model_lod(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_scene_set_material_emissive(_p0: f64, _p1: f64, _p2: f64, _p3: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_scene_attach_model_lod(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64) { +#[no_mangle] pub extern "C" fn bloom_scene_set_material_layered_pbr(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64, _p7: f64, _p8: f64, _p9: f64, _p10: f64, _p11: f64, _p12: f64, _p13: f64, _p14: f64, _p15: f64, _p16: f64, _p17: f64, _p18: f64, _p19: f64) -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_scene_node_vertex_count(_p0: f64) -> f64 { 0.0 @@ -490,7 +588,8 @@ #[no_mangle] pub extern "C" fn bloom_pick_all_distance(_p0: f64) -> f64 { 0.0 } -#[no_mangle] pub extern "C" fn bloom_scene_set_material_water(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64) { +#[no_mangle] pub extern "C" fn bloom_scene_set_material_water(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64) -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_gen_mesh_spline_ribbon(_p0: i64, _p1: f64, _p2: i64, _p3: f64) -> f64 { 0.0 @@ -528,7 +627,8 @@ #[no_mangle] pub extern "C" fn bloom_scene_get_bounds_max_z(_p0: f64) -> f64 { 0.0 } -#[no_mangle] pub extern "C" fn bloom_scene_set_user_data(_p0: f64, _p1: f64) { +#[no_mangle] pub extern "C" fn bloom_scene_set_user_data(_p0: f64, _p1: f64) -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_scene_get_user_data(_p0: f64) -> f64 { 0.0 @@ -537,19 +637,25 @@ } #[no_mangle] pub extern "C" fn bloom_scene_subtract_box(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64) { } -#[no_mangle] pub extern "C" fn bloom_enable_shadows() { +#[no_mangle] pub extern "C" fn bloom_enable_shadows() -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_disable_shadows() { +#[no_mangle] pub extern "C" fn bloom_disable_shadows() -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_dump_shadow_map(_p0: i64) { } -#[no_mangle] pub extern "C" fn bloom_postfx_set_selected(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_postfx_set_selected(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_postfx_set_hovered(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_postfx_set_hovered(_p0: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_postfx_set_outline_color(_p0: f64, _p1: f64, _p2: f64, _p3: f64) { +#[no_mangle] pub extern "C" fn bloom_postfx_set_outline_color(_p0: f64, _p1: f64, _p2: f64, _p3: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_postfx_set_outline_thickness(_p0: f64) { +#[no_mangle] pub extern "C" fn bloom_postfx_set_outline_thickness(_p0: f64) -> f64 { + 0.0 } #[no_mangle] pub extern "C" fn bloom_project_to_screen(_p0: f64, _p1: f64, _p2: f64) -> f64 { 0.0 diff --git a/native/watchos/src/lib.rs b/native/watchos/src/lib.rs index 6e62ff31..96ec5a5a 100644 --- a/native/watchos/src/lib.rs +++ b/native/watchos/src/lib.rs @@ -86,6 +86,23 @@ fn alloc_perry_string(s: &str) -> i64 { } } +/// watchOS currently ships the explicit SceneKit proof-of-life renderer, not +/// the shared wgpu renderer. Capability queries must say so instead of +/// returning a null string or pretending a setter succeeded. +#[no_mangle] +pub extern "C" fn bloom_get_renderer_capabilities() -> i64 { + alloc_perry_string( + r#"{"version":1,"availability":"unavailable","reason":"watchOS uses the proof-of-life SceneKit renderer; shared wgpu rendering is unavailable","adapter":null,"material_binding":null,"runtime_support":{"hardware_ray_query":false,"path_tracing":false,"gpu_driven":{"enabled":false,"indirect_count_supported":false,"submitted":0,"compatibility":0,"indirect_calls":0,"frustum_visible_oracle":0,"frustum_culled_oracle":0,"frustum_culled_ratio":0.0,"classification_source":"unavailable"},"imported_refraction":"disabled-legacy","transparency_modes":[]}}"#, + ) +} + +#[no_mangle] +pub extern "C" fn bloom_get_material_binding_capabilities() -> i64 { + alloc_perry_string( + r#"{"version":1,"detected_tier":"C","selected_tier":"C","override_tier":null,"features":{"texture_binding_array":false,"non_uniform_indexing":false},"limits":{"max_binding_array_elements":0,"max_binding_array_samplers":0,"max_texture_array_layers":0,"max_sampled_textures":0,"max_samplers":0,"max_material_records":0},"capacities":{"tier_a_textures":0,"tier_a_samplers":0,"tier_b_page_layers":0},"diagnostic":"watchOS shared wgpu renderer unavailable","residency":{"materials":0,"textures":0,"samplers":0,"meshes":0,"buffer_views":0,"stale_fallbacks":0,"limit_fallbacks":0},"dispatch":{"tier_a_per_material_bind_group_switches":0,"tier_b_last_page_count":0,"tier_b_last_page_switches":0,"tier_b_last_fallback_materials":0}}"#, + ) +} + use std::ffi::{c_char, c_void}; use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering}; use std::sync::OnceLock; @@ -397,6 +414,7 @@ pub extern "C" fn bloom_is_any_input_pressed() -> f64 { } #[no_mangle] pub extern "C" fn bloom_is_key_pressed(_k: f64) -> f64 { 0.0 } +#[no_mangle] pub extern "C" fn bloom_is_key_repeated(_k: f64) -> f64 { 0.0 } #[no_mangle] pub extern "C" fn bloom_is_key_down(_k: f64) -> f64 { 0.0 } #[no_mangle] pub extern "C" fn bloom_is_key_released(_k: f64) -> f64 { 0.0 } @@ -819,27 +837,33 @@ pub extern "C" fn bloom_load_music(path: i64) -> f64 { #[no_mangle] pub extern "C" fn bloom_scene_create_node() -> f64 { scene::create() as f64 } #[no_mangle] pub extern "C" fn bloom_scene_destroy_node(h: f64) { scene::destroy(h as u32); } -#[no_mangle] pub extern "C" fn bloom_scene_set_visible(h: f64, v: f64) { +#[no_mangle] pub extern "C" fn bloom_scene_set_visible(h: f64, v: f64) -> f64 { scene::set_visible(h as u32, v > 0.5); + 1.0 } -#[no_mangle] pub extern "C" fn bloom_scene_set_cast_shadow(_h: f64, _v: f64) { +#[no_mangle] pub extern "C" fn bloom_scene_set_cast_shadow(_h: f64, _v: f64) -> f64 { // SceneKit manages shadow casting via per-node + light config; deferred. + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_scene_set_receive_shadow(_h: f64, _v: f64) -> f64 { + 0.0 } -#[no_mangle] pub extern "C" fn bloom_scene_set_receive_shadow(_h: f64, _v: f64) {} -#[no_mangle] pub extern "C" fn bloom_scene_set_parent(h: f64, parent: f64) { +#[no_mangle] pub extern "C" fn bloom_scene_set_parent(h: f64, parent: f64) -> f64 { scene::set_parent(h as u32, parent as u32); + 1.0 } /// Transform is a raw pointer to 16 column-major f64s (128 bytes). No /// StringHeader wrapping — bloom's TS side passes a `number[]` which Perry /// forwards as a pointer to the Float64 data. #[no_mangle] -pub extern "C" fn bloom_scene_set_transform(handle: f64, matrix_ptr: i64) { - if matrix_ptr == 0 { return; } +pub extern "C" fn bloom_scene_set_transform(handle: f64, matrix_ptr: i64) -> f64 { + if matrix_ptr == 0 { return 0.0; } let src = unsafe { std::slice::from_raw_parts(matrix_ptr as *const f64, 16) }; let mut arr = [0.0f32; 16]; for i in 0..16 { arr[i] = src[i] as f32; } scene::set_transform(handle as u32, arr); + 1.0 } /// Geometry: vert_ptr points to `vertex_count * 12` f64s (12 floats per @@ -859,16 +883,19 @@ pub extern "C" fn bloom_scene_update_geometry( } #[no_mangle] -pub extern "C" fn bloom_scene_set_material_color(h: f64, r: f64, g: f64, b: f64, a: f64) { - scene::set_color(h as u32, [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a as f32 / 255.0]); +pub extern "C" fn bloom_scene_set_material_color(h: f64, r: f64, g: f64, b: f64, a: f64) -> f64 { + scene::set_color(h as u32, [r as f32, g as f32, b as f32, a as f32]); + 1.0 } #[no_mangle] -pub extern "C" fn bloom_scene_set_material_pbr(h: f64, roughness: f64, metalness: f64) { +pub extern "C" fn bloom_scene_set_material_pbr(h: f64, roughness: f64, metalness: f64) -> f64 { scene::set_pbr(h as u32, roughness as f32, metalness as f32); + 1.0 } #[no_mangle] -pub extern "C" fn bloom_scene_set_material_texture(h: f64, tex: f64) { +pub extern "C" fn bloom_scene_set_material_texture(h: f64, tex: f64) -> f64 { scene::set_texture(h as u32, tex as u32); + 1.0 } #[no_mangle] pub extern "C" fn bloom_scene_node_count() -> f64 { scene::node_count() as f64 } @@ -1033,23 +1060,31 @@ fn apply_primitive(handle: u32, prim: &models::Primitive) { #[no_mangle] pub extern "C" fn bloom_enable_postfx() { postfx::set_enabled(true); } #[no_mangle] pub extern "C" fn bloom_disable_postfx() { postfx::set_enabled(false); } -#[no_mangle] pub extern "C" fn bloom_set_vignette(strength: f64, softness: f64) { +#[no_mangle] pub extern "C" fn bloom_set_vignette(strength: f64, softness: f64) -> f64 { postfx::set_vignette(strength, softness); + 1.0 } -#[no_mangle] pub extern "C" fn bloom_set_chromatic_aberration(strength: f64) { +#[no_mangle] pub extern "C" fn bloom_set_chromatic_aberration(strength: f64) -> f64 { postfx::set_chromatic_aberration(strength); + 1.0 } -#[no_mangle] pub extern "C" fn bloom_set_film_grain(strength: f64) { +#[no_mangle] pub extern "C" fn bloom_set_film_grain(strength: f64) -> f64 { postfx::set_film_grain(strength); + 1.0 } -#[no_mangle] pub extern "C" fn bloom_set_manual_exposure(v: f64) { postfx::set_exposure(v); } -#[no_mangle] pub extern "C" fn bloom_set_auto_exposure(on: f64) { +#[no_mangle] pub extern "C" fn bloom_set_manual_exposure(v: f64) -> f64 { + postfx::set_exposure(v); + 1.0 +} +#[no_mangle] pub extern "C" fn bloom_set_auto_exposure(on: f64) -> f64 { postfx::set_auto_exposure(on > 0.5); + 1.0 } #[no_mangle] pub extern "C" fn bloom_set_sun_shafts( strength: f64, decay: f64, r: f64, g: f64, b: f64, -) { +) -> f64 { postfx::set_sun_shafts(strength, decay, r, g, b); + 1.0 } #[no_mangle] diff --git a/native/web/Cargo.lock b/native/web/Cargo.lock index 0bf658b5..ebd3f002 100644 --- a/native/web/Cargo.lock +++ b/native/web/Cargo.lock @@ -100,7 +100,9 @@ dependencies = [ "image_dds", "lewton", "libc", + "log", "raw-window-handle", + "serde_json", "web-sys", "web-time", "wgpu", diff --git a/native/web/src/input_ffi.rs b/native/web/src/input_ffi.rs index 664add56..3467e6ee 100644 --- a/native/web/src/input_ffi.rs +++ b/native/web/src/input_ffi.rs @@ -1,4 +1,4 @@ -//! Input FFI surface for web: keyboard/mouse/gamepad/touch getters plus +//! Input FFI surface for web: keyboard/mouse/gamepad/touch getters plus //! the injection entry points the JS glue calls from DOM event //! listeners. Split from lib.rs (2000-line file policy). @@ -11,22 +11,38 @@ use wasm_bindgen::prelude::*; #[wasm_bindgen] pub fn bloom_is_key_pressed(key: f64) -> f64 { - if engine().input.is_key_pressed(key as usize) { 1.0 } else { 0.0 } + if engine().input.is_key_pressed(key as usize) { + 1.0 + } else { + 0.0 + } } #[wasm_bindgen] -pub fn bloom_is_key_down(key: f64) -> f64 { - if engine().input.is_key_down(key as usize) { 1.0 } else { 0.0 } +pub fn bloom_is_key_repeated(key: f64) -> f64 { + if engine().input.is_key_repeated(key as usize) { + 1.0 + } else { + 0.0 + } } #[wasm_bindgen] -pub fn bloom_is_key_released(key: f64) -> f64 { - if engine().input.is_key_released(key as usize) { 1.0 } else { 0.0 } +pub fn bloom_is_key_down(key: f64) -> f64 { + if engine().input.is_key_down(key as usize) { + 1.0 + } else { + 0.0 + } } #[wasm_bindgen] -pub fn bloom_is_key_repeated(key: f64) -> f64 { - if engine().input.is_key_repeated(key as usize) { 1.0 } else { 0.0 } +pub fn bloom_is_key_released(key: f64) -> f64 { + if engine().input.is_key_released(key as usize) { + 1.0 + } else { + 0.0 + } } // ============================================================ @@ -45,17 +61,29 @@ pub fn bloom_get_mouse_y() -> f64 { #[wasm_bindgen] pub fn bloom_is_mouse_button_pressed(btn: f64) -> f64 { - if engine().input.is_mouse_button_pressed(btn as usize) { 1.0 } else { 0.0 } + if engine().input.is_mouse_button_pressed(btn as usize) { + 1.0 + } else { + 0.0 + } } #[wasm_bindgen] pub fn bloom_is_mouse_button_down(btn: f64) -> f64 { - if engine().input.is_mouse_button_down(btn as usize) { 1.0 } else { 0.0 } + if engine().input.is_mouse_button_down(btn as usize) { + 1.0 + } else { + 0.0 + } } #[wasm_bindgen] pub fn bloom_is_mouse_button_released(btn: f64) -> f64 { - if engine().input.is_mouse_button_released(btn as usize) { 1.0 } else { 0.0 } + if engine().input.is_mouse_button_released(btn as usize) { + 1.0 + } else { + 0.0 + } } #[wasm_bindgen] @@ -120,7 +148,11 @@ pub fn bloom_get_model_bounds_max_z(model_handle: f64) -> f64 { // prevent; the web crate hand-mirrors it and drifted. #[wasm_bindgen] pub fn bloom_is_gamepad_available() -> f64 { - if engine().input.is_gamepad_available() { 1.0 } else { 0.0 } + if engine().input.is_gamepad_available() { + 1.0 + } else { + 0.0 + } } #[wasm_bindgen] @@ -130,17 +162,29 @@ pub fn bloom_get_gamepad_axis(axis: f64) -> f64 { #[wasm_bindgen] pub fn bloom_is_gamepad_button_pressed(button: f64) -> f64 { - if engine().input.is_gamepad_button_pressed(button as usize) { 1.0 } else { 0.0 } + if engine().input.is_gamepad_button_pressed(button as usize) { + 1.0 + } else { + 0.0 + } } #[wasm_bindgen] pub fn bloom_is_gamepad_button_down(button: f64) -> f64 { - if engine().input.is_gamepad_button_down(button as usize) { 1.0 } else { 0.0 } + if engine().input.is_gamepad_button_down(button as usize) { + 1.0 + } else { + 0.0 + } } #[wasm_bindgen] pub fn bloom_is_gamepad_button_released(button: f64) -> f64 { - if engine().input.is_gamepad_button_released(button as usize) { 1.0 } else { 0.0 } + if engine().input.is_gamepad_button_released(button as usize) { + 1.0 + } else { + 0.0 + } } #[wasm_bindgen] @@ -173,7 +217,11 @@ pub fn bloom_get_touch_count() -> f64 { #[wasm_bindgen] pub fn bloom_is_touch_active(index: f64) -> f64 { - if engine().input.is_touch_active(index as usize) { 1.0 } else { 0.0 } + if engine().input.is_touch_active(index as usize) { + 1.0 + } else { + 0.0 + } } #[wasm_bindgen] @@ -237,7 +285,9 @@ pub fn bloom_inject_mouse_wheel(dy: f64) { #[wasm_bindgen] pub fn bloom_inject_touch(index: f64, x: f64, y: f64, active: f64) { - engine().input.set_touch(index as usize, x, y, active != 0.0); + engine() + .input + .set_touch(index as usize, x, y, active != 0.0); } /// Deferred release: keeps the slot active for the current frame and clears @@ -268,7 +318,6 @@ pub fn bloom_inject_gamepad_button_up(button: f64) { engine().input.set_gamepad_button_up(button as usize); } - /// EN-031 — gamepad rumble. The Gamepad API exposes vibrationActuator, but /// only behind a user-gesture requirement and with patchy support, so the web /// port records the request (keeping the symbol and the state consistent with @@ -283,4 +332,3 @@ pub fn bloom_gamepad_rumble(low: f64, high: f64, seconds: f64) { (seconds as f32).clamp(0.0, 10.0), ]; } - diff --git a/native/web/src/lib.rs b/native/web/src/lib.rs index fee6527e..0469fa2b 100644 --- a/native/web/src/lib.rs +++ b/native/web/src/lib.rs @@ -1,9 +1,9 @@ use bloom_shared::engine::EngineState; use bloom_shared::renderer::Renderer; -use wasm_bindgen::prelude::*; -use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; +use wasm_bindgen::prelude::*; static mut ENGINE: OnceLock = OnceLock::new(); static mut LAST_PROJECT: (f64, f64) = (0.0, 0.0); @@ -53,7 +53,13 @@ impl wgpu::rwh::HasDisplayHandle for WebDisplay { /// orchestrator to gate Perry boot until the engine is actually usable. #[wasm_bindgen] pub fn bloom_is_initialized() -> f64 { - unsafe { if ENGINE.get().is_some() { 1.0 } else { 0.0 } } + unsafe { + if ENGINE.get().is_some() { + 1.0 + } else { + 0.0 + } + } } /// Host-surface attach (PerryTS/perry#5519). Not applicable on web: the @@ -123,8 +129,7 @@ pub fn bloom_init_window(width: f64, height: f64, _title: f64, fullscreen: f64) .expect("No WebGPU/WebGL adapter found"); // BC feature -> cooked BC7 textures upload compressed (else CPU-decode). - let required_features = - adapter.features() & wgpu::Features::TEXTURE_COMPRESSION_BC; + let required_features = adapter.features() & wgpu::Features::TEXTURE_COMPRESSION_BC; // On GL, `Limits::default()` is unsatisfiable: WebGL2 has no compute, so // `max_compute_workgroups_per_dimension` is 0 against a default request of // 65535 and device creation fails outright. Ask that backend for exactly what @@ -142,24 +147,22 @@ pub fn bloom_init_window(width: f64, height: f64, _title: f64, fullscreen: f64) } else { let a = adapter.limits(); let mut l = wgpu::Limits::default(); - l.max_sampled_textures_per_shader_stage = - a.max_sampled_textures_per_shader_stage - .max(l.max_sampled_textures_per_shader_stage); - l.max_samplers_per_shader_stage = - a.max_samplers_per_shader_stage.max(l.max_samplers_per_shader_stage); - l.max_texture_array_layers = - a.max_texture_array_layers.max(l.max_texture_array_layers); + l.max_sampled_textures_per_shader_stage = a + .max_sampled_textures_per_shader_stage + .max(l.max_sampled_textures_per_shader_stage); + l.max_samplers_per_shader_stage = a + .max_samplers_per_shader_stage + .max(l.max_samplers_per_shader_stage); + l.max_texture_array_layers = a.max_texture_array_layers.max(l.max_texture_array_layers); l }; let (device, queue) = adapter - .request_device( - &wgpu::DeviceDescriptor { - label: Some("bloom_device"), - required_features, - required_limits, - ..Default::default() - }, - ) + .request_device(&wgpu::DeviceDescriptor { + label: Some("bloom_device"), + required_features, + required_limits, + ..Default::default() + }) .await .expect("Failed to create device"); @@ -333,8 +336,20 @@ pub use ragdoll_ffi::*; // ============================================================ #[wasm_bindgen] -pub fn bloom_draw_line(x1: f64, y1: f64, x2: f64, y2: f64, thickness: f64, r: f64, g: f64, b: f64, a: f64) { - engine().renderer.draw_line(x1, y1, x2, y2, thickness, r, g, b, a); +pub fn bloom_draw_line( + x1: f64, + y1: f64, + x2: f64, + y2: f64, + thickness: f64, + r: f64, + g: f64, + b: f64, + a: f64, +) { + engine() + .renderer + .draw_line(x1, y1, x2, y2, thickness, r, g, b, a); } #[wasm_bindgen] @@ -343,8 +358,20 @@ pub fn bloom_draw_rect(x: f64, y: f64, w: f64, h: f64, r: f64, g: f64, b: f64, a } #[wasm_bindgen] -pub fn bloom_draw_rect_lines(x: f64, y: f64, w: f64, h: f64, thickness: f64, r: f64, g: f64, b: f64, a: f64) { - engine().renderer.draw_rect_lines(x, y, w, h, thickness, r, g, b, a); +pub fn bloom_draw_rect_lines( + x: f64, + y: f64, + w: f64, + h: f64, + thickness: f64, + r: f64, + g: f64, + b: f64, + a: f64, +) { + engine() + .renderer + .draw_rect_lines(x, y, w, h, thickness, r, g, b, a); } #[wasm_bindgen] @@ -354,17 +381,44 @@ pub fn bloom_draw_circle(cx: f64, cy: f64, radius: f64, r: f64, g: f64, b: f64, #[wasm_bindgen] pub fn bloom_draw_circle_lines(cx: f64, cy: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) { - engine().renderer.draw_circle_lines(cx, cy, radius, r, g, b, a); -} - -#[wasm_bindgen] -pub fn bloom_draw_triangle(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64, r: f64, g: f64, b: f64, a: f64) { - engine().renderer.draw_triangle(x1, y1, x2, y2, x3, y3, r, g, b, a); -} - -#[wasm_bindgen] -pub fn bloom_draw_poly(cx: f64, cy: f64, sides: f64, radius: f64, rotation: f64, r: f64, g: f64, b: f64, a: f64) { - engine().renderer.draw_poly(cx, cy, sides, radius, rotation, r, g, b, a); + engine() + .renderer + .draw_circle_lines(cx, cy, radius, r, g, b, a); +} + +#[wasm_bindgen] +pub fn bloom_draw_triangle( + x1: f64, + y1: f64, + x2: f64, + y2: f64, + x3: f64, + y3: f64, + r: f64, + g: f64, + b: f64, + a: f64, +) { + engine() + .renderer + .draw_triangle(x1, y1, x2, y2, x3, y3, r, g, b, a); +} + +#[wasm_bindgen] +pub fn bloom_draw_poly( + cx: f64, + cy: f64, + sides: f64, + radius: f64, + rotation: f64, + r: f64, + g: f64, + b: f64, + a: f64, +) { + engine() + .renderer + .draw_poly(cx, cy, sides, radius, rotation, r, g, b, a); } // ============================================================ @@ -372,11 +426,21 @@ pub fn bloom_draw_poly(cx: f64, cy: f64, sides: f64, radius: f64, rotation: f64, // ============================================================ #[wasm_bindgen] -pub fn bloom_begin_mode_2d(offset_x: f64, offset_y: f64, target_x: f64, target_y: f64, rotation: f64, zoom: f64) { +pub fn bloom_begin_mode_2d( + offset_x: f64, + offset_y: f64, + target_x: f64, + target_y: f64, + rotation: f64, + zoom: f64, +) { engine().renderer.begin_mode_2d( - offset_x as f32, offset_y as f32, - target_x as f32, target_y as f32, - rotation as f32, zoom as f32, + offset_x as f32, + offset_y as f32, + target_x as f32, + target_y as f32, + rotation as f32, + zoom as f32, ); } @@ -391,16 +455,30 @@ pub fn bloom_end_mode_2d() { #[wasm_bindgen] pub fn bloom_begin_mode_3d( - pos_x: f64, pos_y: f64, pos_z: f64, - target_x: f64, target_y: f64, target_z: f64, - up_x: f64, up_y: f64, up_z: f64, - fovy: f64, projection: f64, + pos_x: f64, + pos_y: f64, + pos_z: f64, + target_x: f64, + target_y: f64, + target_z: f64, + up_x: f64, + up_y: f64, + up_z: f64, + fovy: f64, + projection: f64, ) { engine().renderer.begin_mode_3d( - pos_x as f32, pos_y as f32, pos_z as f32, - target_x as f32, target_y as f32, target_z as f32, - up_x as f32, up_y as f32, up_z as f32, - fovy as f32, projection as f32, + pos_x as f32, + pos_y as f32, + pos_z as f32, + target_x as f32, + target_y as f32, + target_z as f32, + up_x as f32, + up_y as f32, + up_z as f32, + fovy as f32, + projection as f32, ); } @@ -410,28 +488,78 @@ pub fn bloom_end_mode_3d() { } #[wasm_bindgen] -pub fn bloom_draw_cube(x: f64, y: f64, z: f64, w: f64, h: f64, d: f64, r: f64, g: f64, b: f64, a: f64) { +pub fn bloom_draw_cube( + x: f64, + y: f64, + z: f64, + w: f64, + h: f64, + d: f64, + r: f64, + g: f64, + b: f64, + a: f64, +) { engine().renderer.draw_cube(x, y, z, w, h, d, r, g, b, a); } #[wasm_bindgen] -pub fn bloom_draw_cube_wires(x: f64, y: f64, z: f64, w: f64, h: f64, d: f64, r: f64, g: f64, b: f64, a: f64) { - engine().renderer.draw_cube_wires(x, y, z, w, h, d, r, g, b, a); +pub fn bloom_draw_cube_wires( + x: f64, + y: f64, + z: f64, + w: f64, + h: f64, + d: f64, + r: f64, + g: f64, + b: f64, + a: f64, +) { + engine() + .renderer + .draw_cube_wires(x, y, z, w, h, d, r, g, b, a); } #[wasm_bindgen] pub fn bloom_draw_sphere(cx: f64, cy: f64, cz: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) { - engine().renderer.draw_sphere(cx, cy, cz, radius, r, g, b, a); -} - -#[wasm_bindgen] -pub fn bloom_draw_sphere_wires(cx: f64, cy: f64, cz: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) { - engine().renderer.draw_sphere_wires(cx, cy, cz, radius, r, g, b, a); + engine() + .renderer + .draw_sphere(cx, cy, cz, radius, r, g, b, a); } #[wasm_bindgen] -pub fn bloom_draw_cylinder(x: f64, y: f64, z: f64, radius_top: f64, radius_bottom: f64, height: f64, r: f64, g: f64, b: f64, a: f64) { - engine().renderer.draw_cylinder(x, y, z, radius_top, radius_bottom, height, r, g, b, a); +pub fn bloom_draw_sphere_wires( + cx: f64, + cy: f64, + cz: f64, + radius: f64, + r: f64, + g: f64, + b: f64, + a: f64, +) { + engine() + .renderer + .draw_sphere_wires(cx, cy, cz, radius, r, g, b, a); +} + +#[wasm_bindgen] +pub fn bloom_draw_cylinder( + x: f64, + y: f64, + z: f64, + radius_top: f64, + radius_bottom: f64, + height: f64, + r: f64, + g: f64, + b: f64, + a: f64, +) { + engine() + .renderer + .draw_cylinder(x, y, z, radius_top, radius_bottom, height, r, g, b, a); } #[wasm_bindgen] @@ -445,8 +573,21 @@ pub fn bloom_draw_grid(slices: f64, spacing: f64) { } #[wasm_bindgen] -pub fn bloom_draw_ray(origin_x: f64, origin_y: f64, origin_z: f64, dir_x: f64, dir_y: f64, dir_z: f64, r: f64, g: f64, b: f64, a: f64) { - engine().renderer.draw_ray(origin_x, origin_y, origin_z, dir_x, dir_y, dir_z, r, g, b, a); +pub fn bloom_draw_ray( + origin_x: f64, + origin_y: f64, + origin_z: f64, + dir_x: f64, + dir_y: f64, + dir_z: f64, + r: f64, + g: f64, + b: f64, + a: f64, +) { + engine().renderer.draw_ray( + origin_x, origin_y, origin_z, dir_x, dir_y, dir_z, r, g, b, a, + ); } // ============================================================ @@ -456,20 +597,34 @@ pub fn bloom_draw_ray(origin_x: f64, origin_y: f64, origin_z: f64, dir_x: f64, d // The original FFI functions accept f64 (NaN-boxed string handles from Perry WASM). // The JS glue intercepts these and calls the _str variants below with actual strings. #[wasm_bindgen] -pub fn bloom_draw_text(_text: f64, _x: f64, _y: f64, _size: f64, _r: f64, _g: f64, _b: f64, _a: f64) { +pub fn bloom_draw_text( + _text: f64, + _x: f64, + _y: f64, + _size: f64, + _r: f64, + _g: f64, + _b: f64, + _a: f64, +) { // No-op: JS glue calls bloom_draw_text_str instead } #[wasm_bindgen] pub fn bloom_draw_text_str(text: &str, x: f64, y: f64, size: f64, r: f64, g: f64, b: f64, a: f64) { let eng = engine(); - let mut text_renderer = std::mem::replace(&mut eng.text, bloom_shared::text_renderer::TextRenderer::empty()); + let mut text_renderer = std::mem::replace( + &mut eng.text, + bloom_shared::text_renderer::TextRenderer::empty(), + ); text_renderer.draw_text(&mut eng.renderer, text, x, y, size as u32, r, g, b, a); eng.text = text_renderer; } #[wasm_bindgen] -pub fn bloom_measure_text(_text: f64, _size: f64) -> f64 { 0.0 } +pub fn bloom_measure_text(_text: f64, _size: f64) -> f64 { + 0.0 +} #[wasm_bindgen] pub fn bloom_measure_text_str(text: &str, size: f64) -> f64 { @@ -477,7 +632,9 @@ pub fn bloom_measure_text_str(text: &str, size: f64) -> f64 { } #[wasm_bindgen] -pub fn bloom_load_font(_path: f64, _size: f64) -> f64 { 0.0 } +pub fn bloom_load_font(_path: f64, _size: f64) -> f64 { + 0.0 +} /// Load a font from raw bytes (fetched by JS glue via fetch()). #[wasm_bindgen] @@ -491,24 +648,65 @@ pub fn bloom_unload_font(font_handle: f64) { } #[wasm_bindgen] -pub fn bloom_draw_text_ex(_font_handle: f64, _text: f64, _x: f64, _y: f64, _size: f64, _spacing: f64, _r: f64, _g: f64, _b: f64, _a: f64) { +pub fn bloom_draw_text_ex( + _font_handle: f64, + _text: f64, + _x: f64, + _y: f64, + _size: f64, + _spacing: f64, + _r: f64, + _g: f64, + _b: f64, + _a: f64, +) { // No-op: JS glue calls bloom_draw_text_ex_str instead } #[wasm_bindgen] -pub fn bloom_draw_text_ex_str(font_handle: f64, text: &str, x: f64, y: f64, size: f64, spacing: f64, r: f64, g: f64, b: f64, a: f64) { +pub fn bloom_draw_text_ex_str( + font_handle: f64, + text: &str, + x: f64, + y: f64, + size: f64, + spacing: f64, + r: f64, + g: f64, + b: f64, + a: f64, +) { let eng = engine(); - let mut text_renderer = std::mem::replace(&mut eng.text, bloom_shared::text_renderer::TextRenderer::empty()); - text_renderer.draw_text_ex(&mut eng.renderer, font_handle as usize, text, x, y, size as u32, spacing as f32, r, g, b, a); + let mut text_renderer = std::mem::replace( + &mut eng.text, + bloom_shared::text_renderer::TextRenderer::empty(), + ); + text_renderer.draw_text_ex( + &mut eng.renderer, + font_handle as usize, + text, + x, + y, + size as u32, + spacing as f32, + r, + g, + b, + a, + ); eng.text = text_renderer; } #[wasm_bindgen] -pub fn bloom_measure_text_ex(_font_handle: f64, _text: f64, _size: f64, _spacing: f64) -> f64 { 0.0 } +pub fn bloom_measure_text_ex(_font_handle: f64, _text: f64, _size: f64, _spacing: f64) -> f64 { + 0.0 +} #[wasm_bindgen] pub fn bloom_measure_text_ex_str(font_handle: f64, text: &str, size: f64, spacing: f64) -> f64 { - engine().text.measure_text_ex(font_handle as usize, text, size as u32, spacing as f32) + engine() + .text + .measure_text_ex(font_handle as usize, text, size as u32, spacing as f32) } // --- Texture loading from bytes (fetched by JS glue) --- @@ -518,7 +716,8 @@ pub fn bloom_measure_text_ex_str(font_handle: f64, text: &str, size: f64, spacin pub fn bloom_load_texture_bytes(data: &[u8]) -> f64 { let eng = engine(); let renderer_ptr = &mut eng.renderer as *mut bloom_shared::renderer::Renderer; - eng.textures.load_texture(unsafe { &mut *renderer_ptr }, data) + eng.textures + .load_texture(unsafe { &mut *renderer_ptr }, data) } // ============================================================ @@ -535,35 +734,67 @@ pub fn bloom_load_texture(_path: f64) -> f64 { pub fn bloom_unload_texture(handle: f64) { let eng = engine(); let renderer_ptr = &mut eng.renderer as *mut Renderer; - eng.textures.unload_texture(handle, unsafe { &mut *renderer_ptr }); + eng.textures + .unload_texture(handle, unsafe { &mut *renderer_ptr }); } #[wasm_bindgen] -pub fn bloom_draw_texture(handle: f64, x: f64, y: f64, tint_r: f64, tint_g: f64, tint_b: f64, tint_a: f64) { +pub fn bloom_draw_texture( + handle: f64, + x: f64, + y: f64, + tint_r: f64, + tint_g: f64, + tint_b: f64, + tint_a: f64, +) { let eng = engine(); if let Some(tex) = eng.textures.get(handle) { let bind_group_idx = tex.bind_group_idx; - eng.renderer.draw_texture(bind_group_idx, x, y, tint_r, tint_g, tint_b, tint_a); + eng.renderer + .draw_texture(bind_group_idx, x, y, tint_r, tint_g, tint_b, tint_a); } } #[wasm_bindgen] pub fn bloom_draw_texture_pro( handle: f64, - src_x: f64, src_y: f64, src_w: f64, src_h: f64, - dst_x: f64, dst_y: f64, dst_w: f64, dst_h: f64, - origin_x: f64, origin_y: f64, rotation: f64, - tint_r: f64, tint_g: f64, tint_b: f64, tint_a: f64, + src_x: f64, + src_y: f64, + src_w: f64, + src_h: f64, + dst_x: f64, + dst_y: f64, + dst_w: f64, + dst_h: f64, + origin_x: f64, + origin_y: f64, + rotation: f64, + tint_r: f64, + tint_g: f64, + tint_b: f64, + tint_a: f64, ) { let eng = engine(); if let Some(tex) = eng.textures.get(handle) { let bind_group_idx = tex.bind_group_idx; eng.renderer.draw_texture_pro( bind_group_idx, - src_x, src_y, src_w, src_h, - dst_x, dst_y, dst_w, dst_h, - origin_x, origin_y, rotation, - tint_r, tint_g, tint_b, tint_a, + src_x, + src_y, + src_w, + src_h, + dst_x, + dst_y, + dst_w, + dst_h, + origin_x, + origin_y, + rotation, + tint_r, + tint_g, + tint_b, + tint_a, ); } } @@ -571,34 +802,58 @@ pub fn bloom_draw_texture_pro( #[wasm_bindgen] pub fn bloom_draw_texture_rec( handle: f64, - src_x: f64, src_y: f64, src_w: f64, src_h: f64, - dst_x: f64, dst_y: f64, - tint_r: f64, tint_g: f64, tint_b: f64, tint_a: f64, + src_x: f64, + src_y: f64, + src_w: f64, + src_h: f64, + dst_x: f64, + dst_y: f64, + tint_r: f64, + tint_g: f64, + tint_b: f64, + tint_a: f64, ) { let eng = engine(); if let Some(tex) = eng.textures.get(handle) { let bind_group_idx = tex.bind_group_idx; eng.renderer.draw_texture_rec( bind_group_idx, - src_x, src_y, src_w, src_h, - dst_x, dst_y, - tint_r, tint_g, tint_b, tint_a, + src_x, + src_y, + src_w, + src_h, + dst_x, + dst_y, + tint_r, + tint_g, + tint_b, + tint_a, ); } } #[wasm_bindgen] pub fn bloom_get_texture_width(handle: f64) -> f64 { - engine().textures.get(handle).map(|t| t.width as f64).unwrap_or(0.0) + engine() + .textures + .get(handle) + .map(|t| t.width as f64) + .unwrap_or(0.0) } #[wasm_bindgen] pub fn bloom_get_texture_height(handle: f64) -> f64 { - engine().textures.get(handle).map(|t| t.height as f64).unwrap_or(0.0) + engine() + .textures + .get(handle) + .map(|t| t.height as f64) + .unwrap_or(0.0) } #[wasm_bindgen] -pub fn bloom_load_image(_path: f64) -> f64 { 0.0 } +pub fn bloom_load_image(_path: f64) -> f64 { + 0.0 +} #[wasm_bindgen] pub fn bloom_load_image_bytes(data: &[u8]) -> f64 { @@ -612,7 +867,9 @@ pub fn bloom_image_resize(handle: f64, w: f64, h: f64) { #[wasm_bindgen] pub fn bloom_image_crop(handle: f64, x: f64, y: f64, w: f64, h: f64) { - engine().textures.image_crop(handle, x as u32, y as u32, w as u32, h as u32); + engine() + .textures + .image_crop(handle, x as u32, y as u32, w as u32, h as u32); } #[wasm_bindgen] @@ -629,7 +886,8 @@ pub fn bloom_image_flip_v(handle: f64) { pub fn bloom_load_texture_from_image(handle: f64) -> f64 { let eng = engine(); let renderer_ptr = &mut eng.renderer as *mut Renderer; - eng.textures.load_texture_from_image(handle, unsafe { &mut *renderer_ptr }) + eng.textures + .load_texture_from_image(handle, unsafe { &mut *renderer_ptr }) } #[wasm_bindgen] @@ -651,7 +909,9 @@ pub fn bloom_set_texture_filter(handle: f64, mode: f64) { // ============================================================ #[wasm_bindgen] -pub fn bloom_load_model(_path: f64) -> f64 { 0.0 } +pub fn bloom_load_model(_path: f64) -> f64 { + 0.0 +} #[wasm_bindgen] pub fn bloom_load_model_bytes(data: &[u8]) -> f64 { @@ -660,14 +920,32 @@ pub fn bloom_load_model_bytes(data: &[u8]) -> f64 { } #[wasm_bindgen] -pub fn bloom_draw_model(handle: f64, x: f64, y: f64, z: f64, scale: f64, r: f64, g: f64, b: f64, a: f64) { +pub fn bloom_draw_model( + handle: f64, + x: f64, + y: f64, + z: f64, + scale: f64, + r: f64, + g: f64, + b: f64, + a: f64, +) { let eng = engine(); if let Some(model) = eng.models.get(handle) { let position = [x as f32, y as f32, z as f32]; let scale = scale as f32; - let tint = [(r / 255.0) as f32, (g / 255.0) as f32, (b / 255.0) as f32, (a / 255.0) as f32]; + let tint = [ + (r / 255.0) as f32, + (g / 255.0) as f32, + (b / 255.0) as f32, + (a / 255.0) as f32, + ]; let handle_bits = handle.to_bits(); - if eng.renderer.cache_model_if_static(handle_bits, &model.meshes) { + if eng + .renderer + .cache_model_if_static(handle_bits, &model.meshes) + { // Skinned models cache too (bind-pose VB with raw joint indices, // skinned in the scene VS) — they MUST take the skinned cached // draw, which pops the staged pose ONCE for the whole model and @@ -677,9 +955,11 @@ pub fn bloom_draw_model(handle: f64, x: f64, y: f64, z: f64, scale: f64, r: f64, // character animated wrongly on web while native was correct. // Mirrors the shared macro in ffi_core/models.rs. if eng.renderer.is_model_skinned(handle_bits) { - eng.renderer.draw_model_cached_skinned(handle_bits, position, scale, tint); + eng.renderer + .draw_model_cached_skinned(handle_bits, position, scale, tint); } else { - eng.renderer.draw_model_cached(handle_bits, position, scale, tint); + eng.renderer + .draw_model_cached(handle_bits, position, scale, tint); } } } @@ -702,22 +982,31 @@ pub fn bloom_draw_model(handle: f64, x: f64, y: f64, z: f64, scale: f64, r: f64, #[allow(clippy::too_many_arguments)] pub fn bloom_draw_model_transform16( handle: f64, - m0: f64, m1: f64, m2: f64, m3: f64, - m4: f64, m5: f64, m6: f64, m7: f64, - m8: f64, m9: f64, m10: f64, m11: f64, - m12: f64, m13: f64, m14: f64, m15: f64, + m0: f64, + m1: f64, + m2: f64, + m3: f64, + m4: f64, + m5: f64, + m6: f64, + m7: f64, + m8: f64, + m9: f64, + m10: f64, + m11: f64, + m12: f64, + m13: f64, + m14: f64, + m15: f64, color_packed_argb: f64, ) { let bits = color_packed_argb as u32; let a = ((bits >> 24) & 0xff) as f32 / 255.0; let r = ((bits >> 16) & 0xff) as f32 / 255.0; - let g = ((bits >> 8) & 0xff) as f32 / 255.0; - let b = ( bits & 0xff) as f32 / 255.0; + let g = ((bits >> 8) & 0xff) as f32 / 255.0; + let b = (bits & 0xff) as f32 / 255.0; let s = [ - m0, m1, m2, m3, - m4, m5, m6, m7, - m8, m9, m10, m11, - m12, m13, m14, m15, + m0, m1, m2, m3, m4, m5, m6, m7, m8, m9, m10, m11, m12, m13, m14, m15, ]; // Column-major, same unpacking as the shared macro. let mut mat = [[0.0f32; 4]; 4]; @@ -729,26 +1018,34 @@ pub fn bloom_draw_model_transform16( let eng = engine(); if let Some(model) = eng.models.get(handle) { let handle_bits = handle.to_bits(); - if eng.renderer.cache_model_if_static(handle_bits, &model.meshes) { + if eng + .renderer + .cache_model_if_static(handle_bits, &model.meshes) + { if eng.renderer.is_model_skinned(handle_bits) { return; } - eng.renderer.draw_model_cached_transform(handle_bits, mat, [r, g, b, a]); + eng.renderer + .draw_model_cached_transform(handle_bits, mat, [r, g, b, a]); } } } #[wasm_bindgen] pub fn bloom_draw_model_rotated( - handle: f64, x: f64, y: f64, z: f64, - scale: f64, rot_y: f64, + handle: f64, + x: f64, + y: f64, + z: f64, + scale: f64, + rot_y: f64, color_packed_argb: f64, ) { let bits = color_packed_argb as u32; let a = ((bits >> 24) & 0xff) as f32 / 255.0; let r = ((bits >> 16) & 0xff) as f32 / 255.0; - let g = ((bits >> 8) & 0xff) as f32 / 255.0; - let b = ( bits & 0xff) as f32 / 255.0; + let g = ((bits >> 8) & 0xff) as f32 / 255.0; + let b = (bits & 0xff) as f32 / 255.0; let eng = engine(); if let Some(model) = eng.models.get(handle) { let position = [x as f32, y as f32, z as f32]; @@ -766,12 +1063,20 @@ pub fn bloom_draw_model_rotated( // the immediate path here: round 5 fixed native and the web copy was // never updated, so every tree in a browser rendered as torn opaque // sheets — the very artifact that fix was written for. - if eng.renderer.cache_model_if_static(handle_bits, &model.meshes) { + if eng + .renderer + .cache_model_if_static(handle_bits, &model.meshes) + { if eng.renderer.is_model_skinned(handle_bits) { - eng.renderer.draw_model_cached_skinned(handle_bits, position, scale, tint); + eng.renderer + .draw_model_cached_skinned(handle_bits, position, scale, tint); } else { eng.renderer.draw_model_cached_rotated( - handle_bits, position, scale, rot_y as f32, tint, + handle_bits, + position, + scale, + rot_y as f32, + tint, ); } } @@ -800,7 +1105,8 @@ pub fn bloom_gen_mesh_heightmap(image_handle: f64, size_x: f64, size_y: f64, siz let data = img.data.clone(); let w = img.width; let h = img.height; - eng.models.gen_mesh_heightmap(&data, w, h, size_x as f32, size_y as f32, size_z as f32) + eng.models + .gen_mesh_heightmap(&data, w, h, size_x as f32, size_y as f32, size_z as f32) } else { 0.0 } @@ -813,7 +1119,12 @@ pub fn bloom_load_shader(_source: f64) -> f64 { } #[wasm_bindgen] -pub fn bloom_create_mesh(_vertex_ptr: f64, _vertex_count: f64, _index_ptr: f64, _index_count: f64) -> f64 { +pub fn bloom_create_mesh( + _vertex_ptr: f64, + _vertex_count: f64, + _index_ptr: f64, + _index_count: f64, +) -> f64 { // TODO: Phase 4 — need to handle pointer passing from WASM linear memory 0.0 } @@ -823,57 +1134,99 @@ pub fn bloom_create_mesh(_vertex_ptr: f64, _vertex_count: f64, _index_ptr: f64, // ============================================================ #[wasm_bindgen] -pub fn bloom_set_ambient_light(r: f64, g: f64, b: f64, intensity: f64) { +pub fn bloom_set_ambient_light(r: f64, g: f64, b: f64, intensity: f64) -> f64 { engine().renderer.set_ambient_light(r, g, b, intensity); + 1.0 } #[wasm_bindgen] -pub fn bloom_set_directional_light(dx: f64, dy: f64, dz: f64, r: f64, g: f64, b: f64, intensity: f64) { - engine().renderer.set_directional_light(dx, dy, dz, r, g, b, intensity); +pub fn bloom_set_directional_light( + dx: f64, + dy: f64, + dz: f64, + r: f64, + g: f64, + b: f64, + intensity: f64, +) -> f64 { + engine() + .renderer + .set_directional_light(dx, dy, dz, r, g, b, intensity); + 1.0 } #[wasm_bindgen] -pub fn bloom_set_procedural_sky(enabled: f64, rayleigh_density: f64, mie_density: f64, ground_albedo: f64) { +pub fn bloom_set_procedural_sky( + enabled: f64, + rayleigh_density: f64, + mie_density: f64, + ground_albedo: f64, +) -> f64 { engine().renderer.set_procedural_sky( enabled != 0.0, rayleigh_density as f32, mie_density as f32, ground_albedo as f32, ); + 1.0 } #[wasm_bindgen] -pub fn bloom_set_sun_direction(dx: f64, dy: f64, dz: f64, intensity: f64) { - engine().renderer.set_sun_direction(dx as f32, dy as f32, dz as f32, intensity as f32); +pub fn bloom_set_sun_direction(dx: f64, dy: f64, dz: f64, intensity: f64) -> f64 { + engine() + .renderer + .set_sun_direction(dx as f32, dy as f32, dz as f32, intensity as f32); + 1.0 } #[wasm_bindgen] -pub fn bloom_set_joint_test(joint_index: f64, angle: f64) { - engine().renderer.set_joint_test(joint_index as usize, angle as f32); +pub fn bloom_set_joint_test(joint_index: f64, angle: f64) -> f64 { + engine() + .renderer + .set_joint_test(joint_index as usize, angle as f32); + 1.0 } #[wasm_bindgen] pub fn bloom_add_directional_light( - dx: f64, dy: f64, dz: f64, - r: f64, g: f64, b: f64, + dx: f64, + dy: f64, + dz: f64, + r: f64, + g: f64, + b: f64, intensity: f64, ) { engine().renderer.add_directional_light( - dx as f32, dy as f32, dz as f32, - r as f32, g as f32, b as f32, + dx as f32, + dy as f32, + dz as f32, + r as f32, + g as f32, + b as f32, intensity as f32, ); } #[wasm_bindgen] pub fn bloom_add_point_light( - x: f64, y: f64, z: f64, range: f64, - r: f64, g: f64, b: f64, + x: f64, + y: f64, + z: f64, + range: f64, + r: f64, + g: f64, + b: f64, intensity: f64, ) { engine().renderer.add_point_light( - x as f32, y as f32, z as f32, range as f32, - r as f32, g as f32, b as f32, + x as f32, + y as f32, + z as f32, + range as f32, + r as f32, + g as f32, + b as f32, intensity as f32, ); } @@ -894,13 +1247,15 @@ pub fn bloom_close_audio() { } #[wasm_bindgen] -pub fn bloom_load_sound(_path: f64) -> f64 { 0.0 } +pub fn bloom_load_sound(_path: f64) -> f64 { + 0.0 +} /// Load a sound from raw file bytes (WAV or OGG). Fetched by JS glue via fetch(). #[wasm_bindgen] pub fn bloom_load_sound_bytes(data: &[u8]) -> f64 { - if let Some(sound) = bloom_shared::audio::parse_wav(data) - .or_else(|| bloom_shared::audio::parse_ogg(data)) + if let Some(sound) = + bloom_shared::audio::parse_wav(data).or_else(|| bloom_shared::audio::parse_ogg(data)) { engine().audio.load_sound(sound) } else { @@ -930,29 +1285,48 @@ pub fn bloom_set_master_volume(volume: f64) { #[wasm_bindgen] pub fn bloom_play_sound_3d(handle: f64, x: f64, y: f64, z: f64) { - engine().audio.play_sound_3d(handle, x as f32, y as f32, z as f32); + engine() + .audio + .play_sound_3d(handle, x as f32, y as f32, z as f32); } #[wasm_bindgen] pub fn bloom_set_listener_position(x: f64, y: f64, z: f64, fx: f64, fy: f64, fz: f64) { - engine().audio.set_listener_position(x as f32, y as f32, z as f32, fx as f32, fy as f32, fz as f32); + engine().audio.set_listener_position( + x as f32, y as f32, z as f32, fx as f32, fy as f32, fz as f32, + ); } // ---- EN-062: live spatial voices (see shared audio_ffi.rs for docs) -------- #[wasm_bindgen] pub fn bloom_play_sound_3d_ex( - handle: f64, x: f64, y: f64, z: f64, - looping: f64, ref_dist: f64, max_dist: f64, rolloff: f64, + handle: f64, + x: f64, + y: f64, + z: f64, + looping: f64, + ref_dist: f64, + max_dist: f64, + rolloff: f64, ) -> f64 { engine().audio.play_sound_3d_ex( - handle, x as f32, y as f32, z as f32, - looping != 0.0, ref_dist as f32, max_dist as f32, rolloff as f32) + handle, + x as f32, + y as f32, + z as f32, + looping != 0.0, + ref_dist as f32, + max_dist as f32, + rolloff as f32, + ) } #[wasm_bindgen] pub fn bloom_voice_set_position(voice: f64, x: f64, y: f64, z: f64) { - engine().audio.set_voice_position(voice, x as f32, y as f32, z as f32); + engine() + .audio + .set_voice_position(voice, x as f32, y as f32, z as f32); } #[wasm_bindgen] @@ -976,13 +1350,15 @@ pub fn bloom_voice_set_lowpass(voice: f64, cutoff: f64) { } #[wasm_bindgen] -pub fn bloom_load_music(_path: f64) -> f64 { 0.0 } +pub fn bloom_load_music(_path: f64) -> f64 { + 0.0 +} /// Load music from raw file bytes (WAV or OGG). Fetched by JS glue via fetch(). #[wasm_bindgen] pub fn bloom_load_music_bytes(data: &[u8]) -> f64 { - if let Some(sound) = bloom_shared::audio::parse_wav(data) - .or_else(|| bloom_shared::audio::parse_ogg(data)) + if let Some(sound) = + bloom_shared::audio::parse_wav(data).or_else(|| bloom_shared::audio::parse_ogg(data)) { engine().audio.load_music(sound) } else { @@ -1019,7 +1395,11 @@ pub fn bloom_set_music_volume(handle: f64, volume: f64) { #[wasm_bindgen] pub fn bloom_is_music_playing(handle: f64) -> f64 { - if engine().audio.is_music_playing(handle) { 1.0 } else { 0.0 } + if engine().audio.is_music_playing(handle) { + 1.0 + } else { + 0.0 + } } // ============================================================ @@ -1037,44 +1417,74 @@ pub fn bloom_scene_destroy_node(handle: f64) { } #[wasm_bindgen] -pub fn bloom_scene_set_visible(handle: f64, visible: f64) { +pub fn bloom_scene_set_visible(handle: f64, visible: f64) -> f64 { engine().scene.set_visible(handle, visible != 0.0); + 1.0 } #[wasm_bindgen] -pub fn bloom_scene_set_cast_shadow(handle: f64, cast: f64) { +pub fn bloom_scene_set_cast_shadow(handle: f64, cast: f64) -> f64 { engine().scene.set_cast_shadow(handle, cast != 0.0); + 1.0 } #[wasm_bindgen] -pub fn bloom_scene_set_receive_shadow(handle: f64, receive: f64) { +pub fn bloom_scene_set_receive_shadow(handle: f64, receive: f64) -> f64 { engine().scene.set_receive_shadow(handle, receive != 0.0); + 1.0 } #[wasm_bindgen] -pub fn bloom_scene_set_parent(handle: f64, parent: f64) { +pub fn bloom_scene_set_parent(handle: f64, parent: f64) -> f64 { engine().scene.set_parent(handle, parent); + 1.0 } #[wasm_bindgen] -pub fn bloom_scene_set_transform(_handle: f64, _matrix_ptr: f64) { +pub fn bloom_scene_set_transform(_handle: f64, _matrix_ptr: f64) -> f64 { // Pointer-taking form — unreachable from Perry on wasm (a `number[]` // cannot cross an i64 param), like the other pointer stubs here. The // 16-scalar `_transform16` below is the live path; it used to carry // THIS name, which meant setSceneNodeTransform resolved to nothing and // was auto-stubbed by Perry's FFI proxy — a silent no-op on web while // the working implementation sat one rename away. + 0.0 +} + +#[wasm_bindgen] +pub fn bloom_scene_set_trs(handle: f64, px: f64, py: f64, pz: f64, yaw: f64, scale: f64) -> f64 { + engine().scene.set_trs( + handle, + px as f32, + py as f32, + pz as f32, + yaw as f32, + scale as f32, + ); + 1.0 } #[wasm_bindgen] #[allow(clippy::too_many_arguments)] pub fn bloom_scene_set_transform16( handle: f64, - m00: f64, m01: f64, m02: f64, m03: f64, - m10: f64, m11: f64, m12: f64, m13: f64, - m20: f64, m21: f64, m22: f64, m23: f64, - m30: f64, m31: f64, m32: f64, m33: f64, -) { + m00: f64, + m01: f64, + m02: f64, + m03: f64, + m10: f64, + m11: f64, + m12: f64, + m13: f64, + m20: f64, + m21: f64, + m22: f64, + m23: f64, + m30: f64, + m31: f64, + m32: f64, + m33: f64, +) -> f64 { // On web we pass the 16 matrix elements as individual f64 args // (no raw pointer passing from WASM) let mat = [ @@ -1084,6 +1494,7 @@ pub fn bloom_scene_set_transform16( [m30 as f32, m31 as f32, m32 as f32, m33 as f32], ]; engine().scene.set_transform(handle, mat); + 1.0 } #[wasm_bindgen] @@ -1100,18 +1511,27 @@ pub fn bloom_scene_update_geometry( } #[wasm_bindgen] -pub fn bloom_scene_set_material_color(handle: f64, r: f64, g: f64, b: f64, a: f64) { - engine().scene.set_material_color(handle, r as f32, g as f32, b as f32, a as f32); +pub fn bloom_scene_set_material_color(handle: f64, r: f64, g: f64, b: f64, a: f64) -> f64 { + engine() + .scene + .set_material_color(handle, r as f32, g as f32, b as f32, a as f32); + 1.0 } #[wasm_bindgen] -pub fn bloom_scene_set_material_pbr(handle: f64, roughness: f64, metalness: f64) { - engine().scene.set_material_pbr(handle, roughness as f32, metalness as f32); +pub fn bloom_scene_set_material_pbr(handle: f64, roughness: f64, metalness: f64) -> f64 { + engine() + .scene + .set_material_pbr(handle, roughness as f32, metalness as f32); + 1.0 } #[wasm_bindgen] -pub fn bloom_scene_set_material_texture(handle: f64, texture_idx: f64) { - engine().scene.set_material_texture(handle, texture_idx as u32); +pub fn bloom_scene_set_material_texture(handle: f64, texture_idx: f64) -> f64 { + engine() + .scene + .set_material_texture(handle, texture_idx as u32); + 1.0 } #[wasm_bindgen] @@ -1144,7 +1564,9 @@ pub fn bloom_scene_attach_model(node_handle: f64, model_handle: f64, mesh_index: Some(md) => md, None => return, }; - if mi >= model_data.meshes.len() { return; } + if mi >= model_data.meshes.len() { + return; + } let mesh = &model_data.meshes[mi]; let vertices = mesh.vertices.clone(); @@ -1166,10 +1588,12 @@ pub fn bloom_scene_attach_model(node_handle: f64, model_handle: f64, mesh_index: eng.scene.set_material_normal_texture(node_handle, tex_idx); } if let Some(tex_idx) = mr_tex { - eng.scene.set_material_metallic_roughness_texture(node_handle, tex_idx); + eng.scene + .set_material_metallic_roughness_texture(node_handle, tex_idx); } if let Some(tex_idx) = emissive_tex { - eng.scene.set_material_emissive_texture(node_handle, tex_idx); + eng.scene + .set_material_emissive_texture(node_handle, tex_idx); } eng.scene.set_material_emissive_factor( node_handle, @@ -1181,8 +1605,10 @@ pub fn bloom_scene_attach_model(node_handle: f64, model_handle: f64, mesh_index: // macro) — dropping them left attached foliage opaque (solid cards) and // every attached mesh at the default roughness 0.8 regardless of its // authored material. - eng.scene.set_material_pbr(node_handle, roughness_factor, metallic_factor); - eng.scene.set_material_alpha_cutoff(node_handle, alpha_cutoff); + eng.scene + .set_material_pbr(node_handle, roughness_factor, metallic_factor); + eng.scene + .set_material_alpha_cutoff(node_handle, alpha_cutoff); } // ============================================================ @@ -1202,8 +1628,12 @@ pub fn bloom_scene_extrude_polygon( #[wasm_bindgen] pub fn bloom_scene_subtract_box( handle: f64, - min_x: f64, min_y: f64, min_z: f64, - max_x: f64, max_y: f64, max_z: f64, + min_x: f64, + min_y: f64, + min_z: f64, + max_x: f64, + max_y: f64, + max_z: f64, ) { let eng = engine(); if let Some(node) = eng.scene.nodes.get(handle) { @@ -1216,7 +1646,8 @@ pub fn bloom_scene_subtract_box( [min_x as f32, min_y as f32, min_z as f32], [max_x as f32, max_y as f32, max_z as f32], ); - eng.scene.update_geometry(handle, result.vertices, result.indices); + eng.scene + .update_geometry(handle, result.vertices, result.indices); } } @@ -1225,13 +1656,15 @@ pub fn bloom_scene_subtract_box( // ============================================================ #[wasm_bindgen] -pub fn bloom_enable_shadows() { +pub fn bloom_enable_shadows() -> f64 { engine().renderer.shadow_map.enable(); + 1.0 } #[wasm_bindgen] -pub fn bloom_disable_shadows() { +pub fn bloom_disable_shadows() -> f64 { engine().renderer.shadow_map.disable(); + 1.0 } // ============================================================ @@ -1245,7 +1678,10 @@ pub fn bloom_enable_postfx() { let h = eng.renderer.height(); let fmt = eng.renderer.surface_format(); eng.postfx = Some(bloom_shared::postfx::PostFxPipeline::new( - &eng.renderer.device, w, h, fmt, + &eng.renderer.device, + w, + h, + fmt, )); } @@ -1255,34 +1691,46 @@ pub fn bloom_disable_postfx() { } #[wasm_bindgen] -pub fn bloom_postfx_set_selected(handle: f64) { +pub fn bloom_postfx_set_selected(handle: f64) -> f64 { if let Some(pfx) = &mut engine().postfx { if handle == 0.0 { pfx.set_selected(Vec::new()); } else { pfx.set_selected(vec![handle]); } + 1.0 + } else { + 0.0 } } #[wasm_bindgen] -pub fn bloom_postfx_set_hovered(handle: f64) { +pub fn bloom_postfx_set_hovered(handle: f64) -> f64 { if let Some(pfx) = &mut engine().postfx { pfx.set_hovered(handle); + 1.0 + } else { + 0.0 } } #[wasm_bindgen] -pub fn bloom_postfx_set_outline_color(r: f64, g: f64, b: f64, a: f64) { +pub fn bloom_postfx_set_outline_color(r: f64, g: f64, b: f64, a: f64) -> f64 { if let Some(pfx) = &mut engine().postfx { pfx.outline_params.color_selected = [r as f32, g as f32, b as f32, a as f32]; + 1.0 + } else { + 0.0 } } #[wasm_bindgen] -pub fn bloom_postfx_set_outline_thickness(thickness: f64) { +pub fn bloom_postfx_set_outline_thickness(thickness: f64) -> f64 { if let Some(pfx) = &mut engine().postfx { pfx.outline_params.thickness[0] = thickness as f32; + 1.0 + } else { + 0.0 } } @@ -1299,14 +1747,24 @@ pub fn bloom_scene_pick(screen_x: f64, screen_y: f64) -> f64 { let h = eng.renderer.height() as f32; let (origin, direction) = bloom_shared::picking::screen_to_ray( - screen_x as f32, screen_y as f32, - w, h, &inv_vp, &cam_pos, + screen_x as f32, + screen_y as f32, + w, + h, + &inv_vp, + &cam_pos, ); let result = bloom_shared::picking::raycast_scene(&eng.scene, &origin, &direction); let hit = result.hit; - unsafe { LAST_PICK = Some(result); } - if hit { 1.0 } else { 0.0 } + unsafe { + LAST_PICK = Some(result); + } + if hit { + 1.0 + } else { + 0.0 + } } #[wasm_bindgen] @@ -1336,17 +1794,32 @@ pub fn bloom_pick_hit_z() -> f64 { #[wasm_bindgen] pub fn bloom_pick_hit_normal_x() -> f64 { - unsafe { LAST_PICK.as_ref().map(|r| r.normal[0] as f64).unwrap_or(0.0) } + unsafe { + LAST_PICK + .as_ref() + .map(|r| r.normal[0] as f64) + .unwrap_or(0.0) + } } #[wasm_bindgen] pub fn bloom_pick_hit_normal_y() -> f64 { - unsafe { LAST_PICK.as_ref().map(|r| r.normal[1] as f64).unwrap_or(0.0) } + unsafe { + LAST_PICK + .as_ref() + .map(|r| r.normal[1] as f64) + .unwrap_or(0.0) + } } #[wasm_bindgen] pub fn bloom_pick_hit_normal_z() -> f64 { - unsafe { LAST_PICK.as_ref().map(|r| r.normal[2] as f64).unwrap_or(0.0) } + unsafe { + LAST_PICK + .as_ref() + .map(|r| r.normal[2] as f64) + .unwrap_or(0.0) + } } // ============================================================ @@ -1363,12 +1836,14 @@ pub fn bloom_project_to_screen(wx: f64, wy: f64, wz: f64) -> f64 { let x = wx as f32; let y = wy as f32; let z = wz as f32; - let clip_x = vp[0][0]*x + vp[1][0]*y + vp[2][0]*z + vp[3][0]; - let clip_y = vp[0][1]*x + vp[1][1]*y + vp[2][1]*z + vp[3][1]; - let clip_w = vp[0][3]*x + vp[1][3]*y + vp[2][3]*z + vp[3][3]; + let clip_x = vp[0][0] * x + vp[1][0] * y + vp[2][0] * z + vp[3][0]; + let clip_y = vp[0][1] * x + vp[1][1] * y + vp[2][1] * z + vp[3][1]; + let clip_w = vp[0][3] * x + vp[1][3] * y + vp[2][3] * z + vp[3][3]; if clip_w <= 0.0 { - unsafe { LAST_PROJECT = (-9999.0, -9999.0); } + unsafe { + LAST_PROJECT = (-9999.0, -9999.0); + } return -9999.0; } @@ -1377,7 +1852,9 @@ pub fn bloom_project_to_screen(wx: f64, wy: f64, wz: f64) -> f64 { let screen_x = ((ndc_x + 1.0) * 0.5 * w) as f64; let screen_y = ((1.0 - ndc_y) * 0.5 * h) as f64; - unsafe { LAST_PROJECT = (screen_x, screen_y); } + unsafe { + LAST_PROJECT = (screen_x, screen_y); + } screen_x } @@ -1484,10 +1961,16 @@ pub fn bloom_commit_texture(staging_handle: f64) -> f64 { None => return 0.0, }; let eng = engine(); - let bind_group_idx = eng.renderer.register_texture(staged.width, staged.height, &staged.data); - eng.textures.textures.alloc(bloom_shared::textures::TextureData { - bind_group_idx, width: staged.width, height: staged.height, - }) + let bind_group_idx = eng + .renderer + .register_texture(staged.width, staged.height, &staged.data); + eng.textures + .textures + .alloc(bloom_shared::textures::TextureData { + bind_group_idx, + width: staged.width, + height: staged.height, + }) } #[wasm_bindgen] @@ -1504,7 +1987,11 @@ pub fn bloom_commit_model(staging_handle: f64) -> f64 { let mut tex_map: Vec = Vec::with_capacity(staged.textures.len()); for tex in &staged.textures { tex_map.push(eng.renderer.register_texture_kind( - tex.width, tex.height, &tex.data, tex.is_normal)); + tex.width, + tex.height, + &tex.data, + tex.is_normal, + )); } let mut model = staged.model; // Remap EVERY texture slot, not just the base colour — dropping the @@ -1559,14 +2046,24 @@ pub fn bloom_get_platform() -> f64 { #[wasm_bindgen] pub fn bloom_get_language() -> f64 { // navigator.language (e.g. "en-US" / "zh-Hans") -> packed 2-letter code. - let lang = web_sys::window().and_then(|w| w.navigator().language()).unwrap_or_default(); + let lang = web_sys::window() + .and_then(|w| w.navigator().language()) + .unwrap_or_default(); let b = lang.to_ascii_lowercase().into_bytes(); - if b.len() >= 2 { (b[0] as f64) * 256.0 + (b[1] as f64) } else { 25966.0 } + if b.len() >= 2 { + (b[0] as f64) * 256.0 + (b[1] as f64) + } else { + 25966.0 + } } #[wasm_bindgen] pub fn bloom_is_any_input_pressed() -> f64 { - if engine().input.is_any_input_pressed() { 1.0 } else { 0.0 } + if engine().input.is_any_input_pressed() { + 1.0 + } else { + 0.0 + } } #[wasm_bindgen] @@ -1574,7 +2071,6 @@ pub fn bloom_get_crown_rotation() -> f64 { engine().input.consume_crown_rotation() } - // Scene graph QoL — Q4/Q5/Q6/Q7 #[wasm_bindgen] pub fn bloom_scene_get_transform(handle: f64, index: f64) -> f64 { @@ -1582,48 +2078,99 @@ pub fn bloom_scene_get_transform(handle: f64, index: f64) -> f64 { let i = index as usize; let col = i / 4; let row = i % 4; - if col < 4 && row < 4 { mat[col][row] as f64 } else { 0.0 } + if col < 4 && row < 4 { + mat[col][row] as f64 + } else { + 0.0 + } } #[wasm_bindgen] -pub fn bloom_scene_get_bounds_min_x(handle: f64) -> f64 { engine().scene.get_bounds(handle).0[0] as f64 } +pub fn bloom_scene_get_bounds_min_x(handle: f64) -> f64 { + engine().scene.get_bounds(handle).0[0] as f64 +} #[wasm_bindgen] -pub fn bloom_scene_get_bounds_min_y(handle: f64) -> f64 { engine().scene.get_bounds(handle).0[1] as f64 } +pub fn bloom_scene_get_bounds_min_y(handle: f64) -> f64 { + engine().scene.get_bounds(handle).0[1] as f64 +} #[wasm_bindgen] -pub fn bloom_scene_get_bounds_min_z(handle: f64) -> f64 { engine().scene.get_bounds(handle).0[2] as f64 } +pub fn bloom_scene_get_bounds_min_z(handle: f64) -> f64 { + engine().scene.get_bounds(handle).0[2] as f64 +} #[wasm_bindgen] -pub fn bloom_scene_get_bounds_max_x(handle: f64) -> f64 { engine().scene.get_bounds(handle).1[0] as f64 } +pub fn bloom_scene_get_bounds_max_x(handle: f64) -> f64 { + engine().scene.get_bounds(handle).1[0] as f64 +} #[wasm_bindgen] -pub fn bloom_scene_get_bounds_max_y(handle: f64) -> f64 { engine().scene.get_bounds(handle).1[1] as f64 } +pub fn bloom_scene_get_bounds_max_y(handle: f64) -> f64 { + engine().scene.get_bounds(handle).1[1] as f64 +} #[wasm_bindgen] -pub fn bloom_scene_get_bounds_max_z(handle: f64) -> f64 { engine().scene.get_bounds(handle).1[2] as f64 } +pub fn bloom_scene_get_bounds_max_z(handle: f64) -> f64 { + engine().scene.get_bounds(handle).1[2] as f64 +} #[wasm_bindgen] -pub fn bloom_scene_set_user_data(handle: f64, data: f64) { engine().scene.set_user_data(handle, data as i64); } +pub fn bloom_scene_set_user_data(handle: f64, data: f64) -> f64 { + engine().scene.set_user_data(handle, data as i64); + 1.0 +} #[wasm_bindgen] -pub fn bloom_scene_get_user_data(handle: f64) -> f64 { engine().scene.get_user_data(handle) as f64 } +pub fn bloom_scene_get_user_data(handle: f64) -> f64 { + engine().scene.get_user_data(handle) as f64 +} // Q1: Render texture FFI (stub) #[wasm_bindgen] pub fn bloom_load_render_texture(width: f64, height: f64) -> f64 { - engine().textures.load_render_texture(width as u32, height as u32) + engine() + .textures + .load_render_texture(width as u32, height as u32) } #[wasm_bindgen] -pub fn bloom_unload_render_texture(handle: f64) { engine().textures.unload_render_texture(handle); } +pub fn bloom_unload_render_texture(handle: f64) { + engine().textures.unload_render_texture(handle); +} #[wasm_bindgen] -pub fn bloom_begin_texture_mode(_handle: f64) { /* stub */ } +pub fn bloom_begin_texture_mode(_handle: f64) { /* stub */ +} #[wasm_bindgen] -pub fn bloom_end_texture_mode() { /* stub */ } +pub fn bloom_end_texture_mode() { /* stub */ +} #[wasm_bindgen] -pub fn bloom_get_render_texture_texture(handle: f64) -> f64 { engine().textures.get_render_texture_texture(handle) } +pub fn bloom_get_render_texture_texture(handle: f64) -> f64 { + engine().textures.get_render_texture_texture(handle) +} // Q8: Water material #[wasm_bindgen] -pub fn bloom_scene_set_material_water(handle: f64, wave_amp: f64, wave_speed: f64, r: f64, g: f64, b: f64, a: f64) { - engine().scene.set_material_water(handle, wave_amp as f32, wave_speed as f32, r as f32, g as f32, b as f32, a as f32); +pub fn bloom_scene_set_material_water( + handle: f64, + wave_amp: f64, + wave_speed: f64, + r: f64, + g: f64, + b: f64, + a: f64, +) -> f64 { + engine().scene.set_material_water( + handle, + wave_amp as f32, + wave_speed as f32, + r as f32, + g as f32, + b as f32, + a as f32, + ); + 1.0 } // Q9: Spline ribbon mesh #[wasm_bindgen] -pub fn bloom_gen_mesh_spline_ribbon(points_ptr: *const u8, point_count: f64, widths_ptr: *const u8, width_count: f64) -> f64 { +pub fn bloom_gen_mesh_spline_ribbon( + points_ptr: *const u8, + point_count: f64, + widths_ptr: *const u8, + width_count: f64, +) -> f64 { let n = point_count as usize; let wn = width_count as usize; let points = unsafe { std::slice::from_raw_parts(points_ptr as *const f32, n * 3) }; @@ -1642,11 +2189,23 @@ pub fn bloom_scene_pick_all(screen_x: f64, screen_y: f64, max_results: f64) -> f let w = eng.renderer.width() as f32; let h = eng.renderer.height() as f32; let (origin, direction) = bloom_shared::picking::screen_to_ray( - screen_x as f32, screen_y as f32, w, h, &inv_vp, &cam_pos, + screen_x as f32, + screen_y as f32, + w, + h, + &inv_vp, + &cam_pos, + ); + let results = bloom_shared::picking::raycast_scene_all( + &eng.scene, + &origin, + &direction, + max_results as usize, ); - let results = bloom_shared::picking::raycast_scene_all(&eng.scene, &origin, &direction, max_results as usize); let count = results.len(); - unsafe { LAST_PICK_ALL = results; } + unsafe { + LAST_PICK_ALL = results; + } count as f64 } #[wasm_bindgen] @@ -1657,7 +2216,12 @@ pub fn bloom_pick_all_handle(index: f64) -> f64 { #[wasm_bindgen] pub fn bloom_pick_all_distance(index: f64) -> f64 { let i = index as usize; - unsafe { LAST_PICK_ALL.get(i).map(|r| r.distance as f64).unwrap_or(0.0) } + unsafe { + LAST_PICK_ALL + .get(i) + .map(|r| r.distance as f64) + .unwrap_or(0.0) + } } // Q2: Cursor shape @@ -1670,91 +2234,36 @@ pub fn bloom_set_cursor_shape(shape: f64) { #[wasm_bindgen] pub fn bloom_set_clipboard_text(_text_ptr: *const u8) {} #[wasm_bindgen] -pub fn bloom_get_clipboard_text() -> f64 { 0.0 } +pub fn bloom_get_clipboard_text() -> f64 { + 0.0 +} // E5b: File dialogs (stub) #[wasm_bindgen] -pub fn bloom_open_file_dialog(_filter_ptr: *const u8, _title_ptr: *const u8) -> f64 { 0.0 } -#[wasm_bindgen] -pub fn bloom_save_file_dialog(_default_name_ptr: *const u8, _title_ptr: *const u8) -> f64 { 0.0 } - -// ============================================================ -// Render quality toggles (individual + preset) — ticket 011 -// Mirror of the macOS FFI surface added in commit 95da6af, exposed to -// the browser via wasm_bindgen. Without these the TS API's -// setQualityPreset / setShadowsEnabled / etc. would fail with -// "bloom_set_quality_preset is not a function" on the web target. -// ============================================================ - -#[wasm_bindgen] -pub fn bloom_set_quality_preset(preset: f64) { - engine().renderer.apply_quality_preset(preset as u32); -} -#[wasm_bindgen] -pub fn bloom_set_shadows_enabled(on: f64) { - engine().renderer.set_shadows_enabled(on != 0.0); -} -#[wasm_bindgen] -pub fn bloom_set_shadows_always_fresh(on: f64) { - engine().renderer.set_shadows_always_fresh(on != 0.0); -} -#[wasm_bindgen] -pub fn bloom_set_bloom_enabled(on: f64) { - engine().renderer.set_bloom_enabled(on != 0.0); -} -#[wasm_bindgen] -pub fn bloom_set_ssao_enabled(on: f64) { - engine().renderer.set_ssao_enabled(on != 0.0); -} -#[wasm_bindgen] -pub fn bloom_set_ssao_intensity(value: f64) { - engine().renderer.set_ssao_strength(value as f32); -} -#[wasm_bindgen] -pub fn bloom_set_ssao_radius(world_radius: f64) { - engine().renderer.set_ssao_radius(world_radius as f32); +pub fn bloom_open_file_dialog(_filter_ptr: *const u8, _title_ptr: *const u8) -> f64 { + 0.0 } #[wasm_bindgen] -pub fn bloom_set_wind(dir_x: f64, dir_z: f64, amplitude: f64, frequency: f64) { - engine().renderer.set_wind(dir_x as f32, dir_z as f32, amplitude as f32, frequency as f32); +pub fn bloom_save_file_dialog(_default_name_ptr: *const u8, _title_ptr: *const u8) -> f64 { + 0.0 } + #[wasm_bindgen] pub fn bloom_launch_process(_cmd: f64, _args: f64, _cwd: f64) -> f64 { // A web page does not get to launch processes. 0.0 } -#[wasm_bindgen] -pub fn bloom_set_output_scale(scale: f64) { - engine().renderer.set_output_scale(scale as f32); -} -#[wasm_bindgen] -pub fn bloom_get_output_scale() -> f64 { - engine().renderer.output_scale() as f64 -} -#[wasm_bindgen] -pub fn bloom_set_model_foliage_wind(model: f64, amount: f64) { - engine().renderer.set_model_foliage_wind(model.to_bits(), amount as f32); -} -#[wasm_bindgen] -pub fn bloom_set_foliage_shadow_motion(on: f64) { - engine().renderer.set_foliage_shadow_motion(on > 0.5); -} -#[wasm_bindgen] -pub fn bloom_set_cloud_shadows(strength: f64, deck_height: f64, feature_scale: f64, drift_speed: f64) { - engine().renderer.set_cloud_shadows( - strength as f32, deck_height as f32, feature_scale as f32, drift_speed as f32); -} -#[wasm_bindgen] -pub fn bloom_set_ssr_enabled(on: f64) { - engine().renderer.set_ssr_enabled(on != 0.0); -} -#[wasm_bindgen] -pub fn bloom_set_motion_blur_enabled(on: f64) { - engine().renderer.set_motion_blur_enabled(on != 0.0); +#[no_mangle] +pub fn bloom_command_line_arg_count() -> f64 { + 0.0 } -#[wasm_bindgen] -pub fn bloom_set_sss_enabled(on: f64) { - engine().renderer.set_sss_enabled(on != 0.0); + +#[no_mangle] +pub fn bloom_command_line_arg(_index: f64) -> f64 { + // `count()` is always zero on web, so this ABI-compatible sentinel is + // unreachable through the public helper. Web has no Perry string + // allocator and must not pretend a native pointer exists. + 0.0 } // ============================================================ diff --git a/native/web/src/material_ffi.rs b/native/web/src/material_ffi.rs index ebec5c88..a2135463 100644 --- a/native/web/src/material_ffi.rs +++ b/native/web/src/material_ffi.rs @@ -15,26 +15,33 @@ pub fn bloom_set_material_params(_handle: f64, _params_ptr: f64, _param_count: f } #[wasm_bindgen] -pub fn bloom_set_material_params_floats(handle: f64, params: &[f32]) { +pub fn bloom_set_material_params_floats(handle: f64, params: &[f32]) -> f64 { let count = params.len(); if count > 64 { - web_sys::console::error_1(&format!( - "[material] set_material_params: param_count {} > 64 (256-byte UBO cap)", - count - ).into()); - return; + web_sys::console::error_1( + &format!( + "[material] set_material_params: param_count {} > 64 (256-byte UBO cap)", + count + ) + .into(), + ); + return 0.0; } let mut bytes = vec![0u8; count * 4]; for (i, &v) in params.iter().enumerate() { - bytes[i*4..i*4+4].copy_from_slice(&v.to_le_bytes()); + bytes[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes()); } let eng = engine(); if let Err(e) = eng.renderer.material_system.set_user_params( - &eng.renderer.device, &eng.renderer.queue, - handle as u32, &bytes, + &eng.renderer.device, + &eng.renderer.queue, + handle as u32, + &bytes, ) { web_sys::console::error_1(&format!("[material] set_material_params failed: {}", e).into()); + return 0.0; } + 1.0 } #[wasm_bindgen] @@ -62,9 +69,13 @@ pub fn bloom_compile_material_refractive(_source: f64) -> f64 { #[wasm_bindgen] pub fn bloom_compile_material_refractive_str(source: &str) -> f64 { - use bloom_shared::renderer::material_pipeline::{FragmentProfile, Bucket}; + use bloom_shared::renderer::material_pipeline::{Bucket, FragmentProfile}; match engine().renderer.compile_material_with_options( - source, FragmentProfile::Translucent, Bucket::Refractive, true, false, + source, + FragmentProfile::Translucent, + Bucket::Refractive, + true, + false, ) { Ok(handle) => handle as f64, Err(e) => { @@ -82,9 +93,13 @@ pub fn bloom_compile_material_transparent(_source: f64) -> f64 { #[wasm_bindgen] pub fn bloom_compile_material_transparent_str(source: &str) -> f64 { - use bloom_shared::renderer::material_pipeline::{FragmentProfile, Bucket}; + use bloom_shared::renderer::material_pipeline::{Bucket, FragmentProfile}; match engine().renderer.compile_material_with_options( - source, FragmentProfile::Translucent, Bucket::Transparent, false, false, + source, + FragmentProfile::Translucent, + Bucket::Transparent, + false, + false, ) { Ok(handle) => handle as f64, Err(e) => { @@ -102,9 +117,13 @@ pub fn bloom_compile_material_additive(_source: f64) -> f64 { #[wasm_bindgen] pub fn bloom_compile_material_additive_str(source: &str) -> f64 { - use bloom_shared::renderer::material_pipeline::{FragmentProfile, Bucket}; + use bloom_shared::renderer::material_pipeline::{Bucket, FragmentProfile}; match engine().renderer.compile_material_with_options( - source, FragmentProfile::Translucent, Bucket::Additive, false, false, + source, + FragmentProfile::Translucent, + Bucket::Additive, + false, + false, ) { Ok(handle) => handle as f64, Err(e) => { @@ -122,9 +141,13 @@ pub fn bloom_compile_material_cutout(_source: f64) -> f64 { #[wasm_bindgen] pub fn bloom_compile_material_cutout_str(source: &str) -> f64 { - use bloom_shared::renderer::material_pipeline::{FragmentProfile, Bucket}; + use bloom_shared::renderer::material_pipeline::{Bucket, FragmentProfile}; match engine().renderer.compile_material_with_options( - source, FragmentProfile::Opaque, Bucket::Cutout, false, false, + source, + FragmentProfile::Opaque, + Bucket::Cutout, + false, + false, ) { Ok(handle) => handle as f64, Err(e) => { @@ -145,7 +168,9 @@ pub fn bloom_compile_material_instanced_str(source: &str) -> f64 { match engine().renderer.compile_material_instanced(source) { Ok(handle) => handle as f64, Err(e) => { - web_sys::console::error_1(&format!("[material] instanced compile failed: {:?}", e).into()); + web_sys::console::error_1( + &format!("[material] instanced compile failed: {:?}", e).into(), + ); 0.0 } } @@ -159,20 +184,26 @@ pub fn bloom_create_instance_buffer(_data_ptr: f64, _instance_count: f64) -> f64 #[wasm_bindgen] pub fn bloom_create_instance_buffer_floats(data: &[f32], instance_count: f64) -> f64 { - if instance_count <= 0.0 { return 0.0; } + if instance_count <= 0.0 { + return 0.0; + } let count = instance_count as u32; engine().renderer.create_instance_buffer(data, count) as f64 } #[wasm_bindgen] pub fn bloom_submit_material_draw_instanced( - material: f64, mesh_handle: f64, mesh_idx: f64, - instance_buffer: f64, instance_count: f64, + material: f64, + mesh_handle: f64, + mesh_idx: f64, + instance_buffer: f64, + instance_count: f64, ) { let eng = engine(); let handle_bits = mesh_handle.to_bits(); if let Some(model) = eng.models.get(mesh_handle) { - eng.renderer.cache_model_if_static(handle_bits, &model.meshes); + eng.renderer + .cache_model_if_static(handle_bits, &model.meshes); } eng.renderer.submit_material_draw_instanced( material as u32, @@ -193,7 +224,11 @@ pub fn bloom_destroy_instance_buffer(handle: f64) { /// game targets one API across native + browser. #[wasm_bindgen] pub fn bloom_create_planar_reflection( - plane_y: f64, nx: f64, ny: f64, nz: f64, resolution: f64, + plane_y: f64, + nx: f64, + ny: f64, + nz: f64, + resolution: f64, ) -> f64 { engine().renderer.create_planar_reflection( plane_y as f32, @@ -205,10 +240,11 @@ pub fn bloom_create_planar_reflection( /// EN-011 — link a material to a planar reflection probe. `probe = 0` /// reverts the binding to the engine's default 1×1 black texture. #[wasm_bindgen] -pub fn bloom_set_material_reflection_probe( - material: f64, probe: f64, -) { - engine().renderer.set_material_reflection_probe(material as u32, probe as u32); +pub fn bloom_set_material_reflection_probe(material: f64, probe: f64) -> f64 { + engine() + .renderer + .set_material_reflection_probe(material as u32, probe as u32); + 1.0 } /// EN-014 — pointer-shaped variant exists only so the FFI manifest @@ -217,7 +253,11 @@ pub fn bloom_set_material_reflection_probe( /// Same precedent as `bloom_create_instance_buffer` (see EN-001). #[wasm_bindgen] pub fn bloom_create_texture_array( - _data_ptr: f64, _data_len: f64, _width: f64, _height: f64, _layer_count: f64, + _data_ptr: f64, + _data_len: f64, + _width: f64, + _height: f64, + _layer_count: f64, ) -> f64 { 0.0 } @@ -229,7 +269,9 @@ pub fn bloom_create_texture_array( #[wasm_bindgen] pub fn bloom_create_texture_array_bytes( data: &[u8], - width: f64, height: f64, layer_count: f64, + width: f64, + height: f64, + layer_count: f64, ) -> f64 { // EN-014 V2 — V1 forwards to _ex with default sRGB / no mips. bloom_create_texture_array_ex_bytes(data, width, height, layer_count, 0.0, 1.0) @@ -239,8 +281,13 @@ pub fn bloom_create_texture_array_bytes( /// validates against the Perry surface; JS glue uses the `_bytes` form. #[wasm_bindgen] pub fn bloom_create_texture_array_ex( - _data_ptr: f64, _data_len: f64, _width: f64, _height: f64, - _layer_count: f64, _format: f64, _mip_levels: f64, + _data_ptr: f64, + _data_len: f64, + _width: f64, + _height: f64, + _layer_count: f64, + _format: f64, + _mip_levels: f64, ) -> f64 { 0.0 } @@ -251,55 +298,67 @@ pub fn bloom_create_texture_array_ex( #[wasm_bindgen] pub fn bloom_create_texture_array_ex_bytes( data: &[u8], - width: f64, height: f64, layer_count: f64, - format: f64, mip_levels: f64, + width: f64, + height: f64, + layer_count: f64, + format: f64, + mip_levels: f64, ) -> f64 { let w = width as u32; let h = height as u32; - if w == 0 || h == 0 { return 0.0; } - let layers_count = (layer_count as u32) - .min(bloom_shared::renderer::material_system::MAX_TEXTURE_ARRAY_LAYERS); - if layers_count == 0 { return 0.0; } + if w == 0 || h == 0 { + return 0.0; + } + let layers_count = + (layer_count as u32).min(bloom_shared::renderer::material_system::MAX_TEXTURE_ARRAY_LAYERS); + if layers_count == 0 { + return 0.0; + } let layer_size = (w as usize) * (h as usize) * 4; let mut layers: Vec<(&[u8], u32, u32)> = Vec::with_capacity(layers_count as usize); for i in 0..(layers_count as usize) { let start = i * layer_size; - let end = start + layer_size; - if end > data.len() { break; } + let end = start + layer_size; + if end > data.len() { + break; + } layers.push((&data[start..end], w, h)); } - engine().renderer.create_texture_array_ex(&layers, format as u32, mip_levels as u32) as f64 + engine() + .renderer + .create_texture_array_ex(&layers, format as u32, mip_levels as u32) as f64 } /// EN-014 — link a texture-array handle to a material slot /// (0 = albedo / 1 = normal / 2 = MR). `array = 0` reverts to the /// engine's 1×1×1 stub. #[wasm_bindgen] -pub fn bloom_set_material_texture_array( - material: f64, slot: f64, array: f64, -) { - engine().renderer.set_material_texture_array( - material as u32, slot as u32, array as u32, - ); +pub fn bloom_set_material_texture_array(material: f64, slot: f64, array: f64) -> f64 { + engine() + .renderer + .set_material_texture_array(material as u32, slot as u32, array as u32); + 1.0 } /// EN-012 — set the shading model for a material (0=default lit, /// 1=foliage, 2=subsurface V2 stub). #[wasm_bindgen] -pub fn bloom_set_material_shading_model( - material: f64, model: f64, -) { - engine().renderer.set_material_shading_model(material as u32, model as u32); +pub fn bloom_set_material_shading_model(material: f64, model: f64) -> f64 { + engine() + .renderer + .set_material_shading_model(material as u32, model as u32); + 1.0 } /// Whether a material's draws render into planar-reflection probes /// (default true). Authoring control for content that is sub-pixel at /// probe resolution (e.g. instanced grass). #[wasm_bindgen] -pub fn bloom_set_material_probe_visible( - material: f64, visible: f64, -) { - engine().renderer.set_material_probe_visible(material as u32, visible != 0.0); +pub fn bloom_set_material_probe_visible(material: f64, visible: f64) -> f64 { + engine() + .renderer + .set_material_probe_visible(material as u32, visible != 0.0); + 1.0 } /// EN-012 — set the foliage shading parameters for a material. @@ -307,14 +366,19 @@ pub fn bloom_set_material_probe_visible( #[wasm_bindgen] pub fn bloom_set_material_foliage( material: f64, - trans_r: f64, trans_g: f64, trans_b: f64, - trans_amount: f64, wrap_factor: f64, -) { + trans_r: f64, + trans_g: f64, + trans_b: f64, + trans_amount: f64, + wrap_factor: f64, +) -> f64 { engine().renderer.set_material_foliage( material as u32, [trans_r as f32, trans_g as f32, trans_b as f32], - trans_amount as f32, wrap_factor as f32, + trans_amount as f32, + wrap_factor as f32, ); + 1.0 } #[wasm_bindgen] @@ -326,22 +390,25 @@ pub fn bloom_compile_material_from_file(_path: f64, _bucket_kind: f64) -> f64 { #[wasm_bindgen] pub fn bloom_compile_material_from_file_str(path: &str, bucket_kind: f64) -> f64 { - use bloom_shared::renderer::material_pipeline::{FragmentProfile, Bucket}; + use bloom_shared::renderer::material_pipeline::{Bucket, FragmentProfile}; let (profile, bucket, reads_scene) = match bucket_kind as u32 { - 0 => (FragmentProfile::Opaque, Bucket::Opaque, false), + 0 => (FragmentProfile::Opaque, Bucket::Opaque, false), 1 => (FragmentProfile::Translucent, Bucket::Transparent, false), - 2 => (FragmentProfile::Translucent, Bucket::Refractive, true), - 3 => (FragmentProfile::Translucent, Bucket::Additive, false), - 4 => (FragmentProfile::Opaque, Bucket::Cutout, false), + 2 => (FragmentProfile::Translucent, Bucket::Refractive, true), + 3 => (FragmentProfile::Translucent, Bucket::Additive, false), + 4 => (FragmentProfile::Opaque, Bucket::Cutout, false), _ => { - web_sys::console::error_1(&format!( - "[material] from_file: unknown bucket_kind {}", bucket_kind - ).into()); + web_sys::console::error_1( + &format!("[material] from_file: unknown bucket_kind {}", bucket_kind).into(), + ); return 0.0; } }; match engine().renderer.compile_material_from_file( - std::path::Path::new(path), profile, bucket, reads_scene, + std::path::Path::new(path), + profile, + bucket, + reads_scene, ) { Ok(handle) => handle as f64, Err(e) => { @@ -353,7 +420,9 @@ pub fn bloom_compile_material_from_file_str(path: &str, bucket_kind: f64) -> f64 /// EN-017 — stub: JS glue calls `bloom_set_post_pass_str` instead. #[wasm_bindgen] -pub fn bloom_set_post_pass(_source: f64) -> f64 { 0.0 } +pub fn bloom_set_post_pass(_source: f64) -> f64 { + 0.0 +} /// EN-017 — compile + install a fullscreen post-pass material on web. /// See `bloom-macos::bloom_set_post_pass` for the full ABI. Returns @@ -363,9 +432,7 @@ pub fn bloom_set_post_pass_str(source: &str) -> f64 { match engine().renderer.set_post_pass(source) { Ok(()) => 1.0, Err(e) => { - web_sys::console::error_1( - &format!("[post_pass] compile failed: {:?}", e).into(), - ); + web_sys::console::error_1(&format!("[post_pass] compile failed: {:?}", e).into()); 0.0 } } @@ -373,13 +440,16 @@ pub fn bloom_set_post_pass_str(source: &str) -> f64 { /// EN-017 — uninstall the active post-pass. #[wasm_bindgen] -pub fn bloom_clear_post_pass() { +pub fn bloom_clear_post_pass() -> f64 { engine().renderer.clear_post_pass(); + 1.0 } /// EN-017 V2 — stub: JS glue calls `bloom_add_post_pass_str` instead. #[wasm_bindgen] -pub fn bloom_add_post_pass(_source: f64) -> f64 { 0.0 } +pub fn bloom_add_post_pass(_source: f64) -> f64 { + 0.0 +} /// EN-017 V2 — append a fullscreen post-pass to the stack on web. /// See `bloom-macos::bloom_add_post_pass` for the full ABI. Returns @@ -389,9 +459,7 @@ pub fn bloom_add_post_pass_str(source: &str) -> f64 { match engine().renderer.add_post_pass(source) { Ok(h) => h as f64, Err(e) => { - web_sys::console::error_1( - &format!("[post_pass] compile failed: {:?}", e).into(), - ); + web_sys::console::error_1(&format!("[post_pass] compile failed: {:?}", e).into()); 0.0 } } @@ -399,8 +467,9 @@ pub fn bloom_add_post_pass_str(source: &str) -> f64 { /// EN-017 V2 — wipe the entire post-pass stack. #[wasm_bindgen] -pub fn bloom_clear_all_post_passes() { +pub fn bloom_clear_all_post_passes() -> f64 { engine().renderer.clear_all_post_passes(); + 1.0 } #[wasm_bindgen] @@ -408,13 +477,20 @@ pub fn bloom_draw_material( material: f64, mesh_handle: f64, mesh_idx: f64, - x: f64, y: f64, z: f64, scale: f64, - r: f64, g: f64, b: f64, a: f64, + x: f64, + y: f64, + z: f64, + scale: f64, + r: f64, + g: f64, + b: f64, + a: f64, ) { let eng = engine(); let handle_bits = mesh_handle.to_bits(); if let Some(model) = eng.models.get(mesh_handle) { - eng.renderer.cache_model_if_static(handle_bits, &model.meshes); + eng.renderer + .cache_model_if_static(handle_bits, &model.meshes); } eng.renderer.submit_material_draw( material as u32, @@ -422,12 +498,19 @@ pub fn bloom_draw_material( mesh_idx as usize, [x as f32, y as f32, z as f32], scale as f32, - [(r / 255.0) as f32, (g / 255.0) as f32, (b / 255.0) as f32, (a / 255.0) as f32], + [ + (r / 255.0) as f32, + (g / 255.0) as f32, + (b / 255.0) as f32, + (a / 255.0) as f32, + ], ); } #[wasm_bindgen] -pub fn bloom_load_model_animation(_path: f64) -> f64 { 0.0 } +pub fn bloom_load_model_animation(_path: f64) -> f64 { + 0.0 +} #[wasm_bindgen] pub fn bloom_load_model_animation_bytes(data: &[u8]) -> f64 { @@ -442,8 +525,14 @@ pub fn bloom_instantiate_animation(src: f64) -> f64 { #[wasm_bindgen] #[allow(clippy::too_many_arguments)] pub fn bloom_update_model_animation( - handle: f64, anim_index: f64, time: f64, scale: f64, - px: f64, py: f64, pz: f64, rot_y: f64, + handle: f64, + anim_index: f64, + time: f64, + scale: f64, + px: f64, + py: f64, + pz: f64, + rot_y: f64, ) { // Mirrors the shared macro. This was an empty stub carrying the PRE-FIX // 9-arg (rot_sin, rot_cos) signature that native abandoned — so the @@ -455,14 +544,19 @@ pub fn bloom_update_model_animation( let rot_sin = rot_y_f.sin(); let rot_cos = rot_y_f.cos(); let eng = engine(); - eng.models.update_model_animation(handle, anim_index as usize, time as f32); + eng.models + .update_model_animation(handle, anim_index as usize, time as f32); if let Some(anim) = eng.models.get_animation(handle) { if !anim.joint_matrices.is_empty() { // PT-7: the anim handle keys the prev-palette pairing for // skinned motion vectors. eng.renderer.set_joint_matrices_scaled( - handle.to_bits(), &anim.joint_matrices, scale as f32, - [px as f32, py as f32, pz as f32], rot_sin, rot_cos, + handle.to_bits(), + &anim.joint_matrices, + scale as f32, + [px as f32, py as f32, pz as f32], + rot_sin, + rot_cos, ); } } @@ -497,12 +591,32 @@ pub fn bloom_get_model_material_count(handle: f64) -> f64 { #[wasm_bindgen] pub fn bloom_anim_play(handle: f64, clip: f64, fade: f64, speed: f64, looping: f64) { - engine().models.anim_play(handle, clip as usize, fade as f32, speed as f32, looping != 0.0); + engine().models.anim_play( + handle, + clip as usize, + fade as f32, + speed as f32, + looping != 0.0, + ); } #[wasm_bindgen] -pub fn bloom_anim_set_layer(handle: f64, clip: f64, weight: f64, mask_root: f64, speed: f64, looping: f64) { - engine().models.anim_set_layer(handle, clip as i32, weight as f32, mask_root as i32, speed as f32, looping != 0.0); +pub fn bloom_anim_set_layer( + handle: f64, + clip: f64, + weight: f64, + mask_root: f64, + speed: f64, + looping: f64, +) { + engine().models.anim_set_layer( + handle, + clip as i32, + weight as f32, + mask_root as i32, + speed as f32, + looping != 0.0, + ); } #[wasm_bindgen] @@ -523,15 +637,24 @@ pub fn bloom_anim_update(handle: f64, dt: f64, scale: f64, px: f64, py: f64, pz: // web backend was left behind when `key` was added, which is what // broke build-web on main. eng.renderer.set_joint_matrices_scaled( - handle.to_bits(), &anim.joint_matrices, scale as f32, - [px as f32, py as f32, pz as f32], rot_y_f.sin(), rot_y_f.cos()); + handle.to_bits(), + &anim.joint_matrices, + scale as f32, + [px as f32, py as f32, pz as f32], + rot_y_f.sin(), + rot_y_f.cos(), + ); } } } #[wasm_bindgen] pub fn bloom_anim_finished(handle: f64) -> f64 { - if engine().models.anim_finished(handle) { 1.0 } else { 0.0 } + if engine().models.anim_finished(handle) { + 1.0 + } else { + 0.0 + } } #[wasm_bindgen] @@ -543,7 +666,11 @@ pub fn bloom_anim_clip_duration(handle: f64, clip: f64) -> f64 { pub fn bloom_anim_root_delta(handle: f64, axis: f64) -> f64 { let d = engine().models.anim_root_delta(handle); let i = axis as usize; - if i < 3 { d[i] as f64 } else { 0.0 } + if i < 3 { + d[i] as f64 + } else { + 0.0 + } } #[wasm_bindgen] @@ -555,7 +682,9 @@ pub fn bloom_model_find_joint(handle: f64, name: String) -> f64 { pub fn bloom_model_joint_world(handle: f64, joint: f64, comp: f64) -> f64 { let j = joint as i64; let c = comp as usize; - if j < 0 || c > 15 { return 0.0; } + if j < 0 || c > 15 { + return 0.0; + } match engine().models.joint_world(handle, j as usize) { Some(m) => m[c / 4][c % 4] as f64, None => 0.0, @@ -566,7 +695,10 @@ pub fn bloom_model_joint_world(handle: f64, joint: f64, comp: f64) -> f64 { pub fn bloom_particles_create(capacity: f64) -> f64 { let cap = (capacity as usize).clamp(1, 100_000); let eng = engine(); - let ib = eng.renderer.material_system.create_dynamic_instance_buffer(&eng.renderer.device, cap as u32); + let ib = eng + .renderer + .material_system + .create_dynamic_instance_buffer(&eng.renderer.device, cap as u32); eng.particles.create(cap, ib) as f64 } @@ -581,9 +713,22 @@ pub fn bloom_particles_configure(sys: f64) { } #[wasm_bindgen] -pub fn bloom_particles_emit(sys: f64, x: f64, y: f64, z: f64, dx: f64, dy: f64, dz: f64, count: f64) { +pub fn bloom_particles_emit( + sys: f64, + x: f64, + y: f64, + z: f64, + dx: f64, + dy: f64, + dz: f64, + count: f64, +) { if let Some(s) = engine().particles.get_mut(sys as u32) { - s.emit([x as f32, y as f32, z as f32], [dx as f32, dy as f32, dz as f32], (count as usize).min(4096)); + s.emit( + [x as f32, y as f32, z as f32], + [dx as f32, dy as f32, dz as f32], + (count as usize).min(4096), + ); } } @@ -599,31 +744,46 @@ pub fn bloom_particles_update(sys: f64, dt: f64) -> f64 { Some(s) => s.packed()[..(live as usize) * 12].to_vec(), None => return 0.0, }; - eng.renderer.material_system.update_instance_buffer(&eng.renderer.queue, ib, &packed, live); + eng.renderer + .material_system + .update_instance_buffer(&eng.renderer.queue, ib, &packed, live); } live as f64 } #[wasm_bindgen] pub fn bloom_particles_instance_buffer(sys: f64) -> f64 { - engine().particles.get_mut(sys as u32).map(|s| s.instance_buffer as f64).unwrap_or(0.0) + engine() + .particles + .get_mut(sys as u32) + .map(|s| s.instance_buffer as f64) + .unwrap_or(0.0) } #[wasm_bindgen] pub fn bloom_particles_clear(sys: f64) { - if let Some(s) = engine().particles.get_mut(sys as u32) { s.clear(); } + if let Some(s) = engine().particles.get_mut(sys as u32) { + s.clear(); + } } #[wasm_bindgen] pub fn bloom_particles_live(sys: f64) -> f64 { - engine().particles.get_mut(sys as u32).map(|s| s.live as f64).unwrap_or(0.0) + engine() + .particles + .get_mut(sys as u32) + .map(|s| s.live as f64) + .unwrap_or(0.0) } #[wasm_bindgen] pub fn bloom_decals_init(capacity: f64) -> f64 { let cap = (capacity as usize).clamp(1, 8192); let eng = engine(); - let ib = eng.renderer.material_system.create_dynamic_instance_buffer(&eng.renderer.device, cap as u32); + let ib = eng + .renderer + .material_system + .create_dynamic_instance_buffer(&eng.renderer.device, cap as u32); eng.decals.init(cap, ib); ib as f64 } @@ -633,7 +793,9 @@ pub fn bloom_decals_spawn(x: f64, y: f64, z: f64, nx: f64, ny: f64, nz: f64, siz engine().decals.spawn_styled( [x as f32, y as f32, z as f32], [nx as f32, ny as f32, nz as f32], - size as f32, roll as f32); + size as f32, + roll as f32, + ); } #[wasm_bindgen] @@ -653,7 +815,9 @@ pub fn bloom_decals_update(dt: f64) -> f64 { let ib = eng.decals.instance_buffer; if live > 0 { let packed: Vec = eng.decals.packed()[..(live as usize) * 12].to_vec(); - eng.renderer.material_system.update_instance_buffer(&eng.renderer.queue, ib, &packed, live); + eng.renderer + .material_system + .update_instance_buffer(&eng.renderer.queue, ib, &packed, live); } live as f64 } @@ -693,18 +857,39 @@ pub fn bloom_set_bus_gain(bus: f64, gain: f64) { #[wasm_bindgen] pub fn bloom_duck_bus(bus: f64, amount: f64, attack: f64, release: f64, hold: f64) { - engine().audio.duck_bus(bus as u8, amount as f32, attack as f32, release as f32, hold as f32); + engine().audio.duck_bus( + bus as u8, + amount as f32, + attack as f32, + release as f32, + hold as f32, + ); } #[wasm_bindgen] pub fn bloom_set_reverb(size: f64, damp: f64, wet: f64) { - engine().audio.set_reverb(size as f32, damp as f32, wet as f32); + engine() + .audio + .set_reverb(size as f32, damp as f32, wet as f32); } #[wasm_bindgen] -pub fn bloom_compile_material_instanced_bucket(source: String, bucket: f64, reads_scene: f64) -> f64 { - match engine().renderer.compile_material_instanced_bucket(&source, bucket as u32, reads_scene != 0.0) { +pub fn bloom_compile_material_instanced_bucket( + source: String, + bucket: f64, + reads_scene: f64, +) -> f64 { + match engine().renderer.compile_material_instanced_bucket( + &source, + bucket as u32, + reads_scene != 0.0, + ) { Ok(handle) => handle as f64, - Err(e) => { web_sys::console::error_1(&format!("[material] instanced compile failed: {:?}", e).into()); 0.0 } + Err(e) => { + web_sys::console::error_1( + &format!("[material] instanced compile failed: {:?}", e).into(), + ); + 0.0 + } } } diff --git a/native/web/src/parity_ffi.rs b/native/web/src/parity_ffi.rs index adb3af65..f67aa2e2 100644 --- a/native/web/src/parity_ffi.rs +++ b/native/web/src/parity_ffi.rs @@ -83,18 +83,18 @@ pub fn bloom_create_instance_buffer_scratch(instance_count: f64) -> f64 { /// TS `setMaterialParams` wrapper uses everywhere (Perry 0.5.x rejects JS /// arrays in pointer params). Forwards to the existing floats path. #[wasm_bindgen] -pub fn bloom_set_material_params_scratch(handle: f64, param_count: f64) { +pub fn bloom_set_material_params_scratch(handle: f64, param_count: f64) -> f64 { let count = param_count as usize; let params: Vec = { let eng = engine(); if eng.models.scratch_f32.len() < count { - return; + return 0.0; } let p = eng.models.scratch_f32[..count].to_vec(); eng.models.mesh_scratch_reset(); p }; - crate::material_ffi::bloom_set_material_params_floats(handle, ¶ms); + crate::material_ffi::bloom_set_material_params_floats(handle, ¶ms) } // ============================================================ @@ -114,8 +114,8 @@ pub fn bloom_create_texture_array_scratch( if w == 0 || h == 0 { return 0.0; } - let layers_count = (layer_count as u32) - .min(bloom_shared::renderer::material_system::MAX_TEXTURE_ARRAY_LAYERS); + let layers_count = + (layer_count as u32).min(bloom_shared::renderer::material_system::MAX_TEXTURE_ARRAY_LAYERS); if layers_count == 0 { return 0.0; } @@ -231,8 +231,72 @@ pub fn bloom_splat_impulse(x: f64, z: f64, radius: f64, strength: f64) { } #[wasm_bindgen] -pub fn bloom_scene_set_gi_only(handle: f64, gi_only: f64) { +pub fn bloom_scene_set_gi_only(handle: f64, gi_only: f64) -> f64 { engine().scene.set_gi_only(handle, gi_only != 0.0); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_scene_set_material_emissive(handle: f64, r: f64, g: f64, b: f64) -> f64 { + let finite_non_negative = |value: f64| { + if value.is_finite() { + (value as f32).max(0.0) + } else { + 0.0 + } + }; + engine().scene.set_material_emissive_factor( + handle, + finite_non_negative(r), + finite_non_negative(g), + finite_non_negative(b), + ); + 1.0 +} + +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn bloom_scene_set_material_layered_pbr( + handle: f64, + lobe_mask: f64, + clearcoat_factor: f64, + clearcoat_roughness: f64, + clearcoat_normal_scale: f64, + specular_factor: f64, + specular_r: f64, + specular_g: f64, + specular_b: f64, + ior: f64, + sheen_r: f64, + sheen_g: f64, + sheen_b: f64, + sheen_roughness: f64, + anisotropy_strength: f64, + anisotropy_rotation: f64, + iridescence_factor: f64, + iridescence_ior: f64, + iridescence_thickness_minimum: f64, + iridescence_thickness_maximum: f64, +) -> f64 { + let layered = bloom_shared::models::MaterialLayeredPbr::from_authoring_factors( + lobe_mask as u32, + clearcoat_factor as f32, + clearcoat_roughness as f32, + clearcoat_normal_scale as f32, + specular_factor as f32, + [specular_r as f32, specular_g as f32, specular_b as f32], + ior as f32, + [sheen_r as f32, sheen_g as f32, sheen_b as f32], + sheen_roughness as f32, + anisotropy_strength as f32, + anisotropy_rotation as f32, + iridescence_factor as f32, + iridescence_ior as f32, + iridescence_thickness_minimum as f32, + iridescence_thickness_maximum as f32, + ); + engine().scene.set_material_layered_pbr(handle, layered); + 1.0 } // ============================================================ @@ -331,13 +395,116 @@ pub fn bloom_profiler_hist_gpu_us(i: f64) -> f64 { // the calls stay cheap typed no-ops // ============================================================ +/// Browser builds cannot resolve a native filesystem path for HDR input. +/// Call the byte-oriented web loader instead. +#[wasm_bindgen] +pub fn bloom_set_env_clear_from_hdr(_path: f64) -> f64 { + 0.0 +} + /// The browser owns presentation (rAF + compositor); Fifo-equivalent /// behaviour is all a canvas surface can do, so the mode is fixed. #[wasm_bindgen] -pub fn bloom_set_present_mode(_mode: f64) {} +pub fn bloom_set_present_mode(mode: f64) -> f64 { + if mode == 0.0 { + 1.0 + } else { + 0.0 + } +} + +#[wasm_bindgen] +pub fn bloom_get_present_mode() -> f64 { + 0.0 +} + +#[wasm_bindgen] +pub fn bloom_set_path_tracing(mode: f64) -> f64 { + if mode == 0.0 { + 1.0 + } else { + 0.0 + } +} + +#[wasm_bindgen] +pub fn bloom_path_tracing_supported() -> f64 { + 0.0 +} + +#[wasm_bindgen] +pub fn bloom_get_material_binding_capabilities() -> String { + engine().renderer.material_binding_report_json() +} + +#[wasm_bindgen] +pub fn bloom_get_renderer_capabilities() -> String { + engine().renderer.renderer_capability_report_json() +} + +#[wasm_bindgen] +pub fn bloom_get_imported_refraction_mode() -> f64 { + engine().renderer.imported_refraction_mode_code() as f64 +} + +#[wasm_bindgen] +pub fn bloom_set_transparency_composition_mode(mode: f64) -> f64 { + engine() + .renderer + .set_transparency_composition_mode(mode as u32); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_get_transparency_composition_mode() -> f64 { + engine().renderer.transparency_composition_mode_code() as f64 +} + +#[wasm_bindgen] +pub fn bloom_get_active_transparency_composition_mode() -> f64 { + engine() + .renderer + .active_transparency_composition_mode_code() as f64 +} + +#[wasm_bindgen] +pub fn bloom_set_material_binding_tier_override(tier: f64) -> f64 { + engine() + .renderer + .set_material_binding_tier_override(tier as u32) as u8 as f64 +} /// Screenshot readback needs a blocking `device.poll(Wait)`, which a /// single-threaded wasm host cannot do. Right-click-save or the DOM /// `canvas.toBlob` path (from JS) are the web equivalents. #[wasm_bindgen] pub fn bloom_take_screenshot(_path: f64) {} + +#[wasm_bindgen] +pub fn bloom_capture_frame_to_png(_path: f64) -> f64 { + 0.0 +} + +#[wasm_bindgen] +pub fn bloom_capture_debug_intermediates(_path: f64) -> f64 { + 0.0 +} + +#[wasm_bindgen] +pub fn bloom_capture_frame_ready() -> f64 { + 0.0 +} + +/// Browser builds have no direct filesystem path for qualification output. +#[wasm_bindgen] +pub fn bloom_write_quality_telemetry( + _path: f64, + _warmup_frames: f64, + _measured_frames: f64, + _fixed_timestep: f64, + _quality_preset: f64, + _render_scale: f64, + _measurement_wall_ms: f64, +) -> f64 { + 0.0 +} diff --git a/native/web/src/physics_ffi.rs b/native/web/src/physics_ffi.rs index 9695e61a..53063fc4 100644 --- a/native/web/src/physics_ffi.rs +++ b/native/web/src/physics_ffi.rs @@ -70,11 +70,29 @@ extern "C" { #[wasm_bindgen(js_name = shapeMesh)] fn jb_shape_mesh(a0: f64, a1: f64) -> f64; #[wasm_bindgen(js_name = shapeHeightfield)] - fn jb_shape_heightfield(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64) -> f64; + fn jb_shape_heightfield( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + ) -> f64; #[wasm_bindgen(js_name = compoundBegin)] fn jb_compound_begin(); #[wasm_bindgen(js_name = compoundAddChild)] - fn jb_compound_add_child(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64); + fn jb_compound_add_child( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + ); #[wasm_bindgen(js_name = compoundEnd)] fn jb_compound_end() -> f64; #[wasm_bindgen(js_name = shapeBounds)] @@ -82,7 +100,19 @@ extern "C" { #[wasm_bindgen(js_name = shapeVolume)] fn jb_shape_volume(a0: f64) -> f64; #[wasm_bindgen(js_name = bodyCreate)] - pub(crate) fn jb_body_create(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64, a10: f64) -> f64; + pub(crate) fn jb_body_create( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + ) -> f64; #[wasm_bindgen(js_name = bodyDestroy)] pub(crate) fn jb_body_destroy(a0: f64); #[wasm_bindgen(js_name = bodyActivate)] @@ -102,9 +132,29 @@ extern "C" { #[wasm_bindgen(js_name = bodySetRotation)] fn jb_body_set_rotation(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64); #[wasm_bindgen(js_name = bodySetTransform)] - fn jb_body_set_transform(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64); + fn jb_body_set_transform( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + ); #[wasm_bindgen(js_name = bodyMoveKinematic)] - fn jb_body_move_kinematic(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64); + fn jb_body_move_kinematic( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + ); #[wasm_bindgen(js_name = bodyGetLinearVelocity)] fn jb_body_get_linear_velocity(a0: f64, a1: f64) -> f64; #[wasm_bindgen(js_name = bodyGetAngularVelocity)] @@ -166,9 +216,30 @@ extern "C" { #[wasm_bindgen(js_name = bodyGetUserData)] fn jb_body_get_user_data(a0: f64, a1: f64) -> f64; #[wasm_bindgen(js_name = raycast)] - fn jb_raycast(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64) -> f64; + fn jb_raycast( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + ) -> f64; #[wasm_bindgen(js_name = raycastAll)] - fn jb_raycast_all(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64) -> f64; + fn jb_raycast_all( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + ) -> f64; #[wasm_bindgen(js_name = rayHitCount)] fn jb_ray_hit_count() -> f64; #[wasm_bindgen(js_name = rayHitBody)] @@ -184,26 +255,115 @@ extern "C" { #[wasm_bindgen(js_name = overlapPoint)] fn jb_overlap_point(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64) -> f64; #[wasm_bindgen(js_name = overlapBox)] - fn jb_overlap_box(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64, a10: f64, a11: f64, a12: f64) -> f64; + fn jb_overlap_box( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + a11: f64, + a12: f64, + ) -> f64; #[wasm_bindgen(js_name = overlapBody)] fn jb_overlap_body(a0: f64) -> f64; #[wasm_bindgen(js_name = constraintFixed)] - fn jb_constraint_fixed(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64) -> f64; + fn jb_constraint_fixed( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + ) -> f64; #[wasm_bindgen(js_name = constraintPoint)] - fn jb_constraint_point(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64) -> f64; + fn jb_constraint_point( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + ) -> f64; #[wasm_bindgen(js_name = constraintHinge)] - fn jb_constraint_hinge(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64, a10: f64, a11: f64, a12: f64, a13: f64) -> f64; + fn jb_constraint_hinge( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + a11: f64, + a12: f64, + a13: f64, + ) -> f64; #[wasm_bindgen(js_name = constraintSlider)] - fn jb_constraint_slider(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64, a10: f64, a11: f64, a12: f64, a13: f64) -> f64; + fn jb_constraint_slider( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + a11: f64, + a12: f64, + a13: f64, + ) -> f64; #[wasm_bindgen(js_name = constraintDistance)] - fn jb_constraint_distance(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64, a10: f64) -> f64; + fn jb_constraint_distance( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + ) -> f64; // EN-063: the ragdoll joint — six-DOF with translation locked and // per-axis rotation limits. Args: bodyA, bodyB, anchorA(x,y,z), // anchorB(x,y,z), rotLimits(xMin,xMax,yMin,yMax,zMin,zMax), worldSpace. #[wasm_bindgen(js_name = constraintSixDofLockedTranslation)] pub(crate) fn jb_constraint_six_dof( - a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, - a8: f64, a9: f64, a10: f64, a11: f64, a12: f64, a13: f64, a14: f64, + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + a11: f64, + a12: f64, + a13: f64, + a14: f64, ) -> f64; #[wasm_bindgen(js_name = constraintDestroy)] pub(crate) fn jb_constraint_destroy(a0: f64); @@ -216,7 +376,27 @@ extern "C" { #[wasm_bindgen(js_name = clearContacts)] fn jb_clear_contacts(a0: f64); #[wasm_bindgen(js_name = characterCreate)] - fn jb_character_create(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64, a10: f64, a11: f64, a12: f64, a13: f64, a14: f64, a15: f64, a16: f64, a17: f64, a18: f64) -> f64; + fn jb_character_create( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + a11: f64, + a12: f64, + a13: f64, + a14: f64, + a15: f64, + a16: f64, + a17: f64, + a18: f64, + ) -> f64; #[wasm_bindgen(js_name = characterDestroy)] fn jb_character_destroy(a0: f64); #[wasm_bindgen(js_name = characterUpdate)] @@ -244,7 +424,23 @@ extern "C" { #[wasm_bindgen(js_name = characterSetShape)] fn jb_character_set_shape(a0: f64, a1: f64); #[wasm_bindgen(js_name = softBodyCreate)] - fn jb_soft_body_create(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64, a10: f64, a11: f64, a12: f64, a13: f64, a14: f64) -> f64; + fn jb_soft_body_create( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + a11: f64, + a12: f64, + a13: f64, + a14: f64, + ) -> f64; #[wasm_bindgen(js_name = softBodyVertexCount)] fn jb_soft_body_vertex_count(a0: f64) -> f64; #[wasm_bindgen(js_name = softBodyGetVertex)] @@ -254,7 +450,45 @@ extern "C" { #[wasm_bindgen(js_name = softBodySetVertexInvMass)] fn jb_soft_body_set_vertex_inv_mass(a0: f64, a1: f64, a2: f64); #[wasm_bindgen(js_name = vehicleCreate)] - fn jb_vehicle_create(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64, a10: f64, a11: f64, a12: f64, a13: f64, a14: f64, a15: f64, a16: f64, a17: f64, a18: f64, a19: f64, a20: f64, a21: f64, a22: f64, a23: f64, a24: f64, a25: f64, a26: f64, a27: f64, a28: f64, a29: f64, a30: f64, a31: f64, a32: f64, a33: f64, a34: f64, a35: f64, a36: f64) -> f64; + fn jb_vehicle_create( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + a11: f64, + a12: f64, + a13: f64, + a14: f64, + a15: f64, + a16: f64, + a17: f64, + a18: f64, + a19: f64, + a20: f64, + a21: f64, + a22: f64, + a23: f64, + a24: f64, + a25: f64, + a26: f64, + a27: f64, + a28: f64, + a29: f64, + a30: f64, + a31: f64, + a32: f64, + a33: f64, + a34: f64, + a35: f64, + a36: f64, + ) -> f64; #[wasm_bindgen(js_name = vehicleDestroy)] fn jb_vehicle_destroy(a0: f64); #[wasm_bindgen(js_name = vehicleGetChassis)] @@ -271,370 +505,864 @@ extern "C" { #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_create_world(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64) -> f64 { jb_create_world(a0, a1, a2, a3, a4) } +pub fn bloom_physics_create_world(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64) -> f64 { + jb_create_world(a0, a1, a2, a3, a4) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_destroy_world(a0: f64) { jb_destroy_world(a0) } +pub fn bloom_physics_destroy_world(a0: f64) { + jb_destroy_world(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_set_gravity(a0: f64, a1: f64, a2: f64, a3: f64) { jb_set_gravity(a0, a1, a2, a3) } +pub fn bloom_physics_set_gravity(a0: f64, a1: f64, a2: f64, a3: f64) { + jb_set_gravity(a0, a1, a2, a3) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_get_gravity(a0: f64, a1: f64) -> f64 { jb_get_gravity(a0, a1) } +pub fn bloom_physics_get_gravity(a0: f64, a1: f64) -> f64 { + jb_get_gravity(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_optimize_broadphase(a0: f64) { jb_optimize_broadphase(a0) } +pub fn bloom_physics_optimize_broadphase(a0: f64) { + jb_optimize_broadphase(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_step(a0: f64, a1: f64, a2: f64) { jb_step(a0, a1, a2) } +pub fn bloom_physics_step(a0: f64, a1: f64, a2: f64) { + jb_step(a0, a1, a2) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_step_fixed(a0: f64, a1: f64, a2: f64) -> f64 { jb_step_fixed(a0, a1, a2) } +pub fn bloom_physics_step_fixed(a0: f64, a1: f64, a2: f64) -> f64 { + jb_step_fixed(a0, a1, a2) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_set_fixed_timestep(a0: f64, a1: f64, a2: f64) { jb_set_fixed_timestep(a0, a1, a2) } +pub fn bloom_physics_set_fixed_timestep(a0: f64, a1: f64, a2: f64) { + jb_set_fixed_timestep(a0, a1, a2) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_set_interpolation(a0: f64, a1: f64) { jb_set_interpolation(a0, a1) } +pub fn bloom_physics_set_interpolation(a0: f64, a1: f64) { + jb_set_interpolation(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_get_step_alpha(a0: f64) -> f64 { jb_get_step_alpha(a0) } +pub fn bloom_physics_get_step_alpha(a0: f64) -> f64 { + jb_get_step_alpha(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_set_layer_collides(a0: f64, a1: f64, a2: f64, a3: f64) { jb_set_layer_collides(a0, a1, a2, a3) } +pub fn bloom_physics_set_layer_collides(a0: f64, a1: f64, a2: f64, a3: f64) { + jb_set_layer_collides(a0, a1, a2, a3) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_get_layer_collides(a0: f64, a1: f64, a2: f64) -> f64 { jb_get_layer_collides(a0, a1, a2) } +pub fn bloom_physics_get_layer_collides(a0: f64, a1: f64, a2: f64) -> f64 { + jb_get_layer_collides(a0, a1, a2) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_count(a0: f64) -> f64 { jb_body_count(a0) } +pub fn bloom_physics_body_count(a0: f64) -> f64 { + jb_body_count(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_active_body_count(a0: f64) -> f64 { jb_active_body_count(a0) } +pub fn bloom_physics_active_body_count(a0: f64) -> f64 { + jb_active_body_count(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_shape_box(a0: f64, a1: f64, a2: f64, a3: f64) -> f64 { jb_shape_box(a0, a1, a2, a3) } +pub fn bloom_physics_shape_box(a0: f64, a1: f64, a2: f64, a3: f64) -> f64 { + jb_shape_box(a0, a1, a2, a3) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_shape_sphere(a0: f64) -> f64 { jb_shape_sphere(a0) } +pub fn bloom_physics_shape_sphere(a0: f64) -> f64 { + jb_shape_sphere(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_shape_capsule(a0: f64, a1: f64) -> f64 { jb_shape_capsule(a0, a1) } +pub fn bloom_physics_shape_capsule(a0: f64, a1: f64) -> f64 { + jb_shape_capsule(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_shape_cylinder(a0: f64, a1: f64, a2: f64) -> f64 { jb_shape_cylinder(a0, a1, a2) } +pub fn bloom_physics_shape_cylinder(a0: f64, a1: f64, a2: f64) -> f64 { + jb_shape_cylinder(a0, a1, a2) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_shape_scaled(a0: f64, a1: f64, a2: f64, a3: f64) -> f64 { jb_shape_scaled(a0, a1, a2, a3) } +pub fn bloom_physics_shape_scaled(a0: f64, a1: f64, a2: f64, a3: f64) -> f64 { + jb_shape_scaled(a0, a1, a2, a3) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_shape_offset_com(a0: f64, a1: f64, a2: f64, a3: f64) -> f64 { jb_shape_offset_com(a0, a1, a2, a3) } +pub fn bloom_physics_shape_offset_com(a0: f64, a1: f64, a2: f64, a3: f64) -> f64 { + jb_shape_offset_com(a0, a1, a2, a3) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_shape_release(a0: f64) { jb_shape_release(a0) } +pub fn bloom_physics_shape_release(a0: f64) { + jb_shape_release(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_scratch_reset() { jb_scratch_reset() } +pub fn bloom_physics_scratch_reset() { + jb_scratch_reset() +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_scratch_push_f32(a0: f64) { jb_scratch_push_f32(a0) } +pub fn bloom_physics_scratch_push_f32(a0: f64) { + jb_scratch_push_f32(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_scratch_push_u32(a0: f64) { jb_scratch_push_u32(a0) } +pub fn bloom_physics_scratch_push_u32(a0: f64) { + jb_scratch_push_u32(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_shape_convex_hull(a0: f64, a1: f64) -> f64 { jb_shape_convex_hull(a0, a1) } +pub fn bloom_physics_shape_convex_hull(a0: f64, a1: f64) -> f64 { + jb_shape_convex_hull(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_shape_mesh(a0: f64, a1: f64) -> f64 { jb_shape_mesh(a0, a1) } +pub fn bloom_physics_shape_mesh(a0: f64, a1: f64) -> f64 { + jb_shape_mesh(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_shape_heightfield(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64) -> f64 { jb_shape_heightfield(a0, a1, a2, a3, a4, a5, a6, a7) } +pub fn bloom_physics_shape_heightfield( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, +) -> f64 { + jb_shape_heightfield(a0, a1, a2, a3, a4, a5, a6, a7) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_compound_begin() { jb_compound_begin() } +pub fn bloom_physics_compound_begin() { + jb_compound_begin() +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_compound_add_child(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64) { jb_compound_add_child(a0, a1, a2, a3, a4, a5, a6, a7) } +pub fn bloom_physics_compound_add_child( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, +) { + jb_compound_add_child(a0, a1, a2, a3, a4, a5, a6, a7) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_compound_end() -> f64 { jb_compound_end() } +pub fn bloom_physics_compound_end() -> f64 { + jb_compound_end() +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_shape_bounds(a0: f64, a1: f64) -> f64 { jb_shape_bounds(a0, a1) } +pub fn bloom_physics_shape_bounds(a0: f64, a1: f64) -> f64 { + jb_shape_bounds(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_shape_volume(a0: f64) -> f64 { jb_shape_volume(a0) } +pub fn bloom_physics_shape_volume(a0: f64) -> f64 { + jb_shape_volume(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_create(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64, a10: f64) -> f64 { jb_body_create(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10) } +pub fn bloom_physics_body_create( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, +) -> f64 { + jb_body_create(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_destroy(a0: f64) { jb_body_destroy(a0) } +pub fn bloom_physics_body_destroy(a0: f64) { + jb_body_destroy(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_activate(a0: f64) { jb_body_activate(a0) } +pub fn bloom_physics_body_activate(a0: f64) { + jb_body_activate(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_deactivate(a0: f64) { jb_body_deactivate(a0) } +pub fn bloom_physics_body_deactivate(a0: f64) { + jb_body_deactivate(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_is_active(a0: f64) -> f64 { jb_body_is_active(a0) } +pub fn bloom_physics_body_is_active(a0: f64) -> f64 { + jb_body_is_active(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_is_valid(a0: f64) -> f64 { jb_body_is_valid(a0) } +pub fn bloom_physics_body_is_valid(a0: f64) -> f64 { + jb_body_is_valid(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_get_position(a0: f64, a1: f64) -> f64 { jb_body_get_position(a0, a1) } +pub fn bloom_physics_body_get_position(a0: f64, a1: f64) -> f64 { + jb_body_get_position(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_get_rotation(a0: f64, a1: f64) -> f64 { jb_body_get_rotation(a0, a1) } +pub fn bloom_physics_body_get_rotation(a0: f64, a1: f64) -> f64 { + jb_body_get_rotation(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_position(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64) { jb_body_set_position(a0, a1, a2, a3, a4) } +pub fn bloom_physics_body_set_position(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64) { + jb_body_set_position(a0, a1, a2, a3, a4) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_rotation(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64) { jb_body_set_rotation(a0, a1, a2, a3, a4, a5) } +pub fn bloom_physics_body_set_rotation(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64) { + jb_body_set_rotation(a0, a1, a2, a3, a4, a5) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_transform(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64) { jb_body_set_transform(a0, a1, a2, a3, a4, a5, a6, a7, a8) } +pub fn bloom_physics_body_set_transform( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, +) { + jb_body_set_transform(a0, a1, a2, a3, a4, a5, a6, a7, a8) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_move_kinematic(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64) { jb_body_move_kinematic(a0, a1, a2, a3, a4, a5, a6, a7, a8) } +pub fn bloom_physics_body_move_kinematic( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, +) { + jb_body_move_kinematic(a0, a1, a2, a3, a4, a5, a6, a7, a8) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_get_linear_velocity(a0: f64, a1: f64) -> f64 { jb_body_get_linear_velocity(a0, a1) } +pub fn bloom_physics_body_get_linear_velocity(a0: f64, a1: f64) -> f64 { + jb_body_get_linear_velocity(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_get_angular_velocity(a0: f64, a1: f64) -> f64 { jb_body_get_angular_velocity(a0, a1) } +pub fn bloom_physics_body_get_angular_velocity(a0: f64, a1: f64) -> f64 { + jb_body_get_angular_velocity(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_get_point_velocity(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64) -> f64 { jb_body_get_point_velocity(a0, a1, a2, a3, a4) } +pub fn bloom_physics_body_get_point_velocity(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64) -> f64 { + jb_body_get_point_velocity(a0, a1, a2, a3, a4) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_linear_velocity(a0: f64, a1: f64, a2: f64, a3: f64) { jb_body_set_linear_velocity(a0, a1, a2, a3) } +pub fn bloom_physics_body_set_linear_velocity(a0: f64, a1: f64, a2: f64, a3: f64) { + jb_body_set_linear_velocity(a0, a1, a2, a3) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_angular_velocity(a0: f64, a1: f64, a2: f64, a3: f64) { jb_body_set_angular_velocity(a0, a1, a2, a3) } +pub fn bloom_physics_body_set_angular_velocity(a0: f64, a1: f64, a2: f64, a3: f64) { + jb_body_set_angular_velocity(a0, a1, a2, a3) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_add_force(a0: f64, a1: f64, a2: f64, a3: f64) { jb_body_add_force(a0, a1, a2, a3) } +pub fn bloom_physics_body_add_force(a0: f64, a1: f64, a2: f64, a3: f64) { + jb_body_add_force(a0, a1, a2, a3) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_add_impulse(a0: f64, a1: f64, a2: f64, a3: f64) { jb_body_add_impulse(a0, a1, a2, a3) } +pub fn bloom_physics_body_add_impulse(a0: f64, a1: f64, a2: f64, a3: f64) { + jb_body_add_impulse(a0, a1, a2, a3) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_add_torque(a0: f64, a1: f64, a2: f64, a3: f64) { jb_body_add_torque(a0, a1, a2, a3) } +pub fn bloom_physics_body_add_torque(a0: f64, a1: f64, a2: f64, a3: f64) { + jb_body_add_torque(a0, a1, a2, a3) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_add_angular_impulse(a0: f64, a1: f64, a2: f64, a3: f64) { jb_body_add_angular_impulse(a0, a1, a2, a3) } +pub fn bloom_physics_body_add_angular_impulse(a0: f64, a1: f64, a2: f64, a3: f64) { + jb_body_add_angular_impulse(a0, a1, a2, a3) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_add_force_at(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64) { jb_body_add_force_at(a0, a1, a2, a3, a4, a5, a6) } +pub fn bloom_physics_body_add_force_at( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, +) { + jb_body_add_force_at(a0, a1, a2, a3, a4, a5, a6) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_add_impulse_at(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64) { jb_body_add_impulse_at(a0, a1, a2, a3, a4, a5, a6) } +pub fn bloom_physics_body_add_impulse_at( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, +) { + jb_body_add_impulse_at(a0, a1, a2, a3, a4, a5, a6) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_friction(a0: f64, a1: f64) { jb_body_set_friction(a0, a1) } +pub fn bloom_physics_body_set_friction(a0: f64, a1: f64) { + jb_body_set_friction(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_restitution(a0: f64, a1: f64) { jb_body_set_restitution(a0, a1) } +pub fn bloom_physics_body_set_restitution(a0: f64, a1: f64) { + jb_body_set_restitution(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_linear_damping(a0: f64, a1: f64) { jb_body_set_linear_damping(a0, a1) } +pub fn bloom_physics_body_set_linear_damping(a0: f64, a1: f64) { + jb_body_set_linear_damping(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_angular_damping(a0: f64, a1: f64) { jb_body_set_angular_damping(a0, a1) } +pub fn bloom_physics_body_set_angular_damping(a0: f64, a1: f64) { + jb_body_set_angular_damping(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_gravity_factor(a0: f64, a1: f64) { jb_body_set_gravity_factor(a0, a1) } +pub fn bloom_physics_body_set_gravity_factor(a0: f64, a1: f64) { + jb_body_set_gravity_factor(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_ccd(a0: f64, a1: f64) { jb_body_set_ccd(a0, a1) } +pub fn bloom_physics_body_set_ccd(a0: f64, a1: f64) { + jb_body_set_ccd(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_motion_type(a0: f64, a1: f64, a2: f64) { jb_body_set_motion_type(a0, a1, a2) } +pub fn bloom_physics_body_set_motion_type(a0: f64, a1: f64, a2: f64) { + jb_body_set_motion_type(a0, a1, a2) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_object_layer(a0: f64, a1: f64) { jb_body_set_object_layer(a0, a1) } +pub fn bloom_physics_body_set_object_layer(a0: f64, a1: f64) { + jb_body_set_object_layer(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_is_sensor(a0: f64, a1: f64) { jb_body_set_is_sensor(a0, a1) } +pub fn bloom_physics_body_set_is_sensor(a0: f64, a1: f64) { + jb_body_set_is_sensor(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_allow_sleeping(a0: f64, a1: f64) { jb_body_set_allow_sleeping(a0, a1) } +pub fn bloom_physics_body_set_allow_sleeping(a0: f64, a1: f64) { + jb_body_set_allow_sleeping(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_shape(a0: f64, a1: f64, a2: f64, a3: f64) { jb_body_set_shape(a0, a1, a2, a3) } +pub fn bloom_physics_body_set_shape(a0: f64, a1: f64, a2: f64, a3: f64) { + jb_body_set_shape(a0, a1, a2, a3) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_lock_rotation_axes(a0: f64, a1: f64, a2: f64, a3: f64) { jb_body_lock_rotation_axes(a0, a1, a2, a3) } +pub fn bloom_physics_body_lock_rotation_axes(a0: f64, a1: f64, a2: f64, a3: f64) { + jb_body_lock_rotation_axes(a0, a1, a2, a3) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_lock_translation_axes(a0: f64, a1: f64, a2: f64, a3: f64) { jb_body_lock_translation_axes(a0, a1, a2, a3) } +pub fn bloom_physics_body_lock_translation_axes(a0: f64, a1: f64, a2: f64, a3: f64) { + jb_body_lock_translation_axes(a0, a1, a2, a3) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_get_mass(a0: f64) -> f64 { jb_body_get_mass(a0) } +pub fn bloom_physics_body_get_mass(a0: f64) -> f64 { + jb_body_get_mass(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_get_friction(a0: f64) -> f64 { jb_body_get_friction(a0) } +pub fn bloom_physics_body_get_friction(a0: f64) -> f64 { + jb_body_get_friction(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_get_restitution(a0: f64) -> f64 { jb_body_get_restitution(a0) } +pub fn bloom_physics_body_get_restitution(a0: f64) -> f64 { + jb_body_get_restitution(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_get_object_layer(a0: f64) -> f64 { jb_body_get_object_layer(a0) } +pub fn bloom_physics_body_get_object_layer(a0: f64) -> f64 { + jb_body_get_object_layer(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_set_user_data(a0: f64, a1: f64, a2: f64) { jb_body_set_user_data(a0, a1, a2) } +pub fn bloom_physics_body_set_user_data(a0: f64, a1: f64, a2: f64) { + jb_body_set_user_data(a0, a1, a2) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_body_get_user_data(a0: f64, a1: f64) -> f64 { jb_body_get_user_data(a0, a1) } +pub fn bloom_physics_body_get_user_data(a0: f64, a1: f64) -> f64 { + jb_body_get_user_data(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_raycast(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64) -> f64 { jb_raycast(a0, a1, a2, a3, a4, a5, a6, a7, a8) } +pub fn bloom_physics_raycast( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, +) -> f64 { + jb_raycast(a0, a1, a2, a3, a4, a5, a6, a7, a8) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_raycast_all(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64) -> f64 { jb_raycast_all(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9) } +pub fn bloom_physics_raycast_all( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, +) -> f64 { + jb_raycast_all(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_ray_hit_count() -> f64 { jb_ray_hit_count() } +pub fn bloom_physics_ray_hit_count() -> f64 { + jb_ray_hit_count() +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_ray_hit_body(a0: f64) -> f64 { jb_ray_hit_body(a0) } +pub fn bloom_physics_ray_hit_body(a0: f64) -> f64 { + jb_ray_hit_body(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_ray_hit_axis(a0: f64, a1: f64) -> f64 { jb_ray_hit_axis(a0, a1) } +pub fn bloom_physics_ray_hit_axis(a0: f64, a1: f64) -> f64 { + jb_ray_hit_axis(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_ray_hit_fraction(a0: f64) -> f64 { jb_ray_hit_fraction(a0) } +pub fn bloom_physics_ray_hit_fraction(a0: f64) -> f64 { + jb_ray_hit_fraction(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_ray_hit_sub_shape(a0: f64) -> f64 { jb_ray_hit_sub_shape(a0) } +pub fn bloom_physics_ray_hit_sub_shape(a0: f64) -> f64 { + jb_ray_hit_sub_shape(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_overlap_sphere(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64) -> f64 { jb_overlap_sphere(a0, a1, a2, a3, a4, a5, a6) } +pub fn bloom_physics_overlap_sphere( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, +) -> f64 { + jb_overlap_sphere(a0, a1, a2, a3, a4, a5, a6) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_overlap_point(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64) -> f64 { jb_overlap_point(a0, a1, a2, a3, a4, a5) } +pub fn bloom_physics_overlap_point(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64) -> f64 { + jb_overlap_point(a0, a1, a2, a3, a4, a5) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_overlap_box(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64, a10: f64, a11: f64, a12: f64) -> f64 { jb_overlap_box(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12) } +pub fn bloom_physics_overlap_box( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + a11: f64, + a12: f64, +) -> f64 { + jb_overlap_box(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_overlap_body(a0: f64) -> f64 { jb_overlap_body(a0) } +pub fn bloom_physics_overlap_body(a0: f64) -> f64 { + jb_overlap_body(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_constraint_fixed(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64) -> f64 { jb_constraint_fixed(a0, a1, a2, a3, a4, a5, a6, a7, a8) } +pub fn bloom_physics_constraint_fixed( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, +) -> f64 { + jb_constraint_fixed(a0, a1, a2, a3, a4, a5, a6, a7, a8) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_constraint_point(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64) -> f64 { jb_constraint_point(a0, a1, a2, a3, a4, a5, a6, a7, a8) } +pub fn bloom_physics_constraint_point( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, +) -> f64 { + jb_constraint_point(a0, a1, a2, a3, a4, a5, a6, a7, a8) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_constraint_hinge(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64, a10: f64, a11: f64, a12: f64, a13: f64) -> f64 { jb_constraint_hinge(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13) } +pub fn bloom_physics_constraint_hinge( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + a11: f64, + a12: f64, + a13: f64, +) -> f64 { + jb_constraint_hinge(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_constraint_slider(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64, a10: f64, a11: f64, a12: f64, a13: f64) -> f64 { jb_constraint_slider(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13) } +pub fn bloom_physics_constraint_slider( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + a11: f64, + a12: f64, + a13: f64, +) -> f64 { + jb_constraint_slider(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_constraint_distance(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64, a10: f64) -> f64 { jb_constraint_distance(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10) } +pub fn bloom_physics_constraint_distance( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, +) -> f64 { + jb_constraint_distance(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_constraint_destroy(a0: f64) { jb_constraint_destroy(a0) } +pub fn bloom_physics_constraint_destroy(a0: f64) { + jb_constraint_destroy(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_constraint_set_enabled(a0: f64, a1: f64) { jb_constraint_set_enabled(a0, a1) } +pub fn bloom_physics_constraint_set_enabled(a0: f64, a1: f64) { + jb_constraint_set_enabled(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_contact_count() -> f64 { jb_contact_count() } +pub fn bloom_physics_contact_count() -> f64 { + jb_contact_count() +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_contact_field(a0: f64, a1: f64) -> f64 { jb_contact_field(a0, a1) } +pub fn bloom_physics_contact_field(a0: f64, a1: f64) -> f64 { + jb_contact_field(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_clear_contacts(a0: f64) { jb_clear_contacts(a0) } +pub fn bloom_physics_clear_contacts(a0: f64) { + jb_clear_contacts(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_character_create(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64, a10: f64, a11: f64, a12: f64, a13: f64, a14: f64, a15: f64, a16: f64, a17: f64, a18: f64) -> f64 { jb_character_create(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18) } +pub fn bloom_physics_character_create( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + a11: f64, + a12: f64, + a13: f64, + a14: f64, + a15: f64, + a16: f64, + a17: f64, + a18: f64, +) -> f64 { + jb_character_create( + a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, + ) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_character_destroy(a0: f64) { jb_character_destroy(a0) } +pub fn bloom_physics_character_destroy(a0: f64) { + jb_character_destroy(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_character_update(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64) { jb_character_update(a0, a1, a2, a3, a4) } +pub fn bloom_physics_character_update(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64) { + jb_character_update(a0, a1, a2, a3, a4) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_character_get_position(a0: f64, a1: f64) -> f64 { jb_character_get_position(a0, a1) } +pub fn bloom_physics_character_get_position(a0: f64, a1: f64) -> f64 { + jb_character_get_position(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_character_get_rotation(a0: f64, a1: f64) -> f64 { jb_character_get_rotation(a0, a1) } +pub fn bloom_physics_character_get_rotation(a0: f64, a1: f64) -> f64 { + jb_character_get_rotation(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_character_set_position(a0: f64, a1: f64, a2: f64, a3: f64) { jb_character_set_position(a0, a1, a2, a3) } +pub fn bloom_physics_character_set_position(a0: f64, a1: f64, a2: f64, a3: f64) { + jb_character_set_position(a0, a1, a2, a3) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_character_set_rotation(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64) { jb_character_set_rotation(a0, a1, a2, a3, a4) } +pub fn bloom_physics_character_set_rotation(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64) { + jb_character_set_rotation(a0, a1, a2, a3, a4) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_character_get_linear_velocity(a0: f64, a1: f64) -> f64 { jb_character_get_linear_velocity(a0, a1) } +pub fn bloom_physics_character_get_linear_velocity(a0: f64, a1: f64) -> f64 { + jb_character_get_linear_velocity(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_character_set_linear_velocity(a0: f64, a1: f64, a2: f64, a3: f64) { jb_character_set_linear_velocity(a0, a1, a2, a3) } +pub fn bloom_physics_character_set_linear_velocity(a0: f64, a1: f64, a2: f64, a3: f64) { + jb_character_set_linear_velocity(a0, a1, a2, a3) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_character_get_ground_state(a0: f64) -> f64 { jb_character_get_ground_state(a0) } +pub fn bloom_physics_character_get_ground_state(a0: f64) -> f64 { + jb_character_get_ground_state(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_character_get_ground_normal(a0: f64, a1: f64) -> f64 { jb_character_get_ground_normal(a0, a1) } +pub fn bloom_physics_character_get_ground_normal(a0: f64, a1: f64) -> f64 { + jb_character_get_ground_normal(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_character_get_ground_position(a0: f64, a1: f64) -> f64 { jb_character_get_ground_position(a0, a1) } +pub fn bloom_physics_character_get_ground_position(a0: f64, a1: f64) -> f64 { + jb_character_get_ground_position(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_character_get_ground_body(a0: f64) -> f64 { jb_character_get_ground_body(a0) } +pub fn bloom_physics_character_get_ground_body(a0: f64) -> f64 { + jb_character_get_ground_body(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_character_set_shape(a0: f64, a1: f64) { jb_character_set_shape(a0, a1) } +pub fn bloom_physics_character_set_shape(a0: f64, a1: f64) { + jb_character_set_shape(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_soft_body_create(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64, a10: f64, a11: f64, a12: f64, a13: f64, a14: f64) -> f64 { jb_soft_body_create(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14) } +pub fn bloom_physics_soft_body_create( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + a11: f64, + a12: f64, + a13: f64, + a14: f64, +) -> f64 { + jb_soft_body_create( + a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, + ) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_soft_body_vertex_count(a0: f64) -> f64 { jb_soft_body_vertex_count(a0) } +pub fn bloom_physics_soft_body_vertex_count(a0: f64) -> f64 { + jb_soft_body_vertex_count(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_soft_body_get_vertex(a0: f64, a1: f64, a2: f64) -> f64 { jb_soft_body_get_vertex(a0, a1, a2) } +pub fn bloom_physics_soft_body_get_vertex(a0: f64, a1: f64, a2: f64) -> f64 { + jb_soft_body_get_vertex(a0, a1, a2) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_soft_body_set_vertex(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64) { jb_soft_body_set_vertex(a0, a1, a2, a3, a4) } +pub fn bloom_physics_soft_body_set_vertex(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64) { + jb_soft_body_set_vertex(a0, a1, a2, a3, a4) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_soft_body_set_vertex_inv_mass(a0: f64, a1: f64, a2: f64) { jb_soft_body_set_vertex_inv_mass(a0, a1, a2) } +pub fn bloom_physics_soft_body_set_vertex_inv_mass(a0: f64, a1: f64, a2: f64) { + jb_soft_body_set_vertex_inv_mass(a0, a1, a2) +} #[cfg(feature = "jolt")] #[wasm_bindgen] // 37 params, matching the manifest, physics_jolt.rs, and jolt_bridge.js's // vehicleCreate. Carried a 38th (trailing, so nothing shifted) that arrived // NaN and was dropped on the JS floor. -pub fn bloom_physics_vehicle_create(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64, a5: f64, a6: f64, a7: f64, a8: f64, a9: f64, a10: f64, a11: f64, a12: f64, a13: f64, a14: f64, a15: f64, a16: f64, a17: f64, a18: f64, a19: f64, a20: f64, a21: f64, a22: f64, a23: f64, a24: f64, a25: f64, a26: f64, a27: f64, a28: f64, a29: f64, a30: f64, a31: f64, a32: f64, a33: f64, a34: f64, a35: f64, a36: f64) -> f64 { jb_vehicle_create(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22, a23, a24, a25, a26, a27, a28, a29, a30, a31, a32, a33, a34, a35, a36) } +pub fn bloom_physics_vehicle_create( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + a11: f64, + a12: f64, + a13: f64, + a14: f64, + a15: f64, + a16: f64, + a17: f64, + a18: f64, + a19: f64, + a20: f64, + a21: f64, + a22: f64, + a23: f64, + a24: f64, + a25: f64, + a26: f64, + a27: f64, + a28: f64, + a29: f64, + a30: f64, + a31: f64, + a32: f64, + a33: f64, + a34: f64, + a35: f64, + a36: f64, +) -> f64 { + jb_vehicle_create( + a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, + a20, a21, a22, a23, a24, a25, a26, a27, a28, a29, a30, a31, a32, a33, a34, a35, a36, + ) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_vehicle_destroy(a0: f64) { jb_vehicle_destroy(a0) } +pub fn bloom_physics_vehicle_destroy(a0: f64) { + jb_vehicle_destroy(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_vehicle_get_chassis(a0: f64) -> f64 { jb_vehicle_get_chassis(a0) } +pub fn bloom_physics_vehicle_get_chassis(a0: f64) -> f64 { + jb_vehicle_get_chassis(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_vehicle_set_input(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64) { jb_vehicle_set_input(a0, a1, a2, a3, a4) } +pub fn bloom_physics_vehicle_set_input(a0: f64, a1: f64, a2: f64, a3: f64, a4: f64) { + jb_vehicle_set_input(a0, a1, a2, a3, a4) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_vehicle_get_wheel_transform(a0: f64, a1: f64, a2: f64) -> f64 { jb_vehicle_get_wheel_transform(a0, a1, a2) } +pub fn bloom_physics_vehicle_get_wheel_transform(a0: f64, a1: f64, a2: f64) -> f64 { + jb_vehicle_get_wheel_transform(a0, a1, a2) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_vehicle_get_engine_rpm(a0: f64) -> f64 { jb_vehicle_get_engine_rpm(a0) } +pub fn bloom_physics_vehicle_get_engine_rpm(a0: f64) -> f64 { + jb_vehicle_get_engine_rpm(a0) +} #[cfg(feature = "jolt")] #[wasm_bindgen] -pub fn bloom_physics_vehicle_get_wheel_angular_velocity(a0: f64, a1: f64) -> f64 { jb_vehicle_get_wheel_angular_velocity(a0, a1) } +pub fn bloom_physics_vehicle_get_wheel_angular_velocity(a0: f64, a1: f64) -> f64 { + jb_vehicle_get_wheel_angular_velocity(a0, a1) +} #[cfg(feature = "jolt")] #[wasm_bindgen] diff --git a/native/web/src/ragdoll_ffi.rs b/native/web/src/ragdoll_ffi.rs index 79260806..437d64b6 100644 --- a/native/web/src/ragdoll_ffi.rs +++ b/native/web/src/ragdoll_ffi.rs @@ -207,11 +207,10 @@ pub fn bloom_ragdoll_update(rag: f64, anim: f64, dt: f64) -> f64 { let age = eng.ragdolls.get(rag as u32).map(|r| r.age).unwrap_or(0.0); // Split borrow: apply() needs &mut ModelAnimation and &Ragdoll. - let ragdoll_ptr: *const bloom_shared::ragdoll::Ragdoll = - match eng.ragdolls.get(rag as u32) { - Some(r) => r, - None => return 0.0, - }; + let ragdoll_ptr: *const bloom_shared::ragdoll::Ragdoll = match eng.ragdolls.get(rag as u32) { + Some(r) => r, + None => return 0.0, + }; if let Some(a) = eng.models.get_animation_mut(anim) { unsafe { (*ragdoll_ptr).apply(a, &world) }; } @@ -220,8 +219,14 @@ pub fn bloom_ragdoll_update(rag: f64, anim: f64, dt: f64) -> f64 { if !a.joint_matrices.is_empty() { let (s, c) = (rot.sin(), rot.cos()); // PT-7: anim handle = prev-palette pairing key. - eng.renderer - .set_joint_matrices_scaled(anim.to_bits(), &a.joint_matrices, scale, pos, s, c); + eng.renderer.set_joint_matrices_scaled( + anim.to_bits(), + &a.joint_matrices, + scale, + pos, + s, + c, + ); } } age as f64 diff --git a/native/web/src/render_settings.rs b/native/web/src/render_settings.rs index 909ca687..7193145d 100644 --- a/native/web/src/render_settings.rs +++ b/native/web/src/render_settings.rs @@ -12,47 +12,106 @@ use wasm_bindgen::prelude::*; // --------------------------------------------------------------------------- #[wasm_bindgen] -pub fn bloom_set_fog(r: f64, g: f64, b: f64, density: f64, height_ref: f64, height_falloff: f64) { +pub fn bloom_set_fog( + r: f64, + g: f64, + b: f64, + density: f64, + height_ref: f64, + height_falloff: f64, +) -> f64 { let r_ = engine(); r_.renderer.set_fog_color(r as f32, g as f32, b as f32); r_.renderer.set_fog_density(density as f32); - r_.renderer.set_fog_height_falloff(height_ref as f32, height_falloff as f32); + r_.renderer + .set_fog_height_falloff(height_ref as f32, height_falloff as f32); + 1.0 } #[wasm_bindgen] -pub fn bloom_set_chromatic_aberration(strength: f64) { engine().renderer.set_chromatic_aberration(strength as f32); } +pub fn bloom_set_chromatic_aberration(strength: f64) -> f64 { + engine().renderer.set_chromatic_aberration(strength as f32); + 1.0 +} +#[wasm_bindgen] +pub fn bloom_set_vignette(strength: f64, softness: f64) -> f64 { + engine() + .renderer + .set_vignette(strength as f32, softness as f32); + 1.0 +} #[wasm_bindgen] -pub fn bloom_set_vignette(strength: f64, softness: f64) { engine().renderer.set_vignette(strength as f32, softness as f32); } +pub fn bloom_set_film_grain(strength: f64) -> f64 { + engine().renderer.set_film_grain(strength as f32); + 1.0 +} #[wasm_bindgen] -pub fn bloom_set_film_grain(strength: f64) { engine().renderer.set_film_grain(strength as f32); } +pub fn bloom_set_sharpen_strength(strength: f64) -> f64 { + engine().renderer.set_sharpen_strength(strength as f32); + 1.0 +} #[wasm_bindgen] -pub fn bloom_set_sun_shafts(strength: f64, decay: f64, r: f64, g: f64, b: f64) { +pub fn bloom_set_sun_shafts(strength: f64, decay: f64, r: f64, g: f64, b: f64) -> f64 { let eng = engine(); eng.renderer.set_sun_shaft_strength(strength as f32); eng.renderer.set_sun_shaft_decay(decay as f32); - eng.renderer.set_sun_shaft_color(r as f32, g as f32, b as f32); + eng.renderer + .set_sun_shaft_color(r as f32, g as f32, b as f32); + 1.0 } #[wasm_bindgen] -pub fn bloom_set_auto_exposure(on: f64) { engine().renderer.set_auto_exposure(on != 0.0); } +pub fn bloom_set_auto_exposure(on: f64) -> f64 { + engine().renderer.set_auto_exposure(on != 0.0); + 1.0 +} #[wasm_bindgen] -pub fn bloom_set_manual_exposure(value: f64) { engine().renderer.set_manual_exposure(value as f32); } +pub fn bloom_set_manual_exposure(value: f64) -> f64 { + engine().renderer.set_manual_exposure(value as f32); + 1.0 +} #[wasm_bindgen] -pub fn bloom_set_taa_enabled(on: f64) { engine().renderer.set_taa_enabled(on != 0.0); } +pub fn bloom_set_taa_enabled(on: f64) -> f64 { + engine().renderer.set_taa_enabled(on != 0.0); + 1.0 +} #[wasm_bindgen] -pub fn bloom_set_occlusion_culling(on: f64) { engine().renderer.occlusion.enabled = on != 0.0; } +pub fn bloom_reset_temporal_history() -> f64 { + engine().renderer.reset_temporal_history(); + 1.0 +} #[wasm_bindgen] -pub fn bloom_set_render_scale(scale: f64) { engine().renderer.set_render_scale(scale as f32); } +pub fn bloom_set_occlusion_culling(on: f64) -> f64 { + engine().renderer.occlusion.enabled = on != 0.0; + 1.0 +} #[wasm_bindgen] -pub fn bloom_get_render_scale() -> f64 { engine().renderer.render_scale() as f64 } +pub fn bloom_set_render_scale(scale: f64) -> f64 { + engine().renderer.set_render_scale(scale as f32); + 1.0 +} #[wasm_bindgen] -pub fn bloom_set_upscale_mode(mode: f64) { engine().renderer.set_upscale_mode(mode as u32); } +pub fn bloom_get_render_scale() -> f64 { + engine().renderer.render_scale() as f64 +} #[wasm_bindgen] -pub fn bloom_set_cas_strength(strength: f64) { engine().renderer.set_cas_strength(strength as f32); } +pub fn bloom_set_upscale_mode(mode: f64) -> f64 { + engine().renderer.set_upscale_mode(mode as u32); + 1.0 +} #[wasm_bindgen] -pub fn bloom_get_physical_width() -> f64 { engine().renderer.physical_width() as f64 } +pub fn bloom_set_cas_strength(strength: f64) -> f64 { + engine().renderer.set_cas_strength(strength as f32); + 1.0 +} +#[wasm_bindgen] +pub fn bloom_get_physical_width() -> f64 { + engine().renderer.physical_width() as f64 +} #[wasm_bindgen] -pub fn bloom_get_physical_height() -> f64 { engine().renderer.physical_height() as f64 } +pub fn bloom_get_physical_height() -> f64 { + engine().renderer.physical_height() as f64 +} #[wasm_bindgen] -pub fn bloom_set_auto_resolution(target_hz: f64, enabled: f64) { +pub fn bloom_set_auto_resolution(target_hz: f64, enabled: f64) -> f64 { let eng = engine(); if enabled != 0.0 { let current = eng.renderer.render_scale(); @@ -60,39 +119,210 @@ pub fn bloom_set_auto_resolution(target_hz: f64, enabled: f64) { } else { eng.drs.disable(); } + 1.0 } #[wasm_bindgen] -pub fn bloom_set_env_intensity(intensity: f64) { engine().renderer.set_env_intensity(intensity as f32); } +pub fn bloom_set_env_intensity(intensity: f64) -> f64 { + engine().renderer.set_env_intensity(intensity as f32); + 1.0 +} #[wasm_bindgen] -pub fn bloom_set_ssgi_enabled(enabled: f64) { engine().renderer.set_ssgi_enabled(enabled != 0.0); } +pub fn bloom_set_ssgi_enabled(enabled: f64) -> f64 { + engine().renderer.set_ssgi_enabled(enabled != 0.0); + 1.0 +} #[wasm_bindgen] -pub fn bloom_set_ssgi_intensity(intensity: f64) { engine().renderer.set_ssgi_intensity(intensity as f32); } +pub fn bloom_set_ssgi_intensity(intensity: f64) -> f64 { + engine().renderer.set_ssgi_intensity(intensity as f32); + 1.0 +} #[wasm_bindgen] -pub fn bloom_set_ssgi_radius(radius: f64) { engine().renderer.set_ssgi_radius(radius as f32); } +pub fn bloom_set_ssgi_radius(radius: f64) -> f64 { + engine().renderer.set_ssgi_radius(radius as f32); + 1.0 +} #[wasm_bindgen] -pub fn bloom_set_dof(enabled: f64, focus_distance: f64, aperture: f64) { +pub fn bloom_set_dof(enabled: f64, focus_distance: f64, aperture: f64) -> f64 { let r = &mut engine().renderer; r.set_dof_enabled(enabled != 0.0); r.set_dof_focus_distance(focus_distance as f32); r.set_dof_aperture(aperture as f32); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_bloom_intensity(value: f64) -> f64 { + engine().renderer.set_bloom_intensity(value as f32); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_tonemap(kind: f64) -> f64 { + engine().renderer.set_tonemap_kind(kind as u32); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_auto_exposure_key(key: f64) -> f64 { + engine().renderer.set_auto_exposure_key(key as f32); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_auto_exposure_rate(rate: f64) -> f64 { + engine().renderer.set_auto_exposure_rate(rate as f32); + 1.0 +} + +// Render quality toggles (individual + preset) — ticket 011. +// These mirror native's settings surface so browser games never discover +// missing controls only after deployment. +#[wasm_bindgen] +pub fn bloom_set_quality_preset(preset: f64) -> f64 { + engine().renderer.apply_quality_preset(preset as u32); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_shadows_enabled(on: f64) -> f64 { + engine().renderer.set_shadows_enabled(on != 0.0); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_shadows_always_fresh(on: f64) -> f64 { + engine().renderer.set_shadows_always_fresh(on != 0.0); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_bloom_enabled(on: f64) -> f64 { + engine().renderer.set_bloom_enabled(on != 0.0); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_ssao_enabled(on: f64) -> f64 { + engine().renderer.set_ssao_enabled(on != 0.0); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_ssao_intensity(value: f64) -> f64 { + engine().renderer.set_ssao_strength(value as f32); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_ssao_radius(world_radius: f64) -> f64 { + engine().renderer.set_ssao_radius(world_radius as f32); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_wind(dir_x: f64, dir_z: f64, amplitude: f64, frequency: f64) -> f64 { + engine().renderer.set_wind( + dir_x as f32, + dir_z as f32, + amplitude as f32, + frequency as f32, + ); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_output_scale(scale: f64) -> f64 { + engine().renderer.set_output_scale(scale as f32); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_get_output_scale() -> f64 { + engine().renderer.output_scale() as f64 +} + +#[wasm_bindgen] +pub fn bloom_set_model_foliage_wind(model: f64, amount: f64) -> f64 { + engine() + .renderer + .set_model_foliage_wind(model.to_bits(), amount as f32); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_foliage_shadow_motion(on: f64) -> f64 { + engine().renderer.set_foliage_shadow_motion(on > 0.5); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_cloud_shadows( + strength: f64, + deck_height: f64, + feature_scale: f64, + drift_speed: f64, +) -> f64 { + engine().renderer.set_cloud_shadows( + strength as f32, + deck_height as f32, + feature_scale as f32, + drift_speed as f32, + ); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_ssr_enabled(on: f64) -> f64 { + engine().renderer.set_ssr_enabled(on != 0.0); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_motion_blur_enabled(on: f64) -> f64 { + engine().renderer.set_motion_blur_enabled(on != 0.0); + 1.0 +} + +#[wasm_bindgen] +pub fn bloom_set_sss_enabled(on: f64) -> f64 { + engine().renderer.set_sss_enabled(on != 0.0); + 1.0 } // LOD: pointer-taking variants share the cross-module WASM memory TODO // with bloom_scene_update_geometry above; the model-based variant works. #[wasm_bindgen] pub fn bloom_scene_set_lod( - _handle: f64, _lod_index: f64, _vert_ptr: f64, _vert_count: f64, - _idx_ptr: f64, _idx_count: f64, _max_coverage: f64, -) { + _handle: f64, + _lod_index: f64, + _vert_ptr: f64, + _vert_count: f64, + _idx_ptr: f64, + _idx_count: f64, + _max_coverage: f64, +) -> f64 { // TODO: Phase 4 — pointer/buffer passing from Perry WASM linear memory. + 0.0 } #[wasm_bindgen] -pub fn bloom_scene_attach_model_lod(node: f64, model: f64, mesh_index: f64, lod_index: f64, max_coverage: f64) { +pub fn bloom_scene_attach_model_lod( + node: f64, + model: f64, + mesh_index: f64, + lod_index: f64, + max_coverage: f64, +) -> f64 { let eng = engine(); let mi = mesh_index as usize; - let Some(md) = eng.models.models.get(model) else { return }; - if mi >= md.meshes.len() { return; } + let Some(md) = eng.models.models.get(model) else { + return 0.0; + }; + if mi >= md.meshes.len() { + return 0.0; + } let mesh = &md.meshes[mi]; let (v, i) = (mesh.vertices.clone(), mesh.indices.clone()); - eng.scene.set_lod_geometry(node, lod_index as usize, v, i, max_coverage as f32); + eng.scene + .set_lod_geometry(node, lod_index as usize, v, i, max_coverage as f32); + 1.0 } diff --git a/native/windows/Cargo.lock b/native/windows/Cargo.lock index f9d2d48a..3bb729be 100644 --- a/native/windows/Cargo.lock +++ b/native/windows/Cargo.lock @@ -100,6 +100,7 @@ dependencies = [ "image_dds", "lewton", "libc", + "log", "minimp3", "raw-window-handle", "web-sys", diff --git a/native/windows/src/lib.rs b/native/windows/src/lib.rs index 5a4204ae..365fee7c 100644 --- a/native/windows/src/lib.rs +++ b/native/windows/src/lib.rs @@ -1,7 +1,7 @@ +use bloom_shared::audio::{parse_mp3, parse_ogg, parse_wav}; use bloom_shared::engine::EngineState; use bloom_shared::renderer::Renderer; use bloom_shared::string_header::{alloc_perry_string, str_from_header}; -use bloom_shared::audio::{parse_wav, parse_ogg, parse_mp3}; use std::sync::OnceLock; @@ -27,36 +27,35 @@ fn bloom_resolve_asset_path(path: &str) -> std::borrow::Cow<'_, str> { // docs for the contract; tools/validate-ffi.js checks parity in CI. bloom_shared::define_core_ffi!(); - /// Map Win32 virtual key code to Bloom key code. fn map_keycode(vk: u32) -> usize { match vk { - 0x41..=0x5A => vk as usize, // A-Z map directly (65-90) - 0x30..=0x39 => vk as usize, // 0-9 map directly (48-57) + 0x41..=0x5A => vk as usize, // A-Z map directly (65-90) + 0x30..=0x39 => vk as usize, // 0-9 map directly (48-57) 0x70..=0x7B => (vk - 0x70 + 112) as usize, // F1-F12 - 0x26 => 256, // VK_UP - 0x28 => 257, // VK_DOWN - 0x25 => 258, // VK_LEFT - 0x27 => 259, // VK_RIGHT - 0x20 => 32, // VK_SPACE - 0x0D => 265, // VK_RETURN → Bloom ENTER - 0x1B => 27, // VK_ESCAPE - 0x09 => 9, // VK_TAB - 0x08 => 8, // VK_BACK - 0x2E => 127, // VK_DELETE - 0x2D => 260, // VK_INSERT - 0x24 => 261, // VK_HOME - 0x23 => 262, // VK_END - 0x21 => 263, // VK_PRIOR (Page Up) - 0x22 => 264, // VK_NEXT (Page Down) - 0xA0 => 280, // VK_LSHIFT - 0xA1 => 281, // VK_RSHIFT - 0xA2 => 282, // VK_LCONTROL - 0xA3 => 283, // VK_RCONTROL - 0xA4 => 284, // VK_LMENU (Left Alt) - 0xA5 => 285, // VK_RMENU (Right Alt) - 0x5B => 286, // VK_LWIN - 0x5C => 287, // VK_RWIN + 0x26 => 256, // VK_UP + 0x28 => 257, // VK_DOWN + 0x25 => 258, // VK_LEFT + 0x27 => 259, // VK_RIGHT + 0x20 => 32, // VK_SPACE + 0x0D => 265, // VK_RETURN → Bloom ENTER + 0x1B => 27, // VK_ESCAPE + 0x09 => 9, // VK_TAB + 0x08 => 8, // VK_BACK + 0x2E => 127, // VK_DELETE + 0x2D => 260, // VK_INSERT + 0x24 => 261, // VK_HOME + 0x23 => 262, // VK_END + 0x21 => 263, // VK_PRIOR (Page Up) + 0x22 => 264, // VK_NEXT (Page Down) + 0xA0 => 280, // VK_LSHIFT + 0xA1 => 281, // VK_RSHIFT + 0xA2 => 282, // VK_LCONTROL + 0xA3 => 283, // VK_RCONTROL + 0xA4 => 284, // VK_LMENU (Left Alt) + 0xA5 => 285, // VK_RMENU (Right Alt) + 0x5B => 286, // VK_LWIN + 0x5C => 287, // VK_RWIN _ => 0, } } @@ -74,9 +73,27 @@ fn resolve_modifier_vk(vk: u32, lparam: isize) -> u32 { let scancode = ((lparam >> 16) & 0xFF) as u32; let extended = (lparam >> 24) & 1 != 0; match vk { - 0x10 => if scancode == 0x36 { 0xA1 } else { 0xA0 }, // VK_SHIFT -> R/L - 0x11 => if extended { 0xA3 } else { 0xA2 }, // VK_CONTROL -> R/L - 0x12 => if extended { 0xA5 } else { 0xA4 }, // VK_MENU -> R/L + 0x10 => { + if scancode == 0x36 { + 0xA1 + } else { + 0xA0 + } + } // VK_SHIFT -> R/L + 0x11 => { + if extended { + 0xA3 + } else { + 0xA2 + } + } // VK_CONTROL -> R/L + 0x12 => { + if extended { + 0xA5 + } else { + 0xA4 + } + } // VK_MENU -> R/L _ => vk, } } @@ -193,13 +210,15 @@ mod crash_report { #[cfg(windows)] mod win32 { use super::*; - use windows::Win32::UI::WindowsAndMessaging::*; - use windows::Win32::UI::HiDpi::*; + use raw_window_handle::{ + RawDisplayHandle, RawWindowHandle, Win32WindowHandle, WindowsDisplayHandle, + }; + use windows::core::*; use windows::Win32::Foundation::*; - use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::Graphics::Gdi::*; - use windows::core::*; - use raw_window_handle::{RawWindowHandle, Win32WindowHandle, RawDisplayHandle, WindowsDisplayHandle}; + use windows::Win32::System::LibraryLoader::GetModuleHandleW; + use windows::Win32::UI::HiDpi::*; + use windows::Win32::UI::WindowsAndMessaging::*; static mut HWND_GLOBAL: Option = None; @@ -210,7 +229,12 @@ mod win32 { } static mut IS_FULLSCREEN: bool = false; static mut WINDOWED_STYLE: u32 = 0; - static mut WINDOWED_RECT: RECT = RECT { left: 0, top: 0, right: 0, bottom: 0 }; + static mut WINDOWED_RECT: RECT = RECT { + left: 0, + top: 0, + right: 0, + bottom: 0, + }; pub fn set_fullscreen(fullscreen: bool) { unsafe { @@ -230,8 +254,10 @@ mod win32 { // Set borderless fullscreen SetWindowLongW(hwnd, GWL_STYLE, (WS_POPUP | WS_VISIBLE).0 as i32); let _ = SetWindowPos( - hwnd, HWND_TOP, - mi.rcMonitor.left, mi.rcMonitor.top, + hwnd, + HWND_TOP, + mi.rcMonitor.left, + mi.rcMonitor.top, mi.rcMonitor.right - mi.rcMonitor.left, mi.rcMonitor.bottom - mi.rcMonitor.top, SWP_FRAMECHANGED | SWP_NOOWNERZORDER, @@ -241,8 +267,10 @@ mod win32 { // Restore windowed mode SetWindowLongW(hwnd, GWL_STYLE, WINDOWED_STYLE as i32); let _ = SetWindowPos( - hwnd, None, - WINDOWED_RECT.left, WINDOWED_RECT.top, + hwnd, + None, + WINDOWED_RECT.left, + WINDOWED_RECT.top, WINDOWED_RECT.right - WINDOWED_RECT.left, WINDOWED_RECT.bottom - WINDOWED_RECT.top, SWP_FRAMECHANGED | SWP_NOOWNERZORDER | SWP_NOZORDER, @@ -253,10 +281,17 @@ mod win32 { } pub fn toggle_fullscreen() { - unsafe { set_fullscreen(!IS_FULLSCREEN); } + unsafe { + set_fullscreen(!IS_FULLSCREEN); + } } - unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + unsafe extern "system" fn wndproc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + ) -> LRESULT { match msg { WM_CLOSE => { // Diagnostic (title-freeze investigation): who closes us? @@ -466,10 +501,16 @@ mod win32 { class_name, PCWSTR(title_wide.as_ptr()), WS_OVERLAPPEDWINDOW | WS_VISIBLE, - CW_USEDEFAULT, CW_USEDEFAULT, - phys_w, phys_h, - None, None, Some(&hinstance), None, - ).unwrap(); + CW_USEDEFAULT, + CW_USEDEFAULT, + phys_w, + phys_h, + None, + None, + Some(&hinstance), + None, + ) + .unwrap(); ShowWindow(hwnd, SW_SHOW); HWND_GLOBAL = Some(hwnd); @@ -491,7 +532,11 @@ mod win32 { pub fn dpi_scale(hwnd: HWND) -> f64 { unsafe { let dpi = GetDpiForWindow(hwnd); - if dpi == 0 { 1.0 } else { dpi as f64 / 96.0 } + if dpi == 0 { + 1.0 + } else { + dpi as f64 / 96.0 + } } } @@ -576,14 +621,21 @@ mod win32 { } #[no_mangle] -pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u8, fullscreen: f64) { +pub extern "C" fn bloom_init_window( + width: f64, + height: f64, + title_ptr: *const u8, + fullscreen: f64, +) { let title = str_from_header(title_ptr); #[cfg(windows)] { crash_report::install(); let (hwnd, phys_w, phys_h) = win32::create_window(width, height, title); - unsafe { init_engine_for_hwnd(hwnd, width as u32, height as u32, phys_w, phys_h); } + unsafe { + init_engine_for_hwnd(hwnd, width as u32, height as u32, phys_w, phys_h); + } if fullscreen != 0.0 { win32::set_fullscreen(true); } @@ -608,237 +660,183 @@ unsafe fn init_engine_for_hwnd( phys_w: u32, phys_h: u32, ) { - // Compile shaders with DXC, not FXC. - // - // This is not a nicety. wgpu's DX12 backend reports the adapter's - // shader model as min(device, compiler), and FXC — its default — caps - // that at 5.1. Hardware ray query requires 6.5 (wgpu-hal - // dx12/adapter.rs: `supports_ray_tracing`), so with FXC, - // EXPERIMENTAL_RAY_QUERY is never exposed on DX12 on *any* GPU, no - // matter how capable. Lumen then silently takes its software path and - // the frame quietly loses its hardware-traced GI. That is what the - // `ray_query=false` in the boot line has been telling us. - // - // DXC is loaded at runtime from `dxcompiler.dll` + `dxil.dll`. wgpu's - // `static-dxc` feature would avoid the DLLs but needs MSVC's ATL, - // which is not part of a default toolchain install. Both DLLs ship - // with the Windows SDK; `tools/fetch-dxc.ps1` copies them next to the - // binary. If they are missing, wgpu falls back to FXC on its own — we - // lose HW ray query, exactly as before, and nothing else breaks. - let mut backend_options = wgpu::BackendOptions::default(); - backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::DynamicDxc { - dxc_path: String::from("dxcompiler.dll"), - }; + // Compile shaders with DXC, not FXC. + // + // This is not a nicety. wgpu's DX12 backend reports the adapter's + // shader model as min(device, compiler), and FXC — its default — caps + // that at 5.1. Hardware ray query requires 6.5 (wgpu-hal + // dx12/adapter.rs: `supports_ray_tracing`), so with FXC, + // EXPERIMENTAL_RAY_QUERY is never exposed on DX12 on *any* GPU, no + // matter how capable. Lumen then silently takes its software path and + // the frame quietly loses its hardware-traced GI. That is what the + // `ray_query=false` in the boot line has been telling us. + // + // DXC is loaded at runtime from `dxcompiler.dll` + `dxil.dll`. wgpu's + // `static-dxc` feature would avoid the DLLs but needs MSVC's ATL, + // which is not part of a default toolchain install. Both DLLs ship + // with the Windows SDK; `tools/fetch-dxc.ps1` copies them next to the + // binary. If they are missing, wgpu falls back to FXC on its own — we + // lose HW ray query, exactly as before, and nothing else breaks. + let mut backend_options = wgpu::BackendOptions::default(); + backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::DynamicDxc { + dxc_path: String::from("dxcompiler.dll"), + }; - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { - backends: wgpu::Backends::DX12 | wgpu::Backends::VULKAN, - backend_options, - ..wgpu::InstanceDescriptor::new_without_display_handle() - }); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::DX12 | wgpu::Backends::VULKAN, + backend_options, + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); - let surface = unsafe { - let mut handle = raw_window_handle::Win32WindowHandle::new( - std::num::NonZeroIsize::new(hwnd.0 as isize).unwrap() - ); - let raw = raw_window_handle::RawWindowHandle::Win32(handle); - instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { + let surface = unsafe { + let mut handle = raw_window_handle::Win32WindowHandle::new( + std::num::NonZeroIsize::new(hwnd.0 as isize).unwrap(), + ); + let raw = raw_window_handle::RawWindowHandle::Win32(handle); + instance + .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { raw_display_handle: Some(raw_window_handle::RawDisplayHandle::Windows( - raw_window_handle::WindowsDisplayHandle::new() + raw_window_handle::WindowsDisplayHandle::new(), )), raw_window_handle: raw, - }).expect("Failed to create surface") - }; - - // Pick the adapter that can actually trace rays. - // - // We used to take whatever `request_adapter` handed back, which on - // Windows means DX12 — and wgpu's DX12 backend only reports - // EXPERIMENTAL_RAY_QUERY when the driver advertises D3D12 raytracing - // tier 1.1. The same GPU under Vulkan can expose VK_KHR_ray_query - // when DX12 does not. The result was silent: Lumen's hardware trace - // was never selected, the software SDF path ran instead, and nobody - // saw a reason why. So enumerate the candidates, say out loud what - // each one offers, and prefer one that supports ray query. - // - // BLOOM_FORCE_SW_GI keeps its meaning: it also stops us from picking - // a backend *for* ray tracing, so the software path can be tested on - // hardware that would otherwise take the fast route. - let want_rt = !std::env::var("BLOOM_FORCE_SW_GI") - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false); + }) + .expect("Failed to create surface") + }; - let candidates = pollster_block_on( - instance.enumerate_adapters(wgpu::Backends::DX12 | wgpu::Backends::VULKAN), + // Pick the adapter that can actually trace rays. + // + // We used to take whatever `request_adapter` handed back, which on + // Windows means DX12 — and wgpu's DX12 backend only reports + // EXPERIMENTAL_RAY_QUERY when the driver advertises D3D12 raytracing + // tier 1.1. The same GPU under Vulkan can expose VK_KHR_ray_query + // when DX12 does not. The result was silent: Lumen's hardware trace + // was never selected, the software SDF path ran instead, and nobody + // saw a reason why. So enumerate the candidates, say out loud what + // each one offers, and prefer one that supports ray query. + // + // BLOOM_FORCE_SW_GI keeps its meaning: it also stops us from picking + // a backend *for* ray tracing, so the software path can be tested on + // hardware that would otherwise take the fast route. + let want_rt = !std::env::var("BLOOM_FORCE_SW_GI") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + + let candidates = pollster_block_on( + instance.enumerate_adapters(wgpu::Backends::DX12 | wgpu::Backends::VULKAN), + ); + let mut rt_adapter: Option = None; + for cand in candidates { + let info = cand.get_info(); + let surf_ok = cand.is_surface_supported(&surface); + let has_rt = cand + .features() + .contains(wgpu::Features::EXPERIMENTAL_RAY_QUERY); + eprintln!( + "bloom: candidate '{}' ({:?}), ray_query={}, surface={}", + info.name, info.backend, has_rt, surf_ok, ); - let mut rt_adapter: Option = None; - for cand in candidates { - let info = cand.get_info(); - let surf_ok = cand.is_surface_supported(&surface); - let has_rt = cand - .features() - .contains(wgpu::Features::EXPERIMENTAL_RAY_QUERY); - eprintln!( - "bloom: candidate '{}' ({:?}), ray_query={}, surface={}", - info.name, info.backend, has_rt, surf_ok, - ); - if want_rt && has_rt && surf_ok && rt_adapter.is_none() { - rt_adapter = Some(cand); - } + if want_rt && has_rt && surf_ok && rt_adapter.is_none() { + rt_adapter = Some(cand); } + } - let adapter = match rt_adapter { - Some(a) => a, - None => pollster_block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { - compatible_surface: Some(&surface), - power_preference: wgpu::PowerPreference::HighPerformance, - ..Default::default() - })) - .expect("No adapter found"), - }; + let adapter = match rt_adapter { + Some(a) => a, + None => pollster_block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + compatible_surface: Some(&surface), + power_preference: wgpu::PowerPreference::HighPerformance, + ..Default::default() + })) + .expect("No adapter found"), + }; - { - // One line of boot truth: which GPU we got and whether the - // features that silently reshape the frame (HW ray query for - // GI, timestamps for the profiler) are available on it. - let info = adapter.get_info(); - eprintln!( - "bloom: adapter '{}' ({:?}), ray_query={}, timestamps={}, tex_arrays={}", - info.name, - info.backend, - adapter.features().contains(wgpu::Features::EXPERIMENTAL_RAY_QUERY), - adapter.features().contains(wgpu::Features::TIMESTAMP_QUERY), - adapter.features().contains( - wgpu::Features::TEXTURE_BINDING_ARRAY - | wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING - ), - ); - } + { + // One line of boot truth: which GPU we got and whether the + // features that silently reshape the frame (HW ray query for + // GI, timestamps for the profiler) are available on it. + let info = adapter.get_info(); + eprintln!( + "bloom: adapter '{}' ({:?}), ray_query={}, timestamps={}, tex_arrays={}", + info.name, + info.backend, + adapter + .features() + .contains(wgpu::Features::EXPERIMENTAL_RAY_QUERY), + adapter.features().contains(wgpu::Features::TIMESTAMP_QUERY), + adapter.features().contains( + wgpu::Features::TEXTURE_BINDING_ARRAY + | wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING + ), + ); + } - // Ticket 007b: HW ray-query via DXR 1.1 / VK_KHR_ray_query. - let supported = adapter.features(); - // 2026-07-16: HW ray query is OPT-IN now, not granted-by-default. - // Measured on the shooter (760M, PT off): merely granting the - // feature flips SSGI/WSRC to the HW trace and registers every - // skinned draw for per-frame pre-skin + BLAS builds — costing - // +20 ms/frame (14.4 vs 20.3 fps) AND losing the sun's cast - // shadows (screenshot-confirmed twice). Until the HW path is - // both cheaper and visually correct, nobody should get it by - // accident just because dxcompiler.dll sits beside the exe. - // Opt in with BLOOM_HW_GI=1; --pt / BLOOM_PT keep working (the - // path tracer needs the feature at device creation). - let want_hw = std::env::var("BLOOM_HW_GI") + // 2026-07-16: HW ray query is OPT-IN now, not granted-by-default. + // Measured on the shooter (760M, PT off): merely granting the + // feature flips SSGI/WSRC to the HW trace and registers every + // skinned draw for per-frame pre-skin + BLAS builds — costing + // +20 ms/frame (14.4 vs 20.3 fps) AND losing the sun's cast + // shadows (screenshot-confirmed twice). Until the HW path is + // both cheaper and visually correct, nobody should get it by + // accident just because dxcompiler.dll sits beside the exe. + // Opt in with BLOOM_HW_GI=1; --pt / BLOOM_PT keep working (the + // path tracer needs the feature at device creation). + let want_hw = std::env::var("BLOOM_HW_GI") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) + || std::env::var("BLOOM_PT").is_ok() + || std::env::args().any(|a| a == "--pt"); + let force_sw_gi = !want_hw + || std::env::var("BLOOM_FORCE_SW_GI") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false) - || std::env::var("BLOOM_PT").is_ok() - || std::env::args().any(|a| a == "--pt"); - let force_sw_gi = !want_hw - || std::env::var("BLOOM_FORCE_SW_GI") - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false); - let rt_mask = wgpu::Features::EXPERIMENTAL_RAY_QUERY; - eprintln!("bloom: hw-gi opt-in: want_hw={want_hw} force_sw_gi={force_sw_gi}"); - let mut required_features = wgpu::Features::empty(); - // Ticket 011: request TIMESTAMP_QUERY when supported so the profiler - // can record GPU timings. Optional — profiler falls back to CPU-only - // when the adapter doesn't grant it. - if supported.contains(wgpu::Features::TIMESTAMP_QUERY) { - required_features |= wgpu::Features::TIMESTAMP_QUERY; - } - // Cooked BC7 textures (bloom-cook) upload compressed when the - // adapter has BC support; without it they CPU-decode at load. - if supported.contains(wgpu::Features::TEXTURE_COMPRESSION_BC) { - required_features |= wgpu::Features::TEXTURE_COMPRESSION_BC; - } - if !force_sw_gi && supported.contains(rt_mask) { - required_features |= rt_mask; - } - // PT-2: texture binding array + non-uniform indexing for textured - // path-trace hit shading. Both or neither (the kernel indexes the - // array with a per-thread material id). - let pt_tex_mask = wgpu::Features::TEXTURE_BINDING_ARRAY - | wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING; - if supported.contains(pt_tex_mask) { - required_features |= pt_tex_mask; - } - let experimental_features = if required_features.intersects(rt_mask) { - unsafe { wgpu::ExperimentalFeatures::enabled() } - } else { - wgpu::ExperimentalFeatures::disabled() - }; - let mut required_limits = wgpu::Limits::default(); - // Phase 1c: the material ABI declares 5 bind groups (PerFrame, - // PerView, PerMaterial, PerDraw, SceneInputs). wgpu's default - // limit is 4. Metal / Vulkan / D3D12 support at least 7, so 5 is - // safely within every real backend's capabilities. - required_limits.max_bind_groups = 5; - // The refractive/translucent material profile binds up to 19 - // sampled textures in the fragment stage (5 material maps + env/ - // BRDF/3 shadow cascades/env-diffuse + planar reflection + 3 texture - // arrays + the group-4 scene_color/scene_depth/impulse/motion inputs). - // wgpu's default is 16. Raise to whatever the adapter actually - // supports — every real D3D12/Vulkan/Metal GPU exposes ≥128 — so - // opaque/transparent materials are unaffected and refractive ones link. - let adapter_limits = adapter.limits(); - required_limits.max_sampled_textures_per_shader_stage = - adapter_limits.max_sampled_textures_per_shader_stage; - required_limits.max_samplers_per_shader_stage = - adapter_limits.max_samplers_per_shader_stage; - // PT-2: binding arrays have their own element budget, default 0. - // Take whatever the adapter offers; the renderer checks the - // granted value against its fixed array size before compiling - // the textured kernel variant. - if required_features.contains(pt_tex_mask) { - required_limits.max_binding_array_elements_per_shader_stage = - adapter_limits.max_binding_array_elements_per_shader_stage; - } - // PT-4: the path-trace kernel binds 9 storage buffers (accum + - // moments + reservoir ping-pongs on top of instance/geo data); - // the wgpu default limit is 8. - required_limits.max_storage_buffers_per_shader_stage = required_limits - .max_storage_buffers_per_shader_stage - .max(adapter_limits.max_storage_buffers_per_shader_stage.min(16)); - if required_features.intersects(rt_mask) { - required_limits = required_limits - .using_minimum_supported_acceleration_structure_values(); - } - let (device, queue) = pollster_block_on(adapter.request_device( - &wgpu::DeviceDescriptor { - label: Some("bloom_device"), - required_features, - required_limits, - experimental_features, - ..Default::default() + .unwrap_or(false); + eprintln!("bloom: hw-gi opt-in: want_hw={want_hw} force_sw_gi={force_sw_gi}"); + let negotiated = pollster_block_on( + bloom_shared::renderer::device_negotiation::request_device_with_fallback( + &adapter, + bloom_shared::renderer::device_negotiation::DeviceRequestOptions { + allow_ray_query: !force_sw_gi, + profile: + bloom_shared::renderer::device_negotiation::DeviceRequestProfile::NativeFull, }, - )).expect("Failed to create device"); - - let surface_caps = surface.get_capabilities(&adapter); - let format = surface_caps.formats[0]; - // Surface is configured at the *physical* client-area size we - // got back from create_window; Renderer::new takes the - // caller's logical size separately so screenWidth() etc. keep - // returning DPI-independent numbers. - let surface_config = wgpu::SurfaceConfiguration { - // COPY_SRC: bloom_take_screenshot reads the swapchain back; - // without it the readback copy is a swallowed validation - // error and screenshots silently produce nothing on Windows. - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::COPY_SRC, - format, - width: phys_w, - height: phys_h, - present_mode: wgpu::PresentMode::Fifo, - alpha_mode: surface_caps.alpha_modes[0], - view_formats: vec![], - desired_maximum_frame_latency: 2, - }; - surface.configure(&device, &surface_config); - - // `Renderer::new` routes initial target creation through `resize` - // itself, so the construction-time targets already honour - // render_scale — no explicit resize needed here (it used to live - // here, which left every non-Windows host with the bug). - let renderer = Renderer::new(device, queue, surface, surface_config, logical_w, logical_h); - let _ = ENGINE.set(EngineState::new(renderer)); + ), + ) + .unwrap_or_else(|error| panic!("Failed to create renderer device: {error}")); + eprintln!( + "bloom: renderer device negotiation = {}", + negotiated.report.report_json() + ); + let negotiation_report = negotiated.report.report_json(); + let device = negotiated.device; + let queue = negotiated.queue; + + let surface_caps = surface.get_capabilities(&adapter); + let format = surface_caps.formats[0]; + // Surface is configured at the *physical* client-area size we + // got back from create_window; Renderer::new takes the + // caller's logical size separately so screenWidth() etc. keep + // returning DPI-independent numbers. + let surface_config = wgpu::SurfaceConfiguration { + // COPY_SRC: bloom_take_screenshot reads the swapchain back; + // without it the readback copy is a swallowed validation + // error and screenshots silently produce nothing on Windows. + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + format, + width: phys_w, + height: phys_h, + present_mode: wgpu::PresentMode::Fifo, + alpha_mode: surface_caps.alpha_modes[0], + view_formats: vec![], + desired_maximum_frame_latency: 2, + }; + surface.configure(&device, &surface_config); + + // `Renderer::new` routes initial target creation through `resize` + // itself, so the construction-time targets already honour + // render_scale — no explicit resize needed here (it used to live + // here, which left every non-Windows host with the bug). + let mut renderer = Renderer::new(device, queue, surface, surface_config, logical_w, logical_h); + renderer.set_device_negotiation_report(negotiation_report); + let _ = ENGINE.set(EngineState::new(renderer)); } /// Attach the engine to a host-owned `HWND` instead of creating its own @@ -932,7 +930,9 @@ pub extern "C" fn bloom_attach_hwnd(hwnd_bits: f64, width: f64, height: f64) { win32::attach_subclass(hwnd); } #[cfg(not(windows))] - { let _ = (hwnd_bits, width, height); } + { + let _ = (hwnd_bits, width, height); + } } /// Resize the engine's surface. `phys_*` are physical pixels, `log_*` logical. @@ -943,18 +943,29 @@ pub extern "C" fn bloom_resize(phys_w: f64, phys_h: f64, log_w: f64, log_h: f64) #[cfg(windows)] unsafe { if let Some(eng) = ENGINE.get_mut() { - eng.renderer.resize(phys_w as u32, phys_h as u32, log_w as u32, log_h as u32); + eng.renderer + .resize(phys_w as u32, phys_h as u32, log_w as u32, log_h as u32); } } #[cfg(not(windows))] - { let _ = (phys_w, phys_h, log_w, log_h); } + { + let _ = (phys_w, phys_h, log_w, log_h); + } } #[no_mangle] pub extern "C" fn bloom_window_should_close() -> f64 { #[cfg(windows)] - unsafe { if EMBEDDED { return 0.0; } } - if engine().should_close { 1.0 } else { 0.0 } + unsafe { + if EMBEDDED { + return 0.0; + } + } + if engine().should_close { + 1.0 + } else { + 0.0 + } } #[cfg(windows)] @@ -970,14 +981,20 @@ fn poll_xinput_gamepad() { // Axes: left stick X/Y, right stick X/Y, triggers let normalize = |v: i16| -> f32 { - if v > 0 { v as f32 / 32767.0 } else { v as f32 / 32768.0 } + if v > 0 { + v as f32 / 32767.0 + } else { + v as f32 / 32768.0 + } }; eng.input.set_gamepad_axis(0, normalize(gp.sThumbLX)); eng.input.set_gamepad_axis(1, -normalize(gp.sThumbLY)); // invert Y eng.input.set_gamepad_axis(2, normalize(gp.sThumbRX)); eng.input.set_gamepad_axis(3, -normalize(gp.sThumbRY)); - eng.input.set_gamepad_axis(4, gp.bLeftTrigger as f32 / 255.0); - eng.input.set_gamepad_axis(5, gp.bRightTrigger as f32 / 255.0); + eng.input + .set_gamepad_axis(4, gp.bLeftTrigger as f32 / 255.0); + eng.input + .set_gamepad_axis(5, gp.bRightTrigger as f32 / 255.0); eng.input.gamepad_axis_count = 6; // Buttons @@ -1011,7 +1028,11 @@ fn poll_xinput_gamepad() { // XInputSetState when the commanded level actually changes, since the // call goes out over USB/BT and doing it every frame is wasteful. let dt = eng.delta_time as f32; - let (lo, hi, mut left) = (eng.input.rumble[0], eng.input.rumble[1], eng.input.rumble[2]); + let (lo, hi, mut left) = ( + eng.input.rumble[0], + eng.input.rumble[1], + eng.input.rumble[2], + ); let (want_lo, want_hi) = if left > 0.0 { (lo, hi) } else { (0.0, 0.0) }; unsafe { if (want_lo, want_hi) != LAST_RUMBLE { @@ -1031,7 +1052,9 @@ fn poll_xinput_gamepad() { eng.input.gamepad_available = false; // Pad unplugged mid-rumble: forget the commanded level so a // reconnecting pad doesn't inherit a stale "still buzzing" state. - unsafe { LAST_RUMBLE = (0.0, 0.0); } + unsafe { + LAST_RUMBLE = (0.0, 0.0); + } } } @@ -1047,14 +1070,20 @@ pub extern "C" fn bloom_begin_drawing() { // In embedded mode the host (Perry UI) owns the message loop and // dispatches our subclassed window's messages — pumping here would // steal messages from the host. Only poll when Bloom owns the window. - unsafe { if !EMBEDDED { win32::poll_events(); } } + unsafe { + if !EMBEDDED { + win32::poll_events(); + } + } poll_xinput_gamepad(); } engine().begin_frame(); } #[no_mangle] -pub extern "C" fn bloom_end_drawing() { engine().end_frame(); } +pub extern "C" fn bloom_end_drawing() { + engine().end_frame(); +} static AUDIO_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); @@ -1068,8 +1097,8 @@ pub extern "C" fn bloom_init_audio() { // Move the render half into the audio thread; the engine keeps // only the command-producing control half. let renderer = engine().audio.take_renderer(); - std::thread::spawn(move || { - unsafe { wasapi_audio_thread(renderer); } + std::thread::spawn(move || unsafe { + wasapi_audio_thread(renderer); }); } } @@ -1082,23 +1111,20 @@ pub extern "C" fn bloom_close_audio() { #[cfg(windows)] unsafe fn wasapi_audio_thread(mut renderer: Option) { + use std::sync::atomic::Ordering; + use windows::core::*; use windows::Win32::Media::Audio::*; use windows::Win32::System::Com::*; - use windows::core::*; - use std::sync::atomic::Ordering; // Initialize COM on this thread let _ = CoInitializeEx(None, COINIT_MULTITHREADED); // Create device enumerator and get default output device - let enumerator: IMMDeviceEnumerator = match CoCreateInstance( - &MMDeviceEnumerator, - None, - CLSCTX_ALL, - ) { - Ok(e) => e, - Err(_) => return, - }; + let enumerator: IMMDeviceEnumerator = + match CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) { + Ok(e) => e, + Err(_) => return, + }; let device = match enumerator.GetDefaultAudioEndpoint(eRender, eConsole) { Ok(d) => d, @@ -1121,14 +1147,17 @@ unsafe fn wasapi_audio_thread(mut renderer: Option p, - Err(_) => { std::thread::sleep(std::time::Duration::from_millis(2)); continue; } + Err(_) => { + std::thread::sleep(std::time::Duration::from_millis(2)); + continue; + } }; // Mix audio let mix_samples = available * 2; // stereo - for i in 0..mix_samples { mix_buf[i] = 0.0; } + for i in 0..mix_samples { + mix_buf[i] = 0.0; + } // Renderer is moved into this thread at spawn — no shared // engine state is touched from here (see audio/mod.rs contract). if let Some(r) = renderer.as_mut() { @@ -1180,27 +1214,41 @@ unsafe fn wasapi_audio_thread(mut renderer: Option= 2 { out[i * out_channels] = l; out[i * out_channels + 1] = r; - for c in 2..out_channels { out[i * out_channels + c] = 0.0; } + for c in 2..out_channels { + out[i * out_channels + c] = 0.0; + } } else { out[i] = (l + r) * 0.5; } } } else if bits == 16 { - let out = std::slice::from_raw_parts_mut(buffer_ptr as *mut i16, available * out_channels); + let out = + std::slice::from_raw_parts_mut(buffer_ptr as *mut i16, available * out_channels); for i in 0..available { let l = mix_buf[i * 2]; - let r = if i * 2 + 1 < mix_samples { mix_buf[i * 2 + 1] } else { l }; + let r = if i * 2 + 1 < mix_samples { + mix_buf[i * 2 + 1] + } else { + l + }; if out_channels >= 2 { out[i * out_channels] = (l * 32767.0) as i16; out[i * out_channels + 1] = (r * 32767.0) as i16; - for c in 2..out_channels { out[i * out_channels + c] = 0; } + for c in 2..out_channels { + out[i * out_channels + c] = 0; + } } else { out[i] = ((l + r) * 0.5 * 32767.0) as i16; } @@ -1245,16 +1293,20 @@ pub extern "C" fn bloom_toggle_fullscreen() { #[no_mangle] pub extern "C" fn bloom_set_window_title(title_ptr: *const u8) { // Real since 2026-07-17 — the stub read the string and discarded it. - use windows::Win32::UI::WindowsAndMessaging::SetWindowTextW; use windows::core::PCWSTR; + use windows::Win32::UI::WindowsAndMessaging::SetWindowTextW; let title = str_from_header(title_ptr); if let Some(hwnd) = win32::main_hwnd() { let wide: Vec = title.encode_utf16().chain(std::iter::once(0)).collect(); - unsafe { let _ = SetWindowTextW(hwnd, PCWSTR(wide.as_ptr())); } + unsafe { + let _ = SetWindowTextW(hwnd, PCWSTR(wide.as_ptr())); + } } } #[no_mangle] -pub extern "C" fn bloom_set_window_icon(path_ptr: *const u8) { let _ = str_from_header(path_ptr); } +pub extern "C" fn bloom_set_window_icon(path_ptr: *const u8) { + let _ = str_from_header(path_ptr); +} #[no_mangle] pub extern "C" fn bloom_disable_cursor() { @@ -1273,12 +1325,12 @@ pub extern "C" fn bloom_enable_cursor() { // so paste in the editor's text fields silently never worked on Windows). #[no_mangle] pub extern "C" fn bloom_set_clipboard_text(text_ptr: *const u8) { + use windows::Win32::Foundation::{HANDLE, HWND}; use windows::Win32::System::DataExchange::{ - OpenClipboard, CloseClipboard, EmptyClipboard, SetClipboardData, + CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData, }; use windows::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE}; use windows::Win32::System::Ole::CF_UNICODETEXT; - use windows::Win32::Foundation::{HANDLE, HWND}; let text = str_from_header(text_ptr); unsafe { @@ -1304,10 +1356,10 @@ pub extern "C" fn bloom_set_clipboard_text(text_ptr: *const u8) { #[no_mangle] pub extern "C" fn bloom_get_clipboard_text() -> *const u8 { - use windows::Win32::System::DataExchange::{OpenClipboard, CloseClipboard, GetClipboardData}; + use windows::Win32::Foundation::{HGLOBAL, HWND}; + use windows::Win32::System::DataExchange::{CloseClipboard, GetClipboardData, OpenClipboard}; use windows::Win32::System::Memory::{GlobalLock, GlobalUnlock}; use windows::Win32::System::Ole::CF_UNICODETEXT; - use windows::Win32::Foundation::{HGLOBAL, HWND}; unsafe { let owner = win32::main_hwnd().unwrap_or(HWND(std::ptr::null_mut())); @@ -1339,11 +1391,11 @@ pub extern "C" fn bloom_get_clipboard_text() -> *const u8 { // by default, which would break every relative asset path afterward. #[cfg(windows)] fn run_file_dialog(filter: &str, title: &str, save: bool, default_name: &str) -> String { + use windows::core::{PCWSTR, PWSTR}; use windows::Win32::UI::Controls::Dialogs::{ - GetOpenFileNameW, GetSaveFileNameW, OPENFILENAMEW, - OFN_FILEMUSTEXIST, OFN_PATHMUSTEXIST, OFN_NOCHANGEDIR, OFN_OVERWRITEPROMPT, + GetOpenFileNameW, GetSaveFileNameW, OFN_FILEMUSTEXIST, OFN_NOCHANGEDIR, + OFN_OVERWRITEPROMPT, OFN_PATHMUSTEXIST, OPENFILENAMEW, }; - use windows::core::{PCWSTR, PWSTR}; // Filter block: "Matching files\0\0All files\0*.*\0\0". let pattern = if filter.is_empty() { "*.*" } else { filter }; @@ -1383,7 +1435,11 @@ fn run_file_dialog(filter: &str, title: &str, save: bool, default_name: &str) -> }; let ok = unsafe { - if save { GetSaveFileNameW(&mut ofn) } else { GetOpenFileNameW(&mut ofn) } + if save { + GetSaveFileNameW(&mut ofn) + } else { + GetOpenFileNameW(&mut ofn) + } }; if !ok.as_bool() { return String::new(); @@ -1401,14 +1457,19 @@ pub extern "C" fn bloom_open_file_dialog(filter_ptr: *const u8, title_ptr: *cons } #[no_mangle] -pub extern "C" fn bloom_save_file_dialog(default_name_ptr: *const u8, title_ptr: *const u8) -> *const u8 { +pub extern "C" fn bloom_save_file_dialog( + default_name_ptr: *const u8, + title_ptr: *const u8, +) -> *const u8 { let default_name = str_from_header(default_name_ptr); let title = str_from_header(title_ptr); let path = run_file_dialog("", &title, true, &default_name); alloc_perry_string(&path) } #[no_mangle] -pub extern "C" fn bloom_get_platform() -> f64 { 3.0 } +pub extern "C" fn bloom_get_platform() -> f64 { + 3.0 +} /// Preferred OS language packed as `c0*256+c1` (ISO-639 primary subtag), from /// `GetUserDefaultLocaleName` (e.g. "en-US" -> "en"). Falls back to "en". @@ -1418,7 +1479,14 @@ pub extern "C" fn bloom_get_language() -> f64 { let mut buf = [0u16; 85]; // LOCALE_NAME_MAX_LENGTH let n = unsafe { GetUserDefaultLocaleName(&mut buf) }; if n >= 2 { - let lc = |c: u16| -> u8 { let b = c as u8; if b.is_ascii_uppercase() { b + 32 } else { b } }; + let lc = |c: u16| -> u8 { + let b = c as u8; + if b.is_ascii_uppercase() { + b + 32 + } else { + b + } + }; let (c0, c1) = (lc(buf[0]), lc(buf[1])); if c0.is_ascii_alphabetic() && c1.is_ascii_alphabetic() { return (c0 as f64) * 256.0 + (c1 as f64); @@ -1432,11 +1500,13 @@ pub extern "C" fn bloom_get_language() -> f64 { // ============================================================ fn pollster_block_on(future: F) -> F::Output { - use std::task::{Context, Poll, Wake, Waker}; use std::pin::Pin; use std::sync::Arc; + use std::task::{Context, Poll, Wake, Waker}; struct NoopWaker; - impl Wake for NoopWaker { fn wake(self: Arc) {} } + impl Wake for NoopWaker { + fn wake(self: Arc) {} + } let waker = Waker::from(Arc::new(NoopWaker)); let mut cx = Context::from_waker(&waker); let mut future = unsafe { Pin::new_unchecked(Box::new(future)) }; @@ -1448,7 +1518,6 @@ fn pollster_block_on(future: F) -> F::Output { } } - // Q6: Multi-hit picking // ============================================================ diff --git a/package.json b/package.json index 152552af..b1cded26 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "./scene": "./src/scene/index.ts", "./physics": "./src/physics/index.ts", "./world": "./src/world/index.ts", - "./vfx": "./src/vfx/index.ts" + "./vfx": "./src/vfx/index.ts", + "./quality": "./src/quality/index.ts" }, "files": [ "src/", @@ -164,6 +165,25 @@ ], "returns": "void" }, + { + "name": "bloom_capture_frame_to_png", + "params": [ + "string" + ], + "returns": "f64" + }, + { + "name": "bloom_capture_debug_intermediates", + "params": [ + "string" + ], + "returns": "f64" + }, + { + "name": "bloom_capture_frame_ready", + "params": [], + "returns": "f64" + }, { "name": "bloom_clear_background", "params": [ @@ -179,7 +199,7 @@ "params": [ "string" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_target_fps", @@ -1005,7 +1025,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_compile_material_from_file", @@ -1123,7 +1143,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_create_texture_array", @@ -1167,7 +1187,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_material_shading_model", @@ -1175,7 +1195,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_material_probe_visible", @@ -1183,7 +1203,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_material_foliage", @@ -1195,7 +1215,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_post_pass", @@ -1207,7 +1227,7 @@ { "name": "bloom_clear_post_pass", "params": [], - "returns": "void" + "returns": "f64" }, { "name": "bloom_add_post_pass", @@ -1219,7 +1239,7 @@ { "name": "bloom_clear_all_post_passes", "params": [], - "returns": "void" + "returns": "f64" }, { "name": "bloom_draw_material", @@ -1565,7 +1585,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_ambient_light", @@ -1575,7 +1595,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_directional_light", @@ -1588,7 +1608,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_procedural_sky", @@ -1598,7 +1618,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_sun_direction", @@ -1608,7 +1628,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_fog", @@ -1620,14 +1640,14 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_chromatic_aberration", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_vignette", @@ -1635,28 +1655,72 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_film_grain", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_sharpen_strength", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_present_mode", "params": [ "f64" ], - "returns": "void" + "returns": "f64" + }, + { + "name": "bloom_get_present_mode", + "params": [], + "returns": "f64" + }, + { + "name": "bloom_get_material_binding_capabilities", + "params": [], + "returns": "string" + }, + { + "name": "bloom_get_renderer_capabilities", + "params": [], + "returns": "string" + }, + { + "name": "bloom_get_imported_refraction_mode", + "params": [], + "returns": "f64" + }, + { + "name": "bloom_set_transparency_composition_mode", + "params": [ + "f64" + ], + "returns": "f64" + }, + { + "name": "bloom_get_transparency_composition_mode", + "params": [], + "returns": "f64" + }, + { + "name": "bloom_get_active_transparency_composition_mode", + "params": [], + "returns": "f64" + }, + { + "name": "bloom_set_material_binding_tier_override", + "params": [ + "f64" + ], + "returns": "f64" }, { "name": "bloom_set_sun_shafts", @@ -1667,35 +1731,35 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_auto_exposure", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_taa_enabled", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_occlusion_culling", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_render_scale", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_get_render_scale", @@ -1707,14 +1771,14 @@ "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_cas_strength", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_get_physical_width", @@ -1732,35 +1796,40 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_manual_exposure", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_env_intensity", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_ssgi_enabled", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_path_tracing", "params": [ "f64" ], - "returns": "void" + "returns": "f64" + }, + { + "name": "bloom_reset_temporal_history", + "params": [], + "returns": "f64" }, { "name": "bloom_path_tracing_supported", @@ -1772,14 +1841,14 @@ "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_ssgi_radius", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_dof", @@ -1788,84 +1857,84 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_quality_preset", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_shadows_enabled", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_shadows_always_fresh", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_bloom_enabled", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_bloom_intensity", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_tonemap", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_auto_exposure_key", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_auto_exposure_rate", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_ssao_enabled", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_ssao_intensity", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_ssao_radius", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_wind", @@ -1875,7 +1944,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_launch_process", @@ -1886,12 +1955,24 @@ ], "returns": "f64" }, + { + "name": "bloom_command_line_arg_count", + "params": [], + "returns": "f64" + }, + { + "name": "bloom_command_line_arg", + "params": [ + "f64" + ], + "returns": "string" + }, { "name": "bloom_set_output_scale", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_get_output_scale", @@ -1904,14 +1985,14 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_foliage_shadow_motion", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_cloud_shadows", @@ -1921,28 +2002,28 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_ssr_enabled", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_motion_blur_enabled", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_sss_enabled", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_set_profiler_enabled", @@ -1961,6 +2042,19 @@ "params": [], "returns": "f64" }, + { + "name": "bloom_write_quality_telemetry", + "params": [ + "string", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64" + ], + "returns": "f64" + }, { "name": "bloom_print_profiler_summary", "params": [], @@ -2394,7 +2488,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_scene_set_cast_shadow", @@ -2402,7 +2496,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_scene_set_gi_only", @@ -2410,7 +2504,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_scene_set_receive_shadow", @@ -2418,7 +2512,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_scene_set_parent", @@ -2426,7 +2520,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_scene_set_transform", @@ -2434,7 +2528,7 @@ "f64", "i64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_scene_set_trs", @@ -2446,7 +2540,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_scene_update_geometry", @@ -2480,7 +2574,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_scene_update_geometry_scratch", @@ -2502,7 +2596,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_scene_attach_model_lod", @@ -2513,7 +2607,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_scene_set_material_color", @@ -2524,7 +2618,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_scene_set_material_pbr", @@ -2533,7 +2627,43 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" + }, + { + "name": "bloom_scene_set_material_emissive", + "params": [ + "f64", + "f64", + "f64", + "f64" + ], + "returns": "f64" + }, + { + "name": "bloom_scene_set_material_layered_pbr", + "params": [ + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64", + "f64" + ], + "returns": "f64" }, { "name": "bloom_scene_set_material_texture", @@ -2541,7 +2671,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_scene_node_count", @@ -2631,7 +2761,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_gen_mesh_spline_ribbon", @@ -2741,7 +2871,7 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_scene_get_user_data", @@ -2776,12 +2906,12 @@ { "name": "bloom_enable_shadows", "params": [], - "returns": "void" + "returns": "f64" }, { "name": "bloom_disable_shadows", "params": [], - "returns": "void" + "returns": "f64" }, { "name": "bloom_dump_shadow_map", @@ -2814,14 +2944,14 @@ "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_postfx_set_hovered", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_postfx_set_outline_color", @@ -2831,14 +2961,14 @@ "f64", "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_postfx_set_outline_thickness", "params": [ "f64" ], - "returns": "void" + "returns": "f64" }, { "name": "bloom_project_to_screen", diff --git a/scripts/ci-check.sh b/scripts/ci-check.sh index 65da4a07..643322e3 100755 --- a/scripts/ci-check.sh +++ b/scripts/ci-check.sh @@ -1,66 +1,394 @@ #!/usr/bin/env bash -# Local parity for .github/workflows/test.yml. -# -# Runs the subset of CI checks that make sense on a dev machine: -# - bloom-shared unit tests (includes Jolt C++ shim + jolt_sys tests) -# - the *current host's* platform crate build (macOS / Linux / Windows) -# - cargo check for shared on wasm32 (skips the full wasm-pack build) +# Bloom's versioned local/CI qualification entry point. # # Usage: -# ./scripts/ci-check.sh # run the full local suite -# ./scripts/ci-check.sh --fast # skip the host platform crate build -# ./scripts/ci-check.sh --wasm # also run wasm-pack build (slow, needs wasm-pack) +# ./scripts/ci-check.sh --quick +# ./scripts/ci-check.sh --full +# ./scripts/ci-check.sh --web +# ./scripts/ci-check.sh --cross +# ./scripts/ci-check.sh --hardware +# ./scripts/ci-check.sh --quick --component lint +# ./scripts/ci-check.sh --list +# +# With no lane argument, the historical full-local-suite behavior is retained. +# Every invocation writes a JSON summary under target/ci unless --summary is +# supplied. CI selects components so independent jobs can run in parallel; a +# lane without --component runs all of its components in order. set -euo pipefail -FAST=0 -INCLUDE_WASM=0 -for arg in "$@"; do - case "$arg" in - --fast) FAST=1 ;; - --wasm) INCLUDE_WASM=1 ;; +LANE="" +COMPONENT="" +SUMMARY_PATH="" +LIST_ONLY=0 + +usage() { + sed -n '2,14p' "$0" +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --quick|--full|--web|--cross|--hardware) + if [ -n "$LANE" ]; then + echo "choose exactly one lane" >&2 + exit 2 + fi + LANE="${1#--}" + ;; + --component=*) COMPONENT="${1#*=}" ;; + --summary=*) SUMMARY_PATH="${1#*=}" ;; + --component) + if [ "$#" -lt 2 ]; then + echo "--component requires a value" >&2 + exit 2 + fi + COMPONENT="$2" + shift + ;; + --summary) + if [ "$#" -lt 2 ]; then + echo "--summary requires a value" >&2 + exit 2 + fi + SUMMARY_PATH="$2" + shift + ;; + --list) LIST_ONLY=1 ;; + --fast) + echo "warning: --fast is deprecated; use --quick" >&2 + LANE="quick" + ;; + --wasm) + echo "warning: --wasm is deprecated; use --full or --web" >&2 + LANE="full" + ;; -h|--help) - sed -n '2,14p' "$0" + usage exit 0 ;; *) - echo "unknown arg: $arg" >&2 + echo "unknown arg: $1" >&2 + usage >&2 exit 2 ;; esac + shift done +lane_components() { + case "$1" in + quick) printf '%s\n' "contracts lint shared-tests wasm-check quality-contract example-inventory" ;; + full) printf '%s\n' "contracts lint shared-tests wasm-check quality-contract example-inventory host-build wasm-build" ;; + web) printf '%s\n' "wasm-check wasm-build browser-smoke" ;; + cross) printf '%s\n' "target-check" ;; + hardware) printf '%s\n' "example-compile quality-check quality-faults quality-run" ;; + *) + echo "unknown lane: $1" >&2 + return 2 + ;; + esac +} + +if [ "$LIST_ONLY" -eq 1 ]; then + printf 'quick\t%s\n' "$(lane_components quick)" + printf 'full\t%s\n' "$(lane_components full)" + printf 'web\t%s\n' "$(lane_components web)" + printf 'cross\t%s\n' "$(lane_components cross)" + printf 'hardware\t%s\n' "$(lane_components hardware)" + exit 0 +fi + +if [ -z "$LANE" ]; then + LANE="full" +fi + ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" host_os="$(uname -s)" case "$host_os" in - Darwin) host_crate="macos" ;; - Linux) host_crate="linux" ;; + Darwin) host_crate="macos" ;; + Linux) host_crate="linux" ;; MINGW*|MSYS*|CYGWIN*) host_crate="windows" ;; *) host_crate="" ;; esac -hr() { printf '\n==> %s\n' "$*"; } +ALLOWED_COMPONENTS="$(lane_components "$LANE")" +if [ -n "$COMPONENT" ]; then + case " $ALLOWED_COMPONENTS " in + *" $COMPONENT "*) COMPONENTS="$COMPONENT" ;; + *) + echo "component '$COMPONENT' does not belong to the '$LANE' lane" >&2 + exit 2 + ;; + esac +else + COMPONENTS="$ALLOWED_COMPONENTS" +fi -hr "bloom-shared: cargo test --release" -( cd native/shared && cargo test --release ) +if [ -z "$SUMMARY_PATH" ]; then + summary_component="${COMPONENT:-all}" + SUMMARY_PATH="$ROOT/target/ci/${LANE}-${summary_component}.json" +elif [ "${SUMMARY_PATH#/}" = "$SUMMARY_PATH" ]; then + SUMMARY_PATH="$ROOT/$SUMMARY_PATH" +fi -hr "bloom-shared: cargo check (wasm32, web feature)" -( cd native/shared && cargo check --target wasm32-unknown-unknown --no-default-features --features web ) +STARTED_AT="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +START_SECONDS="$(date '+%s')" +CURRENT_COMPONENT="" +COMPLETED_COMPONENTS="" -if [ "$FAST" -eq 0 ] && [ -n "$host_crate" ]; then - hr "bloom-$host_crate: cargo build --release" - ( cd "native/$host_crate" && cargo build --release ) -fi +hr() { + printf '\n==> %s\n' "$*" +} -if [ "$INCLUDE_WASM" -eq 1 ]; then - if ! command -v wasm-pack >/dev/null 2>&1; then - echo "wasm-pack not installed — skipping wasm-pack build" >&2 +append_completed() { + if [ -n "$COMPLETED_COMPONENTS" ]; then + COMPLETED_COMPONENTS="$COMPLETED_COMPONENTS,$1" else - hr "bloom-web: wasm-pack build --release --target web" - ( cd native/web && wasm-pack build --release --target web ) + COMPLETED_COMPONENTS="$1" fi -fi +} + +write_summary() { + status="$1" + exit_code="$2" + finished_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + duration_seconds="$(( $(date '+%s') - START_SECONDS ))" + mkdir -p "$(dirname "$SUMMARY_PATH")" + BLOOM_CI_LANE="$LANE" \ + BLOOM_CI_REQUESTED_COMPONENT="${COMPONENT:-all}" \ + BLOOM_CI_CURRENT_COMPONENT="$CURRENT_COMPONENT" \ + BLOOM_CI_COMPLETED_COMPONENTS="$COMPLETED_COMPONENTS" \ + BLOOM_CI_STATUS="$status" \ + BLOOM_CI_EXIT_CODE="$exit_code" \ + BLOOM_CI_HOST_OS="$host_os" \ + BLOOM_CI_STARTED_AT="$STARTED_AT" \ + BLOOM_CI_FINISHED_AT="$finished_at" \ + BLOOM_CI_DURATION_SECONDS="$duration_seconds" \ + BLOOM_CI_SUMMARY_PATH="$SUMMARY_PATH" \ + node -e ' + const fs = require("fs"); + const env = process.env; + const split = (value) => value ? value.split(",") : []; + const summary = { + schema: "bloom-ci-summary-v1", + lane: env.BLOOM_CI_LANE, + requested_component: env.BLOOM_CI_REQUESTED_COMPONENT, + current_component: env.BLOOM_CI_CURRENT_COMPONENT || null, + completed_components: split(env.BLOOM_CI_COMPLETED_COMPONENTS), + status: env.BLOOM_CI_STATUS, + exit_code: Number(env.BLOOM_CI_EXIT_CODE), + host_os: env.BLOOM_CI_HOST_OS, + started_at: env.BLOOM_CI_STARTED_AT, + finished_at: env.BLOOM_CI_FINISHED_AT, + duration_seconds: Number(env.BLOOM_CI_DURATION_SECONDS) + }; + fs.writeFileSync(env.BLOOM_CI_SUMMARY_PATH, JSON.stringify(summary, null, 2) + "\n"); + ' +} + +on_exit() { + exit_code=$? + trap - EXIT + if [ "$exit_code" -eq 0 ]; then + write_summary "pass" "$exit_code" + else + write_summary "fail" "$exit_code" || true + fi + exit "$exit_code" +} +trap on_exit EXIT + +run_component() { + CURRENT_COMPONENT="$1" + case "$CURRENT_COMPONENT" in + contracts) + hr "CI command inventory" + node tools/check-ci-contract.js + hr "FFI/schema parity" + node tools/validate-ffi.js + hr "file-size ratchet" + node tools/check-file-lines.js + ;; + lint) + hr "bloom-shared: cargo fmt --check" + ( cd native/shared && cargo fmt --check ) + hr "bloom-shared: strict clippy correctness/performance policy" + ( + cd native/shared + cargo clippy --release --no-deps -- \ + -A warnings \ + -D clippy::correctness \ + -D clippy::suspicious \ + -D clippy::perf \ + -A clippy::empty-line-after-doc-comments \ + -A clippy::manual-memcpy \ + -A clippy::not-unsafe-ptr-arg-deref \ + -A clippy::cloned-ref-to-slice-refs + ) + ;; + shared-tests) + hr "bloom-shared: cargo test --release" + ( cd native/shared && cargo test --release ) + ;; + wasm-check) + hr "bloom-shared: cargo check (wasm32, web feature)" + ( + cd native/shared + cargo check \ + --target wasm32-unknown-unknown \ + --no-default-features \ + --features web + ) + ;; + quality-contract) + hr "quality orchestration syntax and governance tests" + python3 -m py_compile \ + tools/quality/run.py \ + tools/quality/build_example.py \ + tools/quality/prepare_bistro.py + python3 -m unittest tools/quality/test_run.py -v + hr "visual metric and fault-engine tests" + cargo test --release --manifest-path tools/bloom-diff/Cargo.toml + hr "offline asset cooker format, corruption, and determinism tests" + cargo fmt --manifest-path tools/bloom-cook/Cargo.toml -- --check + cargo clippy --release --manifest-path tools/bloom-cook/Cargo.toml \ + --no-deps -- -D warnings + cargo test --release --manifest-path tools/bloom-cook/Cargo.toml + ;; + example-inventory) + hr "canonical TypeScript example inventory" + python3 tools/ci/compile_examples.py --check + ;; + host-build) + if [ -z "$host_crate" ]; then + echo "unsupported host for native build: $host_os" >&2 + return 2 + fi + hr "bloom-$host_crate: cargo build --release" + ( cd "native/$host_crate" && cargo build --release ) + ;; + wasm-build) + if ! command -v wasm-pack >/dev/null 2>&1; then + echo "wasm-pack is required for the '$LANE' lane" >&2 + return 2 + fi + hr "bloom-web: wasm-pack build --release --target web" + ( cd native/web && wasm-pack build --release --target web ) + ;; + browser-smoke) + hr "Bloom WebGPU real-browser known-frame smoke" + python3 tools/ci/web_smoke.py + ;; + target-check) + cross_crate="${BLOOM_CROSS_CRATE:-}" + cross_target="${BLOOM_CROSS_TARGET:-}" + cross_features="${BLOOM_CROSS_FEATURES:-}" + if [ -z "$cross_crate" ] || [ -z "$cross_target" ]; then + echo "BLOOM_CROSS_CRATE and BLOOM_CROSS_TARGET are required for target-check" >&2 + return 2 + fi + case "$cross_crate" in + android|ios|tvos|visionos|watchos) ;; + *) + echo "unsupported cross-target crate: $cross_crate" >&2 + return 2 + ;; + esac + case "$cross_target" in + *-linux-android*) + android_ndk="${ANDROID_NDK_HOME:-${ANDROID_NDK_LATEST_HOME:-}}" + if [ -z "$android_ndk" ]; then + echo "ANDROID_NDK_HOME or ANDROID_NDK_LATEST_HOME is required for Android checks" >&2 + return 2 + fi + case "$host_os" in + Linux) android_host="linux-x86_64" ;; + Darwin) android_host="darwin-x86_64" ;; + *) + echo "unsupported Android NDK host: $host_os" >&2 + return 2 + ;; + esac + android_api="${BLOOM_ANDROID_API:-24}" + case "$cross_target" in + aarch64-linux-android) android_clang="aarch64-linux-android${android_api}-clang" ;; + armv7-linux-androideabi) android_clang="armv7a-linux-androideabi${android_api}-clang" ;; + x86_64-linux-android) android_clang="x86_64-linux-android${android_api}-clang" ;; + *) + echo "unsupported Android Rust target: $cross_target" >&2 + return 2 + ;; + esac + android_bin="$android_ndk/toolchains/llvm/prebuilt/$android_host/bin" + android_cc="$android_bin/$android_clang" + if [ ! -x "$android_cc" ]; then + echo "Android compiler not found: $android_cc" >&2 + return 2 + fi + target_env="$(printf '%s' "$cross_target" | tr '-' '_')" + target_env_upper="$(printf '%s' "$target_env" | tr '[:lower:]' '[:upper:]')" + export "CC_${target_env}=$android_cc" + export "CXX_${target_env}=${android_cc}++" + export "AR_${target_env}=$android_bin/llvm-ar" + export "CARGO_TARGET_${target_env_upper}_LINKER=$android_cc" + export ANDROID_PLATFORM="android-$android_api" + ;; + esac + hr "bloom-$cross_crate: cargo check ($cross_target)" + cargo_args=(check --locked --target "$cross_target" --no-default-features) + if [ -n "$cross_features" ]; then + cargo_args+=(--features "$cross_features") + fi + ( cd "native/$cross_crate" && cargo "${cargo_args[@]}" ) + ;; + example-compile) + hr "compile every canonical TypeScript example" + python3 tools/ci/compile_examples.py + ;; + quality-check) + hr "validate quality manifest, assets, and approved baselines" + python3 tools/quality/run.py check + ;; + quality-faults) + hr "prove seeded quality regressions are detected" + python3 tools/quality/run.py faults \ + --out "${BLOOM_QUALITY_FAULTS_OUT:-tools/quality/out/ci-faults}" \ + --timeout "${BLOOM_QUALITY_TIMEOUT:-900}" + ;; + quality-run) + if [ -z "${BLOOM_QUALITY_MACHINE_CLASS:-}" ]; then + echo "BLOOM_QUALITY_MACHINE_CLASS is required for hardware quality runs" >&2 + return 2 + fi + quality_suite="${BLOOM_QUALITY_SUITE:-full}" + quality_out="${BLOOM_QUALITY_OUT:-tools/quality/out/ci-hardware}" + hr "run '$quality_suite' quality suite on $BLOOM_QUALITY_MACHINE_CLASS" + if [ -n "${BLOOM_QUALITY_CASE:-}" ]; then + python3 tools/quality/run.py run "$quality_suite" \ + --case "$BLOOM_QUALITY_CASE" \ + --machine-class "$BLOOM_QUALITY_MACHINE_CLASS" \ + --out "$quality_out" \ + --timeout "${BLOOM_QUALITY_TIMEOUT:-1800}" + else + python3 tools/quality/run.py run "$quality_suite" \ + --machine-class "$BLOOM_QUALITY_MACHINE_CLASS" \ + --out "$quality_out" \ + --timeout "${BLOOM_QUALITY_TIMEOUT:-1800}" + fi + ;; + *) + echo "unknown component: $CURRENT_COMPONENT" >&2 + return 2 + ;; + esac + append_completed "$CURRENT_COMPONENT" +} + +for component in $COMPONENTS; do + run_component "$component" +done -hr "OK — all local checks passed" +CURRENT_COMPONENT="" +hr "OK — '$LANE' lane passed" diff --git a/src/core/index.ts b/src/core/index.ts index 6793baa8..7ca7a477 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -26,59 +26,82 @@ declare function bloom_window_should_close(): number; declare function bloom_begin_drawing(): void; declare function bloom_end_drawing(): void; declare function bloom_take_screenshot(path: number): void; +declare function bloom_capture_frame_to_png(path: string): number; +declare function bloom_capture_debug_intermediates(path: string): number; +declare function bloom_capture_frame_ready(): number; declare function bloom_clear_background(r: number, g: number, b: number, a: number): void; -declare function bloom_set_env_clear_from_hdr(path: number): void; -declare function bloom_set_fog(r: number, g: number, b: number, density: number, height_ref: number, height_falloff: number): void; -declare function bloom_set_chromatic_aberration(strength: number): void; -declare function bloom_set_vignette(strength: number, softness: number): void; -declare function bloom_set_film_grain(strength: number): void; -declare function bloom_set_sharpen_strength(strength: number): void; -declare function bloom_set_present_mode(mode: number): void; -declare function bloom_set_sun_shafts(strength: number, decay: number, r: number, g: number, b: number): void; -declare function bloom_set_auto_exposure(on: number): void; -declare function bloom_set_taa_enabled(on: number): void; -declare function bloom_set_occlusion_culling(on: number): void; -declare function bloom_set_render_scale(scale: number): void; -declare function bloom_set_output_scale(scale: number): void; +declare function bloom_set_env_clear_from_hdr(path: number): number; +declare function bloom_set_fog(r: number, g: number, b: number, density: number, height_ref: number, height_falloff: number): number; +declare function bloom_set_chromatic_aberration(strength: number): number; +declare function bloom_set_vignette(strength: number, softness: number): number; +declare function bloom_set_film_grain(strength: number): number; +declare function bloom_set_sharpen_strength(strength: number): number; +declare function bloom_set_present_mode(mode: number): number; +declare function bloom_get_present_mode(): number; +declare function bloom_get_material_binding_capabilities(): string; +declare function bloom_get_renderer_capabilities(): string; +declare function bloom_get_imported_refraction_mode(): number; +declare function bloom_set_transparency_composition_mode(mode: number): number; +declare function bloom_get_transparency_composition_mode(): number; +declare function bloom_get_active_transparency_composition_mode(): number; +declare function bloom_set_material_binding_tier_override(tier: number): number; +declare function bloom_set_sun_shafts(strength: number, decay: number, r: number, g: number, b: number): number; +declare function bloom_set_auto_exposure(on: number): number; +declare function bloom_set_taa_enabled(on: number): number; +declare function bloom_set_occlusion_culling(on: number): number; +declare function bloom_set_render_scale(scale: number): number; +declare function bloom_set_output_scale(scale: number): number; declare function bloom_launch_process(cmd: string, args: string, cwd: string): number; +declare function bloom_command_line_arg_count(): number; +declare function bloom_command_line_arg(index: number): string; declare function bloom_get_output_scale(): number; declare function bloom_get_render_scale(): number; -declare function bloom_set_upscale_mode(mode: number): void; -declare function bloom_set_cas_strength(strength: number): void; +declare function bloom_set_upscale_mode(mode: number): number; +declare function bloom_set_cas_strength(strength: number): number; declare function bloom_get_physical_width(): number; declare function bloom_get_physical_height(): number; -declare function bloom_set_auto_resolution(targetHz: number, enabled: number): void; -declare function bloom_set_manual_exposure(value: number): void; -declare function bloom_set_env_intensity(intensity: number): void; -declare function bloom_set_ssgi_enabled(on: number): void; -declare function bloom_set_path_tracing(mode: number): void; +declare function bloom_set_auto_resolution(targetHz: number, enabled: number): number; +declare function bloom_set_manual_exposure(value: number): number; +declare function bloom_set_env_intensity(intensity: number): number; +declare function bloom_set_ssgi_enabled(on: number): number; +declare function bloom_set_path_tracing(mode: number): number; +declare function bloom_reset_temporal_history(): number; declare function bloom_path_tracing_supported(): number; -declare function bloom_set_ssgi_intensity(intensity: number): void; -declare function bloom_set_ssgi_radius(radius: number): void; -declare function bloom_set_dof(enabled: number, focusDistance: number, aperture: number): void; -declare function bloom_set_quality_preset(preset: number): void; -declare function bloom_set_shadows_enabled(on: number): void; -declare function bloom_set_shadows_always_fresh(on: number): void; -declare function bloom_set_bloom_enabled(on: number): void; -declare function bloom_set_bloom_intensity(value: number): void; -declare function bloom_set_tonemap(kind: number): void; -declare function bloom_set_auto_exposure_key(key: number): void; -declare function bloom_set_auto_exposure_rate(rate: number): void; -declare function bloom_set_ssao_enabled(on: number): void; +declare function bloom_set_ssgi_intensity(intensity: number): number; +declare function bloom_set_ssgi_radius(radius: number): number; +declare function bloom_set_dof(enabled: number, focusDistance: number, aperture: number): number; +declare function bloom_set_quality_preset(preset: number): number; +declare function bloom_set_shadows_enabled(on: number): number; +declare function bloom_set_shadows_always_fresh(on: number): number; +declare function bloom_set_bloom_enabled(on: number): number; +declare function bloom_set_bloom_intensity(value: number): number; +declare function bloom_set_tonemap(kind: number): number; +declare function bloom_set_auto_exposure_key(key: number): number; +declare function bloom_set_auto_exposure_rate(rate: number): number; +declare function bloom_set_ssao_enabled(on: number): number; declare function bloom_set_post_pass(source: number): number; -declare function bloom_clear_post_pass(): void; +declare function bloom_clear_post_pass(): number; declare function bloom_add_post_pass(source: number): number; -declare function bloom_clear_all_post_passes(): void; -declare function bloom_set_ssao_intensity(intensity: number): void; -declare function bloom_set_ssao_radius(worldRadius: number): void; -declare function bloom_set_wind(dirX: number, dirZ: number, amplitude: number, frequency: number): void; -declare function bloom_set_cloud_shadows(strength: number, deckHeight: number, featureScale: number, driftSpeed: number): void; -declare function bloom_set_ssr_enabled(on: number): void; -declare function bloom_set_motion_blur_enabled(on: number): void; -declare function bloom_set_sss_enabled(on: number): void; +declare function bloom_clear_all_post_passes(): number; +declare function bloom_set_ssao_intensity(intensity: number): number; +declare function bloom_set_ssao_radius(worldRadius: number): number; +declare function bloom_set_wind(dirX: number, dirZ: number, amplitude: number, frequency: number): number; +declare function bloom_set_cloud_shadows(strength: number, deckHeight: number, featureScale: number, driftSpeed: number): number; +declare function bloom_set_ssr_enabled(on: number): number; +declare function bloom_set_motion_blur_enabled(on: number): number; +declare function bloom_set_sss_enabled(on: number): number; declare function bloom_set_profiler_enabled(on: number): void; declare function bloom_get_profiler_frame_cpu_us(): number; declare function bloom_get_profiler_frame_gpu_us(): number; +declare function bloom_write_quality_telemetry( + path: string, + warmupFrames: number, + measuredFrames: number, + fixedTimestep: number, + qualityPreset: number, + renderScale: number, + measurementWallMs: number, +): number; declare function bloom_print_profiler_summary(): void; declare function bloom_profiler_overlay_text(): string; declare function bloom_profiler_frame_history(): string; @@ -95,7 +118,7 @@ declare function bloom_profiler_hist_count(): number; declare function bloom_profiler_hist_cpu_us(i: number): number; declare function bloom_profiler_hist_gpu_us(i: number): number; declare function bloom_splat_impulse(x: number, z: number, radius: number, strength: number): void; -declare function bloom_set_material_params_scratch(handle: number, paramCount: number): void; +declare function bloom_set_material_params_scratch(handle: number, paramCount: number): number; declare function bloom_mesh_scratch_reset(): void; declare function bloom_mesh_scratch_push_f32(v: number): void; declare function bloom_set_target_fps(fps: number): void; @@ -266,6 +289,21 @@ export function takeScreenshot(path: string): void { bloom_take_screenshot(path as any); } +/** Queue a PNG readback and report whether the native renderer accepted it. */ +export function captureFrameToPng(path: string): boolean { + return bloom_capture_frame_to_png(path) !== 0.0; +} + +/** Queue HDR/depth/shadow and per-pixel TAA diagnostics for the next frame. */ +export function captureDebugIntermediates(directory: string): boolean { + return bloom_capture_debug_intermediates(directory) !== 0.0; +} + +/** True after the most recently accepted PNG readback has completed. */ +export function isFrameCaptureReady(): boolean { + return bloom_capture_frame_ready() !== 0.0; +} + export function clearBackground(color: Color): void { bloom_clear_background(color.r, color.g, color.b, color.a); } @@ -277,8 +315,8 @@ export function clearBackground(color: Color): void { * immediately close most of the background-color gap between Bloom's * realtime output and the path-traced reference. */ -export function setEnvClearFromHdr(path: string): void { - bloom_set_env_clear_from_hdr(path as any); +export function setEnvClearFromHdr(path: string): boolean { + return bloom_set_env_clear_from_hdr(path as any) !== 0; } // ---- Post-FX knobs ---- @@ -287,52 +325,266 @@ export function setEnvClearFromHdr(path: string): void { // (or until called again with 0 / disabled values). /** Height-based exponential fog. Density 0 = off. */ -export function setFog(r: number, g: number, b: number, density: number, heightRef: number, heightFalloff: number): void { - bloom_set_fog(r, g, b, density, heightRef, heightFalloff); +export function setFog(r: number, g: number, b: number, density: number, heightRef: number, heightFalloff: number): boolean { + return bloom_set_fog(r, g, b, density, heightRef, heightFalloff) !== 0; } /** Radial RGB-channel split at the screen edges. 0 = off. */ -export function setChromaticAberration(strength: number): void { - bloom_set_chromatic_aberration(strength); +export function setChromaticAberration(strength: number): boolean { + return bloom_set_chromatic_aberration(strength) !== 0; } /** Smooth radial darkening of the corners. strength 0..1, softness 0..1. */ -export function setVignette(strength: number, softness: number): void { - bloom_set_vignette(strength, softness); +export function setVignette(strength: number, softness: number): boolean { + return bloom_set_vignette(strength, softness) !== 0; } /** Animated film grain post-tonemap. 0 = off. */ -export function setFilmGrain(strength: number): void { - bloom_set_film_grain(strength); +export function setFilmGrain(strength: number): boolean { + return bloom_set_film_grain(strength) !== 0; } /** - * Composite unsharp-mask strength. Engine default 0.8; 0 disables the + * Composite unsharp-mask strength. Engine default 0.5; 0 disables the * sharpen taps entirely. At high output resolutions the default visibly * halos high-contrast silhouettes — tune per game. */ -export function setSharpenStrength(strength: number): void { - bloom_set_sharpen_strength(strength); +export function setSharpenStrength(strength: number): boolean { + return bloom_set_sharpen_strength(strength) !== 0; } /** * Swapchain present mode: 0 = Fifo (vsync, default), 1 = Mailbox - * (uncapped, no tearing), 2 = Immediate (uncapped, tearing allowed). + * (uncapped, no tearing), 2 = Immediate (uncapped, tearing allowed), + * 3 = AutoNoVsync (portable uncapped preference). * With a non-vsync mode active, `setTargetFPS`'s sleep-based cap * becomes effective — under Fifo it is inert by design. */ -export function setPresentMode(mode: number): void { - bloom_set_present_mode(mode); +export function setPresentMode(mode: number): boolean { + return bloom_set_present_mode(mode) !== 0; +} + +/** Configured present-mode request, using the numeric values from setPresentMode(). */ +export function getPresentMode(): number { + return bloom_get_present_mode(); +} + +export type MaterialBindingTier = "A" | "B" | "C"; + +export interface MaterialBindingCapabilityReport { + readonly version: number; + readonly detected_tier: MaterialBindingTier; + readonly selected_tier: MaterialBindingTier; + readonly override_tier: MaterialBindingTier | null; + readonly features: Readonly<{ + texture_binding_array: boolean; + non_uniform_indexing: boolean; + }>; + readonly limits: Readonly<{ + max_binding_array_elements: number; + max_binding_array_samplers: number; + max_texture_array_layers: number; + max_sampled_textures: number; + max_samplers: number; + max_material_records: number; + }>; + readonly capacities: Readonly<{ + tier_a_textures: number; + tier_a_samplers: number; + tier_b_page_layers: number; + }>; + readonly diagnostic: string | null; + readonly residency: Readonly<{ + materials: number; + textures: number; + samplers: number; + meshes: number; + buffer_views: number; + stale_fallbacks: number; + limit_fallbacks: number; + }>; + readonly dispatch: Readonly<{ + tier_a_per_material_bind_group_switches: 0; + tier_b_last_page_count: number; + tier_b_last_page_switches: number; + tier_b_last_fallback_materials: number; + }>; +} + +/** Adapter-derived material/texture tier and hard limits. This is read-only. */ +export function getMaterialBindingCapabilities(): MaterialBindingCapabilityReport { + return JSON.parse( + bloom_get_material_binding_capabilities(), + ) as MaterialBindingCapabilityReport; +} + +export type RendererCapabilityTier = "baseline" | "modern" | "high-end"; + +export interface RendererSystemPaths { + readonly materials: string; + readonly geometry: string; + readonly shadows: string; + readonly gi: string; + readonly reflections: string; + readonly anti_aliasing: string; + readonly textures: string; + readonly path_tracing: string; +} + +export interface RendererCapabilityReport { + readonly version: 1; + readonly availability: "available" | "unavailable"; + readonly reason: string | null; + readonly adapter: Readonly<{ + availability: "reported"; + name: string; + vendor_id: number; + device_id: number; + device_type: string; + driver: string; + driver_info: string; + backend: string; + capability_tier: RendererCapabilityTier; + renderer_capabilities: Readonly<{ + detected: RendererCapabilityTier; + selected: RendererCapabilityTier; + requested: RendererCapabilityTier | null; + forced: RendererCapabilityTier | null; + diagnostic: string | null; + available: Readonly<{ + features: Readonly<{ + texture_binding_array: boolean; + non_uniform_indexing: boolean; + indirect_first_instance: boolean; + ray_query: boolean; + }>; + limits: Readonly<{ + max_binding_array_elements_per_shader_stage: number; + max_binding_array_sampler_elements_per_shader_stage: number; + max_texture_array_layers: number; + max_sampled_textures_per_shader_stage: number; + max_samplers_per_shader_stage: number; + max_bind_groups: number; + max_color_attachments: number; + }>; + }>; + paths: Readonly; + }>; + device_negotiation: Readonly<{ + preferred_tier: RendererCapabilityTier; + selected_tier: RendererCapabilityTier; + profile: "native-full" | "folded-mobile"; + selected_request: string; + fallback_cause: string | null; + required_features: string; + required_limits: Readonly<{ + max_bind_groups: number; + max_color_attachments: number; + max_sampled_textures_per_shader_stage: number; + max_samplers_per_shader_stage: number; + max_storage_buffers_per_shader_stage: number; + max_uniform_buffer_binding_size: number; + max_binding_array_elements_per_shader_stage: number; + max_binding_array_sampler_elements_per_shader_stage: number; + }>; + }> | null; + features: readonly string[]; + }> | null; + readonly material_binding: Readonly | null; + readonly runtime_support: Readonly<{ + hardware_ray_query: boolean; + path_tracing: boolean; + gpu_driven: Readonly<{ + enabled: boolean; + indirect_count_supported: boolean; + submitted: number; + compatibility: number; + indirect_calls: number; + frustum_visible_oracle: number; + frustum_culled_oracle: number; + frustum_culled_ratio: number; + classification_source: string; + }>; + imported_refraction: ImportedRefractionMode; + transparency_modes: readonly ("sorted" | "auto" | "weighted")[]; + }>; +} + +/** + * Adapter, tier, renderer-path, material-capacity, and fallback information. + * Read this to choose content/settings; do not infer capabilities from GPU + * names or startup logs. + */ +export function getRendererCapabilities(): RendererCapabilityReport { + return JSON.parse( + bloom_get_renderer_capabilities(), + ) as RendererCapabilityReport; +} + +export type ImportedRefractionMode = + | "disabled-legacy" + | "scene-snapshot" + | "environment-fallback"; + +/** Capability-selected route used by imported glTF transmission materials. */ +export function getImportedRefractionMode(): ImportedRefractionMode { + const mode = bloom_get_imported_refraction_mode(); + return mode === 1 + ? "scene-snapshot" + : mode === 2 + ? "environment-fallback" + : "disabled-legacy"; +} + +export type TransparencyCompositionMode = "sorted" | "auto" | "weighted"; +export type ActiveTransparencyCompositionMode = "sorted" | "weighted"; + +/** + * Select conventional imported glTF transparency composition. + * "auto" preserves sorted alpha for ordinary scenes and enables weighted OIT + * only for high-count sets; "weighted" is useful for intersecting surfaces. + */ +export function setTransparencyCompositionMode( + mode: TransparencyCompositionMode, +): boolean { + return bloom_set_transparency_composition_mode( + mode === "sorted" ? 0 : mode === "weighted" ? 2 : 1, + ) !== 0; +} + +/** Configured transparency policy. */ +export function getTransparencyCompositionMode(): TransparencyCompositionMode { + const mode = bloom_get_transparency_composition_mode(); + return mode === 0 ? "sorted" : mode === 2 ? "weighted" : "auto"; +} + +/** Route selected for the most recently prepared frame. */ +export function getActiveTransparencyCompositionMode(): + ActiveTransparencyCompositionMode { + return bloom_get_active_transparency_composition_mode() === 1 + ? "weighted" + : "sorted"; +} + +/** + * Qualification-only lower-tier override. "auto" restores adapter selection. + * Returns false for an unsupported upward override or an invalid value. + */ +export function setMaterialBindingTierOverride( + tier: "auto" | MaterialBindingTier, +): boolean { + const code = tier === "auto" ? 0 : tier === "C" ? 1 : tier === "B" ? 2 : 3; + return bloom_set_material_binding_tier_override(code) !== 0; } /** Screen-space sun shafts (god rays). strength 0 = off. */ -export function setSunShafts(strength: number, decay: number, r: number, g: number, b: number): void { - bloom_set_sun_shafts(strength, decay, r, g, b); +export function setSunShafts(strength: number, decay: number, r: number, g: number, b: number): boolean { + return bloom_set_sun_shafts(strength, decay, r, g, b) !== 0; } /** Toggle physically-based auto-exposure. 18% gray target, log-average metered. */ -export function setAutoExposure(on: boolean): void { - bloom_set_auto_exposure(on ? 1 : 0); +export function setAutoExposure(on: boolean): boolean { + return bloom_set_auto_exposure(on ? 1 : 0) !== 0; } /** Toggle temporal anti-aliasing (sub-pixel jitter + reprojected history blend). */ @@ -343,25 +595,28 @@ export function setAutoExposure(on: boolean): void { * the kill switch for debugging or for scenes that pathologically * defeat it (e.g. every object visible every frame). */ -export function setOcclusionCulling(on: boolean): void { - bloom_set_occlusion_culling(on ? 1 : 0); +export function setOcclusionCulling(on: boolean): boolean { + return bloom_set_occlusion_culling(on ? 1 : 0) !== 0; } -export function setTaaEnabled(on: boolean): void { - bloom_set_taa_enabled(on ? 1 : 0); +/** Toggle temporal anti-aliasing without changing render resolution. */ +export function setTaaEnabled(on: boolean): boolean { + return bloom_set_taa_enabled(on ? 1 : 0) !== 0; } /** * Render-resolution multiplier. 0.5 = quarter-pixel shading (cheap, soft); - * 1.0 = native (sharp, expensive). Clamped to [0.5, 1.0]. Once called - * explicitly, the choice sticks across `setTaaEnabled` toggles instead of - * being overridden by the legacy 0.5↔1.0 coupling. + * 1.0 = native (sharp, expensive). Clamped to [0.15, 1.0]. Resolution and + * TAA are independent: `setTaaEnabled` never changes this value. * * On a 4K display: 0.75 hits a quality/perf sweet spot for 3D scenes. * Catmull-Rom is the default upscale filter (see `setUpscaleMode`). */ -export function setRenderScale(scale: number): void { - bloom_set_render_scale(Math.min(1.0, Math.max(0.5, scale))); +export function setRenderScale(scale: number): boolean { + // SH-055 — floor lowered from 0.5 to 0.15 alongside the matching Rust-side + // clamp in renderer::render_extent()/set_render_scale(). A weak mobile GPU + // (Adreno 618) needs to go lower than 0.5 to hit a playable frame time. + return bloom_set_render_scale(Math.min(1.0, Math.max(0.15, scale))) !== 0; } /// OUTPUT scale — configure the swapchain at this fraction of the window's real @@ -376,16 +631,16 @@ export function setRenderScale(scale: number): void { /// /// 1.0 = native. Expose it to players: at 4K it is the difference between a locked /// frame rate and a pretty one, and which of those they want is not the game's call. -export function setOutputScale(scale: number): void { - bloom_set_output_scale(Math.min(1.0, Math.max(0.25, scale))); +export function setOutputScale(scale: number): boolean { + return bloom_set_output_scale(Math.min(1.0, Math.max(0.25, scale))) !== 0; } export function getOutputScale(): number { return bloom_get_output_scale(); } export function getRenderScale(): number { return bloom_get_render_scale(); } /** Upscale filter when render_scale < 1 and TAA is off. "bilinear" = cheap/soft, "catmull-rom" = sharper (default). */ export type UpscaleMode = "bilinear" | "catmull-rom"; -export function setUpscaleMode(mode: UpscaleMode): void { - bloom_set_upscale_mode(mode === "catmull-rom" ? 1 : 0); +export function setUpscaleMode(mode: UpscaleMode): boolean { + return bloom_set_upscale_mode(mode === "catmull-rom" ? 1 : 0) !== 0; } /** @@ -393,8 +648,8 @@ export function setUpscaleMode(mode: UpscaleMode): void { * 0.3 subtle; 0.6 punchy; 1.0 max. Useful at any render_scale — pairs * particularly well with TAA-softened native or Catmull-Rom upscale. */ -export function setCasStrength(strength: number): void { - bloom_set_cas_strength(strength); +export function setCasStrength(strength: number): boolean { + return bloom_set_cas_strength(strength) !== 0; } /** Physical-pixel size of the GPU surface (HiDPI-aware on macOS today). */ @@ -411,23 +666,23 @@ export function getPhysicalHeight(): number { return bloom_get_physical_height() * still works while DRS is on (DRS will simply step away from the * value on its next eligible frame). */ -export function setAutoResolution(targetHz: number, enabled: boolean = true): void { - bloom_set_auto_resolution(targetHz, enabled ? 1 : 0); +export function setAutoResolution(targetHz: number, enabled: boolean = true): boolean { + return bloom_set_auto_resolution(targetHz, enabled ? 1 : 0) !== 0; } /** Manual exposure multiplier (ignored when auto-exposure is on). 1.0 = default. */ -export function setManualExposure(value: number): void { - bloom_set_manual_exposure(value); +export function setManualExposure(value: number): boolean { + return bloom_set_manual_exposure(value) !== 0; } /** Env-map intensity multiplier for IBL + sky pass. 1.0 = reference, 0.2–0.5 typical for bright outdoor HDRs. */ -export function setEnvIntensity(intensity: number): void { - bloom_set_env_intensity(intensity); +export function setEnvIntensity(intensity: number): boolean { + return bloom_set_env_intensity(intensity) !== 0; } /** Toggle screen-space global illumination (single-bounce indirect diffuse). Default on. */ -export function setSsgiEnabled(on: boolean): void { - bloom_set_ssgi_enabled(on ? 1 : 0); +export function setSsgiEnabled(on: boolean): boolean { + return bloom_set_ssgi_enabled(on ? 1 : 0) !== 0; } /** @@ -437,11 +692,19 @@ export function setSsgiEnabled(on: boolean): void { * resets on movement. Converges to ground truth — the "final quality" * view for the editor and for stills. * 2 — realtime: denoised 1-sample path tracing for gameplay. - * Requires hardware ray query (see isPathTracingSupported); on devices - * without it the request is a no-op and the engine stays on Lumen. + * Returns false when the requested mode needs unavailable hardware ray query; + * the engine stays on Lumen and no renderer state is changed. + */ +export function setPathTracing(mode: number): boolean { + return bloom_set_path_tracing(mode) !== 0; +} + +/** + * Reset every temporal rendering history after a camera cut, teleport, + * discontinuous FOV change, or world load. Call before the next beginMode3D. */ -export function setPathTracing(mode: number): void { - bloom_set_path_tracing(mode); +export function resetTemporalHistory(): boolean { + return bloom_reset_temporal_history() !== 0; } /** True when the device can hardware-path-trace (ray query + TLAS). */ @@ -450,18 +713,18 @@ export function isPathTracingSupported(): boolean { } /** SSGI intensity multiplier. 0 = off, 0.5 = default, 1+ = strong. */ -export function setSsgiIntensity(intensity: number): void { - bloom_set_ssgi_intensity(intensity); +export function setSsgiIntensity(intensity: number): boolean { + return bloom_set_ssgi_intensity(intensity) !== 0; } /** SSGI max view-space march distance in meters. Default 20. Tune to scene scale. */ -export function setSsgiRadius(radius: number): void { - bloom_set_ssgi_radius(radius); +export function setSsgiRadius(radius: number): boolean { + return bloom_set_ssgi_radius(radius) !== 0; } /** Depth of field. focusDistance = view-space distance in world units. aperture = blur strength (0 = off, 0.03 = subtle, 0.1 = heavy). */ -export function setDepthOfField(focusDistance: number, aperture: number): void { - bloom_set_dof(aperture > 0 ? 1 : 0, focusDistance, aperture); +export function setDepthOfField(focusDistance: number, aperture: number): boolean { + return bloom_set_dof(aperture > 0 ? 1 : 0, focusDistance, aperture) !== 0; } // ============================================================ @@ -471,26 +734,29 @@ export function setDepthOfField(focusDistance: number, aperture: number): void { // ============================================================ export enum QualityPreset { - /** Bare minimum — no shadows, no SSAO, no bloom, no TAA, no SSR/SSGI/DoF/MB/SSS. */ + /** 0.50 scale, bilinear upscale, no TAA/sharpening or optional effects. */ Off = 0, - /** Base pipeline only: HDR tonemap + bloom. No shadows/SSAO/TAA. */ + /** 0.67 scale, Catmull-Rom + light sharpen, bloom; no TAA/shadows/GI. */ Low = 1, - /** Balanced default: shadows + SSAO + bloom + TAA. No SSR/SSGI/cinematic FX. */ + /** 0.75 scale, TAA + balanced sharpen, shadows/SSAO/bloom. */ Medium = 2, - /** + SSR, SSGI, subtle chromatic aberration. */ + /** 0.85 scale, TAA + stronger sharpen, SSR/SSGI and subtle CA. */ High = 3, - /** Everything on (plus DoF if aperture > 0). */ + /** Native 1.0 scale and the full effect stack. */ Ultra = 4, } -/** Apply a quality preset in one call. Call individual setters after for fine-tuning. */ -export function setQualityPreset(preset: QualityPreset): void { - bloom_set_quality_preset(preset); +/** + * Apply a coherent resolution, reconstruction, sharpening, and effect tier. + * Call individual setters afterward for fine-tuning. + */ +export function setQualityPreset(preset: QualityPreset): boolean { + return bloom_set_quality_preset(preset) !== 0; } /** Toggle cascaded shadow maps. Default on. Disable on low-end GPUs — biggest single win. */ -export function setShadowsEnabled(on: boolean): void { - bloom_set_shadows_enabled(on ? 1 : 0); +export function setShadowsEnabled(on: boolean): boolean { + return bloom_set_shadows_enabled(on ? 1 : 0) !== 0; } /** @@ -500,21 +766,21 @@ export function setShadowsEnabled(on: boolean): void { * native code, heavily-deformable casters) where the cache hit rate * would be ~zero anyway, so skipping the check saves a few µs. */ -export function setShadowsAlwaysFresh(on: boolean): void { - bloom_set_shadows_always_fresh(on ? 1 : 0); +export function setShadowsAlwaysFresh(on: boolean): boolean { + return bloom_set_shadows_always_fresh(on ? 1 : 0) !== 0; } /** Toggle the bloom down/upsample chain (~10 passes). Default on. */ -export function setBloomEnabled(on: boolean): void { - bloom_set_bloom_enabled(on ? 1 : 0); +export function setBloomEnabled(on: boolean): boolean { + return bloom_set_bloom_enabled(on ? 1 : 0) !== 0; } /** * Bloom contribution strength added to the HDR scene before tonemap. * 0 = none, ~0.04 subtle default, higher = stronger glow around bright pixels. */ -export function setBloomIntensity(intensity: number): void { - bloom_set_bloom_intensity(intensity); +export function setBloomIntensity(intensity: number): boolean { + return bloom_set_bloom_intensity(intensity) !== 0; } /** Tonemap operator selection. */ @@ -526,8 +792,8 @@ export enum Tonemap { } /** Choose the tonemap operator applied in the composite pass. */ -export function setTonemap(kind: Tonemap): void { - bloom_set_tonemap(kind); +export function setTonemap(kind: Tonemap): boolean { + return bloom_set_tonemap(kind) !== 0; } /** @@ -535,28 +801,28 @@ export function setTonemap(kind: Tonemap): void { * more saturated midpoint (counteracts wash-out from very bright skies); * higher aims brighter. Only affects frames where auto-exposure is on. */ -export function setAutoExposureKey(key: number): void { - bloom_set_auto_exposure_key(key); +export function setAutoExposureKey(key: number): boolean { + return bloom_set_auto_exposure_key(key) !== 0; } /** Auto-exposure adaptation rate per frame (0 = frozen, ~0.05 smooth, 1 = instant). */ -export function setAutoExposureRate(rate: number): void { - bloom_set_auto_exposure_rate(rate); +export function setAutoExposureRate(rate: number): boolean { + return bloom_set_auto_exposure_rate(rate) !== 0; } /** Toggle screen-space ambient occlusion + its bilateral blur. Default on. */ -export function setSsaoEnabled(on: boolean): void { - bloom_set_ssao_enabled(on ? 1 : 0); +export function setSsaoEnabled(on: boolean): boolean { + return bloom_set_ssao_enabled(on ? 1 : 0) !== 0; } /** SSAO strength. 0 disables corner darkening, 1 is default, 2 is heavy. */ -export function setSsaoIntensity(intensity: number): void { - bloom_set_ssao_intensity(intensity); +export function setSsaoIntensity(intensity: number): boolean { + return bloom_set_ssao_intensity(intensity) !== 0; } /** SSAO sampling radius in world units. 0.1..2.0 m is the sane range. */ -export function setSsaoRadius(worldRadius: number): void { - bloom_set_ssao_radius(worldRadius); +export function setSsaoRadius(worldRadius: number): boolean { + return bloom_set_ssao_radius(worldRadius) !== 0; } /// EN-017 — install a game-supplied fullscreen WGSL post-pass. @@ -583,8 +849,8 @@ export function setPostPass(wgslSource: string): boolean { /// EN-017 — uninstall the active post-pass. The composite output /// goes directly to the swapchain again (zero post-pass cost). /// V2 alias for `clearAllPostPasses()`. -export function clearPostPass(): void { - bloom_clear_post_pass(); +export function clearPostPass(): boolean { + return bloom_clear_post_pass() !== 0; } /// EN-017 V2 — append a fullscreen WGSL post-pass to the stack. @@ -606,8 +872,8 @@ export function addPostPass(wgslSource: string): number { /// EN-017 V2 — wipe the entire post-pass stack. The composite /// output goes directly to the swapchain again (zero post-pass cost). -export function clearAllPostPasses(): void { - bloom_clear_all_post_passes(); +export function clearAllPostPasses(): boolean { + return bloom_clear_all_post_passes() !== 0; } /// Set the global wind field used by foliage materials. @@ -615,8 +881,8 @@ export function clearAllPostPasses(): void { /// normalised; magnitude scales effective amplitude). /// amplitude is the displacement scale (~0.1 m typical for grass). /// frequency is in Hz (~1.0 typical). -export function setWind(dirX: number, dirZ: number, amplitude: number, frequency: number): void { - bloom_set_wind(dirX, dirZ, amplitude, frequency); +export function setWind(dirX: number, dirZ: number, amplitude: number, frequency: number): boolean { + return bloom_set_wind(dirX, dirZ, amplitude, frequency) !== 0; } /// Cloud deck — the clouds the sky draws and the shadows they cast, from ONE @@ -642,23 +908,23 @@ export function setCloudShadows( deckHeight: number, featureScale: number, driftSpeed: number, -): void { - bloom_set_cloud_shadows(strength, deckHeight, featureScale, driftSpeed); +): boolean { + return bloom_set_cloud_shadows(strength, deckHeight, featureScale, driftSpeed) !== 0; } /** Toggle screen-space reflections. Default on. */ -export function setSsrEnabled(on: boolean): void { - bloom_set_ssr_enabled(on ? 1 : 0); +export function setSsrEnabled(on: boolean): boolean { + return bloom_set_ssr_enabled(on ? 1 : 0) !== 0; } /** Toggle per-object motion blur. Default off. */ -export function setMotionBlurEnabled(on: boolean): void { - bloom_set_motion_blur_enabled(on ? 1 : 0); +export function setMotionBlurEnabled(on: boolean): boolean { + return bloom_set_motion_blur_enabled(on ? 1 : 0) !== 0; } /** Toggle subsurface scattering (for skin/wax materials). Default off. */ -export function setSssEnabled(on: boolean): void { - bloom_set_sss_enabled(on ? 1 : 0); +export function setSssEnabled(on: boolean): boolean { + return bloom_set_sss_enabled(on ? 1 : 0) !== 0; } // ============================================================ @@ -681,6 +947,27 @@ export function getProfilerFrameGpuUs(): number { return bloom_get_profiler_frame_gpu_us(); } +/** Write a native, allocation-safe snapshot for the deterministic quality harness. */ +export function writeQualityTelemetry( + path: string, + warmupFrames: number, + measuredFrames: number, + fixedTimestep: number, + qualityPreset: number, + renderScale: number, + measurementWallMs: number, +): boolean { + return bloom_write_quality_telemetry( + path, + warmupFrames, + measuredFrames, + fixedTimestep, + qualityPreset, + renderScale, + measurementWallMs, + ) !== 0.0; +} + /** Print a per-phase CPU/GPU timing table to stdout. Useful for quick diagnostics. */ export function printProfilerSummary(): void { bloom_print_profiler_summary(); @@ -712,14 +999,14 @@ export function splatImpulse(x: number, z: number, radius: number, strength: num * `setMaterialParams(matWater, [0.10, 0.30, 0.40, 1.0, 0.20])` * lets game code change colour per-zone without recompiling WGSL. */ -export function setMaterialParams(handle: number, params: number[]): void { +export function setMaterialParams(handle: number, params: number[]): boolean { // Perry 0.5.x rejects JS arrays passed into pointer params, so the floats // go through the all-f64 mesh scratch (same fix as createMesh / // createInstanceBuffer). ≤ 64 floats per the 256-byte UBO cap, so the // per-float FFI cost is negligible. bloom_mesh_scratch_reset(); for (let i = 0; i < params.length; i++) bloom_mesh_scratch_push_f32(params[i]); - bloom_set_material_params_scratch(handle, params.length); + return bloom_set_material_params_scratch(handle, params.length) !== 0; } /** @@ -732,14 +1019,16 @@ export function setMaterialParams(handle: number, params: number[]): void { export function getProfilerOverlay(): { label: string, cpuUs: number, gpuUs: number }[] { // EN-020: per-row numeric FFI — do NOT reintroduce a packed-text + // split()/parseFloat() path here (Perry runtime overread, crashes). - const out: { label: string, cpuUs: number, gpuUs: number }[] = []; const n = bloom_profiler_row_count(); + // Perry's native `.push()` lowering can leave array length/capacity out + // of sync for FFI-derived rows. Pre-size and assign by index. + const out: { label: string, cpuUs: number, gpuUs: number }[] = new Array(n); for (let i = 0; i < n; i++) { - out.push({ + out[i] = { label: bloom_profiler_row_label(i), cpuUs: bloom_profiler_row_cpu_us(i), gpuUs: bloom_profiler_row_gpu_us(i), - }); + }; } return out; } @@ -751,13 +1040,13 @@ export function getProfilerOverlay(): { label: string, cpuUs: number, gpuUs: num */ export function getProfilerFrameHistory(): { cpuUs: number, gpuUs: number }[] { // EN-020: numeric FFI — see getProfilerOverlay. - const out: { cpuUs: number, gpuUs: number }[] = []; const n = bloom_profiler_hist_count(); + const out: { cpuUs: number, gpuUs: number }[] = new Array(n); for (let i = 0; i < n; i++) { - out.push({ + out[i] = { cpuUs: bloom_profiler_hist_cpu_us(i), gpuUs: bloom_profiler_hist_gpu_us(i), - }); + }; } return out; } @@ -1204,3 +1493,11 @@ export function launchProcess(cmd: string, args: string[], cwd: string): number } return bloom_launch_process(cmd, joined, cwd); } + +/** Return the native process argv. Index 0 is the executable path. */ +export function getCommandLineArgs(): string[] { + const count = Math.max(0, Math.floor(bloom_command_line_arg_count())); + const args: string[] = new Array(count); + for (let i = 0; i < count; i++) args[i] = bloom_command_line_arg(i); + return args; +} diff --git a/src/index.ts b/src/index.ts index a22f4be5..844cd2b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,10 @@ export { runGame, setProfilerEnabled, getProfilerFrameCpuUs, getProfilerFrameGpuUs, printProfilerSummary, getProfilerOverlay, getProfilerFrameHistory, + getMaterialBindingCapabilities, getRendererCapabilities, getImportedRefractionMode, + setTransparencyCompositionMode, getTransparencyCompositionMode, + getActiveTransparencyCompositionMode, + setMaterialBindingTierOverride, splatImpulse, setMaterialParams, } from './core/index'; @@ -40,6 +44,10 @@ export type { Rect, Camera2D, Camera3D, Texture, Font, Sound, Music, Quat, Ray, BoundingBox, Model, Mat4, RayHit, FrustumPlanes, + MaterialBindingTier, MaterialBindingCapabilityReport, + RendererCapabilityTier, RendererSystemPaths, RendererCapabilityReport, + ImportedRefractionMode, + TransparencyCompositionMode, ActiveTransparencyCompositionMode, } from './core/index'; // Vec2, Vec3, Vec4 as types come from core, as values (constructors) from math @@ -130,7 +138,8 @@ export { setSceneNodeGiOnly, setSceneNodeParent, setSceneNodeTransform, updateSceneNodeGeometry, - setSceneNodeColor, setSceneNodePbr, setSceneNodeTexture, setSceneNodeWaterMaterial, pickSceneAll, + setSceneNodeColor, setSceneNodePbr, setSceneNodeMaterial, setSceneNodeTexture, + setSceneNodeWaterMaterial, pickSceneAll, getSceneNodeTransform, getSceneNodeBounds, setSceneNodeUserData, getSceneNodeUserData, getSceneNodeCount, @@ -146,7 +155,11 @@ export { projectToScreen, } from './scene/index'; -export type { SceneNodeHandle, PbrMaterial, PickHit } from './scene/index'; +export type { + SceneNodeHandle, PbrMaterial, LayeredPbrMaterial, + ClearcoatMaterial, SpecularMaterial, SheenMaterial, + AnisotropyMaterial, IridescenceMaterial, PickHit, +} from './scene/index'; export { createPhysicsWorld, setGravity, setPhysicsTimestep, diff --git a/src/models/index.ts b/src/models/index.ts index bf7bedac..5ba7b6e3 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -14,8 +14,8 @@ declare function bloom_draw_model_transform16( m12: number, m13: number, m14: number, m15: number, colorPackedArgb: number, ): void; -declare function bloom_set_model_foliage_wind(handle: number, amount: number): void; -declare function bloom_set_foliage_shadow_motion(on: number): void; +declare function bloom_set_model_foliage_wind(handle: number, amount: number): number; +declare function bloom_set_foliage_shadow_motion(on: number): number; declare function bloom_unload_model(handle: number): void; declare function bloom_draw_cube(x: number, y: number, z: number, w: number, h: number, d: number, r: number, g: number, b: number, a: number): void; declare function bloom_draw_cube_wires(x: number, y: number, z: number, w: number, h: number, d: number, r: number, g: number, b: number, a: number): void; @@ -40,13 +40,13 @@ declare function bloom_create_instance_buffer_scratch(instanceCount: number): nu declare function bloom_submit_material_draw_instanced(material: number, meshHandle: number, meshIdx: number, instanceBuffer: number, instanceCount: number): void; declare function bloom_destroy_instance_buffer(handle: number): void; declare function bloom_create_planar_reflection(planeY: number, normalX: number, normalY: number, normalZ: number, resolution: number): number; -declare function bloom_set_material_reflection_probe(material: number, probe: number): void; -declare function bloom_set_material_texture_array(material: number, slot: number, array: number): void; -declare function bloom_set_material_shading_model(material: number, model: number): void; -declare function bloom_set_material_probe_visible(material: number, visible: number): void; -declare function bloom_set_material_foliage(material: number, transR: number, transG: number, transB: number, transAmount: number, wrapFactor: number): void; +declare function bloom_set_material_reflection_probe(material: number, probe: number): number; +declare function bloom_set_material_texture_array(material: number, slot: number, array: number): number; +declare function bloom_set_material_shading_model(material: number, model: number): number; +declare function bloom_set_material_probe_visible(material: number, visible: number): number; +declare function bloom_set_material_foliage(material: number, transR: number, transG: number, transB: number, transAmount: number, wrapFactor: number): number; declare function bloom_compile_material_from_file(path: number, bucketKind: number): number; -declare function bloom_set_material_params_scratch(handle: number, paramCount: number): void; +declare function bloom_set_material_params_scratch(handle: number, paramCount: number): number; declare function bloom_draw_material(material: number, meshHandle: number, meshIdx: number, x: number, y: number, z: number, scale: number, r: number, g: number, b: number, a: number): void; declare function bloom_load_model_animation(path: number): number; declare function bloom_instantiate_animation(src: number): number; @@ -56,10 +56,10 @@ declare function bloom_mesh_scratch_reset(): void; declare function bloom_mesh_scratch_push_f32(v: number): void; declare function bloom_mesh_scratch_push_u32(v: number): void; declare function bloom_create_mesh_scratch(vertexCount: number, indexCount: number): number; -declare function bloom_set_ambient_light(r: number, g: number, b: number, intensity: number): void; -declare function bloom_set_directional_light(dx: number, dy: number, dz: number, r: number, g: number, b: number, intensity: number): void; -declare function bloom_set_procedural_sky(enabled: number, rayleighDensity: number, mieDensity: number, groundAlbedo: number): void; -declare function bloom_set_sun_direction(dx: number, dy: number, dz: number, intensity: number): void; +declare function bloom_set_ambient_light(r: number, g: number, b: number, intensity: number): number; +declare function bloom_set_directional_light(dx: number, dy: number, dz: number, r: number, g: number, b: number, intensity: number): number; +declare function bloom_set_procedural_sky(enabled: number, rayleighDensity: number, mieDensity: number, groundAlbedo: number): number; +declare function bloom_set_sun_direction(dx: number, dy: number, dz: number, intensity: number): number; declare function bloom_gen_mesh_spline_ribbon(pointsPtr: number, pointCount: number, widthsPtr: number, widthCount: number): number; declare function bloom_gen_mesh_spline_ribbon_scratch(pointCount: number, widthCount: number): number; declare function bloom_get_model_mesh_count(handle: number): number; @@ -434,6 +434,22 @@ export const BUCKET_TRANSPARENT = 3; /// absent from the pipeline layout and the shader fails validation when the /// pipeline is created (not when it is written), which is a confusing way to /// find out. +/// +/// Fast-changing particles should also provide an optional second fragment +/// entry named `fs_reactive`. It returns the same HDR color as `fs_main` plus +/// authored 0..1 TAA rejection coverage: +/// +/// @fragment +/// fn fs_reactive(in: VsOut) -> ReactiveTranslucentOut { +/// var out: ReactiveTranslucentOut; +/// out.hdr = particleColor(in); +/// out.reactive = in.alpha; +/// return out; +/// } +/// +/// Only a submitted material with that entry activates the lazy R8 coverage +/// target. Existing shaders and frames containing only ordinary custom +/// translucency keep the original attachment topology and pipeline. export function compileMaterialInstancedBucket( wgslSource: string, bucket: number, readsScene: boolean = false, ): number { @@ -534,8 +550,8 @@ export function createPlanarReflection( /// /// Materials linked to any probe are automatically excluded from /// every probe's render — the water surface doesn't reflect itself. -export function setMaterialReflectionProbe(material: number, probe: number): void { - bloom_set_material_reflection_probe(material, probe); +export function setMaterialReflectionProbe(material: number, probe: number): boolean { + return bloom_set_material_reflection_probe(material, probe) !== 0; } /// Whether this material's draws render into planar-reflection probes @@ -543,8 +559,8 @@ export function setMaterialReflectionProbe(material: number, probe: number): voi /// resolution — e.g. an instanced grass field in a 512-px water probe — /// where it costs full vertex + raster work and contributes nothing /// resolvable to the reflection. -export function setMaterialProbeVisible(material: number, visible: boolean): void { - bloom_set_material_probe_visible(material, visible ? 1 : 0); +export function setMaterialProbeVisible(material: number, visible: boolean): boolean { + return bloom_set_material_probe_visible(material, visible ? 1 : 0) !== 0; } /// EN-014 — slot indices for `setMaterialTextureArray`. @@ -704,8 +720,8 @@ export function createTextureArrayFromFiles( /// Materials don't need to bind every slot — the stub is safe to /// sample. A common pattern is to bind only `TEXTURE_ARRAY_ALBEDO` /// for a non-PBR splat-mapped terrain. -export function setMaterialTextureArray(material: number, slot: number, array: number): void { - bloom_set_material_texture_array(material, slot, array); +export function setMaterialTextureArray(material: number, slot: number, array: number): boolean { + return bloom_set_material_texture_array(material, slot, array) !== 0; } /// EN-012 — shading-model selectors. Pass to `setMaterialShadingModel`. @@ -721,8 +737,8 @@ export const SHADING_MODEL_SUBSURFACE = 2; // V2 stub — currently behaves a /// V1 limitation: SSAO doesn't half-strength on backfaces — the /// G-buffer doesn't carry an isFrontFace channel today. Documented as a /// follow-up requirement on the EN-012 ticket. -export function setMaterialShadingModel(material: number, model: number): void { - bloom_set_material_shading_model(material, model); +export function setMaterialShadingModel(material: number, model: number): boolean { + return bloom_set_material_shading_model(material, model) !== 0; } /// EN-012 — set the foliage shading parameters for a material. Only @@ -737,8 +753,8 @@ export function setMaterialFoliage( material: number, transmissionR: number, transmissionG: number, transmissionB: number, transmissionAmount: number, wrapFactor: number, -): void { - bloom_set_material_foliage(material, transmissionR, transmissionG, transmissionB, transmissionAmount, wrapFactor); +): boolean { + return bloom_set_material_foliage(material, transmissionR, transmissionG, transmissionB, transmissionAmount, wrapFactor) !== 0; } /** @@ -971,12 +987,12 @@ export function createMeshExplicit( return uploadMeshScratch(vertices, vertexCount, indices, indexCount); } -export function setAmbientLight(color: Color, intensity: number): void { - bloom_set_ambient_light(color.r, color.g, color.b, intensity); +export function setAmbientLight(color: Color, intensity: number): boolean { + return bloom_set_ambient_light(color.r, color.g, color.b, intensity) !== 0; } -export function setDirectionalLight(direction: Vec3, color: Color, intensity: number): void { - bloom_set_directional_light(direction.x, direction.y, direction.z, color.r, color.g, color.b, intensity); +export function setDirectionalLight(direction: Vec3, color: Color, intensity: number): boolean { + return bloom_set_directional_light(direction.x, direction.y, direction.z, color.r, color.g, color.b, intensity) !== 0; } // EN-005 — Hillaire 2020 procedural sky. @@ -1002,20 +1018,20 @@ export interface ProceduralSkyOptions { groundAlbedo?: number; } -export function setProceduralSky(enabled: boolean, opts?: ProceduralSkyOptions): void { +export function setProceduralSky(enabled: boolean, opts?: ProceduralSkyOptions): boolean { const rd = opts?.rayleighDensity ?? 1.0; const md = opts?.mieDensity ?? 1.0; const ga = opts?.groundAlbedo ?? 0.1; - bloom_set_procedural_sky(enabled ? 1 : 0, rd, md, ga); + return bloom_set_procedural_sky(enabled ? 1 : 0, rd, md, ga) !== 0; } -export function setSunDirection(direction: Vec3, intensity: number = 1.0): void { - bloom_set_sun_direction(direction.x, direction.y, direction.z, intensity); +export function setSunDirection(direction: Vec3, intensity: number = 1.0): boolean { + return bloom_set_sun_direction(direction.x, direction.y, direction.z, intensity) !== 0; } -declare function bloom_set_joint_test(joint: number, angle: number): void; -export function setJointTest(joint: number, angle: number): void { - bloom_set_joint_test(joint, angle); +declare function bloom_set_joint_test(joint: number, angle: number): number; +export function setJointTest(joint: number, angle: number): boolean { + return bloom_set_joint_test(joint, angle) !== 0; } // Async / threaded loading @@ -1243,8 +1259,8 @@ export function releaseRagdoll(rag: number): void { /// origin — nothing has to be authored into the mesh. Before this the engine /// swayed alpha-cut materials only, which meant leaf cards fluttered while every /// trunk in the scene stood perfectly rigid. -export function setModelFoliageWind(model: Model, amount: number): void { - bloom_set_model_foliage_wind(model.handle, amount); +export function setModelFoliageWind(model: Model, amount: number): boolean { + return bloom_set_model_foliage_wind(model.handle, amount) !== 0; } /// Let foliage sway in the SHADOW pass too, so a bending tree and its shadow bend @@ -1253,6 +1269,6 @@ export function setModelFoliageWind(model: Model, amount: number): void { /// Off by default, and not free: a caster that moves every frame cannot reuse the /// cached static shadow depth, so every plant re-renders into every cascade every /// frame. Measure before leaving it on. -export function setFoliageShadowMotion(on: boolean): void { - bloom_set_foliage_shadow_motion(on ? 1 : 0); +export function setFoliageShadowMotion(on: boolean): boolean { + return bloom_set_foliage_shadow_motion(on ? 1 : 0) !== 0; } diff --git a/src/quality/index.ts b/src/quality/index.ts new file mode 100644 index 00000000..1160238a --- /dev/null +++ b/src/quality/index.ts @@ -0,0 +1,175 @@ +/** + * Deterministic qualification-window helper used by Bloom's versioned scene + * corpus. It is inert unless an example receives `--quality-run`. + * + * The helper intentionally captures on a frame after measurement. Screenshot + * readback is synchronous on native platforms; including it in the measured + * window would turn a correctness artifact into a fake CPU regression. + */ + +import { + captureDebugIntermediates, + captureFrameToPng, + getTime, + isFrameCaptureReady, + setPresentMode, + setProfilerEnabled, + setQualityPreset, + setRenderScale, + setTargetFPS, + writeQualityTelemetry, +} from "../core/index"; + +export interface QualityRunConfig { + warmupFrames: number; + measuredFrames: number; + fixedTimestep: number; + outputPath: string; + telemetryPath: string; + intermediatesPath: string; + qualityPreset: number; + renderScale: number; +} + +function finitePositive(value: number, fallback: number): number { + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +/** + * Parse the shared CLI contract: + * --quality-run WARMUP MEASURED FIXED_DT OUTPUT_PNG TELEMETRY_JSON INTERMEDIATES_DIR + */ +export function parseQualityRun(argv: string[]): QualityRunConfig | null { + let qualityPreset = 3; + let renderScale = 1.0; + for (let i = 1; i < argv.length; i = i + 1) { + if (argv[i] === "--quality-preset" && i + 1 < argv.length) { + qualityPreset = Math.max(0, Math.min(4, Math.floor(parseFloat(argv[i + 1])))); + } else if (argv[i] === "--render-scale" && i + 1 < argv.length) { + renderScale = Math.max(0.5, Math.min(1.0, parseFloat(argv[i + 1]))); + } + } + for (let i = 1; i < argv.length; i = i + 1) { + if (argv[i] === "--quality-run" && i + 5 < argv.length) { + return { + warmupFrames: Math.max(1, Math.floor(parseFloat(argv[i + 1]))), + measuredFrames: Math.max(1, Math.floor(parseFloat(argv[i + 2]))), + fixedTimestep: finitePositive(parseFloat(argv[i + 3]), 1 / 60), + outputPath: argv[i + 4], + telemetryPath: argv[i + 5], + intermediatesPath: i + 6 < argv.length ? argv[i + 6] : "", + qualityPreset, + renderScale, + }; + } + } + return null; +} + +export class QualityRun { + readonly config: QualityRunConfig; + private frame = 0; + private measurementStarted = false; + private measurementFinished = false; + private captureRequested = false; + private screenshotSubmitted = false; + private intermediatesSubmitted = false; + private measurementStartSeconds = 0; + private measurementWallMs = 0; + private telemetryWritten = false; + + constructor(config: QualityRunConfig) { + this.config = config; + // AutoNoVsync is explicitly distinct from the normal FIFO default. + setPresentMode(3); + setTargetFPS(0); + setQualityPreset(config.qualityPreset as any); + setRenderScale(config.renderScale); + setProfilerEnabled(false); + } + + /** Fixed simulation delta for deterministic camera/animation sequences. */ + deltaTime(): number { + return this.config.fixedTimestep; + } + + /** + * Call immediately before beginDrawing(). Returns true only for the extra + * post-measurement frame that should be captured. + */ + beginFrame(): boolean { + if (!this.measurementStarted && this.frame >= this.config.warmupFrames) { + setProfilerEnabled(true); + this.measurementStarted = true; + this.measurementStartSeconds = getTime(); + } + if (this.measurementFinished && !this.captureRequested) { + this.captureRequested = true; + return true; + } + return false; + } + + /** Call before endDrawing() on the frame for which beginFrame() was true. */ + requestCapture(): void { + if (this.captureRequested && !this.screenshotSubmitted) { + if (!this.intermediatesSubmitted && this.config.intermediatesPath.length > 0) { + this.intermediatesSubmitted = captureDebugIntermediates( + this.config.intermediatesPath, + ); + } + this.screenshotSubmitted = captureFrameToPng(this.config.outputPath); + } + } + + /** + * Call immediately after endDrawing(). Returns true when the PNG and + * telemetry have both been requested/written and the example should exit. + */ + endFrame(): boolean { + if (this.captureRequested) { + // If the caller-side capture branch was lost by the native TypeScript + // lowering, queue the readback here and render one additional frame. + // A request made before beginDrawing() is consumed by that frame, so + // this fallback is deterministic and still outside the timed window. + if (!this.screenshotSubmitted) { + if (!this.intermediatesSubmitted && this.config.intermediatesPath.length > 0) { + this.intermediatesSubmitted = captureDebugIntermediates( + this.config.intermediatesPath, + ); + } + this.screenshotSubmitted = captureFrameToPng(this.config.outputPath); + return false; + } + if (!isFrameCaptureReady()) return false; + console.error("BLOOM_QUALITY_DONE " + this.config.telemetryPath); + return true; + } + + this.frame = this.frame + 1; + if ( + this.measurementStarted + && !this.measurementFinished + && this.frame >= this.config.warmupFrames + this.config.measuredFrames + ) { + this.measurementWallMs = (getTime() - this.measurementStartSeconds) * 1000; + this.telemetryWritten = writeQualityTelemetry( + this.config.telemetryPath, + this.config.warmupFrames, + this.config.measuredFrames, + this.config.fixedTimestep, + this.config.qualityPreset, + this.config.renderScale, + this.measurementWallMs, + ); + if (!this.telemetryWritten) { + console.error("BLOOM_QUALITY_ERROR telemetry-write-failed " + this.config.telemetryPath); + } + // Preserve the snapshot already serialized above but remove all + // profiler work from the following screenshot frame. + setProfilerEnabled(false); + this.measurementFinished = true; + } + return false; + } +} diff --git a/src/scene/index.ts b/src/scene/index.ts index a082ca6a..70d99b6c 100644 --- a/src/scene/index.ts +++ b/src/scene/index.ts @@ -22,12 +22,12 @@ declare function bloom_scene_create_node(): number; declare function bloom_scene_destroy_node(handle: number): void; -declare function bloom_scene_set_visible(handle: number, visible: number): void; -declare function bloom_scene_set_gi_only(handle: number, gi_only: number): void; -declare function bloom_scene_set_cast_shadow(handle: number, cast: number): void; -declare function bloom_scene_set_receive_shadow(handle: number, receive: number): void; -declare function bloom_scene_set_parent(handle: number, parent: number): void; -declare function bloom_scene_set_transform(handle: number, matrix: number): void; +declare function bloom_scene_set_visible(handle: number, visible: number): number; +declare function bloom_scene_set_gi_only(handle: number, gi_only: number): number; +declare function bloom_scene_set_cast_shadow(handle: number, cast: number): number; +declare function bloom_scene_set_receive_shadow(handle: number, receive: number): number; +declare function bloom_scene_set_parent(handle: number, parent: number): number; +declare function bloom_scene_set_transform(handle: number, matrix: number): number; // All-scalar transform + geometry entry points. Perry 0.5.x cannot pass a // `number[]` into the i64 pointer params above, so these are the ones the // wrappers actually call. @@ -37,7 +37,7 @@ declare function bloom_scene_set_transform16( m4: number, m5: number, m6: number, m7: number, m8: number, m9: number, m10: number, m11: number, m12: number, m13: number, m14: number, m15: number, -): void; +): number; declare function bloom_scene_update_geometry_scratch( handle: number, vertexCount: number, @@ -46,7 +46,7 @@ declare function bloom_scene_update_geometry_scratch( declare function bloom_mesh_scratch_reset(): void; declare function bloom_mesh_scratch_push_f32(v: number): void; declare function bloom_mesh_scratch_push_u32(v: number): void; -declare function bloom_scene_set_trs(handle: number, px: number, py: number, pz: number, yaw: number, scale: number): void; +declare function bloom_scene_set_trs(handle: number, px: number, py: number, pz: number, yaw: number, scale: number): number; declare function bloom_scene_update_geometry( handle: number, vertices: number, @@ -54,9 +54,48 @@ declare function bloom_scene_update_geometry( indices: number, indexCount: number, ): void; -declare function bloom_scene_set_material_color(handle: number, r: number, g: number, b: number, a: number): void; -declare function bloom_scene_set_material_pbr(handle: number, roughness: number, metalness: number): void; -declare function bloom_scene_set_material_texture(handle: number, textureIdx: number): void; +declare function bloom_scene_set_lod( + handle: number, + lodIndex: number, + vertices: number, + vertexCount: number, + indices: number, + indexCount: number, + maxCoverage: number, +): number; +declare function bloom_scene_attach_model_lod( + node: number, + model: number, + meshIndex: number, + lodIndex: number, + maxCoverage: number, +): number; +declare function bloom_scene_set_material_color(handle: number, r: number, g: number, b: number, a: number): number; +declare function bloom_scene_set_material_pbr(handle: number, roughness: number, metalness: number): number; +declare function bloom_scene_set_material_emissive(handle: number, r: number, g: number, b: number): number; +declare function bloom_scene_set_material_layered_pbr( + handle: number, + lobeMask: number, + clearcoatFactor: number, + clearcoatRoughness: number, + clearcoatNormalScale: number, + specularFactor: number, + specularR: number, + specularG: number, + specularB: number, + ior: number, + sheenR: number, + sheenG: number, + sheenB: number, + sheenRoughness: number, + anisotropyStrength: number, + anisotropyRotation: number, + iridescenceFactor: number, + iridescenceIor: number, + iridescenceThicknessMinimum: number, + iridescenceThicknessMaximum: number, +): number; +declare function bloom_scene_set_material_texture(handle: number, textureIdx: number): number; declare function bloom_scene_node_count(): number; // Frame callbacks @@ -76,17 +115,17 @@ declare function bloom_add_point_light( ): void; // Shadows -declare function bloom_enable_shadows(): void; -declare function bloom_disable_shadows(): void; +declare function bloom_enable_shadows(): number; +declare function bloom_disable_shadows(): number; declare function bloom_dump_shadow_map(path: number): void; // Post-processing declare function bloom_enable_postfx(): void; declare function bloom_disable_postfx(): void; -declare function bloom_postfx_set_selected(handle: number): void; -declare function bloom_postfx_set_hovered(handle: number): void; -declare function bloom_postfx_set_outline_color(r: number, g: number, b: number, a: number): void; -declare function bloom_postfx_set_outline_thickness(thickness: number): void; +declare function bloom_postfx_set_selected(handle: number): number; +declare function bloom_postfx_set_hovered(handle: number): number; +declare function bloom_postfx_set_outline_color(r: number, g: number, b: number, a: number): number; +declare function bloom_postfx_set_outline_thickness(thickness: number): number; // Model attachment declare function bloom_scene_attach_model(nodeHandle: number, modelHandle: number, meshIndex: number): void; @@ -113,7 +152,7 @@ declare function bloom_pick_all_handle(index: number): number; declare function bloom_pick_all_distance(index: number): number; // Q8: water material -declare function bloom_scene_set_material_water(handle: number, waveAmp: number, waveSpeed: number, r: number, g: number, b: number, a: number): void; +declare function bloom_scene_set_material_water(handle: number, waveAmp: number, waveSpeed: number, r: number, g: number, b: number, a: number): number; // Q4: transform read-back declare function bloom_scene_get_transform(handle: number, index: number): number; @@ -125,7 +164,7 @@ declare function bloom_scene_get_bounds_max_x(handle: number): number; declare function bloom_scene_get_bounds_max_y(handle: number): number; declare function bloom_scene_get_bounds_max_z(handle: number): number; // Q7: user data -declare function bloom_scene_set_user_data(handle: number, data: number): void; +declare function bloom_scene_set_user_data(handle: number, data: number): number; declare function bloom_scene_get_user_data(handle: number): number; declare function bloom_scene_extrude_polygon( @@ -146,12 +185,64 @@ declare function bloom_scene_subtract_box( export type SceneNodeHandle = number; +export interface ClearcoatMaterial { + factor?: number; + roughness?: number; + normalScale?: number; +} + +export interface SpecularMaterial { + factor?: number; + color?: [number, number, number]; + ior?: number; +} + +export interface SheenMaterial { + color?: [number, number, number]; + roughness?: number; +} + +export interface AnisotropyMaterial { + strength?: number; + rotation?: number; +} + +export interface IridescenceMaterial { + factor?: number; + ior?: number; + thicknessMinimum?: number; + thicknessMaximum?: number; +} + +export interface LayeredPbrMaterial { + clearcoat?: ClearcoatMaterial; + specular?: SpecularMaterial; + sheen?: SheenMaterial; + anisotropy?: AnisotropyMaterial; + iridescence?: IridescenceMaterial; +} + export interface PbrMaterial { + /** Base color in the engine-wide 0-255 color scale. */ color?: [number, number, number]; + /** Perceptual roughness in [0, 1]. */ roughness?: number; + /** Metallic weight in [0, 1]. */ metalness?: number; + /** Surface opacity in [0, 1]. */ opacity?: number; + /** Linear emissive radiance; values above 1 are valid. */ + emissive?: [number, number, number]; textureIdx?: number; + layered?: LayeredPbrMaterial; +} + +function finiteOr(value: number, fallback: number): number { + return Number.isFinite(value) ? value : fallback; +} + +function unitMaterialValue(value: number, fallback: number): number { + return Math.min(1, Math.max(0, finiteOr(value, fallback))); } // ============================================================ @@ -176,22 +267,22 @@ export function destroySceneNode(handle: SceneNodeHandle): void { /** * Set visibility of a scene node. */ -export function setSceneNodeVisible(handle: SceneNodeHandle, visible: boolean): void { - bloom_scene_set_visible(handle, visible ? 1 : 0); +export function setSceneNodeVisible(handle: SceneNodeHandle, visible: boolean): boolean { + return bloom_scene_set_visible(handle, visible ? 1 : 0) !== 0; } /** * Set whether this node casts shadows. */ -export function setSceneNodeCastShadow(handle: SceneNodeHandle, cast: boolean): void { - bloom_scene_set_cast_shadow(handle, cast ? 1 : 0); +export function setSceneNodeCastShadow(handle: SceneNodeHandle, cast: boolean): boolean { + return bloom_scene_set_cast_shadow(handle, cast ? 1 : 0) !== 0; } /** * Set whether this node receives shadows. */ -export function setSceneNodeReceiveShadow(handle: SceneNodeHandle, receive: boolean): void { - bloom_scene_set_receive_shadow(handle, receive ? 1 : 0); +export function setSceneNodeReceiveShadow(handle: SceneNodeHandle, receive: boolean): boolean { + return bloom_scene_set_receive_shadow(handle, receive ? 1 : 0) !== 0; } /** @@ -206,15 +297,15 @@ export function setSceneNodeReceiveShadow(handle: SceneNodeHandle, receive: bool * flat base colour via setSceneNodeMaterialPbr so the bounce carries the * right hue; leave the node visible (the flag handles exclusion). */ -export function setSceneNodeGiOnly(handle: SceneNodeHandle, giOnly: boolean): void { - bloom_scene_set_gi_only(handle, giOnly ? 1 : 0); +export function setSceneNodeGiOnly(handle: SceneNodeHandle, giOnly: boolean): boolean { + return bloom_scene_set_gi_only(handle, giOnly ? 1 : 0) !== 0; } /** * Set the parent of a scene node. Pass 0 for no parent (root node). */ -export function setSceneNodeParent(handle: SceneNodeHandle, parent: SceneNodeHandle): void { - bloom_scene_set_parent(handle, parent); +export function setSceneNodeParent(handle: SceneNodeHandle, parent: SceneNodeHandle): boolean { + return bloom_scene_set_parent(handle, parent) !== 0; } /** @@ -230,14 +321,14 @@ export function setSceneNodeParent(handle: SceneNodeHandle, parent: SceneNodeHan * ("Expected safe integer for native i64 parameter"), which made the old * pointer-based entry point unreachable from TypeScript. */ -export function setSceneNodeTransform(handle: SceneNodeHandle, matrix: number[]): void { - bloom_scene_set_transform16( +export function setSceneNodeTransform(handle: SceneNodeHandle, matrix: number[]): boolean { + return bloom_scene_set_transform16( handle, matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5], matrix[6], matrix[7], matrix[8], matrix[9], matrix[10], matrix[11], matrix[12], matrix[13], matrix[14], matrix[15], - ); + ) !== 0; } /** @@ -250,8 +341,8 @@ export function setSceneNodeTrs( x: number, y: number, z: number, yaw: number = 0, scale: number = 1, -): void { - bloom_scene_set_trs(handle, x, y, z, yaw, scale); +): boolean { + return bloom_scene_set_trs(handle, x, y, z, yaw, scale) !== 0; } /** @@ -294,8 +385,8 @@ export function updateSceneNodeGeometry( * alone took 0-1 floats; passing a `Colors` constant silently rendered * white. Values are clamped; alpha defaults to opaque. */ -export function setSceneNodeColor(handle: SceneNodeHandle, r: number, g: number, b: number, a: number = 255): void { - bloom_scene_set_material_color(handle, r / 255, g / 255, b / 255, a / 255); +export function setSceneNodeColor(handle: SceneNodeHandle, r: number, g: number, b: number, a: number = 255): boolean { + return bloom_scene_set_material_color(handle, r / 255, g / 255, b / 255, a / 255) !== 0; } /** @@ -314,8 +405,8 @@ export function setSceneNodeColor(handle: SceneNodeHandle, r: number, g: number, export function setSceneNodeLod( handle: SceneNodeHandle, lodIndex: number, vertices: number[], indices: number[], maxCoverage: number, -): void { - bloom_scene_set_lod(handle, lodIndex, vertices as any, vertices.length / 12, indices as any, indices.length, maxCoverage); +): boolean { + return bloom_scene_set_lod(handle, lodIndex, vertices as any, vertices.length / 12, indices as any, indices.length, maxCoverage) !== 0; } /** @@ -326,20 +417,107 @@ export function setSceneNodeLod( export function attachModelLodToNode( node: SceneNodeHandle, model: { handle: number }, meshIndex: number, lodIndex: number, maxCoverage: number, -): void { - bloom_scene_attach_model_lod(node, model.handle, meshIndex, lodIndex, maxCoverage); +): boolean { + return bloom_scene_attach_model_lod(node, model.handle, meshIndex, lodIndex, maxCoverage) !== 0; } -export function setSceneNodePbr(handle: SceneNodeHandle, roughness: number, metalness: number): void { - bloom_scene_set_material_pbr(handle, roughness, metalness); +export function setSceneNodePbr(handle: SceneNodeHandle, roughness: number, metalness: number): boolean { + return bloom_scene_set_material_pbr(handle, roughness, metalness) !== 0; +} + +/** + * Replace a node's complete PBR material from one descriptor. + * + * Every property is optional and falls back to the engine/glTF default. + * Omitted layered lobes are disabled and retain the unchanged base-material + * fast path. Layered colors use linear 0-1 values; base `color` uses Bloom's + * engine-wide 0-255 color scale. Iridescence thickness is in nanometres and + * anisotropy rotation is in radians. + * + * Layer textures and texture transforms remain asset-authored in this first + * API version; imported glTF materials preserve and render them losslessly. + */ +export function setSceneNodeMaterial( + handle: SceneNodeHandle, + material: PbrMaterial = {}, +): boolean { + const color = material.color ?? [255, 255, 255]; + const emissive = material.emissive ?? [0, 0, 0]; + const emissiveR = Math.max(0, finiteOr(emissive[0], 0)); + const emissiveG = Math.max(0, finiteOr(emissive[1], 0)); + const emissiveB = Math.max(0, finiteOr(emissive[2], 0)); + const colorApplied = bloom_scene_set_material_color( + handle, + Math.min(255, Math.max(0, finiteOr(color[0], 255))) / 255, + Math.min(255, Math.max(0, finiteOr(color[1], 255))) / 255, + Math.min(255, Math.max(0, finiteOr(color[2], 255))) / 255, + unitMaterialValue(material.opacity ?? 1, 1), + ) !== 0; + const pbrApplied = bloom_scene_set_material_pbr( + handle, + unitMaterialValue(material.roughness ?? 0.8, 0.8), + unitMaterialValue(material.metalness ?? 0, 0), + ) !== 0; + const emissiveApplied = bloom_scene_set_material_emissive( + handle, + emissiveR, + emissiveG, + emissiveB, + ) !== 0; + const textureApplied = bloom_scene_set_material_texture( + handle, + Math.floor(Math.max(0, finiteOr(material.textureIdx ?? 0, 0))), + ) !== 0; + + const clearcoat = material.layered?.clearcoat; + const specular = material.layered?.specular; + const sheen = material.layered?.sheen; + const anisotropy = material.layered?.anisotropy; + const iridescence = material.layered?.iridescence; + const lobeMask = (clearcoat === undefined ? 0 : 1 << 0) + | (sheen === undefined ? 0 : 1 << 1) + | (anisotropy === undefined ? 0 : 1 << 2) + | (iridescence === undefined ? 0 : 1 << 3) + | (specular === undefined ? 0 : 1 << 4); + const specularColor = specular?.color ?? [1, 1, 1]; + const sheenColor = sheen?.color ?? [0, 0, 0]; + const layeredApplied = bloom_scene_set_material_layered_pbr( + handle, + lobeMask, + clearcoat?.factor ?? 0, + clearcoat?.roughness ?? 0, + clearcoat?.normalScale ?? 1, + specular?.factor ?? 1, + specularColor[0], + specularColor[1], + specularColor[2], + specular?.ior ?? 1.5, + sheenColor[0], + sheenColor[1], + sheenColor[2], + sheen?.roughness ?? 0, + anisotropy?.strength ?? 0, + anisotropy?.rotation ?? 0, + iridescence?.factor ?? 0, + iridescence?.ior ?? 1.3, + iridescence?.thicknessMinimum ?? 100, + iridescence?.thicknessMaximum ?? 400, + ) !== 0; + const emissiveDisabled = + emissiveR === 0 && emissiveG === 0 && emissiveB === 0; + return colorApplied + && pbrApplied + && textureApplied + && (emissiveApplied || emissiveDisabled) + && (layeredApplied || lobeMask === 0); } /** * Set the texture for a scene node's material. * Pass 0 for the default white texture. */ -export function setSceneNodeTexture(handle: SceneNodeHandle, textureIdx: number): void { - bloom_scene_set_material_texture(handle, textureIdx); +export function setSceneNodeTexture(handle: SceneNodeHandle, textureIdx: number): boolean { + return bloom_scene_set_material_texture(handle, textureIdx) !== 0; } /** @@ -405,15 +583,15 @@ export function addDirectionalLight( * Enable shadow mapping (single directional light). * Shadows are rendered from the primary directional light's perspective. */ -export function enableShadows(): void { - bloom_enable_shadows(); +export function enableShadows(): boolean { + return bloom_enable_shadows() !== 0; } /** * Disable shadow mapping. */ -export function disableShadows(): void { - bloom_disable_shadows(); +export function disableShadows(): boolean { + return bloom_disable_shadows() !== 0; } /** @@ -446,31 +624,31 @@ export function disablePostFx(): void { * Set the selected scene node for outline rendering. * Pass 0 to clear selection. Matching Pascal Editor's outliner.selectedObjects. */ -export function setPostFxSelected(handle: SceneNodeHandle): void { - bloom_postfx_set_selected(handle); +export function setPostFxSelected(handle: SceneNodeHandle): boolean { + return bloom_postfx_set_selected(handle) !== 0; } /** * Set the hovered scene node for outline rendering. * Pass 0 to clear hover. */ -export function setPostFxHovered(handle: SceneNodeHandle): void { - bloom_postfx_set_hovered(handle); +export function setPostFxHovered(handle: SceneNodeHandle): boolean { + return bloom_postfx_set_hovered(handle) !== 0; } /** * Set the outline color for selected objects (0-1 range). */ /** Selection-outline color, 0-255 per channel (engine-wide convention). */ -export function setOutlineColor(r: number, g: number, b: number, a: number = 255): void { - bloom_postfx_set_outline_color(r / 255, g / 255, b / 255, a / 255); +export function setOutlineColor(r: number, g: number, b: number, a: number = 255): boolean { + return bloom_postfx_set_outline_color(r / 255, g / 255, b / 255, a / 255) !== 0; } /** * Set the outline thickness in pixels. */ -export function setOutlineThickness(thickness: number): void { - bloom_postfx_set_outline_thickness(thickness); +export function setOutlineThickness(thickness: number): boolean { + return bloom_postfx_set_outline_thickness(thickness) !== 0; } // ============================================================ @@ -639,8 +817,8 @@ export function getSceneNodeBounds(handle: SceneNodeHandle): { min: { x: number; * Attach an arbitrary integer to a scene node. Used by editors to associate * entity ids with scene nodes so picking can return the entity id directly. */ -export function setSceneNodeUserData(handle: SceneNodeHandle, data: number): void { - bloom_scene_set_user_data(handle, data); +export function setSceneNodeUserData(handle: SceneNodeHandle, data: number): boolean { + return bloom_scene_set_user_data(handle, data) !== 0; } export function getSceneNodeUserData(handle: SceneNodeHandle): number { @@ -685,8 +863,8 @@ export function setSceneNodeWaterMaterial( handle: SceneNodeHandle, waveAmplitude: number, waveSpeed: number, r: number, g: number, b: number, a: number, -): void { - bloom_scene_set_material_water(handle, waveAmplitude, waveSpeed, r / 255, g / 255, b / 255, a / 255); +): boolean { + return bloom_scene_set_material_water(handle, waveAmplitude, waveSpeed, r / 255, g / 255, b / 255, a / 255) !== 0; } export function addPointLight( diff --git a/tools/README.md b/tools/README.md index 17b6cd0d..2f3e6c8a 100644 --- a/tools/README.md +++ b/tools/README.md @@ -1,9 +1,36 @@ # Bloom renderer validation tools -Two companion binaries that close the loop between the Bloom realtime -renderer and a reference ground truth. Used to track whether each -renderer change moves the realtime output closer to or farther from -physically-correct rendering. +The canonical regression workflow is +[`tools/quality`](quality/README.md). It owns versioned cameras/assets, +fixed-step uncapped capture, named render-graph evidence, perceptual gates, +GPU/CPU budgets, adapter metadata, negative controls, and baseline governance. + +The companion binaries below close the loop between the Bloom realtime +renderer and reference ground truth. The quality harness reuses them; direct +invocation remains useful for investigation and reference generation. + +## `bloom-cook` — offline asset cooking + +The cooker produces BC7 texture artifacts and the versioned, opt-in meshlet +geometry artifacts used by the staged virtualized-geometry work: + +```shell +cargo run --release --manifest-path tools/bloom-cook/Cargo.toml -- \ + geometry examples/renderer-test/assets/DamagedHelmet.glb /tmp/helmet.bgeo + +cargo run --release --manifest-path tools/bloom-cook/Cargo.toml -- \ + geometry-inspect /tmp/helmet.bgeo +``` + +The geometry path is offline-only today. It does not change ordinary glTF +rendering or allocate runtime resources. See +[`docs/virtualized-geometry.md`](../docs/virtualized-geometry.md) for the +format, corruption guarantees, limits, compatibility reasons, and staged +runtime boundary. + +The opt-in hierarchy cooker uses the `meshopt` Rust wrapper (MIT OR +Apache-2.0) and its vendored meshoptimizer implementation (MIT). It is linked +only into this offline tool, not the engine runtime. ## `bloom-reference` — CPU path tracer @@ -33,6 +60,76 @@ cargo build --release --out ref.png ``` +For the engine path-tracing golden, use the built-in scene rather than +maintaining a second asset. It mirrors the test's floor slab, six +colored cubes, camera, sun, ambient sky, and deterministic seed, and +writes a machine-readable reproduction record: + +```shell +cargo run --release -- \ + --builtin pt-golden \ + --out pt-golden-reference.png \ + --metadata pt-golden-reference.json \ + --width 256 --height 256 --spp 256 --bounces 8 --seed 0 \ + --camera 5 4 7 0 0.5 0 50 \ + --sun-dir 0.5 1 0.3 --sun-intensity 1.2 +``` + +Use this reference to inspect energy, visibility, silhouettes, and +occlusion. Do not gate raw whole-frame RMSE against the GPU golden: +the reference renders analytic sky on camera misses, performs +environment NEE/MIS, and uses a fixed ACES+sRGB display transform, +while the engine PT preserves the raster sky and passes through the +engine HDR/post stack. + +### Layered-PBR parameter reference + +`bloom-brdf-reference` evaluates the versioned layered-material contracts +without scene, texture, sampling-noise, or tone-mapping variables: + +```shell +cargo run --release \ + --manifest-path tools/bloom-reference/Cargo.toml \ + --bin bloom-brdf-reference -- \ + --out tools/bloom-reference/reference/layered-pbr-v1.json +``` + +The checked-in 48-case matrix includes separate diffuse/specular terms, +directional-light response, MIS PDF, and deterministic white-furnace +reflectance. Tests require exact regeneration plus reciprocal, finite, and +energy-bounded behavior. See +[`docs/layered-pbr.md`](../docs/layered-pbr.md). + +Version 2 adds clearcoat and dielectric specular/IOR: + +```shell +cargo run --release \ + --manifest-path tools/bloom-reference/Cargo.toml \ + --bin bloom-brdf-reference -- \ + --version 2 \ + --out tools/bloom-reference/reference/layered-pbr-v2.json +``` + +Its 39 checked cases cover base/default equivalence, water/diamond/zero IOR, +disabled/colored specular, smooth/rough clearcoat, and combined lobes at three +view angles. The tests additionally sweep a larger reciprocal white-furnace +matrix and require pure conductors to remain independent of dielectric +specular controls. + +Version 3 adds Charlie sheen and tangent-space anisotropic GGX: + +```shell +cargo run --release \ + --manifest-path tools/bloom-reference/Cargo.toml \ + --bin bloom-brdf-reference -- \ + --version 3 \ + --out tools/bloom-reference/reference/layered-pbr-v3.json +``` + +The 30 checked rows cover sheen roughness, anisotropy strength/rotation, +fabric, clearcoat-over-sheen, and all-lobe combinations at three view angles. +The same tool owns the 128×128 R16F sheen directional-albedo LUT oracle. + ## `bloom-diff` — pixel comparison Compares two PNGs (reference vs realtime) and produces quantitative @@ -96,9 +193,10 @@ change an improvement?". As the Bloom realtime renderer gains normal maps, MR textures, HDR IBL etc. through the v2 spec phases, those numbers should monotonically decrease. -## Multi-camera validation suite (`tools/validate.sh`) +## Legacy multi-camera exploration (`tools/validate.sh`) -Wraps the workflow above in a script that runs four cameras of the +This script predates `tools/quality` and is no longer a merge/qualification +gate. It runs four cameras of the helmet scene (front, three-quarter, side, top-down) through both renderers and reports per-view + aggregate metrics: @@ -123,16 +221,20 @@ Numbers from this suite are higher than single-camera diff at native resolution because the realtime output is downsampled via `sips` to match the reference resolution — sips's resampling adds blur that inflates RMSE. The suite is consistent with itself across runs, so -it's good for regression detection; just don't directly compare its -numbers to single-camera native-res diff. +it remains useful for macOS-only visual exploration. Do not use it to certify a +renderer change: its cameras live in the shell script, references are cached, +output is resized through `sips`, and it does not enforce the quality harness's +fixed-step, warm-up, adapter, timing, intermediate, or baseline-review +contracts. ## PBR material grid (`examples/pbr-spheres/`) Diagnostic scene: a 5×5 grid of spheres where rows vary metallic (0 → 1) and columns vary roughness (0 → 1), all sharing a gold base -color and lit purely by the outdoor HDR. Visual-only validation -right now (no comparable bloom-reference path yet — synthetic -scenes don't load through glTF). +color and lit purely by the outdoor HDR. The versioned CPU parameter matrix +above now supplies a lobe-level numeric reference; image comparison still +requires a shared scene/camera because synthetic scenes do not load through +glTF. ``` cd examples/pbr-spheres diff --git a/tools/bloom-cook/Cargo.lock b/tools/bloom-cook/Cargo.lock index 769ca7af..c7e19fd5 100644 --- a/tools/bloom-cook/Cargo.lock +++ b/tools/bloom-cook/Cargo.lock @@ -14,6 +14,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "bcdec_rs" version = "0.2.0" @@ -26,12 +32,25 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bloom-cook" version = "0.1.0" dependencies = [ + "gltf", "image", "image_dds", + "meshopt", + "serde_json", + "sha2", ] [[package]] @@ -66,12 +85,31 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -87,6 +125,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "ddsfile" version = "0.5.2" @@ -99,6 +147,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "enum-primitive-derive" version = "0.2.2" @@ -119,6 +177,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" version = "1.1.9" @@ -129,6 +193,64 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gltf" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7" +dependencies = [ + "base64", + "byteorder", + "gltf-json", + "image", + "lazy_static", + "serde_json", + "urlencoding", +] + +[[package]] +name = "gltf-derive" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51" +dependencies = [ + "inflections", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "gltf-json" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14" +dependencies = [ + "gltf-derive", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "half" version = "2.7.1" @@ -174,9 +296,15 @@ dependencies = [ "half", "image", "intel_tex_2", - "thiserror", + "thiserror 1.0.69", ] +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + [[package]] name = "intel_tex_2" version = "0.4.0" @@ -196,12 +324,42 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "meshopt" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01e77ead21976b3a9f01ec1724f766923da74f0726364e2b0f425658935d71a" +dependencies = [ + "bitflags", + "cc", + "float-cmp", + "thiserror 2.0.19", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -278,6 +436,65 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -306,13 +523,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", ] [[package]] @@ -326,12 +563,41 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "zerocopy" version = "0.8.52" @@ -352,6 +618,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zune-core" version = "0.5.1" diff --git a/tools/bloom-cook/Cargo.toml b/tools/bloom-cook/Cargo.toml index 8f050af4..38245305 100644 --- a/tools/bloom-cook/Cargo.toml +++ b/tools/bloom-cook/Cargo.toml @@ -8,6 +8,10 @@ edition = "2021" # place that pays that build cost; the engine runtime only decodes. image_dds = { version = "0.7", default-features = false, features = ["ddsfile", "image", "encode"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tga"] } +gltf = { version = "1.4", default-features = false, features = ["import", "utils"] } +meshopt = "0.6.2" +serde_json = "1" +sha2 = "0.10" [[bin]] name = "bloom-cook" diff --git a/tools/bloom-cook/geometry_cook.rs b/tools/bloom-cook/geometry_cook.rs new file mode 100644 index 00000000..1da731e9 --- /dev/null +++ b/tools/bloom-cook/geometry_cook.rs @@ -0,0 +1,680 @@ +//! glTF-to-meshlet cooking and command-line reporting. + +use crate::geometry_format::{ + decode_geometry, encode_geometry, hex_hash, CompatibilityReason, CompatibilityRecord, + DEFAULT_PAGE_BYTES, +}; +use crate::hierarchy::{ + build_meshlet_hierarchy, build_spatial_leaf_meshlets, offset_relations, order_for_streaming, + HierarchyStats, +}; +use crate::meshlet::{build_leaf_meshlets, Meshlet, MeshletLimits, StaticPrimitive, StaticVertex}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::io::Write; +use std::path::Path; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct CookOptions { + meshlet_limits: MeshletLimits, + page_bytes: u32, + hierarchy_levels: u32, +} + +impl Default for CookOptions { + fn default() -> Self { + Self { + meshlet_limits: MeshletLimits::default(), + page_bytes: DEFAULT_PAGE_BYTES, + hierarchy_levels: 0, + } + } +} + +#[derive(Debug)] +struct GeometrySource { + source_sha256: [u8; 32], + primitives: Vec, + compatibility: Vec, + source_meshes: usize, + source_primitives: usize, + eligible_triangles: u64, +} + +pub fn cook_geometry_command( + input: &Path, + output: &Path, + flags: &[String], +) -> Result { + let options = parse_options(flags)?; + let source = load_geometry_source(input)?; + let mut meshlets = Vec::::new(); + let mut hierarchy = HierarchyStats::default(); + for primitive in &source.primitives { + let leaves = if options.hierarchy_levels == 0 { + build_leaf_meshlets(primitive, options.meshlet_limits)? + } else { + build_spatial_leaf_meshlets(primitive, options.meshlet_limits)? + }; + if options.hierarchy_levels == 0 { + meshlets.extend(leaves); + } else { + let base = meshlets.len(); + let (mut primitive_hierarchy, stats) = + build_meshlet_hierarchy(leaves, options.meshlet_limits, options.hierarchy_levels)?; + offset_relations(&mut primitive_hierarchy, base)?; + hierarchy.merge(stats); + meshlets.extend(primitive_hierarchy); + } + } + if options.hierarchy_levels != 0 { + order_for_streaming(&mut meshlets)?; + } + let bytes = encode_geometry( + &meshlets, + &source.compatibility, + source.source_sha256, + options.page_bytes, + )?; + write_atomically(output, &bytes)?; + let archive = decode_geometry(&bytes)?; + + serde_json::to_string_pretty(&json!({ + "schema": "bloom-geometry-cook-report-v1", + "input": input.display().to_string(), + "output": output.display().to_string(), + "format_version": crate::geometry_format::VERSION, + "source_sha256": hex_hash(archive.source_sha256), + "payload_sha256": hex_hash(archive.payload_sha256), + "source": { + "meshes": source.source_meshes, + "primitives": source.source_primitives, + "eligible_triangles": source.eligible_triangles, + }, + "cooked": { + "meshlets": archive.clusters.len(), + "triangles": archive.triangle_count(), + "pages": archive.pages.len(), + "payload_bytes": archive.payload_bytes(), + "page_budget_bytes": archive.page_budget_bytes, + "maximum_page_bytes": archive.maximum_page_bytes(), + "max_vertices_per_meshlet": options.meshlet_limits.max_vertices, + "max_triangles_per_meshlet": options.meshlet_limits.max_triangles, + "leaf_hierarchy_only": options.hierarchy_levels == 0, + "hierarchy": { + "requested_levels": options.hierarchy_levels, + "leaf_clusters": hierarchy.leaf_clusters, + "leaf_triangles": hierarchy.leaf_triangles, + "leaf_payload_bytes": hierarchy.leaf_payload_bytes, + "parent_clusters": hierarchy.parent_clusters, + "root_clusters": hierarchy.root_clusters, + "root_triangles": hierarchy.root_triangles, + "maximum_level": hierarchy.maximum_level, + "maximum_absolute_error": hierarchy.maximum_error, + "root_payload_bytes": hierarchy.root_payload_bytes, + "root_pages": archive.coarse_root_page_count(), + "root_resident_page_bytes": archive.coarse_root_page_bytes(), + "root_clusters_by_level": hierarchy.root_clusters_by_level, + "root_payload_bytes_by_level": hierarchy.root_payload_bytes_by_level, + }, + }, + "compatibility": compatibility_json(&archive.compatibility), + "shipping_runtime_changes": { + "passes": 0, + "draws": 0, + "buffers": 0, + "shader_branches": 0, + } + })) + .map_err(|error| format!("serialize geometry report: {error}")) +} + +pub fn inspect_geometry_command(input: &Path) -> Result { + let bytes = std::fs::read(input).map_err(|error| format!("read {input:?}: {error}"))?; + let archive = decode_geometry(&bytes)?; + serde_json::to_string_pretty(&json!({ + "schema": "bloom-geometry-inspect-report-v1", + "input": input.display().to_string(), + "format_version": crate::geometry_format::VERSION, + "file_bytes": bytes.len(), + "source_sha256": hex_hash(archive.source_sha256), + "payload_sha256": hex_hash(archive.payload_sha256), + "meshlets": archive.clusters.len(), + "triangles": archive.triangle_count(), + "pages": archive.pages.len(), + "payload_bytes": archive.payload_bytes(), + "page_budget_bytes": archive.page_budget_bytes, + "maximum_page_bytes": archive.maximum_page_bytes(), + "coarse_root_pages": archive.coarse_root_page_count(), + "coarse_root_page_bytes": archive.coarse_root_page_bytes(), + "compatibility": compatibility_json(&archive.compatibility), + "validation": "pass", + })) + .map_err(|error| format!("serialize geometry inspection: {error}")) +} + +fn compatibility_json(records: &[CompatibilityRecord]) -> serde_json::Value { + serde_json::Value::Array( + records + .iter() + .map(|record| { + json!({ + "mesh": record.mesh_index, + "primitive": record.primitive_index, + "reason": record.reason.label(), + "detail": record.detail, + }) + }) + .collect(), + ) +} + +fn parse_options(flags: &[String]) -> Result { + let mut options = CookOptions::default(); + let mut index = 0; + while index < flags.len() { + let flag = &flags[index]; + let value = flags + .get(index + 1) + .ok_or_else(|| format!("{flag} requires an integer value"))?; + let parsed = value + .parse::() + .map_err(|_| format!("{flag} requires an unsigned integer, got {value:?}"))?; + match flag.as_str() { + "--max-vertices" => options.meshlet_limits.max_vertices = parsed, + "--max-triangles" => options.meshlet_limits.max_triangles = parsed, + "--page-bytes" => options.page_bytes = parsed, + "--hierarchy-levels" => options.hierarchy_levels = parsed, + _ => return Err(format!("unknown geometry option {flag:?}")), + } + index += 2; + } + options.meshlet_limits.validate()?; + if options.hierarchy_levels > 16 { + return Err(format!( + "geometry hierarchy levels must be in 0..=16, got {}", + options.hierarchy_levels + )); + } + // The format writer owns the exact power-of-two/range validation. This + // dry call keeps CLI errors local without duplicating that contract. + if options.page_bytes == 0 { + return Err("geometry page budget must be greater than zero".to_string()); + } + Ok(options) +} + +fn load_geometry_source(path: &Path) -> Result { + let source_bytes = std::fs::read(path).map_err(|error| format!("read {path:?}: {error}"))?; + let mut gltf = gltf::Gltf::from_slice(&source_bytes) + .map_err(|error| format!("parse {}: {error}", path.display()))?; + let buffers = gltf::import_buffers(&gltf.document, path.parent(), gltf.blob.take()) + .map_err(|error| format!("load buffers for {}: {error}", path.display()))?; + let skinned_meshes: HashSet = gltf + .nodes() + .filter(|node| node.skin().is_some()) + .filter_map(|node| node.mesh().map(|mesh| mesh.index())) + .collect(); + + let mut source_hasher = Sha256::new(); + source_hasher.update(b"bloom-static-geometry-source-v1"); + source_hasher.update((source_bytes.len() as u64).to_le_bytes()); + source_hasher.update(&source_bytes); + for (index, buffer) in buffers.iter().enumerate() { + source_hasher.update((index as u64).to_le_bytes()); + source_hasher.update((buffer.0.len() as u64).to_le_bytes()); + source_hasher.update(&buffer.0); + } + let source_sha256 = source_hasher.finalize().into(); + + let mut primitives = Vec::new(); + let mut compatibility = Vec::new(); + let mut source_primitives = 0usize; + let mut eligible_triangles = 0u64; + let source_meshes = gltf.meshes().len(); + + for mesh in gltf.meshes() { + for primitive in mesh.primitives() { + source_primitives += 1; + let mesh_index = mesh.index() as u32; + let primitive_index = primitive.index() as u32; + let material = primitive.material(); + let material_index = material.index().map(|index| index as u32); + + let compatibility_reason = if primitive.mode() != gltf::mesh::Mode::Triangles { + Some(( + CompatibilityReason::NonTriangleTopology, + mode_code(primitive.mode()), + )) + } else if skinned_meshes.contains(&mesh.index()) { + Some((CompatibilityReason::Skinned, 0)) + } else if primitive.morph_targets().next().is_some() { + Some((CompatibilityReason::MorphTargets, 0)) + } else if material.alpha_mode() == gltf::material::AlphaMode::Blend { + Some(( + CompatibilityReason::AlphaBlend, + material_index.unwrap_or(u32::MAX), + )) + } else { + None + }; + if let Some((reason, detail)) = compatibility_reason { + compatibility.push(CompatibilityRecord { + mesh_index, + primitive_index, + reason, + detail, + }); + continue; + } + + let reader = primitive + .reader(|buffer| buffers.get(buffer.index()).map(|data| data.0.as_slice())); + let positions: Vec<[f32; 3]> = reader + .read_positions() + .ok_or_else(|| { + format!("mesh {mesh_index} primitive {primitive_index} is missing POSITION") + })? + .collect(); + if positions.is_empty() { + return Err(format!( + "mesh {mesh_index} primitive {primitive_index} has no positions" + )); + } + let indices: Vec = match reader.read_indices() { + Some(indices) => indices.into_u32().collect(), + None => (0..positions.len() as u32).collect(), + }; + if indices.is_empty() || !indices.len().is_multiple_of(3) { + return Err(format!( + "mesh {mesh_index} primitive {primitive_index} has {} indices, \ + expected a non-empty triangle list", + indices.len() + )); + } + if let Some(index) = indices + .iter() + .find(|index| **index as usize >= positions.len()) + { + return Err(format!( + "mesh {mesh_index} primitive {primitive_index} index {index} \ + exceeds {} positions", + positions.len() + )); + } + + let normals = match reader.read_normals() { + Some(normals) => collect_attribute( + normals, + positions.len(), + mesh_index, + primitive_index, + "NORMAL", + )?, + None => generate_normals(&positions, &indices), + }; + let tangents = match reader.read_tangents() { + Some(tangents) => collect_attribute( + tangents, + positions.len(), + mesh_index, + primitive_index, + "TANGENT", + )?, + None => vec![[0.0; 4]; positions.len()], + }; + let uv0 = match reader.read_tex_coords(0) { + Some(uvs) => collect_attribute( + uvs.into_f32(), + positions.len(), + mesh_index, + primitive_index, + "TEXCOORD_0", + )?, + None => vec![[0.0; 2]; positions.len()], + }; + let uv1 = match reader.read_tex_coords(1) { + Some(uvs) => collect_attribute( + uvs.into_f32(), + positions.len(), + mesh_index, + primitive_index, + "TEXCOORD_1", + )?, + None => vec![[0.0; 2]; positions.len()], + }; + let colors = match reader.read_colors(0) { + Some(colors) => collect_attribute( + colors.into_rgba_f32(), + positions.len(), + mesh_index, + primitive_index, + "COLOR_0", + )?, + None => vec![[1.0; 4]; positions.len()], + }; + let vertices = positions + .into_iter() + .zip(normals) + .zip(tangents) + .zip(uv0) + .zip(uv1) + .zip(colors) + .map( + |(((((position, normal), tangent), uv0), uv1), color)| StaticVertex { + position, + normal, + tangent, + uv0, + uv1, + color, + }, + ) + .collect(); + eligible_triangles += (indices.len() / 3) as u64; + primitives.push(StaticPrimitive { + mesh_index, + primitive_index, + material_index, + double_sided: material.double_sided(), + alpha_masked: material.alpha_mode() == gltf::material::AlphaMode::Mask, + vertices, + indices, + }); + } + } + + if source_primitives == 0 { + return Err(format!("{} contains no mesh primitives", path.display())); + } + Ok(GeometrySource { + source_sha256, + primitives, + compatibility, + source_meshes, + source_primitives, + eligible_triangles, + }) +} + +fn collect_attribute( + values: impl Iterator, + expected: usize, + mesh_index: u32, + primitive_index: u32, + semantic: &str, +) -> Result, String> { + let values: Vec<_> = values.collect(); + if values.len() != expected { + return Err(format!( + "mesh {mesh_index} primitive {primitive_index} {semantic} count {} \ + does not match POSITION count {expected}", + values.len() + )); + } + Ok(values) +} + +fn generate_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> { + let mut normals = vec![[0.0; 3]; positions.len()]; + for triangle in indices.chunks_exact(3) { + let a = positions[triangle[0] as usize]; + let b = positions[triangle[1] as usize]; + let c = positions[triangle[2] as usize]; + let face = cross3(sub3(b, a), sub3(c, a)); + for index in triangle { + normals[*index as usize] = add3(normals[*index as usize], face); + } + } + for normal in &mut normals { + let length = dot3(*normal, *normal).sqrt(); + *normal = if length > 1e-20 { + mul3(*normal, length.recip()) + } else { + [0.0, 1.0, 0.0] + }; + } + normals +} + +fn mode_code(mode: gltf::mesh::Mode) -> u32 { + match mode { + gltf::mesh::Mode::Points => 0, + gltf::mesh::Mode::Lines => 1, + gltf::mesh::Mode::LineLoop => 2, + gltf::mesh::Mode::LineStrip => 3, + gltf::mesh::Mode::Triangles => 4, + gltf::mesh::Mode::TriangleStrip => 5, + gltf::mesh::Mode::TriangleFan => 6, + } +} + +fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("create {}: {error}", parent.display()))?; + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("output path {} has no UTF-8 file name", path.display()))?; + let unique = format!( + "{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| format!("system clock precedes Unix epoch: {error}"))? + .as_nanos() + ); + let temporary = path.with_file_name(format!(".{file_name}.{unique}.tmp")); + let write_result = (|| { + let mut file = std::fs::File::create(&temporary) + .map_err(|error| format!("create {}: {error}", temporary.display()))?; + file.write_all(bytes) + .map_err(|error| format!("write {}: {error}", temporary.display()))?; + file.sync_all() + .map_err(|error| format!("flush {}: {error}", temporary.display()))?; + match std::fs::rename(&temporary, path) { + Ok(()) => Ok(()), + Err(first_error) if path.exists() => { + // Unix replaces atomically in the first branch. Windows does + // not replace an existing destination, so preserve the old + // artifact as a rollback target while installing the fully + // flushed temporary. + let backup = path.with_file_name(format!(".{file_name}.{unique}.previous")); + std::fs::rename(path, &backup).map_err(|backup_error| { + format!( + "replace {} after rename error {first_error}: \ + could not preserve prior artifact: {backup_error}", + path.display() + ) + })?; + match std::fs::rename(&temporary, path) { + Ok(()) => { + std::fs::remove_file(&backup).map_err(|error| { + format!( + "installed {} but could not remove backup {}: {error}", + path.display(), + backup.display() + ) + })?; + Ok(()) + } + Err(install_error) => { + let restore = std::fs::rename(&backup, path); + Err(format!( + "install {} failed: {install_error}; prior artifact restore: {}", + path.display(), + if restore.is_ok() { "pass" } else { "failed" } + )) + } + } + } + Err(error) => Err(format!( + "install {} as {}: {error}", + temporary.display(), + path.display() + )), + } + })(); + if write_result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + write_result +} + +fn add3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} + +fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +fn mul3(value: [f32; 3], factor: f32) -> [f32; 3] { + [value[0] * factor, value[1] * factor, value[2] * factor] +} + +fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn geometry_options_are_strict_and_bounded() { + let flags = vec![ + "--max-vertices".to_string(), + "32".to_string(), + "--max-triangles".to_string(), + "48".to_string(), + "--page-bytes".to_string(), + "32768".to_string(), + "--hierarchy-levels".to_string(), + "6".to_string(), + ]; + let options = parse_options(&flags).unwrap(); + assert_eq!(options.meshlet_limits.max_vertices, 32); + assert_eq!(options.meshlet_limits.max_triangles, 48); + assert_eq!(options.page_bytes, 32768); + assert_eq!(options.hierarchy_levels, 6); + assert!(parse_options(&["--surprise".to_string(), "1".to_string()]) + .unwrap_err() + .contains("unknown")); + assert!(parse_options(&["--max-vertices".to_string()]) + .unwrap_err() + .contains("requires")); + } + + #[test] + fn missing_normals_are_generated_deterministically() { + let positions = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; + let a = generate_normals(&positions, &[0, 1, 2]); + let b = generate_normals(&positions, &[0, 1, 2]); + assert_eq!(a, b); + assert_eq!(a, vec![[0.0, 0.0, 1.0]; 3]); + } + + #[test] + fn minimal_glb_loads_and_cooks_without_images() { + let bytes = minimal_triangle_glb(); + let path = std::env::temp_dir().join(format!( + "bloom-cook-geometry-{}-{}.glb", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, bytes).unwrap(); + let source = load_geometry_source(&path).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(source.source_meshes, 1); + assert_eq!(source.source_primitives, 1); + assert_eq!(source.eligible_triangles, 1); + assert_eq!(source.primitives.len(), 1); + assert_eq!(source.primitives[0].vertices[0].normal, [0.0, 0.0, 1.0]); + let meshlets = + build_leaf_meshlets(&source.primitives[0], MeshletLimits::default()).unwrap(); + let encoded = encode_geometry( + &meshlets, + &source.compatibility, + source.source_sha256, + DEFAULT_PAGE_BYTES, + ) + .unwrap(); + assert_eq!(decode_geometry(&encoded).unwrap().triangle_count(), 1); + } + + #[test] + fn atomic_output_can_replace_an_existing_artifact() { + let path = std::env::temp_dir().join(format!( + "bloom-cook-atomic-{}-{}.bgeo", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + write_atomically(&path, b"first").unwrap(); + write_atomically(&path, b"second").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"second"); + let _ = std::fs::remove_file(path); + } + + fn minimal_triangle_glb() -> Vec { + let mut binary = Vec::new(); + for value in [0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0] { + binary.extend_from_slice(&value.to_le_bytes()); + } + for index in [0u16, 1, 2] { + binary.extend_from_slice(&index.to_le_bytes()); + } + binary.resize(binary.len().div_ceil(4) * 4, 0); + let json = format!( + r#"{{ + "asset":{{"version":"2.0"}}, + "buffers":[{{"byteLength":{}}}], + "bufferViews":[ + {{"buffer":0,"byteOffset":0,"byteLength":36}}, + {{"buffer":0,"byteOffset":36,"byteLength":6}} + ], + "accessors":[ + {{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3", + "min":[0,0,0],"max":[1,1,0]}}, + {{"bufferView":1,"componentType":5123,"count":3,"type":"SCALAR"}} + ], + "meshes":[{{"primitives":[{{"attributes":{{"POSITION":0}},"indices":1}}]}}], + "nodes":[{{"mesh":0}}], + "scenes":[{{"nodes":[0]}}], + "scene":0 + }}"#, + binary.len() + ); + let mut json = json.into_bytes(); + json.resize(json.len().div_ceil(4) * 4, b' '); + let total_length = 12 + 8 + json.len() + 8 + binary.len(); + let mut glb = Vec::with_capacity(total_length); + glb.extend_from_slice(b"glTF"); + glb.extend_from_slice(&2u32.to_le_bytes()); + glb.extend_from_slice(&(total_length as u32).to_le_bytes()); + glb.extend_from_slice(&(json.len() as u32).to_le_bytes()); + glb.extend_from_slice(&0x4e4f_534au32.to_le_bytes()); + glb.extend_from_slice(&json); + glb.extend_from_slice(&(binary.len() as u32).to_le_bytes()); + glb.extend_from_slice(&0x004e_4942u32.to_le_bytes()); + glb.extend_from_slice(&binary); + glb + } +} diff --git a/tools/bloom-cook/geometry_format.rs b/tools/bloom-cook/geometry_format.rs new file mode 100644 index 00000000..a95fd82e --- /dev/null +++ b/tools/bloom-cook/geometry_format.rs @@ -0,0 +1,1208 @@ +//! Versioned, little-endian cooked geometry container. +//! +//! Version 1 stores deterministic meshlets in independently hashed, +//! budget-bounded pages. Its fixed cluster record supports either the default +//! leaf-only artifact or opt-in atomic parent/child replacement groups. All +//! offsets and hierarchy relations are validated before payload access. + +use crate::meshlet::{ + Meshlet, StaticVertex, FLAG_ALPHA_MASKED, FLAG_COARSE_ROOT, FLAG_DOUBLE_SIDED, NO_RELATION, +}; +use sha2::{Digest, Sha256}; + +pub const MAGIC: [u8; 8] = *b"BLMGEO1\0"; +pub const VERSION: u32 = 1; +pub const ENDIAN_TAG: u32 = 0x0102_0304; +pub const HEADER_BYTES: usize = 160; +pub const CLUSTER_RECORD_BYTES: usize = 128; +pub const PAGE_RECORD_BYTES: usize = 64; +pub const COMPATIBILITY_RECORD_BYTES: usize = 16; +pub const DEFAULT_PAGE_BYTES: u32 = 64 * 1024; +pub const MIN_PAGE_BYTES: u32 = 4 * 1024; +pub const MAX_PAGE_BYTES: u32 = 4 * 1024 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum CompatibilityReason { + NonTriangleTopology = 1, + Skinned = 2, + MorphTargets = 3, + AlphaBlend = 4, +} + +impl CompatibilityReason { + pub fn label(self) -> &'static str { + match self { + Self::NonTriangleTopology => "non-triangle-topology", + Self::Skinned => "skinned", + Self::MorphTargets => "morph-targets", + Self::AlphaBlend => "alpha-blend", + } + } + + fn from_code(code: u32) -> Result { + match code { + 1 => Ok(Self::NonTriangleTopology), + 2 => Ok(Self::Skinned), + 3 => Ok(Self::MorphTargets), + 4 => Ok(Self::AlphaBlend), + _ => Err(format!("unknown compatibility reason code {code}")), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CompatibilityRecord { + pub mesh_index: u32, + pub primitive_index: u32, + pub reason: CompatibilityReason, + /// Reason-specific detail. Alpha blend records store the material index or + /// `u32::MAX`; topology records store the glTF mode discriminant. + pub detail: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ClusterRecord { + pub mesh_index: u32, + pub primitive_index: u32, + pub material_index: Option, + pub flags: u32, + pub page_index: u32, + pub vertex_count: u32, + pub triangle_count: u32, + pub lod_level: u32, + pub vertex_offset: u64, + pub index_offset: u64, + pub aabb_min: [f32; 3], + pub aabb_max: [f32; 3], + pub sphere_center: [f32; 3], + pub sphere_radius: f32, + pub normal_cone_axis: [f32; 3], + pub normal_cone_cutoff: f32, + pub geometric_error: f32, + pub parent: u32, + pub parent_count: u32, + pub first_child: u32, + pub child_count: u32, + pub vertex_stride: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PageRecord { + pub payload_offset: u64, + pub payload_bytes: u32, + pub first_cluster: u32, + pub cluster_count: u32, + pub sha256: [u8; 32], +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GeometryArchive { + pub source_sha256: [u8; 32], + pub payload_sha256: [u8; 32], + pub page_budget_bytes: u32, + pub clusters: Vec, + pub pages: Vec, + pub compatibility: Vec, +} + +impl GeometryArchive { + pub fn payload_bytes(&self) -> u64 { + self.pages + .iter() + .map(|page| page.payload_bytes as u64) + .sum() + } + + pub fn triangle_count(&self) -> u64 { + self.clusters + .iter() + .map(|cluster| cluster.triangle_count as u64) + .sum() + } + + pub fn maximum_page_bytes(&self) -> u32 { + self.pages + .iter() + .map(|page| page.payload_bytes) + .max() + .unwrap_or(0) + } + + pub fn coarse_root_page_count(&self) -> usize { + self.pages + .iter() + .take_while(|page| { + self.clusters[page.first_cluster as usize].flags & FLAG_COARSE_ROOT != 0 + }) + .count() + } + + pub fn coarse_root_page_bytes(&self) -> u64 { + self.pages + .iter() + .take(self.coarse_root_page_count()) + .map(|page| page.payload_bytes as u64) + .sum() + } +} + +pub fn encode_geometry( + meshlets: &[Meshlet], + compatibility: &[CompatibilityRecord], + source_sha256: [u8; 32], + page_budget_bytes: u32, +) -> Result, String> { + validate_page_budget(page_budget_bytes)?; + + let mut payload = Vec::new(); + let mut clusters = Vec::with_capacity(meshlets.len()); + let mut pages = Vec::::new(); + let mut page_start = 0usize; + let mut page_first_cluster = 0usize; + let mut previous_page_class = None; + + for meshlet in meshlets { + validate_meshlet(meshlet)?; + let encoded_bytes = align_up(meshlet.encoded_payload_bytes(), 16); + if encoded_bytes > page_budget_bytes as usize { + return Err(format!( + "meshlet payload {encoded_bytes} bytes exceeds page budget {page_budget_bytes}" + )); + } + let current_page_bytes = payload.len() - page_start; + let page_class = (meshlet.lod_level, meshlet.flags & FLAG_COARSE_ROOT != 0); + if current_page_bytes > 0 + && (previous_page_class != Some(page_class) + || current_page_bytes + .checked_add(encoded_bytes) + .is_none_or(|bytes| bytes > page_budget_bytes as usize)) + { + finish_page( + &payload, + page_start, + page_first_cluster, + clusters.len(), + &mut pages, + )?; + page_start = payload.len(); + page_first_cluster = clusters.len(); + } + previous_page_class = Some(page_class); + + let page_index = pages.len() as u32; + let vertex_offset = payload.len() as u64; + for vertex in &meshlet.vertices { + encode_vertex(&mut payload, vertex); + } + let index_offset = payload.len() as u64; + payload.extend_from_slice(&meshlet.local_indices); + payload.resize(align_up(payload.len(), 16), 0); + + clusters.push(ClusterRecord { + mesh_index: meshlet.mesh_index, + primitive_index: meshlet.primitive_index, + material_index: meshlet.material_index, + flags: meshlet.flags, + page_index, + vertex_count: meshlet.vertices.len() as u32, + triangle_count: meshlet.triangle_count(), + lod_level: meshlet.lod_level, + vertex_offset, + index_offset, + aabb_min: meshlet.bounds.aabb_min, + aabb_max: meshlet.bounds.aabb_max, + sphere_center: meshlet.bounds.sphere_center, + sphere_radius: meshlet.bounds.sphere_radius, + normal_cone_axis: meshlet.bounds.normal_cone_axis, + normal_cone_cutoff: meshlet.bounds.normal_cone_cutoff, + geometric_error: meshlet.geometric_error, + parent: meshlet.parent, + parent_count: meshlet.parent_count, + first_child: meshlet.first_child, + child_count: meshlet.child_count, + vertex_stride: StaticVertex::ENCODED_BYTES, + }); + } + finish_page( + &payload, + page_start, + page_first_cluster, + clusters.len(), + &mut pages, + )?; + + let cluster_table_offset = HEADER_BYTES; + let page_table_offset = checked_table_end( + cluster_table_offset, + clusters.len(), + CLUSTER_RECORD_BYTES, + "cluster table", + )?; + let compatibility_table_offset = checked_table_end( + page_table_offset, + pages.len(), + PAGE_RECORD_BYTES, + "page table", + )?; + let compatibility_end = checked_table_end( + compatibility_table_offset, + compatibility.len(), + COMPATIBILITY_RECORD_BYTES, + "compatibility table", + )?; + let payload_offset = align_up(compatibility_end, 16); + let file_bytes = payload_offset + .checked_add(payload.len()) + .ok_or("geometry file length overflow")?; + let payload_sha256 = sha256(&payload); + + let mut output = Vec::with_capacity(file_bytes); + output.extend_from_slice(&MAGIC); + push_u32(&mut output, VERSION); + push_u32(&mut output, HEADER_BYTES as u32); + push_u32(&mut output, ENDIAN_TAG); + push_u32(&mut output, 0); + output.extend_from_slice(&source_sha256); + output.extend_from_slice(&payload_sha256); + push_u32(&mut output, checked_u32(clusters.len(), "cluster count")?); + push_u32(&mut output, checked_u32(pages.len(), "page count")?); + push_u32( + &mut output, + checked_u32(compatibility.len(), "compatibility count")?, + ); + push_u32(&mut output, 0); + push_u64(&mut output, cluster_table_offset as u64); + push_u64(&mut output, page_table_offset as u64); + push_u64(&mut output, compatibility_table_offset as u64); + push_u64(&mut output, payload_offset as u64); + push_u64(&mut output, payload.len() as u64); + push_u64(&mut output, file_bytes as u64); + push_u32(&mut output, page_budget_bytes); + push_u32(&mut output, 0); + debug_assert_eq!(output.len(), HEADER_BYTES); + + for cluster in &clusters { + encode_cluster_record(&mut output, cluster); + } + for page in &pages { + encode_page_record(&mut output, page); + } + for record in compatibility { + push_u32(&mut output, record.mesh_index); + push_u32(&mut output, record.primitive_index); + push_u32(&mut output, record.reason as u32); + push_u32(&mut output, record.detail); + } + output.resize(payload_offset, 0); + output.extend_from_slice(&payload); + debug_assert_eq!(output.len(), file_bytes); + + // Exercise the strict reader before returning an artifact. A writer bug + // cannot put an unchecked container on disk. + decode_geometry(&output)?; + Ok(output) +} + +pub fn decode_geometry(bytes: &[u8]) -> Result { + if bytes.len() < HEADER_BYTES { + return Err(format!( + "geometry header truncated: {} bytes, expected at least {HEADER_BYTES}", + bytes.len() + )); + } + if bytes[..8] != MAGIC { + return Err("invalid cooked geometry magic".to_string()); + } + let version = read_u32(bytes, 8, "version")?; + if version != VERSION { + return Err(format!( + "unsupported cooked geometry version {version}; expected {VERSION}" + )); + } + let header_bytes = read_u32(bytes, 12, "header size")? as usize; + if header_bytes != HEADER_BYTES { + return Err(format!( + "invalid cooked geometry header size {header_bytes}; expected {HEADER_BYTES}" + )); + } + let endian_tag = read_u32(bytes, 16, "endian tag")?; + if endian_tag != ENDIAN_TAG { + return Err(format!( + "unsupported cooked geometry endian tag 0x{endian_tag:08x}" + )); + } + + let source_sha256 = read_hash(bytes, 24, "source hash")?; + let expected_payload_sha256 = read_hash(bytes, 56, "payload hash")?; + let cluster_count = read_u32(bytes, 88, "cluster count")? as usize; + let page_count = read_u32(bytes, 92, "page count")? as usize; + let compatibility_count = read_u32(bytes, 96, "compatibility count")? as usize; + let cluster_table_offset = read_usize(bytes, 104, "cluster table offset")?; + let page_table_offset = read_usize(bytes, 112, "page table offset")?; + let compatibility_table_offset = read_usize(bytes, 120, "compatibility table offset")?; + let payload_offset = read_usize(bytes, 128, "payload offset")?; + let payload_bytes = read_usize(bytes, 136, "payload length")?; + let declared_file_bytes = read_usize(bytes, 144, "file length")?; + let page_budget_bytes = read_u32(bytes, 152, "page budget")?; + validate_page_budget(page_budget_bytes)?; + + if declared_file_bytes != bytes.len() { + return Err(format!( + "cooked geometry length mismatch: header {declared_file_bytes}, actual {}", + bytes.len() + )); + } + let expected_page_offset = checked_table_end( + HEADER_BYTES, + cluster_count, + CLUSTER_RECORD_BYTES, + "cluster table", + )?; + let expected_compatibility_offset = checked_table_end( + expected_page_offset, + page_count, + PAGE_RECORD_BYTES, + "page table", + )?; + let compatibility_end = checked_table_end( + expected_compatibility_offset, + compatibility_count, + COMPATIBILITY_RECORD_BYTES, + "compatibility table", + )?; + let expected_payload_offset = align_up(compatibility_end, 16); + if cluster_table_offset != HEADER_BYTES + || page_table_offset != expected_page_offset + || compatibility_table_offset != expected_compatibility_offset + || payload_offset != expected_payload_offset + { + return Err("cooked geometry table offsets are non-canonical or overlapping".to_string()); + } + let payload_end = payload_offset + .checked_add(payload_bytes) + .ok_or("payload range overflow")?; + if payload_end != bytes.len() { + return Err(format!( + "payload range ends at {payload_end}, file length is {}", + bytes.len() + )); + } + let payload = &bytes[payload_offset..payload_end]; + let payload_sha256 = sha256(payload); + if payload_sha256 != expected_payload_sha256 { + return Err(format!( + "cooked geometry payload hash mismatch: expected {}, actual {}", + hex_hash(expected_payload_sha256), + hex_hash(payload_sha256) + )); + } + + let mut clusters = Vec::with_capacity(cluster_count); + for index in 0..cluster_count { + let offset = cluster_table_offset + index * CLUSTER_RECORD_BYTES; + clusters.push(decode_cluster_record(bytes, offset)?); + } + let mut pages = Vec::with_capacity(page_count); + for index in 0..page_count { + let offset = page_table_offset + index * PAGE_RECORD_BYTES; + pages.push(decode_page_record(bytes, offset)?); + } + let mut compatibility = Vec::with_capacity(compatibility_count); + for index in 0..compatibility_count { + let offset = compatibility_table_offset + index * COMPATIBILITY_RECORD_BYTES; + compatibility.push(CompatibilityRecord { + mesh_index: read_u32(bytes, offset, "compatibility mesh index")?, + primitive_index: read_u32(bytes, offset + 4, "compatibility primitive index")?, + reason: CompatibilityReason::from_code(read_u32( + bytes, + offset + 8, + "compatibility reason", + )?)?, + detail: read_u32(bytes, offset + 12, "compatibility detail")?, + }); + } + + validate_pages(&pages, &clusters, payload, page_budget_bytes)?; + validate_clusters(&clusters, &pages, payload)?; + Ok(GeometryArchive { + source_sha256, + payload_sha256, + page_budget_bytes, + clusters, + pages, + compatibility, + }) +} + +pub fn sha256(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +pub fn hex_hash(hash: [u8; 32]) -> String { + let mut output = String::with_capacity(64); + for byte in hash { + use std::fmt::Write as _; + let _ = write!(output, "{byte:02x}"); + } + output +} + +fn validate_page_budget(page_budget_bytes: u32) -> Result<(), String> { + if !(MIN_PAGE_BYTES..=MAX_PAGE_BYTES).contains(&page_budget_bytes) + || !page_budget_bytes.is_power_of_two() + { + return Err(format!( + "geometry page budget must be a power of two in \ + {MIN_PAGE_BYTES}..={MAX_PAGE_BYTES}, got {page_budget_bytes}" + )); + } + Ok(()) +} + +fn validate_meshlet(meshlet: &Meshlet) -> Result<(), String> { + if meshlet.vertices.len() < 3 || meshlet.vertices.len() > u8::MAX as usize { + return Err(format!( + "meshlet vertex count {} is outside 3..={}", + meshlet.vertices.len(), + u8::MAX + )); + } + if meshlet.local_indices.is_empty() || !meshlet.local_indices.len().is_multiple_of(3) { + return Err("meshlet indices are not a non-empty triangle list".to_string()); + } + if meshlet + .local_indices + .iter() + .any(|index| *index as usize >= meshlet.vertices.len()) + { + return Err("meshlet local index exceeds its vertex count".to_string()); + } + let bounds = &meshlet.bounds; + let finite = bounds + .aabb_min + .iter() + .chain(bounds.aabb_max.iter()) + .chain(bounds.sphere_center.iter()) + .chain(std::iter::once(&bounds.sphere_radius)) + .chain(bounds.normal_cone_axis.iter()) + .chain(std::iter::once(&bounds.normal_cone_cutoff)) + .chain(std::iter::once(&meshlet.geometric_error)) + .all(|component| component.is_finite()); + if !finite + || bounds.sphere_radius < 0.0 + || !(-1.0..=1.0).contains(&bounds.normal_cone_cutoff) + || meshlet.geometric_error < 0.0 + { + return Err("meshlet bounds/error contain invalid values".to_string()); + } + Ok(()) +} + +fn finish_page( + payload: &[u8], + page_start: usize, + first_cluster: usize, + cluster_end: usize, + pages: &mut Vec, +) -> Result<(), String> { + if cluster_end == first_cluster { + return Ok(()); + } + let slice = payload + .get(page_start..) + .ok_or("internal page start exceeds payload")?; + pages.push(PageRecord { + payload_offset: page_start as u64, + payload_bytes: checked_u32(slice.len(), "page length")?, + first_cluster: checked_u32(first_cluster, "first page cluster")?, + cluster_count: checked_u32(cluster_end - first_cluster, "page cluster count")?, + sha256: sha256(slice), + }); + Ok(()) +} + +fn encode_vertex(output: &mut Vec, vertex: &StaticVertex) { + for value in vertex + .position + .iter() + .chain(vertex.normal.iter()) + .chain(vertex.tangent.iter()) + .chain(vertex.uv0.iter()) + .chain(vertex.uv1.iter()) + .chain(vertex.color.iter()) + { + push_f32(output, *value); + } +} + +fn encode_cluster_record(output: &mut Vec, record: &ClusterRecord) { + let start = output.len(); + push_u32(output, record.mesh_index); + push_u32(output, record.primitive_index); + push_u32(output, record.material_index.unwrap_or(u32::MAX)); + push_u32(output, record.flags); + push_u32(output, record.page_index); + push_u32(output, record.vertex_count); + push_u32(output, record.triangle_count); + push_u32(output, record.lod_level); + push_u64(output, record.vertex_offset); + push_u64(output, record.index_offset); + push_f32x3(output, record.aabb_min); + push_f32x3(output, record.aabb_max); + push_f32x3(output, record.sphere_center); + push_f32(output, record.sphere_radius); + push_f32x3(output, record.normal_cone_axis); + push_f32(output, record.normal_cone_cutoff); + push_f32(output, record.geometric_error); + push_u32(output, record.parent); + push_u32(output, record.first_child); + push_u32(output, record.child_count); + push_u32(output, record.vertex_stride); + push_u32(output, record.parent_count); + debug_assert_eq!(output.len() - start, CLUSTER_RECORD_BYTES); +} + +fn decode_cluster_record(bytes: &[u8], offset: usize) -> Result { + let material_index = match read_u32(bytes, offset + 8, "cluster material")? { + u32::MAX => None, + index => Some(index), + }; + Ok(ClusterRecord { + mesh_index: read_u32(bytes, offset, "cluster mesh index")?, + primitive_index: read_u32(bytes, offset + 4, "cluster primitive index")?, + material_index, + flags: read_u32(bytes, offset + 12, "cluster flags")?, + page_index: read_u32(bytes, offset + 16, "cluster page")?, + vertex_count: read_u32(bytes, offset + 20, "cluster vertex count")?, + triangle_count: read_u32(bytes, offset + 24, "cluster triangle count")?, + lod_level: read_u32(bytes, offset + 28, "cluster LOD level")?, + vertex_offset: read_u64(bytes, offset + 32, "cluster vertex offset")?, + index_offset: read_u64(bytes, offset + 40, "cluster index offset")?, + aabb_min: read_f32x3(bytes, offset + 48, "cluster aabb min")?, + aabb_max: read_f32x3(bytes, offset + 60, "cluster aabb max")?, + sphere_center: read_f32x3(bytes, offset + 72, "cluster sphere center")?, + sphere_radius: read_f32(bytes, offset + 84, "cluster sphere radius")?, + normal_cone_axis: read_f32x3(bytes, offset + 88, "cluster normal cone")?, + normal_cone_cutoff: read_f32(bytes, offset + 100, "cluster normal cutoff")?, + geometric_error: read_f32(bytes, offset + 104, "cluster geometric error")?, + parent: read_u32(bytes, offset + 108, "cluster parent")?, + first_child: read_u32(bytes, offset + 112, "cluster first child")?, + child_count: read_u32(bytes, offset + 116, "cluster child count")?, + vertex_stride: read_u32(bytes, offset + 120, "cluster vertex stride")?, + parent_count: read_u32(bytes, offset + 124, "cluster parent count")?, + }) +} + +fn encode_page_record(output: &mut Vec, record: &PageRecord) { + let start = output.len(); + push_u64(output, record.payload_offset); + push_u32(output, record.payload_bytes); + push_u32(output, record.first_cluster); + push_u32(output, record.cluster_count); + push_u32(output, 0); + output.extend_from_slice(&record.sha256); + push_u64(output, 0); + debug_assert_eq!(output.len() - start, PAGE_RECORD_BYTES); +} + +fn decode_page_record(bytes: &[u8], offset: usize) -> Result { + Ok(PageRecord { + payload_offset: read_u64(bytes, offset, "page payload offset")?, + payload_bytes: read_u32(bytes, offset + 8, "page payload length")?, + first_cluster: read_u32(bytes, offset + 12, "page first cluster")?, + cluster_count: read_u32(bytes, offset + 16, "page cluster count")?, + sha256: read_hash(bytes, offset + 24, "page hash")?, + }) +} + +fn validate_pages( + pages: &[PageRecord], + clusters: &[ClusterRecord], + payload: &[u8], + page_budget_bytes: u32, +) -> Result<(), String> { + if pages.is_empty() { + return if clusters.is_empty() && payload.is_empty() { + Ok(()) + } else { + Err("non-empty cooked geometry contains no pages".to_string()) + }; + } + let mut expected_payload_offset = 0u64; + let mut expected_cluster = 0u32; + let mut reached_non_root_pages = false; + for (page_index, page) in pages.iter().enumerate() { + if page.payload_offset != expected_payload_offset + || page.first_cluster != expected_cluster + || page.cluster_count == 0 + { + return Err(format!( + "page {page_index} has a gap, overlap, or empty cluster range" + )); + } + if page.payload_bytes == 0 || page.payload_bytes > page_budget_bytes { + return Err(format!( + "page {page_index} length {} violates budget {page_budget_bytes}", + page.payload_bytes + )); + } + let cluster_start = page.first_cluster as usize; + let cluster_end = cluster_start + page.cluster_count as usize; + let page_clusters = clusters + .get(cluster_start..cluster_end) + .ok_or_else(|| format!("page {page_index} cluster range exceeds cluster table"))?; + let first_class = ( + page_clusters[0].lod_level, + page_clusters[0].flags & FLAG_COARSE_ROOT != 0, + ); + if page_clusters.iter().any(|cluster| { + (cluster.lod_level, cluster.flags & FLAG_COARSE_ROOT != 0) != first_class + }) { + return Err(format!( + "page {page_index} mixes hierarchy levels or root residency classes" + )); + } + if first_class.1 { + if reached_non_root_pages { + return Err(format!( + "page {page_index} places coarse roots after streamable pages" + )); + } + } else { + reached_non_root_pages = true; + } + let start = usize::try_from(page.payload_offset) + .map_err(|_| format!("page {page_index} offset exceeds host address space"))?; + let end = start + .checked_add(page.payload_bytes as usize) + .ok_or_else(|| format!("page {page_index} range overflow"))?; + let page_payload = payload + .get(start..end) + .ok_or_else(|| format!("page {page_index} exceeds payload"))?; + let actual_hash = sha256(page_payload); + if actual_hash != page.sha256 { + return Err(format!( + "page {page_index} hash mismatch: expected {}, actual {}", + hex_hash(page.sha256), + hex_hash(actual_hash) + )); + } + expected_payload_offset = end as u64; + expected_cluster = expected_cluster + .checked_add(page.cluster_count) + .ok_or("page cluster range overflow")?; + } + if expected_payload_offset != payload.len() as u64 { + return Err("page ranges do not cover the complete payload".to_string()); + } + if expected_cluster as usize != clusters.len() { + return Err("page cluster ranges do not cover the cluster table".to_string()); + } + Ok(()) +} + +fn validate_clusters( + clusters: &[ClusterRecord], + pages: &[PageRecord], + payload: &[u8], +) -> Result<(), String> { + for (cluster_index, cluster) in clusters.iter().enumerate() { + let known_flags = FLAG_DOUBLE_SIDED | FLAG_ALPHA_MASKED | FLAG_COARSE_ROOT; + if cluster.vertex_stride != StaticVertex::ENCODED_BYTES + || !(3..=u8::MAX as u32).contains(&cluster.vertex_count) + || cluster.triangle_count == 0 + || cluster.flags & !known_flags != 0 + { + return Err(format!( + "cluster {cluster_index} has invalid counts, stride, or flags" + )); + } + let page = pages + .get(cluster.page_index as usize) + .ok_or_else(|| format!("cluster {cluster_index} references missing page"))?; + let first = page.first_cluster as usize; + let end = first + page.cluster_count as usize; + if cluster_index < first || cluster_index >= end { + return Err(format!( + "cluster {cluster_index} is outside its page cluster range" + )); + } + let page_start = page.payload_offset; + let page_end = page_start + page.payload_bytes as u64; + let vertex_bytes = (cluster.vertex_count as u64) + .checked_mul(cluster.vertex_stride as u64) + .ok_or_else(|| format!("cluster {cluster_index} vertex range overflow"))?; + let index_bytes = (cluster.triangle_count as u64) + .checked_mul(3) + .ok_or_else(|| format!("cluster {cluster_index} index range overflow"))?; + let vertex_end = cluster + .vertex_offset + .checked_add(vertex_bytes) + .ok_or_else(|| format!("cluster {cluster_index} vertex range overflow"))?; + let index_end = cluster + .index_offset + .checked_add(index_bytes) + .ok_or_else(|| format!("cluster {cluster_index} index range overflow"))?; + if cluster.vertex_offset < page_start + || cluster.vertex_offset % 16 != 0 + || cluster.index_offset != vertex_end + || index_end > page_end + { + return Err(format!( + "cluster {cluster_index} payload offsets exceed or overlap its page" + )); + } + let vertex_start = cluster.vertex_offset as usize; + let vertex_end = vertex_end as usize; + let index_start = cluster.index_offset as usize; + let index_end = index_end as usize; + for component in payload[vertex_start..vertex_end].chunks_exact(4) { + if !f32::from_le_bytes(component.try_into().unwrap()).is_finite() { + return Err(format!( + "cluster {cluster_index} vertex payload contains NaN/Inf" + )); + } + } + if payload[index_start..index_end] + .iter() + .any(|index| *index as u32 >= cluster.vertex_count) + { + return Err(format!( + "cluster {cluster_index} local index exceeds vertex count" + )); + } + let finite_bounds = cluster + .aabb_min + .iter() + .chain(cluster.aabb_max.iter()) + .chain(cluster.sphere_center.iter()) + .chain(std::iter::once(&cluster.sphere_radius)) + .chain(cluster.normal_cone_axis.iter()) + .chain(std::iter::once(&cluster.normal_cone_cutoff)) + .chain(std::iter::once(&cluster.geometric_error)) + .all(|value| value.is_finite()); + if !finite_bounds + || cluster + .aabb_min + .iter() + .zip(cluster.aabb_max) + .any(|(min, max)| *min > max) + || cluster.sphere_radius < 0.0 + || !(-1.0..=1.0).contains(&cluster.normal_cone_cutoff) + || cluster.geometric_error < 0.0 + { + return Err(format!("cluster {cluster_index} has invalid bounds/error")); + } + validate_relation(cluster_index, "parent", cluster.parent, clusters.len())?; + if cluster.parent == NO_RELATION { + if cluster.parent_count != 0 { + return Err(format!( + "cluster {cluster_index} has no parent but a non-zero parent count" + )); + } + } else { + let parent_end = (cluster.parent as usize) + .checked_add(cluster.parent_count as usize) + .ok_or_else(|| format!("cluster {cluster_index} parent range overflow"))?; + if cluster.parent_count == 0 || parent_end > clusters.len() { + return Err(format!( + "cluster {cluster_index} parent range exceeds cluster table" + )); + } + } + if cluster.child_count == 0 { + if cluster.first_child != NO_RELATION { + return Err(format!( + "cluster {cluster_index} has no children but a first-child index" + )); + } + } else { + let first = cluster.first_child as usize; + let end = first + .checked_add(cluster.child_count as usize) + .ok_or_else(|| format!("cluster {cluster_index} child range overflow"))?; + if first >= clusters.len() || end > clusters.len() { + return Err(format!( + "cluster {cluster_index} child range exceeds cluster table" + )); + } + } + } + validate_hierarchy(clusters)?; + Ok(()) +} + +fn validate_hierarchy(clusters: &[ClusterRecord]) -> Result<(), String> { + let hierarchy_present = clusters.iter().any(|cluster| { + cluster.parent_count != 0 + || cluster.child_count != 0 + || cluster.lod_level != 0 + || cluster.flags & FLAG_COARSE_ROOT != 0 + }); + for (cluster_index, cluster) in clusters.iter().enumerate() { + if cluster.parent == NO_RELATION { + if hierarchy_present && cluster.flags & FLAG_COARSE_ROOT == 0 { + return Err(format!( + "hierarchy cluster {cluster_index} has no parent and is not a coarse root" + )); + } + continue; + } + if cluster.flags & FLAG_COARSE_ROOT != 0 { + return Err(format!( + "cluster {cluster_index} is both a hierarchy child and coarse root" + )); + } + let parent_start = cluster.parent as usize; + let parent_end = parent_start + cluster.parent_count as usize; + let first_parent = &clusters[parent_start]; + for (parent_index, parent) in clusters[parent_start..parent_end].iter().enumerate() { + let parent_index = parent_start + parent_index; + let child_start = parent.first_child as usize; + let child_end = child_start + .checked_add(parent.child_count as usize) + .ok_or_else(|| format!("cluster {parent_index} child range overflow"))?; + if !(child_start..child_end).contains(&cluster_index) + || parent.lod_level <= cluster.lod_level + || parent.first_child != first_parent.first_child + || parent.child_count != first_parent.child_count + || parent.lod_level != first_parent.lod_level + || parent.geometric_error != first_parent.geometric_error + { + return Err(format!( + "cluster {cluster_index} has a non-reciprocal or inconsistent parent group" + )); + } + } + } + for (parent_index, parent) in clusters.iter().enumerate() { + if parent.child_count == 0 { + continue; + } + let child_start = parent.first_child as usize; + let child_end = child_start + parent.child_count as usize; + let first_child = &clusters[child_start]; + let parent_start = first_child.parent as usize; + let parent_end = parent_start + .checked_add(first_child.parent_count as usize) + .ok_or_else(|| format!("cluster {parent_index} sibling range overflow"))?; + if first_child.parent == NO_RELATION + || first_child.parent_count == 0 + || !(parent_start..parent_end).contains(&parent_index) + { + return Err(format!( + "parent {parent_index} is outside its reciprocal sibling group" + )); + } + for (child_index, child) in clusters[child_start..child_end].iter().enumerate() { + let child_index = child_start + child_index; + if child.parent != first_child.parent + || child.parent_count != first_child.parent_count + || child.lod_level >= parent.lod_level + || child.mesh_index != parent.mesh_index + || child.primitive_index != parent.primitive_index + || child.material_index != parent.material_index + || (child.flags & !FLAG_COARSE_ROOT) != (parent.flags & !FLAG_COARSE_ROOT) + || parent.geometric_error < child.geometric_error + { + return Err(format!( + "parent {parent_index} and child {child_index} violate hierarchy identity/error" + )); + } + } + } + Ok(()) +} + +fn validate_relation( + cluster_index: usize, + label: &str, + relation: u32, + cluster_count: usize, +) -> Result<(), String> { + if relation != NO_RELATION && relation as usize >= cluster_count { + return Err(format!( + "cluster {cluster_index} {label} index {relation} exceeds cluster table" + )); + } + Ok(()) +} + +fn checked_table_end( + start: usize, + count: usize, + stride: usize, + label: &str, +) -> Result { + count + .checked_mul(stride) + .and_then(|bytes| start.checked_add(bytes)) + .ok_or_else(|| format!("{label} range overflow")) +} + +fn checked_u32(value: usize, label: &str) -> Result { + u32::try_from(value).map_err(|_| format!("{label} exceeds u32")) +} + +fn align_up(value: usize, alignment: usize) -> usize { + value.div_ceil(alignment) * alignment +} + +fn push_u32(output: &mut Vec, value: u32) { + output.extend_from_slice(&value.to_le_bytes()); +} + +fn push_u64(output: &mut Vec, value: u64) { + output.extend_from_slice(&value.to_le_bytes()); +} + +fn push_f32(output: &mut Vec, value: f32) { + output.extend_from_slice(&value.to_le_bytes()); +} + +fn push_f32x3(output: &mut Vec, value: [f32; 3]) { + for component in value { + push_f32(output, component); + } +} + +fn read_u32(bytes: &[u8], offset: usize, label: &str) -> Result { + let value = bytes + .get(offset..offset + 4) + .ok_or_else(|| format!("{label} is truncated"))?; + Ok(u32::from_le_bytes(value.try_into().unwrap())) +} + +fn read_u64(bytes: &[u8], offset: usize, label: &str) -> Result { + let value = bytes + .get(offset..offset + 8) + .ok_or_else(|| format!("{label} is truncated"))?; + Ok(u64::from_le_bytes(value.try_into().unwrap())) +} + +fn read_usize(bytes: &[u8], offset: usize, label: &str) -> Result { + let value = read_u64(bytes, offset, label)?; + usize::try_from(value).map_err(|_| format!("{label} exceeds host address space")) +} + +fn read_f32(bytes: &[u8], offset: usize, label: &str) -> Result { + Ok(f32::from_bits(read_u32(bytes, offset, label)?)) +} + +fn read_f32x3(bytes: &[u8], offset: usize, label: &str) -> Result<[f32; 3], String> { + Ok([ + read_f32(bytes, offset, label)?, + read_f32(bytes, offset + 4, label)?, + read_f32(bytes, offset + 8, label)?, + ]) +} + +fn read_hash(bytes: &[u8], offset: usize, label: &str) -> Result<[u8; 32], String> { + bytes + .get(offset..offset + 32) + .ok_or_else(|| format!("{label} is truncated"))? + .try_into() + .map_err(|_| format!("{label} has invalid length")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::meshlet::{ + build_leaf_meshlets, MeshletLimits, StaticPrimitive, StaticVertex, FLAG_COARSE_ROOT, + }; + + fn triangle(x: f32, primitive_index: u32) -> Meshlet { + let vertex = |position| StaticVertex { + position, + normal: [0.0, 0.0, 1.0], + tangent: [1.0, 0.0, 0.0, 1.0], + uv0: [0.0, 0.0], + uv1: [0.0, 0.0], + color: [1.0; 4], + }; + let primitive = StaticPrimitive { + mesh_index: 0, + primitive_index, + material_index: Some(2), + double_sided: false, + alpha_masked: false, + vertices: vec![ + vertex([x, 0.0, 0.0]), + vertex([x + 1.0, 0.0, 0.0]), + vertex([x, 1.0, 0.0]), + ], + indices: vec![0, 1, 2], + }; + build_leaf_meshlets(&primitive, MeshletLimits::default()) + .unwrap() + .remove(0) + } + + fn sample_archive(page_budget: u32) -> Vec { + let meshlets = vec![triangle(0.0, 0), triangle(2.0, 1)]; + encode_geometry( + &meshlets, + &[CompatibilityRecord { + mesh_index: 1, + primitive_index: 0, + reason: CompatibilityReason::Skinned, + detail: 0, + }], + sha256(b"source"), + page_budget, + ) + .unwrap() + } + + #[test] + fn encoding_is_deterministic_and_round_trips() { + let a = sample_archive(DEFAULT_PAGE_BYTES); + let b = sample_archive(DEFAULT_PAGE_BYTES); + assert_eq!(a, b); + let decoded = decode_geometry(&a).unwrap(); + assert_eq!(decoded.clusters.len(), 2); + assert_eq!(decoded.pages.len(), 1); + assert_eq!(decoded.compatibility.len(), 1); + assert_eq!(decoded.triangle_count(), 2); + assert_eq!(decoded.source_sha256, sha256(b"source")); + assert_eq!( + decoded.compatibility[0].reason, + CompatibilityReason::Skinned + ); + } + + #[test] + fn compatibility_only_archive_is_valid_and_inspectable() { + let bytes = encode_geometry( + &[], + &[CompatibilityRecord { + mesh_index: 4, + primitive_index: 2, + reason: CompatibilityReason::AlphaBlend, + detail: 9, + }], + sha256(b"transparent"), + DEFAULT_PAGE_BYTES, + ) + .unwrap(); + let decoded = decode_geometry(&bytes).unwrap(); + assert!(decoded.clusters.is_empty()); + assert!(decoded.pages.is_empty()); + assert_eq!(decoded.compatibility.len(), 1); + assert_eq!( + decoded.compatibility[0].reason, + CompatibilityReason::AlphaBlend + ); + } + + #[test] + fn page_budget_is_hard_and_pages_are_independently_hashed() { + let meshlets: Vec<_> = (0..20) + .map(|index| triangle(index as f32 * 2.0, index)) + .collect(); + let bytes = encode_geometry(&meshlets, &[], sha256(b"source"), MIN_PAGE_BYTES).unwrap(); + let decoded = decode_geometry(&bytes).unwrap(); + assert_eq!(decoded.pages.len(), 2); + assert!(decoded.maximum_page_bytes() <= MIN_PAGE_BYTES); + + let oversized_vertex_count = + (MIN_PAGE_BYTES as usize / StaticVertex::ENCODED_BYTES as usize) + 1; + let mut large = triangle(0.0, 0); + large.vertices = vec![large.vertices[0]; oversized_vertex_count]; + assert!( + encode_geometry(&[large], &[], sha256(b"source"), MIN_PAGE_BYTES) + .unwrap_err() + .contains("exceeds page budget") + ); + } + + #[test] + fn payload_corruption_never_reaches_offsets() { + let mut bytes = sample_archive(DEFAULT_PAGE_BYTES); + let payload_offset = read_usize(&bytes, 128, "payload").unwrap(); + bytes[payload_offset] ^= 0x80; + assert!(decode_geometry(&bytes) + .unwrap_err() + .contains("payload hash mismatch")); + } + + #[test] + fn malformed_header_ranges_are_rejected() { + let mut bytes = sample_archive(DEFAULT_PAGE_BYTES); + bytes[104..112].copy_from_slice(&0u64.to_le_bytes()); + assert!(decode_geometry(&bytes) + .unwrap_err() + .contains("non-canonical or overlapping")); + + let mut bytes = sample_archive(DEFAULT_PAGE_BYTES); + bytes[8..12].copy_from_slice(&2u32.to_le_bytes()); + assert!(decode_geometry(&bytes) + .unwrap_err() + .contains("unsupported cooked geometry version")); + + let mut bytes = sample_archive(DEFAULT_PAGE_BYTES); + bytes[16..20].copy_from_slice(&ENDIAN_TAG.swap_bytes().to_le_bytes()); + assert!(decode_geometry(&bytes).unwrap_err().contains("endian tag")); + } + + #[test] + fn malformed_atomic_parent_groups_are_rejected() { + let mut child = triangle(0.0, 0); + child.parent = 0; + child.parent_count = 1; + let mut parent = child.clone(); + parent.flags |= FLAG_COARSE_ROOT; + parent.lod_level = 1; + parent.geometric_error = 0.25; + parent.parent = NO_RELATION; + parent.parent_count = 0; + parent.first_child = 1; + parent.child_count = 1; + let mut bytes = encode_geometry( + &[parent, child], + &[], + sha256(b"hierarchy"), + DEFAULT_PAGE_BYTES, + ) + .unwrap(); + + let child_parent_count = HEADER_BYTES + CLUSTER_RECORD_BYTES + 124; + bytes[child_parent_count..child_parent_count + 4].copy_from_slice(&0u32.to_le_bytes()); + assert!(decode_geometry(&bytes) + .unwrap_err() + .contains("parent range exceeds cluster table")); + } + + #[test] + fn pages_may_not_mix_hierarchy_residency_classes() { + let mut child_a = triangle(0.0, 0); + let mut child_b = triangle(2.0, 0); + child_a.parent = 0; + child_a.parent_count = 1; + child_b.parent = 0; + child_b.parent_count = 1; + let mut parent = child_a.clone(); + parent.flags |= FLAG_COARSE_ROOT; + parent.lod_level = 1; + parent.geometric_error = 0.25; + parent.parent = NO_RELATION; + parent.parent_count = 0; + parent.first_child = 1; + parent.child_count = 2; + let mut bytes = encode_geometry( + &[parent, child_a, child_b], + &[], + sha256(b"page-classes"), + DEFAULT_PAGE_BYTES, + ) + .unwrap(); + + let second_child_level = HEADER_BYTES + CLUSTER_RECORD_BYTES * 2 + 28; + bytes[second_child_level..second_child_level + 4].copy_from_slice(&1u32.to_le_bytes()); + assert!(decode_geometry(&bytes) + .unwrap_err() + .contains("mixes hierarchy levels or root residency classes")); + } +} diff --git a/tools/bloom-cook/hierarchy.rs b/tools/bloom-cook/hierarchy.rs new file mode 100644 index 00000000..b5b1799a --- /dev/null +++ b/tools/bloom-cook/hierarchy.rs @@ -0,0 +1,678 @@ +//! Deterministic, crack-safe coarse hierarchy construction. +//! +//! A hierarchy edge replaces one contiguous child group with one contiguous +//! parent group. Simplification locks the topological border of the complete +//! group, so traversal can switch the group atomically without opening cracks. +//! Parents keep the full child-group bounds and accumulated absolute error for +//! conservative culling and future projected-pixel selection. + +use crate::meshlet::{ + build_leaf_meshlets, Meshlet, MeshletBounds, MeshletLimits, StaticPrimitive, StaticVertex, + FLAG_COARSE_ROOT, NO_RELATION, +}; +use meshopt::{SimplifyOptions, VertexDataAdapter}; +use std::cmp::Reverse; +use std::collections::BTreeMap; + +const GROUP_FANOUT: usize = 8; +const ATTRIBUTE_STRIDE_FLOATS: usize = 15; +const ATTRIBUTE_WEIGHTS: [f32; ATTRIBUTE_STRIDE_FLOATS] = [ + 0.5, 0.5, 0.5, // normal + 0.25, 0.25, 0.25, 0.25, // tangent + handedness + 10.0, 10.0, // UV0 + 10.0, 10.0, // UV1 + 1.0, 1.0, 1.0, 1.0, // color +]; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct HierarchyStats { + pub leaf_clusters: u32, + pub leaf_triangles: u64, + pub leaf_payload_bytes: u64, + pub parent_clusters: u32, + pub root_clusters: u32, + pub root_triangles: u64, + pub maximum_level: u32, + pub maximum_error: f32, + pub root_payload_bytes: u64, + pub root_clusters_by_level: [u32; 17], + pub root_payload_bytes_by_level: [u64; 17], +} + +impl HierarchyStats { + pub fn merge(&mut self, other: Self) { + self.leaf_clusters += other.leaf_clusters; + self.leaf_triangles += other.leaf_triangles; + self.leaf_payload_bytes += other.leaf_payload_bytes; + self.parent_clusters += other.parent_clusters; + self.root_clusters += other.root_clusters; + self.root_triangles += other.root_triangles; + self.maximum_level = self.maximum_level.max(other.maximum_level); + self.maximum_error = self.maximum_error.max(other.maximum_error); + self.root_payload_bytes += other.root_payload_bytes; + for level in 0..self.root_clusters_by_level.len() { + self.root_clusters_by_level[level] += other.root_clusters_by_level[level]; + self.root_payload_bytes_by_level[level] += other.root_payload_bytes_by_level[level]; + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct VertexKey([u32; 18]); + +struct CombinedMesh { + vertices: Vec, + indices: Vec, +} + +#[derive(Clone, Copy)] +struct ClusterGroup { + start: usize, + count: usize, +} + +impl ClusterGroup { + fn end(self) -> Result { + self.start + .checked_add(self.count) + .ok_or("cluster group range overflow".to_string()) + } +} + +pub fn build_spatial_leaf_meshlets( + primitive: &StaticPrimitive, + limits: MeshletLimits, +) -> Result, String> { + let limits = limits.validate()?; + let maximum_triangles = (limits.max_triangles / 4) * 4; + if maximum_triangles < 4 { + return build_leaf_meshlets(primitive, limits); + } + let position_bytes = encode_positions(&primitive.vertices); + let adapter = VertexDataAdapter::new(&position_bytes, 12, 0) + .map_err(|error| format!("create meshoptimizer leaf adapter: {error}"))?; + let optimized = meshopt::build_meshlets( + &primitive.indices, + &adapter, + limits.max_vertices as usize, + maximum_triangles as usize, + 0.5, + ); + if optimized.is_empty() { + return Err(format!( + "meshoptimizer produced no leaf clusters for mesh {} primitive {}", + primitive.mesh_index, primitive.primitive_index + )); + } + let mut leaves = Vec::with_capacity(optimized.len()); + for optimized_meshlet in optimized.iter() { + let vertices: Vec<_> = optimized_meshlet + .vertices + .iter() + .map(|index| primitive.vertices[*index as usize]) + .collect(); + let local = StaticPrimitive { + mesh_index: primitive.mesh_index, + primitive_index: primitive.primitive_index, + material_index: primitive.material_index, + double_sided: primitive.double_sided, + alpha_masked: primitive.alpha_masked, + vertices, + indices: optimized_meshlet + .triangles + .iter() + .map(|index| *index as u32) + .collect(), + }; + let mut built = build_leaf_meshlets(&local, limits)?; + if built.len() != 1 { + return Err("optimized leaf exceeded the configured meshlet limits".to_string()); + } + leaves.push(built.remove(0)); + } + Ok(leaves) +} + +pub fn build_meshlet_hierarchy( + leaves: Vec, + limits: MeshletLimits, + maximum_levels: u32, +) -> Result<(Vec, HierarchyStats), String> { + let limits = limits.validate()?; + if leaves.is_empty() { + return Ok((leaves, HierarchyStats::default())); + } + validate_same_primitive(&leaves)?; + + let leaf_count = leaves.len(); + let leaf_triangles = leaves + .iter() + .map(|meshlet| meshlet.triangle_count() as u64) + .sum(); + let leaf_payload_bytes = leaves + .iter() + .map(|meshlet| meshlet.encoded_payload_bytes() as u64) + .sum(); + let mut nodes = leaves; + let mut current: Vec<_> = (0..leaf_count) + .map(|start| ClusterGroup { start, count: 1 }) + .collect(); + let mut roots = Vec::::new(); + let mut maximum_level = 0; + + for level in 1..=maximum_levels { + if current.is_empty() { + break; + } + let mut next = Vec::::new(); + let partitions = current + .chunks(GROUP_FANOUT) + .map(merge_contiguous_groups) + .collect::, _>>()?; + for child_group in partitions { + if child_group.count == 1 { + roots.push(child_group.start); + continue; + } + let child_end = child_group.end()?; + let children = &nodes[child_group.start..child_end]; + let Some(mut parents) = simplify_group(children, limits, level)? else { + roots.extend(child_group.start..child_end); + continue; + }; + if parents.len() >= child_group.count { + roots.extend(child_group.start..child_end); + continue; + } + + let parent_start = nodes.len(); + let parent_count = + u32::try_from(parents.len()).map_err(|_| "parent group count exceeds u32")?; + let child_start = + u32::try_from(child_group.start).map_err(|_| "child group start exceeds u32")?; + let child_count = + u32::try_from(child_group.count).map_err(|_| "child group count exceeds u32")?; + let parent_start_u32 = + u32::try_from(parent_start).map_err(|_| "parent group start exceeds u32")?; + for child in &mut nodes[child_group.start..child_end] { + child.parent = parent_start_u32; + child.parent_count = parent_count; + child.flags &= !FLAG_COARSE_ROOT; + } + for parent in &mut parents { + parent.first_child = child_start; + parent.child_count = child_count; + } + let count = parents.len(); + nodes.extend(parents); + next.push(ClusterGroup { + start: parent_start, + count, + }); + maximum_level = level; + } + current = next; + } + + for group in current { + roots.extend(group.start..group.end()?); + } + roots.sort_unstable(); + roots.dedup(); + for root in &roots { + nodes[*root].flags |= FLAG_COARSE_ROOT; + debug_assert_eq!(nodes[*root].parent, NO_RELATION); + debug_assert_eq!(nodes[*root].parent_count, 0); + } + + let maximum_error = nodes + .iter() + .map(|meshlet| meshlet.geometric_error) + .fold(0.0, f32::max); + let root_payload_bytes = roots + .iter() + .map(|index| nodes[*index].encoded_payload_bytes() as u64) + .sum(); + let root_triangles = roots + .iter() + .map(|index| nodes[*index].triangle_count() as u64) + .sum(); + let mut root_clusters_by_level = [0; 17]; + let mut root_payload_bytes_by_level = [0; 17]; + for root in &roots { + let level = nodes[*root].lod_level as usize; + root_clusters_by_level[level] += 1; + root_payload_bytes_by_level[level] += nodes[*root].encoded_payload_bytes() as u64; + } + let parent_clusters = (nodes.len() - leaf_count) as u32; + Ok(( + nodes, + HierarchyStats { + leaf_clusters: leaf_count as u32, + leaf_triangles, + leaf_payload_bytes, + parent_clusters, + root_clusters: roots.len() as u32, + root_triangles, + maximum_level, + maximum_error, + root_payload_bytes, + root_clusters_by_level, + root_payload_bytes_by_level, + }, + )) +} + +/// Offset local relation indices after appending one primitive hierarchy to a +/// file-global cluster array. +pub fn offset_relations(meshlets: &mut [Meshlet], base: usize) -> Result<(), String> { + let base = u32::try_from(base).map_err(|_| "global cluster base exceeds u32")?; + for meshlet in meshlets { + if meshlet.parent != NO_RELATION { + meshlet.parent = meshlet + .parent + .checked_add(base) + .ok_or("global parent index overflow")?; + } + if meshlet.first_child != NO_RELATION { + meshlet.first_child = meshlet + .first_child + .checked_add(base) + .ok_or("global child index overflow")?; + } + } + Ok(()) +} + +/// Put all coarse roots first, followed by non-root clusters from coarse to +/// fine, while preserving the contiguity of every atomic relation range. +pub fn order_for_streaming(meshlets: &mut Vec) -> Result<(), String> { + if meshlets.len() <= 1 { + return Ok(()); + } + let mut order: Vec<_> = (0..meshlets.len()).collect(); + order.sort_by_key(|index| { + let meshlet = &meshlets[*index]; + ( + u8::from(meshlet.flags & FLAG_COARSE_ROOT == 0), + Reverse(meshlet.lod_level), + meshlet.mesh_index, + meshlet.primitive_index, + meshlet.material_index, + *index, + ) + }); + let mut new_index = vec![0usize; meshlets.len()]; + for (new, old) in order.iter().enumerate() { + new_index[*old] = new; + } + let mut reordered: Vec<_> = order.iter().map(|old| meshlets[*old].clone()).collect(); + for (new, old) in order.iter().enumerate() { + let source = &meshlets[*old]; + if source.parent != NO_RELATION { + reordered[new].parent = + remap_contiguous_range(source.parent, source.parent_count, &new_index, "parent")?; + } + if source.first_child != NO_RELATION { + reordered[new].first_child = remap_contiguous_range( + source.first_child, + source.child_count, + &new_index, + "child", + )?; + } + } + *meshlets = reordered; + Ok(()) +} + +fn remap_contiguous_range( + old_start: u32, + count: u32, + new_index: &[usize], + label: &str, +) -> Result { + let old_start = old_start as usize; + let old_end = old_start + .checked_add(count as usize) + .ok_or_else(|| format!("{label} relation range overflow during streaming order"))?; + if count == 0 || old_end > new_index.len() { + return Err(format!( + "{label} relation range exceeds cluster table during streaming order" + )); + } + let new_start = new_index[old_start]; + for (ordinal, mapped) in new_index[old_start..old_end].iter().enumerate() { + if *mapped != new_start + ordinal { + return Err(format!( + "{label} relation group lost contiguity during streaming order" + )); + } + } + u32::try_from(new_start).map_err(|_| format!("{label} relation start exceeds u32")) +} + +fn merge_contiguous_groups(groups: &[ClusterGroup]) -> Result { + let first = *groups + .first() + .ok_or("cannot merge an empty cluster-group set")?; + let mut end = first.end()?; + for group in &groups[1..] { + if group.start != end { + return Err("hierarchy child groups are not contiguous".to_string()); + } + end = group.end()?; + } + Ok(ClusterGroup { + start: first.start, + count: end - first.start, + }) +} + +fn validate_same_primitive(meshlets: &[Meshlet]) -> Result<(), String> { + let first = &meshlets[0]; + if meshlets.iter().any(|meshlet| { + meshlet.mesh_index != first.mesh_index + || meshlet.primitive_index != first.primitive_index + || meshlet.material_index != first.material_index + || (meshlet.flags & !FLAG_COARSE_ROOT) != (first.flags & !FLAG_COARSE_ROOT) + }) { + return Err( + "a hierarchy may not cross mesh, primitive, material, or flag boundaries".to_string(), + ); + } + Ok(()) +} + +fn simplify_group( + children: &[Meshlet], + limits: MeshletLimits, + level: u32, +) -> Result>, String> { + let combined = combine_children(children); + let target_triangles = (combined.indices.len() / 6).max(1); + let position_bytes = encode_positions(&combined.vertices); + let adapter = VertexDataAdapter::new(&position_bytes, 12, 0) + .map_err(|error| format!("create meshoptimizer position adapter: {error}"))?; + let attributes = encode_attributes(&combined.vertices); + let vertex_locks = vec![false; combined.vertices.len()]; + let mut added_error = 0.0; + let simplified = meshopt::simplify_with_attributes_and_locks( + &combined.indices, + &adapter, + &attributes, + &ATTRIBUTE_WEIGHTS, + ATTRIBUTE_STRIDE_FLOATS * std::mem::size_of::(), + &vertex_locks, + target_triangles * 3, + f32::MAX, + SimplifyOptions::LockBorder | SimplifyOptions::ErrorAbsolute, + Some(&mut added_error), + ); + if simplified.is_empty() + || !simplified.len().is_multiple_of(3) + || simplified.len() >= combined.indices.len() + || !added_error.is_finite() + || added_error < 0.0 + { + return Ok(None); + } + + let first = &children[0]; + let primitive = StaticPrimitive { + mesh_index: first.mesh_index, + primitive_index: first.primitive_index, + material_index: first.material_index, + double_sided: first.flags & crate::meshlet::FLAG_DOUBLE_SIDED != 0, + alpha_masked: first.flags & crate::meshlet::FLAG_ALPHA_MASKED != 0, + vertices: combined.vertices, + indices: simplified, + }; + let mut parents = build_spatial_leaf_meshlets(&primitive, limits)?; + if parents.is_empty() { + return Ok(None); + } + let error = children + .iter() + .map(|child| child.geometric_error) + .fold(0.0, f32::max) + + added_error; + let bounds = hierarchy_bounds(children); + for parent in &mut parents { + parent.lod_level = level; + parent.geometric_error = error; + parent.bounds.aabb_min = bounds.aabb_min; + parent.bounds.aabb_max = bounds.aabb_max; + parent.bounds.sphere_center = bounds.sphere_center; + parent.bounds.sphere_radius = bounds.sphere_radius; + } + Ok(Some(parents)) +} + +fn combine_children(children: &[Meshlet]) -> CombinedMesh { + let mut vertices = Vec::::new(); + let mut indices = Vec::::new(); + let mut remap = BTreeMap::::new(); + for child in children { + for local_index in &child.local_indices { + let vertex = child.vertices[*local_index as usize]; + let key = vertex_key(vertex); + let global_index = match remap.get(&key) { + Some(index) => *index, + None => { + let index = vertices.len() as u32; + vertices.push(vertex); + remap.insert(key, index); + index + } + }; + indices.push(global_index); + } + } + CombinedMesh { vertices, indices } +} + +fn encode_positions(vertices: &[StaticVertex]) -> Vec { + let mut bytes = Vec::with_capacity(vertices.len() * 12); + for vertex in vertices { + for value in vertex.position { + bytes.extend_from_slice(&value.to_le_bytes()); + } + } + bytes +} + +fn encode_attributes(vertices: &[StaticVertex]) -> Vec { + let mut attributes = Vec::with_capacity(vertices.len() * ATTRIBUTE_STRIDE_FLOATS); + for vertex in vertices { + attributes.extend_from_slice(&vertex.normal); + attributes.extend_from_slice(&vertex.tangent); + attributes.extend_from_slice(&vertex.uv0); + attributes.extend_from_slice(&vertex.uv1); + attributes.extend_from_slice(&vertex.color); + } + attributes +} + +fn hierarchy_bounds(children: &[Meshlet]) -> MeshletBounds { + let mut bounds = children[0].bounds; + bounds.aabb_min = [f32::INFINITY; 3]; + bounds.aabb_max = [f32::NEG_INFINITY; 3]; + for child in children { + for axis in 0..3 { + bounds.aabb_min[axis] = bounds.aabb_min[axis].min(child.bounds.aabb_min[axis]); + bounds.aabb_max[axis] = bounds.aabb_max[axis].max(child.bounds.aabb_max[axis]); + } + } + bounds.sphere_center = [ + (bounds.aabb_min[0] + bounds.aabb_max[0]) * 0.5, + (bounds.aabb_min[1] + bounds.aabb_max[1]) * 0.5, + (bounds.aabb_min[2] + bounds.aabb_max[2]) * 0.5, + ]; + bounds.sphere_radius = children + .iter() + .map(|child| { + distance3(bounds.sphere_center, child.bounds.sphere_center) + child.bounds.sphere_radius + }) + .fold(0.0, f32::max); + bounds +} + +fn vertex_key(vertex: StaticVertex) -> VertexKey { + let mut bits = [0u32; 18]; + for (cursor, value) in vertex + .position + .iter() + .chain(vertex.normal.iter()) + .chain(vertex.tangent.iter()) + .chain(vertex.uv0.iter()) + .chain(vertex.uv1.iter()) + .chain(vertex.color.iter()) + .enumerate() + { + bits[cursor] = value.to_bits(); + } + VertexKey(bits) +} + +fn distance3(a: [f32; 3], b: [f32; 3]) -> f32 { + let x = a[0] - b[0]; + let y = a[1] - b[1]; + let z = a[2] - b[2]; + (x * x + y * y + z * z).sqrt() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geometry_format::{decode_geometry, encode_geometry, sha256, DEFAULT_PAGE_BYTES}; + + fn grid_primitive(width: usize, height: usize) -> StaticPrimitive { + let mut vertices = Vec::new(); + for y in 0..=height { + for x in 0..=width { + let xf = x as f32 / width as f32; + let yf = y as f32 / height as f32; + vertices.push(StaticVertex { + position: [xf, yf, (xf * 4.0).sin() * (yf * 3.0).cos() * 0.05], + normal: [0.0, 0.0, 1.0], + tangent: [1.0, 0.0, 0.0, 1.0], + uv0: [xf, yf], + uv1: [xf, yf], + color: [xf, yf, 1.0 - xf, 1.0], + }); + } + } + let stride = width + 1; + let mut indices = Vec::new(); + for y in 0..height { + for x in 0..width { + let a = (y * stride + x) as u32; + let b = a + 1; + let c = a + stride as u32; + let d = c + 1; + indices.extend([a, b, d, a, d, c]); + } + } + StaticPrimitive { + mesh_index: 0, + primitive_index: 0, + material_index: Some(0), + double_sided: false, + alpha_masked: false, + vertices, + indices, + } + } + + #[test] + fn hierarchy_is_deterministic_grouped_and_monotonic() { + let primitive = grid_primitive(32, 32); + let limits = MeshletLimits { + max_vertices: 64, + max_triangles: 64, + }; + let leaves = build_spatial_leaf_meshlets(&primitive, limits).unwrap(); + let leaf_count = leaves.len(); + let (mut a, stats) = build_meshlet_hierarchy(leaves.clone(), limits, 8).unwrap(); + let (mut b, other_stats) = build_meshlet_hierarchy(leaves, limits, 8).unwrap(); + assert_eq!(stats, other_stats); + assert!(stats.parent_clusters > 0); + assert!(stats.root_clusters < leaf_count as u32); + assert!(stats.maximum_level > 0); + + order_for_streaming(&mut a).unwrap(); + order_for_streaming(&mut b).unwrap(); + let encoded_a = encode_geometry(&a, &[], sha256(b"grid"), DEFAULT_PAGE_BYTES).unwrap(); + let encoded_b = encode_geometry(&b, &[], sha256(b"grid"), DEFAULT_PAGE_BYTES).unwrap(); + assert_eq!(encoded_a, encoded_b); + let archive = decode_geometry(&encoded_a).unwrap(); + assert!(archive.coarse_root_page_count() > 0); + let root_cluster_count: usize = archive.pages[..archive.coarse_root_page_count()] + .iter() + .map(|page| page.cluster_count as usize) + .sum(); + assert_eq!(root_cluster_count, stats.root_clusters as usize); + + for (index, node) in a.iter().enumerate() { + if node.parent == NO_RELATION { + assert_eq!(node.parent_count, 0); + assert_ne!(node.flags & FLAG_COARSE_ROOT, 0); + continue; + } + assert!(node.parent_count > 0); + let parents = + &a[node.parent as usize..node.parent as usize + node.parent_count as usize]; + for parent in parents { + assert!(parent.lod_level > node.lod_level); + let children = parent.first_child as usize + ..parent.first_child as usize + parent.child_count as usize; + assert!(children.contains(&index)); + assert!(parent.geometric_error >= node.geometric_error); + for axis in 0..3 { + assert!(parent.bounds.aabb_min[axis] <= node.bounds.aabb_min[axis]); + assert!(parent.bounds.aabb_max[axis] >= node.bounds.aabb_max[axis]); + } + } + } + assert_eq!( + a.iter() + .filter(|node| node.flags & FLAG_COARSE_ROOT != 0) + .count(), + stats.root_clusters as usize + ); + } + + #[test] + fn locked_outer_border_survives_coarse_roots() { + let primitive = grid_primitive(24, 24); + let limits = MeshletLimits { + max_vertices: 64, + max_triangles: 64, + }; + let leaves = build_spatial_leaf_meshlets(&primitive, limits).unwrap(); + let (nodes, _) = build_meshlet_hierarchy(leaves, limits, 8).unwrap(); + let root_positions: Vec<_> = nodes + .iter() + .filter(|node| node.flags & FLAG_COARSE_ROOT != 0) + .flat_map(|node| node.vertices.iter().map(|vertex| vertex.position)) + .collect(); + let boundary_positions: Vec<_> = primitive + .vertices + .iter() + .filter(|vertex| { + vertex.position[0] == 0.0 + || vertex.position[0] == 1.0 + || vertex.position[1] == 0.0 + || vertex.position[1] == 1.0 + }) + .map(|vertex| vertex.position) + .collect(); + for boundary in boundary_positions { + assert!( + root_positions.contains(&boundary), + "coarse hierarchy dropped locked boundary vertex {boundary:?}" + ); + } + } +} diff --git a/tools/bloom-cook/main.rs b/tools/bloom-cook/main.rs index 08b421c3..e3c979e9 100644 --- a/tools/bloom-cook/main.rs +++ b/tools/bloom-cook/main.rs @@ -16,12 +16,19 @@ //! Usage: //! bloom-cook texture [--normal] [--linear] //! bloom-cook texture-dir [--linear] +//! bloom-cook geometry [geometry limits] +//! bloom-cook geometry-inspect //! //! --normal treat as a normal map (linear color, BC7) //! --linear non-color data (masks, LUTs): skip the sRGB transfer use std::path::{Path, PathBuf}; use std::process::ExitCode; +mod geometry_cook; +mod geometry_format; +mod hierarchy; +mod meshlet; + fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); match args.first().map(String::as_str) { @@ -51,9 +58,43 @@ fn main() -> ExitCode { } } } + Some("geometry") if args.len() >= 3 => { + match geometry_cook::cook_geometry_command( + Path::new(&args[1]), + Path::new(&args[2]), + &args[3..], + ) { + Ok(report) => { + println!("{report}"); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("bloom-cook: {error}"); + ExitCode::FAILURE + } + } + } + Some("geometry-inspect") if args.len() == 2 => { + match geometry_cook::inspect_geometry_command(Path::new(&args[1])) { + Ok(report) => { + println!("{report}"); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("bloom-cook: {error}"); + ExitCode::FAILURE + } + } + } _ => { eprintln!("usage: bloom-cook texture [--normal] [--linear]"); eprintln!(" bloom-cook texture-dir [--linear]"); + eprintln!( + " bloom-cook geometry \ + [--max-vertices N] [--max-triangles N] [--page-bytes N] \ + [--hierarchy-levels N]" + ); + eprintln!(" bloom-cook geometry-inspect "); ExitCode::FAILURE } } @@ -61,7 +102,9 @@ fn main() -> ExitCode { fn cook_texture(input: &Path, output: &Path, flags: &[&str]) -> Result { let linear = flags.contains(&"--linear") || flags.contains(&"--normal"); - let src_len = std::fs::metadata(input).map_err(|e| format!("{input:?}: {e}"))?.len(); + let src_len = std::fs::metadata(input) + .map_err(|e| format!("{input:?}: {e}"))? + .len(); let img = image::open(input) .map_err(|e| format!("{input:?}: {e}"))? .to_rgba8(); @@ -88,7 +131,8 @@ fn cook_texture(input: &Path, output: &Path, flags: &[&str]) -> Result Result, + pub double_sided: bool, + pub alpha_masked: bool, + pub vertices: Vec, + pub indices: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MeshletLimits { + pub max_vertices: u32, + pub max_triangles: u32, +} + +impl Default for MeshletLimits { + fn default() -> Self { + Self { + max_vertices: DEFAULT_MAX_VERTICES, + max_triangles: DEFAULT_MAX_TRIANGLES, + } + } +} + +impl MeshletLimits { + pub fn validate(self) -> Result { + if !(3..=MAX_ENCODED_VERTICES).contains(&self.max_vertices) { + return Err(format!( + "meshlet max vertices must be in 3..={MAX_ENCODED_VERTICES}, got {}", + self.max_vertices + )); + } + if self.max_triangles == 0 { + return Err("meshlet max triangles must be greater than zero".to_string()); + } + Ok(self) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MeshletBounds { + pub aabb_min: [f32; 3], + pub aabb_max: [f32; 3], + pub sphere_center: [f32; 3], + pub sphere_radius: f32, + pub normal_cone_axis: [f32; 3], + /// Minimum dot product between the cone axis and a triangle normal. + /// `-1` disables cone rejection conservatively. + pub normal_cone_cutoff: f32, +} + +#[derive(Clone, Debug)] +pub struct Meshlet { + pub mesh_index: u32, + pub primitive_index: u32, + pub material_index: Option, + pub flags: u32, + pub vertices: Vec, + pub local_indices: Vec, + pub bounds: MeshletBounds, + /// Leaf clusters have zero geometric error. Hierarchy construction will + /// assign non-zero parent errors without changing the format. + pub geometric_error: f32, + pub lod_level: u32, + /// First cluster in the atomic parent replacement group. + pub parent: u32, + /// Number of clusters in the atomic parent replacement group. Zero when + /// `parent` is absent. + pub parent_count: u32, + /// First cluster in the atomic child group replaced by this cluster and + /// its siblings. + pub first_child: u32, + pub child_count: u32, +} + +impl Meshlet { + pub fn triangle_count(&self) -> u32 { + (self.local_indices.len() / 3) as u32 + } + + pub fn encoded_payload_bytes(&self) -> usize { + self.vertices.len() * StaticVertex::ENCODED_BYTES as usize + self.local_indices.len() + } +} + +pub fn build_leaf_meshlets( + primitive: &StaticPrimitive, + limits: MeshletLimits, +) -> Result, String> { + let limits = limits.validate()?; + validate_primitive(primitive)?; + + let mut result = Vec::new(); + let mut source_vertices = Vec::::with_capacity(limits.max_vertices as usize); + let mut local_indices = Vec::::with_capacity(limits.max_triangles as usize * 3); + + for triangle in primitive.indices.chunks_exact(3) { + let additional_vertices = triangle + .iter() + .filter(|index| !source_vertices.contains(index)) + .count() as u32; + let triangle_count = local_indices.len() as u32 / 3; + let would_overflow = !local_indices.is_empty() + && (source_vertices.len() as u32 + additional_vertices > limits.max_vertices + || triangle_count + 1 > limits.max_triangles); + if would_overflow { + result.push(finish_meshlet(primitive, &source_vertices, &local_indices)); + source_vertices.clear(); + local_indices.clear(); + } + + for source_index in triangle { + let local_index = match source_vertices + .iter() + .position(|candidate| candidate == source_index) + { + Some(index) => index, + None => { + source_vertices.push(*source_index); + source_vertices.len() - 1 + } + }; + local_indices.push(local_index as u8); + } + } + + if !local_indices.is_empty() { + result.push(finish_meshlet(primitive, &source_vertices, &local_indices)); + } + Ok(result) +} + +fn validate_primitive(primitive: &StaticPrimitive) -> Result<(), String> { + if primitive.vertices.is_empty() { + return Err(format!( + "mesh {} primitive {} has no vertices", + primitive.mesh_index, primitive.primitive_index + )); + } + if primitive.indices.is_empty() || !primitive.indices.len().is_multiple_of(3) { + return Err(format!( + "mesh {} primitive {} index count {} is not a non-empty triangle list", + primitive.mesh_index, + primitive.primitive_index, + primitive.indices.len() + )); + } + for (vertex_index, vertex) in primitive.vertices.iter().enumerate() { + let finite = vertex + .position + .iter() + .chain(vertex.normal.iter()) + .chain(vertex.tangent.iter()) + .chain(vertex.uv0.iter()) + .chain(vertex.uv1.iter()) + .chain(vertex.color.iter()) + .all(|component| component.is_finite()); + if !finite { + return Err(format!( + "mesh {} primitive {} vertex {vertex_index} contains NaN/Inf", + primitive.mesh_index, primitive.primitive_index + )); + } + } + if let Some(index) = primitive + .indices + .iter() + .find(|index| **index as usize >= primitive.vertices.len()) + { + return Err(format!( + "mesh {} primitive {} index {index} exceeds {} vertices", + primitive.mesh_index, + primitive.primitive_index, + primitive.vertices.len() + )); + } + Ok(()) +} + +fn finish_meshlet( + primitive: &StaticPrimitive, + source_indices: &[u32], + local_indices: &[u8], +) -> Meshlet { + let vertices: Vec<_> = source_indices + .iter() + .map(|index| primitive.vertices[*index as usize]) + .collect(); + let flags = (u32::from(primitive.double_sided) * FLAG_DOUBLE_SIDED) + | (u32::from(primitive.alpha_masked) * FLAG_ALPHA_MASKED); + let bounds = calculate_bounds(&vertices, local_indices, primitive.double_sided); + Meshlet { + mesh_index: primitive.mesh_index, + primitive_index: primitive.primitive_index, + material_index: primitive.material_index, + flags, + vertices, + local_indices: local_indices.to_vec(), + bounds, + geometric_error: 0.0, + lod_level: 0, + parent: NO_RELATION, + parent_count: 0, + first_child: NO_RELATION, + child_count: 0, + } +} + +fn calculate_bounds( + vertices: &[StaticVertex], + local_indices: &[u8], + double_sided: bool, +) -> MeshletBounds { + let mut aabb_min = [f32::INFINITY; 3]; + let mut aabb_max = [f32::NEG_INFINITY; 3]; + for vertex in vertices { + for axis in 0..3 { + aabb_min[axis] = aabb_min[axis].min(vertex.position[axis]); + aabb_max[axis] = aabb_max[axis].max(vertex.position[axis]); + } + } + let sphere_center = [ + (aabb_min[0] + aabb_max[0]) * 0.5, + (aabb_min[1] + aabb_max[1]) * 0.5, + (aabb_min[2] + aabb_max[2]) * 0.5, + ]; + let sphere_radius = vertices + .iter() + .map(|vertex| length3(sub3(vertex.position, sphere_center))) + .fold(0.0, f32::max); + + let mut face_normals = Vec::with_capacity(local_indices.len() / 3); + let mut normal_sum = [0.0; 3]; + for triangle in local_indices.chunks_exact(3) { + let a = vertices[triangle[0] as usize].position; + let b = vertices[triangle[1] as usize].position; + let c = vertices[triangle[2] as usize].position; + let cross = cross3(sub3(b, a), sub3(c, a)); + let length = length3(cross); + if length > 1e-20 { + let normal = mul3(cross, length.recip()); + face_normals.push(normal); + normal_sum = add3(normal_sum, normal); + } + } + let sum_length = length3(normal_sum); + let (normal_cone_axis, normal_cone_cutoff) = + if double_sided || face_normals.is_empty() || sum_length <= 1e-6 { + ([0.0, 0.0, 1.0], -1.0) + } else { + let axis = mul3(normal_sum, sum_length.recip()); + let cutoff = face_normals + .iter() + .map(|normal| dot3(axis, *normal)) + .fold(1.0, f32::min) + .clamp(-1.0, 1.0); + (axis, cutoff) + }; + + MeshletBounds { + aabb_min, + aabb_max, + sphere_center, + sphere_radius, + normal_cone_axis, + normal_cone_cutoff, + } +} + +fn add3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} + +fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +fn mul3(value: [f32; 3], factor: f32) -> [f32; 3] { + [value[0] * factor, value[1] * factor, value[2] * factor] +} + +fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +fn length3(value: [f32; 3]) -> f32 { + dot3(value, value).sqrt() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vertex(x: f32, y: f32, z: f32) -> StaticVertex { + StaticVertex { + position: [x, y, z], + normal: [0.0, 0.0, 1.0], + tangent: [1.0, 0.0, 0.0, 1.0], + uv0: [x, y], + uv1: [x, y], + color: [1.0; 4], + } + } + + fn primitive(vertices: Vec, indices: Vec) -> StaticPrimitive { + StaticPrimitive { + mesh_index: 2, + primitive_index: 3, + material_index: Some(7), + double_sided: false, + alpha_masked: true, + vertices, + indices, + } + } + + #[test] + fn partition_preserves_triangle_order_and_limits() { + let mut vertices = Vec::new(); + let mut indices = Vec::new(); + for triangle in 0..11 { + let base = vertices.len() as u32; + vertices.push(vertex(triangle as f32, 0.0, 0.0)); + vertices.push(vertex(triangle as f32, 1.0, 0.0)); + vertices.push(vertex(triangle as f32, 0.0, 1.0)); + indices.extend([base, base + 1, base + 2]); + } + let source = primitive(vertices.clone(), indices); + let meshlets = build_leaf_meshlets( + &source, + MeshletLimits { + max_vertices: 9, + max_triangles: 4, + }, + ) + .unwrap(); + assert_eq!(meshlets.len(), 4); + assert!(meshlets + .iter() + .all(|meshlet| meshlet.vertices.len() <= 9 && meshlet.triangle_count() <= 4)); + + let reconstructed: Vec<_> = meshlets + .iter() + .flat_map(|meshlet| { + meshlet + .local_indices + .iter() + .map(|index| meshlet.vertices[*index as usize].position) + }) + .collect(); + let expected: Vec<_> = source + .indices + .iter() + .map(|index| vertices[*index as usize].position) + .collect(); + assert_eq!(reconstructed, expected); + } + + #[test] + fn bounds_and_normal_cone_are_conservative() { + let source = primitive( + vec![ + vertex(-1.0, -2.0, 0.0), + vertex(3.0, -2.0, 0.0), + vertex(-1.0, 2.0, 0.0), + ], + vec![0, 1, 2], + ); + let meshlet = build_leaf_meshlets(&source, MeshletLimits::default()).unwrap()[0].clone(); + assert_eq!(meshlet.bounds.aabb_min, [-1.0, -2.0, 0.0]); + assert_eq!(meshlet.bounds.aabb_max, [3.0, 2.0, 0.0]); + assert_eq!(meshlet.bounds.sphere_center, [1.0, 0.0, 0.0]); + assert!((meshlet.bounds.sphere_radius - 2.0_f32.sqrt() * 2.0).abs() < 1e-6); + assert_eq!(meshlet.bounds.normal_cone_axis, [0.0, 0.0, 1.0]); + assert_eq!(meshlet.bounds.normal_cone_cutoff, 1.0); + assert_eq!(meshlet.flags, FLAG_ALPHA_MASKED); + } + + #[test] + fn double_sided_meshlets_disable_cone_rejection() { + let mut source = primitive( + vec![ + vertex(0.0, 0.0, 0.0), + vertex(1.0, 0.0, 0.0), + vertex(0.0, 1.0, 0.0), + ], + vec![0, 1, 2], + ); + source.double_sided = true; + let meshlet = &build_leaf_meshlets(&source, MeshletLimits::default()).unwrap()[0]; + assert_eq!(meshlet.bounds.normal_cone_cutoff, -1.0); + assert_ne!(meshlet.flags & FLAG_DOUBLE_SIDED, 0); + } + + #[test] + fn invalid_source_is_rejected_before_indexing() { + let mut source = primitive(vec![vertex(0.0, 0.0, 0.0); 3], vec![0, 1, 4]); + assert!(build_leaf_meshlets(&source, MeshletLimits::default()) + .unwrap_err() + .contains("exceeds")); + source.indices = vec![0, 1]; + assert!(build_leaf_meshlets(&source, MeshletLimits::default()) + .unwrap_err() + .contains("triangle list")); + source.indices = vec![0, 1, 2]; + source.vertices[1].position[0] = f32::NAN; + assert!(build_leaf_meshlets(&source, MeshletLimits::default()) + .unwrap_err() + .contains("NaN/Inf")); + } +} diff --git a/tools/bloom-diff/src/main.rs b/tools/bloom-diff/src/main.rs index 4de674c7..ab0efbe1 100644 --- a/tools/bloom-diff/src/main.rs +++ b/tools/bloom-diff/src/main.rs @@ -51,6 +51,18 @@ struct DiffStats { /// identical; 0.0 is "nothing in common". Tends to correlate well /// with perceptual similarity, unlike raw RMSE. ssim: f32, + /// Mean Euclidean colour distance in OKLab. OKLab is approximately + /// perceptually uniform, so this catches chroma/hue errors that a + /// luminance-only metric can miss. It is intentionally named rather + /// than presented as FLIP: FLIP includes a display model that we cannot + /// infer from an arbitrary PNG. + mean_oklab_delta: f32, + /// Mean absolute difference between Sobel edge magnitudes. This gives + /// small, displaced shadow and geometry edges a targeted signal even + /// when they occupy too little of the frame to move global SSIM. + mean_edge_delta: f32, + /// Number of pixels selected by the optional mask. + selected_pixels: u64, width: u32, height: u32, } @@ -59,6 +71,38 @@ fn luminance(rgb: [f32; 3]) -> f32 { 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] } +fn srgb_to_linear(v: f32) -> f32 { + if v <= 0.04045 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } +} + +/// Convert an sRGB triple to OKLab using Björn Ottosson's reference +/// matrices. PNG bytes are display-referred sRGB, so conversion to linear +/// light is part of the transform. +fn srgb_to_oklab(rgb: [f32; 3]) -> [f32; 3] { + let r = srgb_to_linear(rgb[0]); + let g = srgb_to_linear(rgb[1]); + let b = srgb_to_linear(rgb[2]); + let l = 0.412_221_46 * r + 0.536_332_55 * g + 0.051_445_995 * b; + let m = 0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b; + let s = 0.088_302_46 * r + 0.281_718_85 * g + 0.629_978_7 * b; + let l = l.cbrt(); + let m = m.cbrt(); + let s = s.cbrt(); + [ + 0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s, + 1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s, + 0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s, + ] +} + +fn mask_includes(mask: Option<&[u8]>, index: usize) -> bool { + mask.map(|m| m[index] != 0).unwrap_or(true) +} + /// Load an RGB image as normalized linear f32 triples. We operate in /// gamma-encoded (sRGB byte) space rather than re-linearizing — the /// reference renderer already wrote sRGB-encoded output, so a byte- @@ -85,14 +129,20 @@ fn compute_stats( width: u32, height: u32, tolerance: f32, + mask: Option<&[u8]>, ) -> DiffStats { - let n = reference.len() as f32; let mut sum_sq = [0f64; 3]; let mut sum_sq_lum = 0f64; + let mut sum_oklab_delta = 0f64; let mut max_abs = 0f32; let mut n_above = 0u64; + let mut selected = 0u64; - for (r, c) in reference.iter().zip(candidate.iter()) { + for (i, (r, c)) in reference.iter().zip(candidate.iter()).enumerate() { + if !mask_includes(mask, i) { + continue; + } + selected += 1; let dr = r[0] - c[0]; let dg = r[1] - c[1]; let db = r[2] - c[2]; @@ -112,14 +162,23 @@ fn compute_stats( if mag > tolerance { n_above += 1; } + let lab_r = srgb_to_oklab(*r); + let lab_c = srgb_to_oklab(*c); + let dl = lab_r[0] - lab_c[0]; + let da = lab_r[1] - lab_c[1]; + let db = lab_r[2] - lab_c[2]; + sum_oklab_delta += ((dl * dl + da * da + db * db).sqrt()) as f64; } + let n = selected.max(1) as f64; + let rmse_r = (sum_sq[0] / n as f64).sqrt() as f32; let rmse_g = (sum_sq[1] / n as f64).sqrt() as f32; let rmse_b = (sum_sq[2] / n as f64).sqrt() as f32; let rmse_luminance = (sum_sq_lum / n as f64).sqrt() as f32; - let ssim = compute_ssim_luminance(reference, candidate, width, height); + let ssim = compute_ssim_luminance(reference, candidate, width, height, mask); + let mean_edge_delta = compute_edge_delta(reference, candidate, width, height, mask); DiffStats { rmse_r, @@ -127,13 +186,61 @@ fn compute_stats( rmse_b, rmse_luminance, max_abs_error: max_abs, - percent_above_tolerance: 100.0 * (n_above as f32) / n, + percent_above_tolerance: 100.0 * (n_above as f32) / n as f32, ssim, + mean_oklab_delta: (sum_oklab_delta / n) as f32, + mean_edge_delta, + selected_pixels: selected, width, height, } } +fn sobel_luminance(pixels: &[[f32; 3]], width: usize, x: usize, y: usize) -> f32 { + let lum = |xx: usize, yy: usize| luminance(pixels[yy * width + xx]); + let gx = -lum(x - 1, y - 1) + lum(x + 1, y - 1) - 2.0 * lum(x - 1, y) + 2.0 * lum(x + 1, y) + - lum(x - 1, y + 1) + + lum(x + 1, y + 1); + let gy = -lum(x - 1, y - 1) - 2.0 * lum(x, y - 1) - lum(x + 1, y - 1) + + lum(x - 1, y + 1) + + 2.0 * lum(x, y + 1) + + lum(x + 1, y + 1); + (gx * gx + gy * gy).sqrt() * 0.25 +} + +fn compute_edge_delta( + reference: &[[f32; 3]], + candidate: &[[f32; 3]], + width: u32, + height: u32, + mask: Option<&[u8]>, +) -> f32 { + let width = width as usize; + let height = height as usize; + if width < 3 || height < 3 { + return 0.0; + } + let mut total = 0.0f64; + let mut count = 0u64; + for y in 1..height - 1 { + for x in 1..width - 1 { + let i = y * width + x; + if !mask_includes(mask, i) { + continue; + } + total += (sobel_luminance(reference, width, x, y) + - sobel_luminance(candidate, width, x, y)) + .abs() as f64; + count += 1; + } + } + if count == 0 { + 0.0 + } else { + (total / count as f64) as f32 + } +} + /// SSIM over the luminance channel with a single-scale 8×8 window. /// Not as good as MS-SSIM but fast and plenty accurate for our /// "did this PR move the image closer to truth" check. @@ -142,6 +249,7 @@ fn compute_ssim_luminance( candidate: &[[f32; 3]], width: u32, height: u32, + mask: Option<&[u8]>, ) -> f32 { const WINDOW: usize = 8; // SSIM's stability constants (from the original Wang et al. paper, @@ -169,8 +277,16 @@ fn compute_ssim_luminance( while y + WINDOW <= h { let mut x = 0usize; while x + WINDOW <= w { + let selected = (y..y + WINDOW) + .flat_map(|yy| (x..x + WINDOW).map(move |xx| yy * w + xx)) + .filter(|&i| mask_includes(mask, i)) + .count(); + if selected == 0 { + x += WINDOW; + continue; + } let (mean_r, mean_c, var_r, var_c, cov) = - window_luminance_stats(reference, candidate, w, x, y, WINDOW); + window_luminance_stats(reference, candidate, w, x, y, WINDOW, mask); let num = (2.0 * mean_r * mean_c + c1) * (2.0 * cov + c2); let den = (mean_r * mean_r + mean_c * mean_c + c1) * (var_r + var_c + c2); sum += (num / den) as f64; @@ -196,17 +312,23 @@ fn window_luminance_stats( x0: usize, y0: usize, size: usize, + mask: Option<&[u8]>, ) -> (f32, f32, f32, f32, f32) { let mut sum_r = 0f32; let mut sum_c = 0f32; + let mut selected = 0usize; for yy in 0..size { for xx in 0..size { let i = (y0 + yy) * width + (x0 + xx); + if !mask_includes(mask, i) { + continue; + } sum_r += luminance(reference[i]); sum_c += luminance(candidate[i]); + selected += 1; } } - let n = (size * size) as f32; + let n = selected.max(1) as f32; let mean_r = sum_r / n; let mean_c = sum_c / n; @@ -216,6 +338,9 @@ fn window_luminance_stats( for yy in 0..size { for xx in 0..size { let i = (y0 + yy) * width + (x0 + xx); + if !mask_includes(mask, i) { + continue; + } let dr = luminance(reference[i]) - mean_r; let dc = luminance(candidate[i]) - mean_c; var_r += dr * dr; @@ -245,7 +370,7 @@ fn heatmap_color(magnitude: f32) -> Rgb { Rgb([ (r * 255.0) as u8, (g * 255.0) as u8, - (b * 255.0) as u8, + (b * 255.0) as u8 ]) } @@ -293,6 +418,182 @@ fn write_composite( out.save(path).map_err(|e| format!("save {:?}: {e}", path)) } +/// Test-only negative controls for the quality harness. These are deliberately +/// simple, deterministic image-space stand-ins for common renderer failures; +/// they prove that the configured metric mix and thresholds reject the class +/// of error without adding fault branches to production shaders. + +fn apply_seeded_fault(image: &RgbImage, fault: &str) -> Result { + let (width, height) = image.dimensions(); + let mut out = image.clone(); + match fault { + "brdf-energy" => { + for pixel in out.pixels_mut() { + for channel in & mut pixel.0 { + *channel = ((*channel as f32 * 1.18).round() as u16).min(255) as u8; + } + } + } + "shadow-placement" => { + let shift = (width / 32).clamp(2, 16); + for y in 0..height { + for x in 0..width { + let sx = x.saturating_sub(shift); + out.put_pixel(x,y, *image.get_pixel(sx, y)); + } + } + } + "gi-leakage" => { + for pixel in out.pixels_mut() { + let luma = + 0.2126 * pixel[0] as f32 + 0.7152 * pixel[1] as f32 + 0.0722 * pixel[2] as f32; + if luma < 128.0 { + pixel[0] = pixel[0].saturating_add(24); + pixel[1] = pixel[1].saturating_add(38); + pixel[2] = pixel[2].saturating_add(20); + } + } + } + "motion-history" => { + let shift = ( width / 48).clamp(2, 12); + for y in 0..height { + for x in 0..width { + let history = image.get_pixel(x.saturating_sub(shift), y); + let current = image.get_pixel(x, y); + out.put_pixel( + x, + y, + Rgb([ + ((current[0] as u16 * 3 + history[0] as u16) / 4) as u8, + ((current[1] as u16 * 3 + history[1] as u16) / 4) as u8, + ((current[2] as u16 * 3 + history[2] as u16) / 4) as u8, + ]), + ); + } + } + } + "texture-orientation" => { + for y in 0..height { + for x in 0..width { + out.put_pixel(x, y, *image.get_pixel(x, height - 1 - y)); + } + } + } + other => { + return Err(format!( + "unknown seeded fault {other:?}; expected brdf-energy, shadow-placement, \ + gi-leakage, motion-history, or texture-orientation" + )); + } + } + Ok(out) +} + +fn json_escape(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 8); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out +} + +fn write_metrics_json( + path: &Path, + args: &Args, + stats: DiffStats, + passed: bool, + failures: &[String], +) -> Result<(), String> { + let optional = |value: Option| { + value + .map(|v| format!("{v:.9}")) + .unwrap_or_else(|| "null".to_owned()) + }; + let failure_json = failures + .iter() + .map(|s| format!("\"{}\"", json_escape(s))) + .collect::>() + .join(", "); + let mask_json = args + .mask_path + .as_ref() + .map(|s| format!("\"{}\"", json_escape(s))) + .unwrap_or_else(|| "null".to_owned()); + let fault_json = args + .seed_fault + .as_ref() + .map(|s| format!("\"{}\"", json_escape(s))) + .unwrap_or_else(|| "null".to_owned()); + let json = format!( + concat!( + "{{\n", + " \"schema\": \"bloom-diff-result-v2\",\n", + " \"reference\": \"{}\",\n", + " \"candidate\": \"{}\",\n", + " \"mask\": {},\n", + " \"seeded_fault\": {},\n", + " \"width\": {},\n", + " \"height\": {},\n", + " \"selected_pixels\": {},\n", + " \"metrics\": {{\n", + " \"rmse_luminance\": {:.9},\n", + " \"rmse_r\": {:.9},\n", + " \"rmse_g\": {:.9},\n", + " \"rmse_b\": {:.9},\n", + " \"max_abs_error\": {:.9},\n", + " \"percent_above_tolerance\": {:.9},\n", + " \"ssim_luminance\": {:.9},\n", + " \"mean_oklab_delta\": {:.9},\n", + " \"mean_edge_delta\": {:.9}\n", + " }},\n", + " \"thresholds\": {{\n", + " \"pixel_tolerance\": {:.9},\n", + " \"min_ssim\": {},\n", + " \"max_rmse\": {},\n", + " \"max_oklab_delta\": {},\n", + " \"max_edge_delta\": {}\n", + " }},\n", + " \"report_only\": {},\n", + " \"passed\": {},\n", + " \"failures\": [{}]\n", + "}}\n" + ), + json_escape(&args.reference_path), + json_escape(&args.candidate_path), + mask_json, + fault_json, + stats.width, + stats.height, + stats.selected_pixels, + stats.rmse_luminance, + stats.rmse_r, + stats.rmse_g, + stats.rmse_b, + stats.max_abs_error, + stats.percent_above_tolerance, + stats.ssim, + stats.mean_oklab_delta, + stats.mean_edge_delta, + args.tolerance, + optional(args.min_ssim), + optional(args.max_rmse), + optional(args.max_oklab_delta), + optional(args.max_edge_delta), + args.report_only, + passed, + failure_json, + ); + std::fs::write(path, json).map_err(|e| format!("write {:?}: {e}", path)) +} + // ============================================================ // CLI // ============================================================ @@ -300,18 +601,36 @@ fn write_composite( struct Args { reference_path: String, candidate_path: String, + mask_path: Option, heatmap_path: Option, composite_path: Option, + metrics_json_path: Option, tolerance: f32, + min_ssim: Option, + max_rmse: Option, + max_oklab_delta: Option, + max_edge_delta: Option, + seed_fault: Option, + fault_output_path: Option, + report_only: bool, quiet: bool, } fn parse_args() -> Result { let mut reference_path: Option = None; let mut candidate_path: Option = None; + let mut mask_path: Option = None; let mut heatmap_path: Option = None; let mut composite_path: Option = None; + let mut metrics_json_path: Option = None; let mut tolerance: f32 = 0.02; + let mut min_ssim: Option = None; + let mut max_rmse: Option = None; + let mut max_oklab_delta: Option = None; + let mut max_edge_delta: Option = None; + let mut seed_fault: Option = None; + let mut fault_output_path: Option = None; + let mut report_only = false; let mut quiet = false; let mut iter = env::args().skip(1); @@ -319,8 +638,10 @@ fn parse_args() -> Result { match arg.as_str() { "--reference" | "-r" => reference_path = iter.next(), "--candidate" | "-c" => candidate_path = iter.next(), + "--mask" => mask_path = iter.next(), "--heatmap" => heatmap_path = iter.next(), "--composite" => composite_path = iter.next(), + "--metrics-json" => metrics_json_path = iter.next(), "--tolerance" => { tolerance = iter .next() @@ -328,19 +649,64 @@ fn parse_args() -> Result { .parse() .map_err(|e| format!("invalid --tolerance: {e}"))?; } + "--min-ssim" => { + min_ssim = Some( + iter.next() + .ok_or("--min-ssim needs a value")? + .parse() + .map_err(|e| format!("invalid --min-ssim: {e}"))?, + ); + } + "--max-rmse" => { + max_rmse = Some( + iter.next() + .ok_or("--max-rmse needs a value")? + .parse() + .map_err(|e| format!("invalid --max-rmse: {e}"))?, + ); + } + "--max-oklab-delta" => { + max_oklab_delta = Some( + iter.next() + .ok_or("--max-oklab-delta needs a value")? + .parse() + .map_err(|e| format!("invalid --max-oklab-delta: {e}"))?, + ); + } + "--max-edge-delta" => { + max_edge_delta = Some( + iter.next() + .ok_or("--max-edge-delta needs a value")? + .parse() + .map_err(|e| format!("invalid --max-edge-delta: {e}"))?, + ); + } + "--seed-fault" => seed_fault = iter.next(), + "--fault-output" => fault_output_path = iter.next(), + "--report-only" => report_only = true, "--quiet" | "-q" => quiet = true, "-h" | "--help" => { println!("bloom-diff — compare two PNG images"); println!(); println!(" --reference PATH ground-truth image (from bloom-reference)"); println!(" --candidate PATH image to compare (e.g. realtime screenshot)"); + println!(" --mask PATH optional non-black inclusion mask"); println!(" --heatmap PATH write per-pixel false-color diff"); println!(" --composite PATH write 3-up side-by-side (ref|cand|heat)"); + println!(" --metrics-json P write stable machine-readable metrics"); println!(" --tolerance F per-pixel diff threshold for 'differs' %"); println!(" (default 0.02 = 2/255 on any channel)"); + println!(" --min-ssim F explicit SSIM hard gate"); + println!(" --max-rmse F explicit luminance-RMSE hard gate"); + println!(" --max-oklab-delta F perceptual colour hard gate"); + println!(" --max-edge-delta F Sobel edge hard gate"); + println!(" --seed-fault NAME test-only brdf-energy|shadow-placement|"); + println!(" gi-leakage|motion-history|texture-orientation"); + println!(" --fault-output P write the intentionally corrupted candidate"); + println!(" --report-only produce evidence but always exit zero"); println!(" --quiet suppress stdout output"); println!(); - println!("Exit code: 0 if max(RMSE_luminance, (1 - SSIM)) ≤ tolerance,"); + println!("Exit code:0 if max(RMSE_luminance, (1 - SSIM)) ≤ tolerance,"); println!(" 1 otherwise. Intended for use in CI."); std::process::exit(0); } @@ -350,10 +716,19 @@ fn parse_args() -> Result { Ok(Args { reference_path: reference_path.ok_or("--reference is required")?, - candidate_path: candidate_path.ok_or("--candidate is required")?, + candidate_path: candidate_path.ok_or("- - candidate is required")?, + mask_path, heatmap_path, composite_path, + metrics_json_path, tolerance, + min_ssim, + max_rmse, + max_oklab_delta, + max_edge_delta, + seed_fault, + fault_output_path, + report_only, quiet, }) } @@ -374,14 +749,6 @@ fn main() -> ExitCode { return ExitCode::from(1); } }; - let candidate_pixels = match load_rgb_normalized(Path::new(&args.candidate_path)) { - Ok(p) => p, - Err(e) => { - eprintln!("error loading candidate: {e}"); - return ExitCode::from(1); - } - }; - // We need the dimensions explicitly for SSIM windowing + heatmap // output — load the raw images once more (cheap; we already have // them in memory via the decoder). @@ -392,13 +759,44 @@ fn main() -> ExitCode { return ExitCode::from(1); } }; - let candidate_img = match image::open(&args.candidate_path) { + let mut candidate_img = match image::open(&args.candidate_path) { Ok(img) => img.to_rgb8(), Err(e) => { eprintln!("error re-opening candidate: {e}"); return ExitCode::from(1); } }; + if let Some(fault) = &args.seed_fault { + candidate_img = match apply_seeded_fault(&candidate_img, fault) { + Ok(image) => image, + Err(e) => { + eprintln!("error applying seeded fault: {e}"); + return ExitCode::from(2); + } + }; + if let Some(path) = &args.fault_output_path { + if let Some(parent) = Path::new(path).parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + eprintln!("error creating fault-output directory {:?}: {e}", parent); + return ExitCode::from(1); + } + } + if let Err(e) = candidate_img.save(path) { + eprintln!("error writing fault output {path}: {e}"); + return ExitCode::from(1); + } + } + } + let candidate_pixels: Vec<[f32; 3]> = candidate_img + .pixels() + .map(|p| { + [ + p[0] as f32 / 255.0, + p[1] as f32 / 255.0, + p[2] as f32 / 255.0, + ] + }) + .collect(); if reference_img.dimensions() != candidate_img.dimensions() { eprintln!( @@ -416,13 +814,40 @@ fn main() -> ExitCode { } let (width, height) = reference_img.dimensions(); + let mask = if let Some(path) = &args.mask_path { + let image = match image::open(path) { + Ok(image) => image.to_luma8(), + Err(e) => { + eprintln!("error loading mask {path}: {e}"); + return ExitCode::from(1); + } + }; + if image.dimensions() != (width, height) { + eprintln!( + "error: mask dimensions mismatch — expected {}x{}, got {}x{}", + width, + height, + image.width(), + image.height() + ); + return ExitCode::from(1); + } + Some(image.into_raw()) + } else { + None + }; let stats = compute_stats( &reference_pixels, &candidate_pixels, width, height, args.tolerance, + mask.as_deref(), ); + if stats.selected_pixels == 0 { + eprintln!("error: mask selects zero pixels"); + return ExitCode::from(1); + } if !args.quiet { println!("reference: {} ({}×{})", args.reference_path, width, height); @@ -436,7 +861,7 @@ fn main() -> ExitCode { "RMSE (R/G/B): {:.5} / {:.5} / {:.5}", stats.rmse_r, stats.rmse_g, stats.rmse_b ); - println!("max abs error: {:.5}", stats.max_abs_error); + println!("maxabs error: {:.5}", stats.max_abs_error); println!( "% above tolerance: {:.2}% (tolerance = {})", stats.percent_above_tolerance, args.tolerance @@ -445,6 +870,11 @@ fn main() -> ExitCode { "SSIM (luminance): {:.5} (1 = identical, 0 = nothing in common)", stats.ssim ); + println!("OKLab mean delta: {:.5}", stats.mean_oklab_delta); + println!("edge mean delta: {:.5}", stats.mean_edge_delta); + if args.mask_path.is_some() { + println!("selected pixels: {}", stats.selected_pixels); + } } if let Some(path) = &args.heatmap_path { @@ -469,8 +899,12 @@ fn main() -> ExitCode { if let Some(path) = &args.composite_path { let heatmap_buf = make_heatmap_buffer(&reference_pixels, &candidate_pixels, width, height); - if let Err(e) = write_composite(&reference_img, &candidate_img, &heatmap_buf, Path::new(path)) - { + if let Err(e) = write_composite( + &reference_img, + &candidate_img, + &heatmap_buf, + Path::new(path), + ) { eprintln!("error writing composite: {e}"); return ExitCode::from(1); } @@ -479,23 +913,78 @@ fn main() -> ExitCode { } } - // Pass/fail policy: fail if luminance RMSE OR the complement of - // SSIM exceeds tolerance. Combining both catches cases where one - // metric is fooled (e.g. uniform offset passes RMSE but fails - // SSIM; speckle noise the other way around). - let fail_threshold = args.tolerance; - let ssim_deficit = (1.0 - stats.ssim).max(0.0); - let fail = stats.rmse_luminance > fail_threshold || ssim_deficit > fail_threshold; + let has_explicit_thresholds = args.min_ssim.is_some() + || args.max_rmse.is_some() + || args.max_oklab_delta.is_some() + || args.max_edge_delta.is_some(); + let mut failures = Vec::new(); + if let Some(limit) = args.min_ssim { + if stats.ssim < limit { + failures.push(format!("ssim {:.9} < {:.9}", stats.ssim, limit)); + } + } + if let Some(limit) = args.max_rmse { + if stats.rmse_luminance > limit { + failures.push(format!( + "rmse_luminance {:.9} > {:.9}", + stats.rmse_luminance, limit + )); + } + } + if let Some(limit) = args.max_oklab_delta { + if stats.mean_oklab_delta > limit { + failures.push(format!( + "mean_oklab_delta {:.9} > {:.9}", + stats.mean_oklab_delta, limit + )); + } + } + if let Some(limit) = args.max_edge_delta { + if stats.mean_edge_delta > limit { + failures.push(format!( + "mean_edge_delta {:.9} > {:.9}", + stats.mean_edge_delta, limit + )); + } + } + if !has_explicit_thresholds { + // Backwards-compatible legacy policy. + let ssim_deficit = (1.0 - stats.ssim).max(0.0); + if stats.rmse_luminance > args.tolerance || ssim_deficit > args.tolerance { + failures.push(format!( + "legacy combined error {:.9} > {:.9}", + stats.rmse_luminance.max(ssim_deficit), + args.tolerance + )); + } + } + let passed = failures.is_empty(); + if let Some(path) = &args.metrics_json_path { + if let Some(parent) = Path::new(path).parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + eprintln!("error creating metrics directory {:?}: {e}", parent); + return ExitCode::from(1); + } + } + if let Err(e) = write_metrics_json(Path::new(path), &args, stats, passed, &failures) { + eprintln!("error writing metrics JSON: {e}"); + return ExitCode::from(1); + } + if !args.quiet { + println!("wrote metrics: {path}"); + } + } + let fail = !passed; if fail { if !args.quiet { println!(); - println!( - "FAIL: exceeds tolerance ({} > {})", - stats.rmse_luminance.max(ssim_deficit), - fail_threshold - ); + println!("FAIL: {}", failures.join("; ")); + } + if args.report_only { + ExitCode::SUCCESS + } else { + ExitCode::from(1) } - ExitCode::from(1) } else { if !args.quiet { println!(); @@ -526,3 +1015,92 @@ fn make_heatmap_buffer( } img } + +#[cfg(test)] +mod tests { + use super::*; + + fn checkerboard(width: u32, height: u32) -> RgbImage { + ImageBuffer::from_fn(width, height, |x, y| { + if ((x / 4) + (y / 4)) % 2 == 0 { + Rgb([32, 64, 96]) + } else { + Rgb([224, 192, 128]) + } + }) + } + + fn normalized(image: &RgbImage) -> Vec<[f32; 3]> { + image + .pixels() + .map(|p| { + [ + p[0] as f32 / 255.0, + p[1] as f32 / 255.0, + p[2] as f32 / 255.0, + ] + }) + .collect() + } + + #[test] + fn identical_images_are_exact_under_every_metric() { + let image = checkerboard(32, 32); + let pixels = normalized(&image); + let stats = compute_stats(&pixels, &pixels, 32, 32, 0.02, None); + assert_eq!(stats.rmse_luminance, 0.0); + assert_eq!(stats.mean_oklab_delta, 0.0); + assert_eq!(stats.mean_edge_delta, 0.0); + assert_eq!(stats.percent_above_tolerance, 0.0); + assert!((stats.ssim - 1.0).abs() < 1e-6); + } + + #[test] + fn mask_excludes_faults_outside_the_target_region() { + let reference = checkerboard(16, 16); + let mut candidate = reference.clone(); + candidate.put_pixel(15, 15, Rgb([255, 0, 255])); + let mut mask = vec![0u8; 16 * 16]; + mask[0] = 255; + let stats = compute_stats( + &normalized(&reference), + &normalized(&candidate), + 16, + 16, + 0.02, + Some(&mask), + ); + assert_eq!(stats.selected_pixels, 1); + assert_eq!(stats.rmse_luminance, 0.0); + } + + #[test] + fn every_quality_negative_control_changes_a_signal() { + let reference = checkerboard(64, 64); + let reference_pixels = normalized(&reference); + for fault in [ + "brdf-energy", + "shadow-placement", + "gi-leakage", + "motion-history", + "texture-orientation", + ] { + let candidate = apply_seeded_fault(&reference, fault).unwrap(); + let stats = compute_stats( + &reference_pixels, + &normalized(&candidate), + 64, + 64, + 0.02, + None, + ); + assert!( + stats.rmse_luminance > 0.001 + || stats.mean_oklab_delta > 0.001 + || stats.mean_edge_delta > 0.001 + || stats.ssim < 0.999, + "{fault} did not move any regression signal: {stats:?}" + ); + } + } +} diff --git a/tools/bloom-reference/Cargo.lock b/tools/bloom-reference/Cargo.lock index 8cb741bc..49061674 100644 --- a/tools/bloom-reference/Cargo.lock +++ b/tools/bloom-reference/Cargo.lock @@ -146,6 +146,7 @@ version = "0.1.0" dependencies = [ "glam", "gltf", + "half", "image", "rayon", "serde", diff --git a/tools/bloom-reference/Cargo.toml b/tools/bloom-reference/Cargo.toml index b8adcd15..73da1f8d 100644 --- a/tools/bloom-reference/Cargo.toml +++ b/tools/bloom-reference/Cargo.toml @@ -8,10 +8,15 @@ description = "Reference CPU path tracer for the Bloom renderer. Produces ground name = "bloom-reference" path = "src/main.rs" +[[bin]] +name = "bloom-brdf-reference" +path = "src/brdf_reference.rs" + [dependencies] gltf = "1.4" image = "0.25" glam = "0.30" +half = "2.6" rayon = "1.10" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/tools/bloom-reference/examples/check_tangents.rs b/tools/bloom-reference/examples/check_tangents.rs index 12a0838b..5caa694e 100644 --- a/tools/bloom-reference/examples/check_tangents.rs +++ b/tools/bloom-reference/examples/check_tangents.rs @@ -1,11 +1,20 @@ fn main() { - let data = std::fs::read("/Users/amlug/projects/bloom/engine/examples/renderer-test/assets/DamagedHelmet.glb").unwrap(); + let data = std::fs::read( + "/Users/amlug/projects/bloom/engine/examples/renderer-test/assets/DamagedHelmet.glb", + ) + .unwrap(); let gltf = gltf::Gltf::from_slice(&data).unwrap(); let mut buffer_data: Vec> = Vec::new(); for buffer in gltf.buffers() { match buffer.source() { - gltf::buffer::Source::Bin => { if let Some(b) = gltf.blob.as_ref() { buffer_data.push(b.clone()); } } - _ => { buffer_data.push(Vec::new()); } + gltf::buffer::Source::Bin => { + if let Some(b) = gltf.blob.as_ref() { + buffer_data.push(b.clone()); + } + } + _ => { + buffer_data.push(Vec::new()); + } } } for mesh in gltf.meshes() { @@ -15,7 +24,10 @@ fn main() { let has_norm = reader.read_normals().is_some(); let mat = prim.material(); let has_nmap = mat.normal_texture().is_some(); - let has_mr = mat.pbr_metallic_roughness().metallic_roughness_texture().is_some(); + let has_mr = mat + .pbr_metallic_roughness() + .metallic_roughness_texture() + .is_some(); let has_em = mat.emissive_texture().is_some(); let has_occl = mat.occlusion_texture().is_some(); println!("mesh {} prim {}: tangents={} normals={} normal_map={} mr_tex={} em_tex={} occl_tex={}", mesh.index(), i, has_tan, has_norm, has_nmap, has_mr, has_em, has_occl); diff --git a/tools/bloom-reference/reference/layered-pbr-angular-v1.json b/tools/bloom-reference/reference/layered-pbr-angular-v1.json new file mode 100644 index 00000000..f754d61d --- /dev/null +++ b/tools/bloom-reference/reference/layered-pbr-angular-v1.json @@ -0,0 +1,4056 @@ +{ + "schema": "bloom-layered-pbr-angular-reference", + "corpus_version": 1, + "material_contract_version": 4, + "comparison_target": "tools/bloom-reference::layered_pbr::evaluate_layered_brdf (linear RGB)", + "coordinate_convention": "+Z normal, +X tangent, right-handed; azimuth is counter-clockwise from +X", + "coverage": "base, specular/IOR, clearcoat, sheen, anisotropy, iridescence, and combined; 3 view directions x 3 light directions per scenario", + "thresholds": { + "serialized_decimal_places": 6, + "checked_in_regeneration": "byte exact", + "cpu_brdf_component_absolute": 0.00003, + "reciprocity_max_absolute": 0.00003 + }, + "known_model_differences": [ + "direct punctual-light BRDF only; environment IBL, SSR, exposure, and tone mapping are excluded", + "realtime finite-highlight compression is excluded and must be compared in image space", + "normal maps and their LEADR/Toksvig or derivative variance are evaluated by the motion corpus", + "path tracing is stochastic; transport comparison uses converged radiance tolerances, not byte equality", + "iridescence is the bounded Khronos Belcour/Barla Rec.709 approximation, not spectral conductor Fresnel" + ], + "maximum_reciprocity_error": 0.0, + "samples": [ + { + "id": "base-v0-l0", + "lobe": "base", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.030005, + 0.014294, + 0.004605 + ], + "direct_base_specular": [ + 0.009513, + 0.007073, + 0.005802 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.009879, + 0.005342, + 0.002602 + ], + "direct_pdf": 0.047668, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "base-v0-l1", + "lobe": "base", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.04865, + 0.023176, + 0.007466 + ], + "direct_base_specular": [ + 0.002408, + 0.001413, + 0.000894 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.028082, + 0.013524, + 0.004598 + ], + "direct_pdf": 0.101592, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "base-v0-l2", + "lobe": "base", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.050598, + 0.024104, + 0.007765 + ], + "direct_base_specular": [ + 0.002456, + 0.00144, + 0.00091 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.047748, + 0.022989, + 0.007808 + ], + "direct_pdf": 0.166543, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "base-v1-l0", + "lobe": "base", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.052191, + 0.024862, + 0.00801 + ], + "direct_base_specular": [ + 0.0027, + 0.001601, + 0.001029 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.013723, + 0.006616, + 0.00226 + ], + "direct_pdf": 0.070056, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "base-v1-l1", + "lobe": "base", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.070052, + 0.033371, + 0.010751 + ], + "direct_base_specular": [ + 0.001324, + 0.000776, + 0.000491 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.039257, + 0.018781, + 0.006183 + ], + "direct_pdf": 0.153718, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "base-v1-l2", + "lobe": "base", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.070984, + 0.033815, + 0.010894 + ], + "direct_base_specular": [ + 0.001919, + 0.001125, + 0.000711 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.065613, + 0.031446, + 0.010445 + ], + "direct_pdf": 0.251904, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "base-v2-l0", + "lobe": "base", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.056423, + 0.026879, + 0.008659 + ], + "direct_base_specular": [ + 0.002327, + 0.001364, + 0.000863 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.014687, + 0.007061, + 0.002381 + ], + "direct_pdf": 0.072738, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "base-v2-l1", + "lobe": "base", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.071795, + 0.034202, + 0.011019 + ], + "direct_base_specular": [ + 0.00166, + 0.000973, + 0.000615 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.0404, + 0.019346, + 0.006399 + ], + "direct_pdf": 0.159803, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "base-v2-l2", + "lobe": "base", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.072847, + 0.034702, + 0.01118 + ], + "direct_base_specular": [ + 0.006136, + 0.003597, + 0.002275 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.071084, + 0.034469, + 0.012109 + ], + "direct_pdf": 0.264155, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "specular-ior-v0-l0", + "lobe": "specular-ior", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.82, + "specular_factor": 0.72, + "specular_color": [ + 1.2, + 0.62, + 0.28 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.041406, + 0.017745, + 0.005422 + ], + "direct_base_specular": [ + 0.010046, + 0.006171, + 0.004059 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.012863, + 0.005979, + 0.00237 + ], + "direct_pdf": 0.053255, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "specular-ior-v0-l1", + "lobe": "specular-ior", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.82, + "specular_factor": 0.72, + "specular_color": [ + 1.2, + 0.62, + 0.28 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.061715, + 0.026449, + 0.008082 + ], + "direct_base_specular": [ + 0.002956, + 0.001375, + 0.000513 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.035569, + 0.015303, + 0.004727 + ], + "direct_pdf": 0.114457, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "specular-ior-v0-l2", + "lobe": "specular-ior", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.82, + "specular_factor": 0.72, + "specular_color": [ + 1.2, + 0.62, + 0.28 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.063824, + 0.027353, + 0.008358 + ], + "direct_base_specular": [ + 0.003015, + 0.001401, + 0.000522 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.060155, + 0.025879, + 0.007992 + ], + "direct_pdf": 0.187543, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "specular-ior-v1-l0", + "lobe": "specular-ior", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.82, + "specular_factor": 0.72, + "specular_color": [ + 1.2, + 0.62, + 0.28 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.06339, + 0.027167, + 0.008301 + ], + "direct_base_specular": [ + 0.003296, + 0.00155, + 0.000599 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.016671, + 0.007179, + 0.002225 + ], + "direct_pdf": 0.07025, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "specular-ior-v1-l1", + "lobe": "specular-ior", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.82, + "specular_factor": 0.72, + "specular_color": [ + 1.2, + 0.62, + 0.28 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.078216, + 0.033521, + 0.010243 + ], + "direct_base_specular": [ + 0.001626, + 0.000756, + 0.000281 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.043913, + 0.018852, + 0.005788 + ], + "direct_pdf": 0.154152, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "specular-ior-v1-l2", + "lobe": "specular-ior", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.82, + "specular_factor": 0.72, + "specular_color": [ + 1.2, + 0.62, + 0.28 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.078809, + 0.033775, + 0.01032 + ], + "direct_base_specular": [ + 0.002356, + 0.001095, + 0.000408 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.073049, + 0.031383, + 0.009655 + ], + "direct_pdf": 0.252607, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "specular-ior-v2-l0", + "lobe": "specular-ior", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.82, + "specular_factor": 0.72, + "specular_color": [ + 1.2, + 0.62, + 0.28 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.06787, + 0.029087, + 0.008888 + ], + "direct_base_specular": [ + 0.002857, + 0.001328, + 0.000495 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.017682, + 0.007604, + 0.002346 + ], + "direct_pdf": 0.072341, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "specular-ior-v2-l1", + "lobe": "specular-ior", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.82, + "specular_factor": 0.72, + "specular_color": [ + 1.2, + 0.62, + 0.28 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.079391, + 0.034025, + 0.010396 + ], + "direct_base_specular": [ + 0.002038, + 0.000947, + 0.000353 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.044786, + 0.019234, + 0.005912 + ], + "direct_pdf": 0.158919, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "specular-ior-v2-l2", + "lobe": "specular-ior", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.82, + "specular_factor": 0.72, + "specular_color": [ + 1.2, + 0.62, + 0.28 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.080099, + 0.034328, + 0.010489 + ], + "direct_base_specular": [ + 0.007535, + 0.003502, + 0.001303 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.07887, + 0.034047, + 0.010613 + ], + "direct_pdf": 0.262862, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "clearcoat-v0-l0", + "lobe": "clearcoat", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.85, + "clearcoat_perceptual_roughness": 0.17, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.01619, + 0.007713, + 0.002485 + ], + "direct_base_specular": [ + 0.005133, + 0.003816, + 0.003131 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.00023, + 0.00023, + 0.00023 + ], + "direct_brdf_cos": [ + 0.005388, + 0.00294, + 0.001461 + ], + "direct_pdf": 0.035701, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "clearcoat-v0-l1", + "lobe": "clearcoat", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.85, + "clearcoat_perceptual_roughness": 0.17, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.03232, + 0.015397, + 0.00496 + ], + "direct_base_specular": [ + 0.0016, + 0.000939, + 0.000594 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.000032, + 0.000032, + 0.000032 + ], + "direct_brdf_cos": [ + 0.018674, + 0.009002, + 0.003072 + ], + "direct_pdf": 0.076011, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "clearcoat-v0-l2", + "lobe": "clearcoat", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.85, + "clearcoat_perceptual_roughness": 0.17, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.034146, + 0.016266, + 0.005241 + ], + "direct_base_specular": [ + 0.001657, + 0.000972, + 0.000614 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.000033, + 0.000033, + 0.000033 + ], + "direct_brdf_cos": [ + 0.032252, + 0.015544, + 0.005299 + ], + "direct_pdf": 0.124615, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "clearcoat-v1-l0", + "lobe": "clearcoat", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.85, + "clearcoat_perceptual_roughness": 0.17, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.037912, + 0.01806, + 0.005818 + ], + "direct_base_specular": [ + 0.001962, + 0.001163, + 0.000748 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.000036, + 0.000036, + 0.000036 + ], + "direct_brdf_cos": [ + 0.009977, + 0.004815, + 0.00165 + ], + "direct_pdf": 0.065472, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "clearcoat-v1-l1", + "lobe": "clearcoat", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.85, + "clearcoat_perceptual_roughness": 0.17, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.062652, + 0.029846, + 0.009615 + ], + "direct_base_specular": [ + 0.001184, + 0.000694, + 0.000439 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.000016, + 0.000016, + 0.000016 + ], + "direct_brdf_cos": [ + 0.035119, + 0.016806, + 0.005539 + ], + "direct_pdf": 0.14365, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "clearcoat-v1-l2", + "lobe": "clearcoat", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.85, + "clearcoat_perceptual_roughness": 0.17, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.06449, + 0.030722, + 0.009898 + ], + "direct_base_specular": [ + 0.001743, + 0.001022, + 0.000646 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.000024, + 0.000024, + 0.000024 + ], + "direct_brdf_cos": [ + 0.059632, + 0.028591, + 0.009511 + ], + "direct_pdf": 0.235414, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "clearcoat-v2-l0", + "lobe": "clearcoat", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.85, + "clearcoat_perceptual_roughness": 0.17, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.042094, + 0.020053, + 0.00646 + ], + "direct_base_specular": [ + 0.001736, + 0.001018, + 0.000644 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.00003, + 0.00003, + 0.00003 + ], + "direct_brdf_cos": [ + 0.010965, + 0.005275, + 0.001784 + ], + "direct_pdf": 0.06992, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "clearcoat-v2-l1", + "lobe": "clearcoat", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.85, + "clearcoat_perceptual_roughness": 0.17, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.065948, + 0.031416, + 0.010121 + ], + "direct_base_specular": [ + 0.001525, + 0.000894, + 0.000565 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.000021, + 0.000021, + 0.000021 + ], + "direct_brdf_cos": [ + 0.037121, + 0.017782, + 0.005889 + ], + "direct_pdf": 0.15361, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "clearcoat-v2-l2", + "lobe": "clearcoat", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.85, + "clearcoat_perceptual_roughness": 0.17, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.067972, + 0.03238, + 0.010432 + ], + "direct_base_specular": [ + 0.005725, + 0.003356, + 0.002122 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.000086, + 0.000086, + 0.000086 + ], + "direct_brdf_cos": [ + 0.066405, + 0.03224, + 0.011376 + ], + "direct_pdf": 0.253974, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "sheen-v0-l0", + "lobe": "sheen", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.25, + 0.65, + 0.9 + ], + "sheen_perceptual_roughness": 0.43, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.019442, + 0.009262, + 0.002984 + ], + "direct_base_specular": [ + 0.006164, + 0.004583, + 0.00376 + ], + "direct_sheen_specular": [ + 0.056977, + 0.14814, + 0.205117 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.020646, + 0.040496, + 0.052965 + ], + "direct_pdf": 0.088955, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "sheen-v0-l1", + "lobe": "sheen", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.25, + 0.65, + 0.9 + ], + "sheen_perceptual_roughness": 0.43, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.031524, + 0.015017, + 0.004838 + ], + "direct_base_specular": [ + 0.001561, + 0.000915, + 0.000579 + ], + "direct_sheen_specular": [ + 0.048258, + 0.12547, + 0.173728 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.044738, + 0.077771, + 0.09853 + ], + "direct_pdf": 0.095325, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "sheen-v0-l2", + "lobe": "sheen", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.25, + 0.65, + 0.9 + ], + "sheen_perceptual_roughness": 0.43, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.032786, + 0.015618, + 0.005032 + ], + "direct_base_specular": [ + 0.001591, + 0.000933, + 0.00059 + ], + "direct_sheen_specular": [ + 0.017162, + 0.04462, + 0.061782 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.046385, + 0.055054, + 0.060663 + ], + "direct_pdf": 0.122036, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "sheen-v1-l0", + "lobe": "sheen", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.25, + 0.65, + 0.9 + ], + "sheen_perceptual_roughness": 0.43, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.036453, + 0.017366, + 0.005595 + ], + "direct_base_specular": [ + 0.001886, + 0.001119, + 0.000719 + ], + "direct_sheen_specular": [ + 0.037209, + 0.096742, + 0.133951 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.018887, + 0.028807, + 0.035066 + ], + "direct_pdf": 0.078558, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "sheen-v1-l1", + "lobe": "sheen", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.25, + 0.65, + 0.9 + ], + "sheen_perceptual_roughness": 0.43, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.060358, + 0.028753, + 0.009263 + ], + "direct_base_specular": [ + 0.001141, + 0.000669, + 0.000423 + ], + "direct_sheen_specular": [ + 0.027299, + 0.070977, + 0.098277 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.048839, + 0.05522, + 0.05938 + ], + "direct_pdf": 0.116772, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "sheen-v1-l2", + "lobe": "sheen", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.25, + 0.65, + 0.9 + ], + "sheen_perceptual_roughness": 0.43, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.061162, + 0.029136, + 0.009387 + ], + "direct_base_specular": [ + 0.001653, + 0.000969, + 0.000613 + ], + "direct_sheen_specular": [ + 0.007064, + 0.018366, + 0.02543 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.062891, + 0.043624, + 0.031886 + ], + "direct_pdf": 0.158957, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "sheen-v2-l0", + "lobe": "sheen", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.25, + 0.65, + 0.9 + ], + "sheen_perceptual_roughness": 0.43, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.039409, + 0.018774, + 0.006048 + ], + "direct_base_specular": [ + 0.001625, + 0.000953, + 0.000603 + ], + "direct_sheen_specular": [ + 0.015746, + 0.040939, + 0.056684 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.014195, + 0.015166, + 0.015834 + ], + "direct_pdf": 0.064592, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "sheen-v2-l1", + "lobe": "sheen", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.25, + 0.65, + 0.9 + ], + "sheen_perceptual_roughness": 0.43, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.063382, + 0.030194, + 0.009728 + ], + "direct_base_specular": [ + 0.001465, + 0.000859, + 0.000543 + ], + "direct_sheen_specular": [ + 0.008268, + 0.021496, + 0.029763 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.040213, + 0.028902, + 0.022019 + ], + "direct_pdf": 0.106072, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "sheen-v2-l2", + "lobe": "sheen", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.25, + 0.65, + 0.9 + ], + "sheen_perceptual_roughness": 0.43, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.070292, + 0.033486, + 0.010788 + ], + "direct_base_specular": [ + 0.005921, + 0.003471, + 0.002195 + ], + "direct_sheen_specular": [ + 0.000647, + 0.001681, + 0.002328 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.069174, + 0.034774, + 0.01378 + ], + "direct_pdf": 0.155564, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "anisotropy-v0-l0", + "lobe": "anisotropy", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.82, + "anisotropy_rotation": 0.61, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.035386, + 0.015165, + 0.004634 + ], + "direct_base_specular": [ + 0.004166, + 0.003097, + 0.002541 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.009888, + 0.004566, + 0.001794 + ], + "direct_pdf": 0.049153, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "anisotropy-v0-l1", + "lobe": "anisotropy", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.82, + "anisotropy_rotation": 0.61, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.057374, + 0.024589, + 0.007513 + ], + "direct_base_specular": [ + 0.098779, + 0.057944, + 0.036675 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.085885, + 0.045393, + 0.024304 + ], + "direct_pdf": 0.148993, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "anisotropy-v0-l2", + "lobe": "anisotropy", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.82, + "anisotropy_rotation": 0.61, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.059672, + 0.025574, + 0.007814 + ], + "direct_base_specular": [ + 0.003568, + 0.002092, + 0.001323 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.056916, + 0.024899, + 0.008224 + ], + "direct_pdf": 0.168667, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "anisotropy-v1-l0", + "lobe": "anisotropy", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.82, + "anisotropy_rotation": 0.61, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.061551, + 0.026379, + 0.00806 + ], + "direct_base_specular": [ + 0.000561, + 0.000333, + 0.000214 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.015528, + 0.006678, + 0.002068 + ], + "direct_pdf": 0.069881, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "anisotropy-v1-l1", + "lobe": "anisotropy", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.82, + "anisotropy_rotation": 0.61, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.082615, + 0.035406, + 0.010819 + ], + "direct_base_specular": [ + 0.00807, + 0.004731, + 0.002992 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.049877, + 0.022075, + 0.007596 + ], + "direct_pdf": 0.155412, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "anisotropy-v1-l2", + "lobe": "anisotropy", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.82, + "anisotropy_rotation": 0.61, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.083714, + 0.035877, + 0.010963 + ], + "direct_base_specular": [ + 0.071833, + 0.042109, + 0.026628 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.139993, + 0.070188, + 0.033831 + ], + "direct_pdf": 0.28647, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "anisotropy-v2-l0", + "lobe": "anisotropy", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.82, + "anisotropy_rotation": 0.61, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.066542, + 0.028518, + 0.008714 + ], + "direct_base_specular": [ + 0.000439, + 0.000257, + 0.000163 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.016745, + 0.007194, + 0.002219 + ], + "direct_pdf": 0.072496, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "anisotropy-v2-l1", + "lobe": "anisotropy", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.82, + "anisotropy_rotation": 0.61, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.084671, + 0.036288, + 0.011088 + ], + "direct_base_specular": [ + 0.001586, + 0.000929, + 0.000588 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.047441, + 0.020469, + 0.006422 + ], + "direct_pdf": 0.159863, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "anisotropy-v2-l2", + "lobe": "anisotropy", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.82, + "anisotropy_rotation": 0.61, + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.085911, + 0.036819, + 0.01125 + ], + "direct_base_specular": [ + 0.027195, + 0.015942, + 0.010081 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.101795, + 0.047485, + 0.019198 + ], + "direct_pdf": 0.276544, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "iridescence-v0-l0", + "lobe": "iridescence", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.9, + "iridescence_ior": 1.36, + "iridescence_thickness_nm": 475.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.030876, + 0.013233, + 0.004043 + ], + "direct_base_specular": [ + 0.003239, + 0.00699, + 0.006497 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.008529, + 0.005056, + 0.002635 + ], + "direct_pdf": 0.049043, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "iridescence-v0-l1", + "lobe": "iridescence", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.9, + "iridescence_ior": 1.36, + "iridescence_thickness_nm": 475.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.052945, + 0.022691, + 0.006933 + ], + "direct_base_specular": [ + 0.002727, + 0.000911, + 0.000326 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.03062, + 0.012981, + 0.003993 + ], + "direct_pdf": 0.104758, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "iridescence-v0-l2", + "lobe": "iridescence", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.9, + "iridescence_ior": 1.36, + "iridescence_thickness_nm": 475.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.054731, + 0.023456, + 0.007167 + ], + "direct_base_specular": [ + 0.002955, + 0.0007, + 0.000414 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.051917, + 0.02174, + 0.006823 + ], + "direct_pdf": 0.17171, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "iridescence-v1-l0", + "lobe": "iridescence", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.9, + "iridescence_ior": 1.36, + "iridescence_thickness_nm": 475.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.057694, + 0.024726, + 0.007555 + ], + "direct_base_specular": [ + 0.002313, + 0.001545, + 0.00038 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.015002, + 0.006568, + 0.001984 + ], + "direct_pdf": 0.072233, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "iridescence-v1-l1", + "lobe": "iridescence", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.9, + "iridescence_ior": 1.36, + "iridescence_thickness_nm": 475.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.081898, + 0.035099, + 0.010725 + ], + "direct_base_specular": [ + 0.001581, + 0.000209, + 0.00033 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.045913, + 0.019419, + 0.00608 + ], + "direct_pdf": 0.158601, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "iridescence-v1-l2", + "lobe": "iridescence", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.9, + "iridescence_ior": 1.36, + "iridescence_thickness_nm": 475.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.082484, + 0.03535, + 0.010801 + ], + "direct_base_specular": [ + 0.002339, + 0.000362, + 0.000428 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.07634, + 0.032141, + 0.010107 + ], + "direct_pdf": 0.25981, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "iridescence-v2-l0", + "lobe": "iridescence", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.9, + "iridescence_ior": 1.36, + "iridescence_thickness_nm": 475.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.062146, + 0.026634, + 0.008138 + ], + "direct_base_specular": [ + 0.002725, + 0.000777, + 0.000347 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.016218, + 0.006853, + 0.002121 + ], + "direct_pdf": 0.073686, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "iridescence-v2-l1", + "lobe": "iridescence", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.9, + "iridescence_ior": 1.36, + "iridescence_thickness_nm": 475.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.083632, + 0.035842, + 0.010952 + ], + "direct_base_specular": [ + 0.001988, + 0.000267, + 0.000409 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.047091, + 0.01986, + 0.006248 + ], + "direct_pdf": 0.16192, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "iridescence-v2-l2", + "lobe": "iridescence", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "iridescence_factor": 0.9, + "iridescence_ior": 1.36, + "iridescence_thickness_nm": 475.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.084341, + 0.036146, + 0.011045 + ], + "direct_base_specular": [ + 0.007384, + 0.001017, + 0.001483 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.082552, + 0.033446, + 0.011275 + ], + "direct_pdf": 0.26725, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "combined-v0-l0", + "lobe": "combined", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.7, + "specular_factor": 0.8, + "specular_color": [ + 1.1, + 0.7, + 0.4 + ], + "clearcoat_factor": 0.65, + "clearcoat_perceptual_roughness": 0.2, + "sheen_color": [ + 0.2, + 0.55, + 0.9 + ], + "sheen_perceptual_roughness": 0.38, + "anisotropy_strength": 0.75, + "anisotropy_rotation": 0.63, + "iridescence_factor": 0.9, + "iridescence_ior": 1.38, + "iridescence_thickness_nm": 560.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.014322, + 0.006138, + 0.001875 + ], + "direct_base_specular": [ + 0.00291, + 0.001384, + 0.001084 + ], + "direct_sheen_specular": [ + 0.025149, + 0.069159, + 0.113169 + ], + "direct_clearcoat_specular": [ + 0.000334, + 0.000334, + 0.000334 + ], + "direct_brdf_cos": [ + 0.010679, + 0.019254, + 0.029116 + ], + "direct_pdf": 0.072358, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "combined-v0-l1", + "lobe": "combined", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.7, + "specular_factor": 0.8, + "specular_color": [ + 1.1, + 0.7, + 0.4 + ], + "clearcoat_factor": 0.65, + "clearcoat_perceptual_roughness": 0.2, + "sheen_color": [ + 0.2, + 0.55, + 0.9 + ], + "sheen_perceptual_roughness": 0.38, + "anisotropy_strength": 0.75, + "anisotropy_rotation": 0.63, + "iridescence_factor": 0.9, + "iridescence_ior": 1.38, + "iridescence_thickness_nm": 560.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.026128, + 0.011198, + 0.003422 + ], + "direct_base_specular": [ + 0.015545, + 0.010741, + 0.007937 + ], + "direct_sheen_specular": [ + 0.027163, + 0.074699, + 0.122234 + ], + "direct_clearcoat_specular": [ + 0.000047, + 0.000047, + 0.000047 + ], + "direct_brdf_cos": [ + 0.037886, + 0.053176, + 0.073502 + ], + "direct_pdf": 0.099033, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "combined-v0-l2", + "lobe": "combined", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.7, + "specular_factor": 0.8, + "specular_color": [ + 1.1, + 0.7, + 0.4 + ], + "clearcoat_factor": 0.65, + "clearcoat_perceptual_roughness": 0.2, + "sheen_color": [ + 0.2, + 0.55, + 0.9 + ], + "sheen_perceptual_roughness": 0.38, + "anisotropy_strength": 0.75, + "anisotropy_rotation": 0.63, + "iridescence_factor": 0.9, + "iridescence_ior": 1.38, + "iridescence_thickness_nm": 560.0 + }, + "n_dot_v": 0.2, + "view_azimuth_radians": 0.0, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.029373, + 0.012588, + 0.003846 + ], + "direct_base_specular": [ + 0.000573, + 0.000809, + 0.000477 + ], + "direct_sheen_specular": [ + 0.008057, + 0.022157, + 0.036257 + ], + "direct_clearcoat_specular": [ + 0.000048, + 0.000048, + 0.000048 + ], + "direct_brdf_cos": [ + 0.034246, + 0.032042, + 0.036566 + ], + "direct_pdf": 0.103747, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "combined-v1-l0", + "lobe": "combined", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.7, + "specular_factor": 0.8, + "specular_color": [ + 1.1, + 0.7, + 0.4 + ], + "clearcoat_factor": 0.65, + "clearcoat_perceptual_roughness": 0.2, + "sheen_color": [ + 0.2, + 0.55, + 0.9 + ], + "sheen_perceptual_roughness": 0.38, + "anisotropy_strength": 0.75, + "anisotropy_rotation": 0.63, + "iridescence_factor": 0.9, + "iridescence_ior": 1.38, + "iridescence_thickness_nm": 560.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.032092, + 0.013754, + 0.004203 + ], + "direct_base_specular": [ + 0.000433, + 0.000069, + 0.00009 + ], + "direct_sheen_specular": [ + 0.020651, + 0.056789, + 0.092928 + ], + "direct_clearcoat_specular": [ + 0.000053, + 0.000053, + 0.000053 + ], + "direct_brdf_cos": [ + 0.013307, + 0.017666, + 0.024318 + ], + "direct_pdf": 0.07308, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "combined-v1-l1", + "lobe": "combined", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.7, + "specular_factor": 0.8, + "specular_color": [ + 1.1, + 0.7, + 0.4 + ], + "clearcoat_factor": 0.65, + "clearcoat_perceptual_roughness": 0.2, + "sheen_color": [ + 0.2, + 0.55, + 0.9 + ], + "sheen_perceptual_roughness": 0.38, + "anisotropy_strength": 0.75, + "anisotropy_rotation": 0.63, + "iridescence_factor": 0.9, + "iridescence_ior": 1.38, + "iridescence_thickness_nm": 560.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.059599, + 0.025543, + 0.007805 + ], + "direct_base_specular": [ + 0.001765, + 0.004516, + 0.002055 + ], + "direct_sheen_specular": [ + 0.017463, + 0.048024, + 0.078585 + ], + "direct_clearcoat_specular": [ + 0.000024, + 0.000024, + 0.000024 + ], + "direct_brdf_cos": [ + 0.043368, + 0.042958, + 0.048658 + ], + "direct_pdf": 0.112004, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "combined-v1-l2", + "lobe": "combined", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.7, + "specular_factor": 0.8, + "specular_color": [ + 1.1, + 0.7, + 0.4 + ], + "clearcoat_factor": 0.65, + "clearcoat_perceptual_roughness": 0.2, + "sheen_color": [ + 0.2, + 0.55, + 0.9 + ], + "sheen_perceptual_roughness": 0.38, + "anisotropy_strength": 0.75, + "anisotropy_rotation": 0.63, + "iridescence_factor": 0.9, + "iridescence_ior": 1.38, + "iridescence_thickness_nm": 560.0 + }, + "n_dot_v": 0.5, + "view_azimuth_radians": 0.7, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.065279, + 0.027977, + 0.008548 + ], + "direct_base_specular": [ + 0.010644, + 0.025506, + 0.012328 + ], + "direct_sheen_specular": [ + 0.003296, + 0.009064, + 0.014832 + ], + "direct_clearcoat_specular": [ + 0.000035, + 0.000035, + 0.000035 + ], + "direct_brdf_cos": [ + 0.071329, + 0.056324, + 0.032169 + ], + "direct_pdf": 0.1672, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "combined-v2-l0", + "lobe": "combined", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.7, + "specular_factor": 0.8, + "specular_color": [ + 1.1, + 0.7, + 0.4 + ], + "clearcoat_factor": 0.65, + "clearcoat_perceptual_roughness": 0.2, + "sheen_color": [ + 0.2, + 0.55, + 0.9 + ], + "sheen_perceptual_roughness": 0.38, + "anisotropy_strength": 0.75, + "anisotropy_rotation": 0.63, + "iridescence_factor": 0.9, + "iridescence_ior": 1.38, + "iridescence_thickness_nm": 560.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.25, + "light_azimuth_radians": 2.4, + "direct_diffuse": [ + 0.038152, + 0.016351, + 0.004996 + ], + "direct_base_specular": [ + 0.000108, + 0.000111, + 0.000074 + ], + "direct_sheen_specular": [ + 0.0077, + 0.021175, + 0.03465 + ], + "direct_clearcoat_specular": [ + 0.000044, + 0.000044, + 0.000044 + ], + "direct_brdf_cos": [ + 0.011501, + 0.00942, + 0.009941 + ], + "direct_pdf": 0.061454, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "combined-v2-l1", + "lobe": "combined", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.7, + "specular_factor": 0.8, + "specular_color": [ + 1.1, + 0.7, + 0.4 + ], + "clearcoat_factor": 0.65, + "clearcoat_perceptual_roughness": 0.2, + "sheen_color": [ + 0.2, + 0.55, + 0.9 + ], + "sheen_perceptual_roughness": 0.38, + "anisotropy_strength": 0.75, + "anisotropy_rotation": 0.63, + "iridescence_factor": 0.9, + "iridescence_ior": 1.38, + "iridescence_thickness_nm": 560.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.55, + "light_azimuth_radians": 1.2, + "direct_diffuse": [ + 0.068707, + 0.029446, + 0.008997 + ], + "direct_base_specular": [ + 0.000366, + 0.000937, + 0.000429 + ], + "direct_sheen_specular": [ + 0.004061, + 0.011167, + 0.018274 + ], + "direct_clearcoat_specular": [ + 0.00003, + 0.00003, + 0.00003 + ], + "direct_brdf_cos": [ + 0.040241, + 0.022869, + 0.015252 + ], + "direct_pdf": 0.106296, + "reciprocity_max_abs_error": 0.0 + }, + { + "id": "combined-v2-l2", + "lobe": "combined", + "material": { + "base_color": [ + 0.42, + 0.18, + 0.055 + ], + "metallic": 0.2, + "perceptual_roughness": 0.38, + "ior": 1.7, + "specular_factor": 0.8, + "specular_color": [ + 1.1, + 0.7, + 0.4 + ], + "clearcoat_factor": 0.65, + "clearcoat_perceptual_roughness": 0.2, + "sheen_color": [ + 0.2, + 0.55, + 0.9 + ], + "sheen_perceptual_roughness": 0.38, + "anisotropy_strength": 0.75, + "anisotropy_rotation": 0.63, + "iridescence_factor": 0.9, + "iridescence_ior": 1.38, + "iridescence_thickness_nm": 560.0 + }, + "n_dot_v": 0.85, + "view_azimuth_radians": 1.4, + "n_dot_l": 0.9, + "light_azimuth_radians": 0.35, + "direct_diffuse": [ + 0.081422, + 0.034895, + 0.010662 + ], + "direct_base_specular": [ + 0.006331, + 0.016116, + 0.007448 + ], + "direct_sheen_specular": [ + 0.000158, + 0.000436, + 0.000713 + ], + "direct_clearcoat_specular": [ + 0.000124, + 0.000124, + 0.000124 + ], + "direct_brdf_cos": [ + 0.079232, + 0.046414, + 0.017053 + ], + "direct_pdf": 0.166093, + "reciprocity_max_abs_error": 0.0 + } + ] +} diff --git a/tools/bloom-reference/reference/layered-pbr-v1.json b/tools/bloom-reference/reference/layered-pbr-v1.json new file mode 100644 index 00000000..e3fd9e4b --- /dev/null +++ b/tools/bloom-reference/reference/layered-pbr-v1.json @@ -0,0 +1,1548 @@ +{ + "schema": "bloom-layered-pbr-reference", + "version": 1, + "model": "metallic-roughness / correlated GGX / Schlick / energy-normalized Burley", + "dielectric_f0": 0.04, + "minimum_perceptual_roughness": 0.04, + "visibility": "height-correlated Smith, including 1/(4 NdotV NdotL)", + "diffuse": "energy-normalized Burley, reciprocal view/light Fresnel transmission, 1/pi", + "furnace_integration": "96x192 deterministic GGX-VNDF and cosine samples", + "samples": [ + { + "id": "neutral-m0-r0.04-v0.1", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 0.0, + "perceptual_roughness": 0.04, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.015165, + 0.015165, + 0.015165 + ], + "direct_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.007583, + 0.007583, + 0.007583 + ], + "direct_pdf": 0.152789, + "white_furnace_reflectance": [ + 0.652435, + 0.652435, + 0.652435 + ] + }, + { + "id": "neutral-m0-r0.04-v0.5", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 0.0, + "perceptual_roughness": 0.04, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.047584, + 0.047584, + 0.047584 + ], + "direct_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.023792, + 0.023792, + 0.023792 + ], + "direct_pdf": 0.152789, + "white_furnace_reflectance": [ + 0.216608, + 0.216608, + 0.216608 + ] + }, + { + "id": "neutral-m0-r0.04-v1.0", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 0.0, + "perceptual_roughness": 0.04, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.049769, + 0.049769, + 0.049769 + ], + "direct_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.024884, + 0.024884, + 0.024884 + ], + "direct_pdf": 0.152789, + "white_furnace_reflectance": [ + 0.193497, + 0.193497, + 0.193497 + ] + }, + { + "id": "neutral-m0-r0.25-v0.1", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 0.0, + "perceptual_roughness": 0.25, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.018166, + 0.018166, + 0.018166 + ], + "direct_specular": [ + 0.000286, + 0.000286, + 0.000286 + ], + "direct_brdf_cos": [ + 0.009226, + 0.009226, + 0.009226 + ], + "direct_pdf": 0.152794, + "white_furnace_reflectance": [ + 0.462684, + 0.462684, + 0.462684 + ] + }, + { + "id": "neutral-m0-r0.25-v0.5", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 0.0, + "perceptual_roughness": 0.25, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.045215, + 0.045215, + 0.045215 + ], + "direct_specular": [ + 0.000095, + 0.000095, + 0.000095 + ], + "direct_brdf_cos": [ + 0.022655, + 0.022655, + 0.022655 + ], + "direct_pdf": 0.152802, + "white_furnace_reflectance": [ + 0.209431, + 0.209431, + 0.209431 + ] + }, + { + "id": "neutral-m0-r0.25-v1.0", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 0.0, + "perceptual_roughness": 0.25, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.046651, + 0.046651, + 0.046651 + ], + "direct_specular": [ + 0.000388, + 0.000388, + 0.000388 + ], + "direct_brdf_cos": [ + 0.02352, + 0.02352, + 0.02352 + ], + "direct_pdf": 0.152983, + "white_furnace_reflectance": [ + 0.183793, + 0.183793, + 0.183793 + ] + }, + { + "id": "neutral-m0-r0.50-v0.1", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 0.0, + "perceptual_roughness": 0.5, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.021014, + 0.021014, + 0.021014 + ], + "direct_specular": [ + 0.002616, + 0.002616, + 0.002616 + ], + "direct_brdf_cos": [ + 0.011815, + 0.011815, + 0.011815 + ], + "direct_pdf": 0.152878, + "white_furnace_reflectance": [ + 0.2248, + 0.2248, + 0.2248 + ] + }, + { + "id": "neutral-m0-r0.50-v0.5", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 0.0, + "perceptual_roughness": 0.5, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.042197, + 0.042197, + 0.042197 + ], + "direct_specular": [ + 0.001346, + 0.001346, + 0.001346 + ], + "direct_brdf_cos": [ + 0.021771, + 0.021771, + 0.021771 + ], + "direct_pdf": 0.152995, + "white_furnace_reflectance": [ + 0.184002, + 0.184002, + 0.184002 + ] + }, + { + "id": "neutral-m0-r0.50-v1.0", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 0.0, + "perceptual_roughness": 0.5, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.042847, + 0.042847, + 0.042847 + ], + "direct_specular": [ + 0.004325, + 0.004325, + 0.004325 + ], + "direct_brdf_cos": [ + 0.023586, + 0.023586, + 0.023586 + ], + "direct_pdf": 0.155046, + "white_furnace_reflectance": [ + 0.168662, + 0.168662, + 0.168662 + ] + }, + { + "id": "neutral-m0-r1.00-v0.1", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 0.0, + "perceptual_roughness": 1.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.024216, + 0.024216, + 0.024216 + ], + "direct_specular": [ + 0.010612, + 0.010612, + 0.010612 + ], + "direct_brdf_cos": [ + 0.017414, + 0.017414, + 0.017414 + ], + "direct_pdf": 0.15394, + "white_furnace_reflectance": [ + 0.1119, + 0.1119, + 0.1119 + ] + }, + { + "id": "neutral-m0-r1.00-v0.5", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 0.0, + "perceptual_roughness": 1.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.035497, + 0.035497, + 0.035497 + ], + "direct_specular": [ + 0.006366, + 0.006366, + 0.006366 + ], + "direct_brdf_cos": [ + 0.020931, + 0.020931, + 0.020931 + ], + "direct_pdf": 0.154577, + "white_furnace_reflectance": [ + 0.127288, + 0.127288, + 0.127288 + ] + }, + { + "id": "neutral-m0-r1.00-v1.0", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 0.0, + "perceptual_roughness": 1.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.034935, + 0.034935, + 0.034935 + ], + "direct_specular": [ + 0.004249, + 0.004249, + 0.004249 + ], + "direct_brdf_cos": [ + 0.019592, + 0.019592, + 0.019592 + ], + "direct_pdf": 0.155972, + "white_furnace_reflectance": [ + 0.119819, + 0.119819, + 0.119819 + ] + }, + { + "id": "neutral-m1-r0.04-v0.1", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 1.0, + "perceptual_roughness": 0.04, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.000001, + 0.000001, + 0.000001 + ], + "direct_brdf_cos": [ + 0.0, + 0.0, + 0.0 + ], + "direct_pdf": 0.000001, + "white_furnace_reflectance": [ + 0.663921, + 0.663921, + 0.663921 + ] + }, + { + "id": "neutral-m1-r0.04-v0.5", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 1.0, + "perceptual_roughness": 0.04, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.0, + 0.0, + 0.0 + ], + "direct_pdf": 0.000001, + "white_furnace_reflectance": [ + 0.205628, + 0.205628, + 0.205628 + ] + }, + { + "id": "neutral-m1-r0.04-v1.0", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 1.0, + "perceptual_roughness": 0.04, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.000001, + 0.000001, + 0.000001 + ], + "direct_brdf_cos": [ + 0.000001, + 0.000001, + 0.000001 + ], + "direct_pdf": 0.000004, + "white_furnace_reflectance": [ + 0.179988, + 0.179988, + 0.179988 + ] + }, + { + "id": "neutral-m1-r0.25-v0.1", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 1.0, + "perceptual_roughness": 0.25, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.001288, + 0.001288, + 0.001288 + ], + "direct_brdf_cos": [ + 0.000644, + 0.000644, + 0.000644 + ], + "direct_pdf": 0.000142, + "white_furnace_reflectance": [ + 0.482831, + 0.482831, + 0.482831 + ] + }, + { + "id": "neutral-m1-r0.25-v0.5", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 1.0, + "perceptual_roughness": 0.25, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.000429, + 0.000429, + 0.000429 + ], + "direct_brdf_cos": [ + 0.000214, + 0.000214, + 0.000214 + ], + "direct_pdf": 0.000337, + "white_furnace_reflectance": [ + 0.205059, + 0.205059, + 0.205059 + ] + }, + { + "id": "neutral-m1-r0.25-v1.0", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 1.0, + "perceptual_roughness": 0.25, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.001745, + 0.001745, + 0.001745 + ], + "direct_brdf_cos": [ + 0.000872, + 0.000872, + 0.000872 + ], + "direct_pdf": 0.00486, + "white_furnace_reflectance": [ + 0.179915, + 0.179915, + 0.179915 + ] + }, + { + "id": "neutral-m1-r0.50-v0.1", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 1.0, + "perceptual_roughness": 0.5, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.011769, + 0.011769, + 0.011769 + ], + "direct_brdf_cos": [ + 0.005885, + 0.005885, + 0.005885 + ], + "direct_pdf": 0.00223, + "white_furnace_reflectance": [ + 0.275051, + 0.275051, + 0.275051 + ] + }, + { + "id": "neutral-m1-r0.50-v0.5", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 1.0, + "perceptual_roughness": 0.5, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.006055, + 0.006055, + 0.006055 + ], + "direct_brdf_cos": [ + 0.003027, + 0.003027, + 0.003027 + ], + "direct_pdf": 0.005148, + "white_furnace_reflectance": [ + 0.172618, + 0.172618, + 0.172618 + ] + }, + { + "id": "neutral-m1-r0.50-v1.0", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 1.0, + "perceptual_roughness": 0.5, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.019447, + 0.019447, + 0.019447 + ], + "direct_brdf_cos": [ + 0.009723, + 0.009723, + 0.009723 + ], + "direct_pdf": 0.056432, + "white_furnace_reflectance": [ + 0.16483, + 0.16483, + 0.16483 + ] + }, + { + "id": "neutral-m1-r1.00-v0.1", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 1.0, + "perceptual_roughness": 1.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.047748, + 0.047748, + 0.047748 + ], + "direct_brdf_cos": [ + 0.023874, + 0.023874, + 0.023874 + ], + "direct_pdf": 0.028776, + "white_furnace_reflectance": [ + 0.156327, + 0.156327, + 0.156327 + ] + }, + { + "id": "neutral-m1-r1.00-v0.5", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 1.0, + "perceptual_roughness": 1.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.028648, + 0.028648, + 0.028648 + ], + "direct_brdf_cos": [ + 0.014324, + 0.014324, + 0.014324 + ], + "direct_pdf": 0.044699, + "white_furnace_reflectance": [ + 0.083575, + 0.083575, + 0.083575 + ] + }, + { + "id": "neutral-m1-r1.00-v1.0", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 1.0, + "perceptual_roughness": 1.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.019102, + 0.019102, + 0.019102 + ], + "direct_brdf_cos": [ + 0.009551, + 0.009551, + 0.009551 + ], + "direct_pdf": 0.079578, + "white_furnace_reflectance": [ + 0.055263, + 0.055263, + 0.055263 + ] + }, + { + "id": "red-m0-r0.04-v0.1", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 0.04, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.067401, + 0.00674, + 0.002528 + ], + "direct_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.033701, + 0.00337, + 0.001264 + ], + "direct_pdf": 0.152789, + "white_furnace_reflectance": [ + 0.810445, + 0.626949, + 0.614206 + ] + }, + { + "id": "red-m0-r0.04-v0.5", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 0.04, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.211486, + 0.021149, + 0.007931 + ], + "direct_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.105743, + 0.010574, + 0.003965 + ], + "direct_pdf": 0.152789, + "white_furnace_reflectance": [ + 0.721573, + 0.135161, + 0.094437 + ] + }, + { + "id": "red-m0-r0.04-v1.0", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 0.04, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.221194, + 0.022119, + 0.008295 + ], + "direct_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.110597, + 0.01106, + 0.004148 + ], + "direct_pdf": 0.152789, + "white_furnace_reflectance": [ + 0.722235, + 0.108221, + 0.065579 + ] + }, + { + "id": "red-m0-r0.25-v0.1", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 0.25, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.080738, + 0.008074, + 0.003028 + ], + "direct_specular": [ + 0.000286, + 0.000286, + 0.000286 + ], + "direct_brdf_cos": [ + 0.040512, + 0.00418, + 0.001657 + ], + "direct_pdf": 0.152794, + "white_furnace_reflectance": [ + 0.637378, + 0.434508, + 0.420419 + ] + }, + { + "id": "red-m0-r0.25-v0.5", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 0.25, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.200957, + 0.020096, + 0.007536 + ], + "direct_specular": [ + 0.000095, + 0.000095, + 0.000095 + ], + "direct_brdf_cos": [ + 0.100526, + 0.010095, + 0.003816 + ], + "direct_pdf": 0.152802, + "white_furnace_reflectance": [ + 0.686265, + 0.132522, + 0.094067 + ] + }, + { + "id": "red-m0-r0.25-v1.0", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 0.25, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.20734, + 0.020734, + 0.007775 + ], + "direct_specular": [ + 0.000388, + 0.000388, + 0.000388 + ], + "direct_brdf_cos": [ + 0.103864, + 0.010561, + 0.004082 + ], + "direct_pdf": 0.152983, + "white_furnace_reflectance": [ + 0.679182, + 0.103904, + 0.063955 + ] + }, + { + "id": "red-m0-r0.50-v0.1", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 0.5, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.093394, + 0.009339, + 0.003502 + ], + "direct_specular": [ + 0.002616, + 0.002616, + 0.002616 + ], + "direct_brdf_cos": [ + 0.048005, + 0.005978, + 0.003059 + ], + "direct_pdf": 0.152878, + "white_furnace_reflectance": [ + 0.414337, + 0.194229, + 0.178944 + ] + }, + { + "id": "red-m0-r0.50-v0.5", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 0.5, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.187542, + 0.018754, + 0.007033 + ], + "direct_specular": [ + 0.001346, + 0.001346, + 0.001346 + ], + "direct_brdf_cos": [ + 0.094444, + 0.01005, + 0.004189 + ], + "direct_pdf": 0.152995, + "white_furnace_reflectance": [ + 0.625778, + 0.112747, + 0.077119 + ] + }, + { + "id": "red-m0-r0.50-v1.0", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 0.5, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.190432, + 0.019043, + 0.007141 + ], + "direct_specular": [ + 0.004325, + 0.004325, + 0.004325 + ], + "direct_brdf_cos": [ + 0.097379, + 0.011684, + 0.005733 + ], + "direct_pdf": 0.155046, + "white_furnace_reflectance": [ + 0.623352, + 0.095319, + 0.05865 + ] + }, + { + "id": "red-m0-r1.00-v0.1", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 1.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.107627, + 0.010763, + 0.004036 + ], + "direct_specular": [ + 0.010612, + 0.010612, + 0.010612 + ], + "direct_brdf_cos": [ + 0.05912, + 0.010687, + 0.007324 + ], + "direct_pdf": 0.15394, + "white_furnace_reflectance": [ + 0.313939, + 0.079313, + 0.06302 + ] + }, + { + "id": "red-m0-r1.00-v0.5", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 1.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.157763, + 0.015776, + 0.005916 + ], + "direct_specular": [ + 0.006366, + 0.006366, + 0.006366 + ], + "direct_brdf_cos": [ + 0.082065, + 0.011071, + 0.006141 + ], + "direct_pdf": 0.154577, + "white_furnace_reflectance": [ + 0.493749, + 0.068182, + 0.038629 + ] + }, + { + "id": "red-m0-r1.00-v1.0", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 1.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.155268, + 0.015527, + 0.005823 + ], + "direct_specular": [ + 0.004249, + 0.004249, + 0.004249 + ], + "direct_brdf_cos": [ + 0.079758, + 0.009888, + 0.005036 + ], + "direct_pdf": 0.155972, + "white_furnace_reflectance": [ + 0.490126, + 0.060091, + 0.030224 + ] + }, + { + "id": "red-m1-r0.04-v0.1", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 1.0, + "perceptual_roughness": 0.04, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.000004, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.000002, + 0.0, + 0.0 + ], + "direct_pdf": 0.000001, + "white_furnace_reflectance": [ + 0.917948, + 0.622951, + 0.602465 + ] + }, + { + "id": "red-m1-r0.04-v0.5", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 1.0, + "perceptual_roughness": 0.04, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.000001, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.000001, + 0.0, + 0.0 + ], + "direct_pdf": 0.000001, + "white_furnace_reflectance": [ + 0.806271, + 0.108753, + 0.060315 + ] + }, + { + "id": "red-m1-r0.04-v1.0", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 1.0, + "perceptual_roughness": 0.04, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.000005, + 0.000001, + 0.0 + ], + "direct_brdf_cos": [ + 0.000003, + 0.0, + 0.0 + ], + "direct_pdf": 0.000004, + "white_furnace_reflectance": [ + 0.799869, + 0.07999, + 0.030001 + ] + }, + { + "id": "red-m1-r0.25-v0.1", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 1.0, + "perceptual_roughness": 0.25, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.005726, + 0.000573, + 0.000215 + ], + "direct_brdf_cos": [ + 0.002863, + 0.000286, + 0.000107 + ], + "direct_pdf": 0.000142, + "white_furnace_reflectance": [ + 0.796672, + 0.432218, + 0.406906 + ] + }, + { + "id": "red-m1-r0.25-v0.5", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 1.0, + "perceptual_roughness": 0.25, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.001906, + 0.000191, + 0.000071 + ], + "direct_brdf_cos": [ + 0.000953, + 0.000095, + 0.000036 + ], + "direct_pdf": 0.000337, + "white_furnace_reflectance": [ + 0.798769, + 0.109299, + 0.061419 + ] + }, + { + "id": "red-m1-r0.25-v1.0", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 1.0, + "perceptual_roughness": 0.25, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.007752, + 0.000776, + 0.000291 + ], + "direct_brdf_cos": [ + 0.003876, + 0.000388, + 0.000146 + ], + "direct_pdf": 0.00486, + "white_furnace_reflectance": [ + 0.799513, + 0.079962, + 0.029992 + ] + }, + { + "id": "red-m1-r0.50-v0.1", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 1.0, + "perceptual_roughness": 0.5, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.052307, + 0.005231, + 0.001962 + ], + "direct_brdf_cos": [ + 0.026153, + 0.002615, + 0.000981 + ], + "direct_pdf": 0.002229, + "white_furnace_reflectance": [ + 0.741276, + 0.199853, + 0.162255 + ] + }, + { + "id": "red-m1-r0.50-v0.5", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 1.0, + "perceptual_roughness": 0.5, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.02691, + 0.002691, + 0.001009 + ], + "direct_brdf_cos": [ + 0.013455, + 0.001346, + 0.000505 + ], + "direct_pdf": 0.005148, + "white_furnace_reflectance": [ + 0.69021, + 0.089136, + 0.047395 + ] + }, + { + "id": "red-m1-r0.50-v1.0", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 1.0, + "perceptual_roughness": 0.5, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.086415, + 0.008646, + 0.003245 + ], + "direct_brdf_cos": [ + 0.043207, + 0.004323, + 0.001623 + ], + "direct_pdf": 0.056432, + "white_furnace_reflectance": [ + 0.732497, + 0.073276, + 0.027494 + ] + }, + { + "id": "red-m1-r1.00-v0.1", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 1.0, + "perceptual_roughness": 1.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.212207, + 0.021222, + 0.007959 + ], + "direct_brdf_cos": [ + 0.106103, + 0.010611, + 0.00398 + ], + "direct_pdf": 0.028775, + "white_furnace_reflectance": [ + 0.612838, + 0.082696, + 0.04588 + ] + }, + { + "id": "red-m1-r1.00-v0.5", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 1.0, + "perceptual_roughness": 1.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.127324, + 0.012732, + 0.004775 + ], + "direct_brdf_cos": [ + 0.063662, + 0.006366, + 0.002387 + ], + "direct_pdf": 0.044699, + "white_furnace_reflectance": [ + 0.361151, + 0.038805, + 0.01642 + ] + }, + { + "id": "red-m1-r1.00-v1.0", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 1.0, + "perceptual_roughness": 1.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_specular": [ + 0.084884, + 0.008492, + 0.003188 + ], + "direct_brdf_cos": [ + 0.042442, + 0.004246, + 0.001594 + ], + "direct_pdf": 0.079578, + "white_furnace_reflectance": [ + 0.2455, + 0.02458, + 0.009239 + ] + } + ] +} diff --git a/tools/bloom-reference/reference/layered-pbr-v2.json b/tools/bloom-reference/reference/layered-pbr-v2.json new file mode 100644 index 00000000..a0ef94ba --- /dev/null +++ b/tools/bloom-reference/reference/layered-pbr-v2.json @@ -0,0 +1,1809 @@ +{ + "schema": "bloom-layered-pbr-reference", + "version": 2, + "base_contract_version": 1, + "model": "version-1 base + KHR clearcoat/specular/IOR", + "ior_zero_mode": "positive-infinity compatibility mode; dielectric F0=1", + "specular_fresnel": "IOR F0 * color (clamped before factor), explicit factor F90", + "diffuse_complement": "max-channel dielectric Fresnel at reciprocal view/light interfaces", + "clearcoat_ior": 1.5, + "clearcoat_layering": "fixed-IOR GGX; reciprocal view/light transmission attenuates the full base", + "minimum_perceptual_roughness": 0.04, + "furnace_integration": "96x192 deterministic per-lobe GGX-VNDF and cosine samples", + "samples": [ + { + "id": "base-neutral-dielectric-v0.1", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 0.0, + "perceptual_roughness": 0.5, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.021014, + 0.021014, + 0.021014 + ], + "direct_base_specular": [ + 0.002616, + 0.002616, + 0.002616 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.011815, + 0.011815, + 0.011815 + ], + "direct_pdf": 0.063921, + "white_furnace_reflectance": [ + 0.2248, + 0.2248, + 0.2248 + ] + }, + { + "id": "base-neutral-dielectric-v0.5", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 0.0, + "perceptual_roughness": 0.5, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.042197, + 0.042197, + 0.042197 + ], + "direct_base_specular": [ + 0.001346, + 0.001346, + 0.001346 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.021771, + 0.021771, + 0.021771 + ], + "direct_pdf": 0.148374, + "white_furnace_reflectance": [ + 0.184002, + 0.184002, + 0.184002 + ] + }, + { + "id": "base-neutral-dielectric-v1.0", + "base_color": [ + 0.18, + 0.18, + 0.18 + ], + "metallic": 0.0, + "perceptual_roughness": 0.5, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.042847, + 0.042847, + 0.042847 + ], + "direct_base_specular": [ + 0.004325, + 0.004325, + 0.004325 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.023586, + 0.023586, + 0.023586 + ], + "direct_pdf": 0.155046, + "white_furnace_reflectance": [ + 0.168662, + 0.168662, + 0.168662 + ] + }, + { + "id": "base-red-mixed-v0.1", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.5, + "perceptual_roughness": 0.25, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.014735, + 0.00387, + 0.00153 + ], + "direct_base_specular": [ + 0.003006, + 0.00043, + 0.000251 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.008871, + 0.00215, + 0.00089 + ], + "direct_pdf": 0.036598, + "white_furnace_reflectance": [ + 0.645461, + 0.432897, + 0.413709 + ] + }, + { + "id": "base-red-mixed-v0.5", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.5, + "perceptual_roughness": 0.25, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.036676, + 0.009634, + 0.003807 + ], + "direct_base_specular": [ + 0.001001, + 0.000143, + 0.000083 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.018839, + 0.004888, + 0.001945 + ], + "direct_pdf": 0.1118, + "white_furnace_reflectance": [ + 0.547174, + 0.119642, + 0.077863 + ] + }, + { + "id": "base-red-mixed-v1.0", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.5, + "perceptual_roughness": 0.25, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.037841, + 0.00994, + 0.003928 + ], + "direct_base_specular": [ + 0.00407, + 0.000582, + 0.00034 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.020956, + 0.005261, + 0.002134 + ], + "direct_pdf": 0.118509, + "white_furnace_reflectance": [ + 0.536413, + 0.090617, + 0.047101 + ] + }, + { + "id": "base-gold-conductor-v0.1", + "base_color": [ + 1.0, + 0.71, + 0.29 + ], + "metallic": 1.0, + "perceptual_roughness": 0.4, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.034921, + 0.024794, + 0.010127 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.01746, + 0.012397, + 0.005064 + ], + "direct_pdf": 0.000921, + "white_furnace_reflectance": [ + 0.888224, + 0.69232, + 0.40859 + ] + }, + { + "id": "base-gold-conductor-v0.5", + "base_color": [ + 1.0, + 0.71, + 0.29 + ], + "metallic": 1.0, + "perceptual_roughness": 0.4, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.014885, + 0.010568, + 0.004317 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.007442, + 0.005284, + 0.002158 + ], + "direct_pdf": 0.002169, + "white_furnace_reflectance": [ + 0.926366, + 0.666112, + 0.289192 + ] + }, + { + "id": "base-gold-conductor-v1.0", + "base_color": [ + 1.0, + 0.71, + 0.29 + ], + "metallic": 1.0, + "perceptual_roughness": 0.4, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.055183, + 0.03918, + 0.016005 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.027591, + 0.01959, + 0.008002 + ], + "direct_pdf": 0.028111, + "white_furnace_reflectance": [ + 0.967294, + 0.686825, + 0.280548 + ] + }, + { + "id": "ior-water-v0.1", + "base_color": [ + 0.7, + 0.7, + 0.7 + ], + "metallic": 0.0, + "perceptual_roughness": 0.2, + "ior": 1.33, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.070917, + 0.070917, + 0.070917 + ], + "direct_base_specular": [ + 0.000062, + 0.000062, + 0.000062 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.035489, + 0.035489, + 0.035489 + ], + "direct_pdf": 0.063903, + "white_furnace_reflectance": [ + 0.680237, + 0.680237, + 0.680237 + ] + }, + { + "id": "ior-water-v0.5", + "base_color": [ + 0.7, + 0.7, + 0.7 + ], + "metallic": 0.0, + "perceptual_roughness": 0.2, + "ior": 1.33, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.185559, + 0.185559, + 0.185559 + ], + "direct_base_specular": [ + 0.00002, + 0.00002, + 0.00002 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.092789, + 0.092789, + 0.092789 + ], + "direct_pdf": 0.151095, + "white_furnace_reflectance": [ + 0.620592, + 0.620592, + 0.620592 + ] + }, + { + "id": "ior-water-v1.0", + "base_color": [ + 0.7, + 0.7, + 0.7 + ], + "metallic": 0.0, + "perceptual_roughness": 0.2, + "ior": 1.33, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.192071, + 0.192071, + 0.192071 + ], + "direct_base_specular": [ + 0.000081, + 0.000081, + 0.000081 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.096076, + 0.096076, + 0.096076 + ], + "direct_pdf": 0.156003, + "white_furnace_reflectance": [ + 0.61224, + 0.61224, + 0.61224 + ] + }, + { + "id": "ior-diamond-v0.1", + "base_color": [ + 0.7, + 0.7, + 0.7 + ], + "metallic": 0.0, + "perceptual_roughness": 0.2, + "ior": 2.42, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.050582, + 0.050582, + 0.050582 + ], + "direct_base_specular": [ + 0.000531, + 0.000531, + 0.000531 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.025557, + 0.025557, + 0.025557 + ], + "direct_pdf": 0.053978, + "white_furnace_reflectance": [ + 0.692634, + 0.692634, + 0.692634 + ] + }, + { + "id": "ior-diamond-v0.5", + "base_color": [ + 0.7, + 0.7, + 0.7 + ], + "metallic": 0.0, + "perceptual_roughness": 0.2, + "ior": 2.42, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.132351, + 0.132351, + 0.132351 + ], + "direct_base_specular": [ + 0.000169, + 0.000169, + 0.000169 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.06626, + 0.06626, + 0.06626 + ], + "direct_pdf": 0.127629, + "white_furnace_reflectance": [ + 0.604324, + 0.604324, + 0.604324 + ] + }, + { + "id": "ior-diamond-v1.0", + "base_color": [ + 0.7, + 0.7, + 0.7 + ], + "metallic": 0.0, + "perceptual_roughness": 0.2, + "ior": 2.42, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.136996, + 0.136996, + 0.136996 + ], + "direct_base_specular": [ + 0.000695, + 0.000695, + 0.000695 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.068846, + 0.068846, + 0.068846 + ], + "direct_pdf": 0.132065, + "white_furnace_reflectance": [ + 0.594757, + 0.594757, + 0.594757 + ] + }, + { + "id": "ior-zero-compatibility-v0.1", + "base_color": [ + 0.4, + 0.1, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 0.35, + "ior": 0.0, + "specular_factor": 1.0, + "specular_color": [ + 0.9, + 0.35, + 0.1 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.000468, + 0.000117, + 0.000035 + ], + "direct_base_specular": [ + 0.020736, + 0.008064, + 0.002304 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.010602, + 0.004091, + 0.00117 + ], + "direct_pdf": 0.008504, + "white_furnace_reflectance": [ + 0.823731, + 0.480266, + 0.324354 + ] + }, + { + "id": "ior-zero-compatibility-v0.5", + "base_color": [ + 0.4, + 0.1, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 0.35, + "ior": 0.0, + "specular_factor": 1.0, + "specular_color": [ + 0.9, + 0.35, + 0.1 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.001062, + 0.000265, + 0.00008 + ], + "direct_base_specular": [ + 0.008037, + 0.003125, + 0.000893 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.004549, + 0.001695, + 0.000486 + ], + "direct_pdf": 0.028396, + "white_furnace_reflectance": [ + 0.865142, + 0.355059, + 0.123743 + ] + }, + { + "id": "ior-zero-compatibility-v1.0", + "base_color": [ + 0.4, + 0.1, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 0.35, + "ior": 0.0, + "specular_factor": 1.0, + "specular_color": [ + 0.9, + 0.35, + 0.1 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.001088, + 0.000272, + 0.000082 + ], + "direct_base_specular": [ + 0.031146, + 0.012113, + 0.003462 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.016117, + 0.006193, + 0.001772 + ], + "direct_pdf": 0.043252, + "white_furnace_reflectance": [ + 0.886868, + 0.344398, + 0.098436 + ] + }, + { + "id": "specular-disabled-v0.1", + "base_color": [ + 0.5, + 0.15, + 0.04 + ], + "metallic": 0.0, + "perceptual_roughness": 0.55, + "ior": 1.5, + "specular_factor": 0.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.163242, + 0.048973, + 0.013059 + ], + "direct_base_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.081621, + 0.024486, + 0.00653 + ], + "direct_pdf": 0.159155, + "white_furnace_reflectance": [ + 0.430793, + 0.129237, + 0.034463 + ] + }, + { + "id": "specular-disabled-v0.5", + "base_color": [ + 0.5, + 0.15, + 0.04 + ], + "metallic": 0.0, + "perceptual_roughness": 0.55, + "ior": 1.5, + "specular_factor": 0.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.1335, + 0.04005, + 0.01068 + ], + "direct_base_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.06675, + 0.020025, + 0.00534 + ], + "direct_pdf": 0.159155, + "white_furnace_reflectance": [ + 0.41246, + 0.123738, + 0.032997 + ] + }, + { + "id": "specular-disabled-v1.0", + "base_color": [ + 0.5, + 0.15, + 0.04 + ], + "metallic": 0.0, + "perceptual_roughness": 0.55, + "ior": 1.5, + "specular_factor": 0.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.130906, + 0.039272, + 0.010472 + ], + "direct_base_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.065453, + 0.019636, + 0.005236 + ], + "direct_pdf": 0.159155, + "white_furnace_reflectance": [ + 0.410754, + 0.123221, + 0.032862 + ] + }, + { + "id": "specular-half-v0.1", + "base_color": [ + 0.5, + 0.15, + 0.04 + ], + "metallic": 0.0, + "perceptual_roughness": 0.55, + "ior": 1.5, + "specular_factor": 0.5, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.109729, + 0.032919, + 0.008778 + ], + "direct_base_specular": [ + 0.001667, + 0.001667, + 0.001667 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.055698, + 0.017293, + 0.005223 + ], + "direct_pdf": 0.111845, + "white_furnace_reflectance": [ + 0.359371, + 0.158405, + 0.095244 + ] + }, + { + "id": "specular-half-v0.5", + "base_color": [ + 0.5, + 0.15, + 0.04 + ], + "metallic": 0.0, + "perceptual_roughness": 0.55, + "ior": 1.5, + "specular_factor": 0.5, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.124319, + 0.037296, + 0.009945 + ], + "direct_base_specular": [ + 0.00093, + 0.00093, + 0.00093 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.062624, + 0.019113, + 0.005438 + ], + "direct_pdf": 0.153842, + "white_furnace_reflectance": [ + 0.406289, + 0.139704, + 0.05592 + ] + }, + { + "id": "specular-half-v1.0", + "base_color": [ + 0.5, + 0.15, + 0.04 + ], + "metallic": 0.0, + "perceptual_roughness": 0.55, + "ior": 1.5, + "specular_factor": 0.5, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.123798, + 0.037139, + 0.009904 + ], + "direct_base_specular": [ + 0.002698, + 0.002698, + 0.002698 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.063248, + 0.019919, + 0.006301 + ], + "direct_pdf": 0.157406, + "white_furnace_reflectance": [ + 0.402569, + 0.133042, + 0.048329 + ] + }, + { + "id": "specular-colored-v0.1", + "base_color": [ + 0.5, + 0.15, + 0.04 + ], + "metallic": 0.0, + "perceptual_roughness": 0.55, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.5, + 0.4, + 0.2 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.057222, + 0.017167, + 0.004578 + ], + "direct_base_specular": [ + 0.005001, + 0.001334, + 0.000667 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.031112, + 0.00925, + 0.002623 + ], + "direct_pdf": 0.064055, + "white_furnace_reflectance": [ + 0.30838, + 0.170447, + 0.131615 + ] + }, + { + "id": "specular-colored-v0.5", + "base_color": [ + 0.5, + 0.15, + 0.04 + ], + "metallic": 0.0, + "perceptual_roughness": 0.55, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.5, + 0.4, + 0.2 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.110703, + 0.033211, + 0.008856 + ], + "direct_base_specular": [ + 0.00279, + 0.000744, + 0.000372 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.056747, + 0.016978, + 0.004614 + ], + "direct_pdf": 0.150011, + "white_furnace_reflectance": [ + 0.402933, + 0.13248, + 0.05215 + ] + }, + { + "id": "specular-colored-v1.0", + "base_color": [ + 0.5, + 0.15, + 0.04 + ], + "metallic": 0.0, + "perceptual_roughness": 0.55, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.5, + 0.4, + 0.2 + ], + "clearcoat_factor": 0.0, + "clearcoat_perceptual_roughness": 0.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.112054, + 0.033616, + 0.008964 + ], + "direct_base_specular": [ + 0.008091, + 0.002162, + 0.001084 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.060073, + 0.017889, + 0.005024 + ], + "direct_pdf": 0.156626, + "white_furnace_reflectance": [ + 0.397738, + 0.117599, + 0.034652 + ] + }, + { + "id": "clearcoat-smooth-v0.1", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 0.7, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 1.0, + "clearcoat_perceptual_roughness": 0.04, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.036891, + 0.003689, + 0.001383 + ], + "direct_base_specular": [ + 0.002111, + 0.002111, + 0.002111 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.019501, + 0.0029, + 0.001747 + ], + "direct_pdf": 0.042037, + "white_furnace_reflectance": [ + 0.729145, + 0.645774, + 0.639984 + ] + }, + { + "id": "clearcoat-smooth-v0.5", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 0.7, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 1.0, + "clearcoat_perceptual_roughness": 0.04, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.152317, + 0.015232, + 0.005712 + ], + "direct_base_specular": [ + 0.003258, + 0.003258, + 0.003258 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.077787, + 0.009245, + 0.004485 + ], + "direct_pdf": 0.139466, + "white_furnace_reflectance": [ + 0.558705, + 0.146431, + 0.117801 + ] + }, + { + "id": "clearcoat-smooth-v1.0", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.0, + "perceptual_roughness": 0.7, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 1.0, + "clearcoat_perceptual_roughness": 0.04, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.157653, + 0.015765, + 0.005912 + ], + "direct_base_specular": [ + 0.00639, + 0.00639, + 0.00639 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.082022, + 0.011078, + 0.006151 + ], + "direct_pdf": 0.150885, + "white_furnace_reflectance": [ + 0.547595, + 0.113025, + 0.082851 + ] + }, + { + "id": "clearcoat-rough-v0.1", + "base_color": [ + 1.0, + 0.71, + 0.29 + ], + "metallic": 1.0, + "perceptual_roughness": 0.7, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 1.0, + "clearcoat_perceptual_roughness": 0.75, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.052776, + 0.037471, + 0.015305 + ], + "direct_clearcoat_specular": [ + 0.006638, + 0.006638, + 0.006638 + ], + "direct_brdf_cos": [ + 0.029707, + 0.022054, + 0.010971 + ], + "direct_pdf": 0.009203, + "white_furnace_reflectance": [ + 0.38354, + 0.302241, + 0.184494 + ] + }, + { + "id": "clearcoat-rough-v0.5", + "base_color": [ + 1.0, + 0.71, + 0.29 + ], + "metallic": 1.0, + "perceptual_roughness": 0.7, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 1.0, + "clearcoat_perceptual_roughness": 0.75, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.081454, + 0.057833, + 0.023622 + ], + "direct_clearcoat_specular": [ + 0.00442, + 0.00442, + 0.00442 + ], + "direct_brdf_cos": [ + 0.042937, + 0.031126, + 0.014021 + ], + "direct_pdf": 0.01775, + "white_furnace_reflectance": [ + 0.62581, + 0.456485, + 0.211258 + ] + }, + { + "id": "clearcoat-rough-v1.0", + "base_color": [ + 1.0, + 0.71, + 0.29 + ], + "metallic": 1.0, + "perceptual_roughness": 0.7, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 1.0, + "clearcoat_perceptual_roughness": 0.75, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.159586, + 0.113308, + 0.046285 + ], + "direct_clearcoat_specular": [ + 0.007087, + 0.007087, + 0.007087 + ], + "direct_brdf_cos": [ + 0.083337, + 0.060198, + 0.026686 + ], + "direct_pdf": 0.103453, + "white_furnace_reflectance": [ + 0.643161, + 0.463965, + 0.204392 + ] + }, + { + "id": "clearcoat-half-v0.1", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.5, + "perceptual_roughness": 0.35, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.5, + "clearcoat_perceptual_roughness": 0.2, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.010578, + 0.002779, + 0.001098 + ], + "direct_base_specular": [ + 0.006505, + 0.000929, + 0.000542 + ], + "direct_clearcoat_specular": [ + 0.000062, + 0.000062, + 0.000062 + ], + "direct_brdf_cos": [ + 0.008572, + 0.001885, + 0.000851 + ], + "direct_pdf": 0.027274, + "white_furnace_reflectance": [ + 0.584646, + 0.422886, + 0.408565 + ] + }, + { + "id": "clearcoat-half-v0.5", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.5, + "perceptual_roughness": 0.35, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.5, + "clearcoat_perceptual_roughness": 0.2, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.033262, + 0.008737, + 0.003453 + ], + "direct_base_specular": [ + 0.003493, + 0.000499, + 0.000291 + ], + "direct_clearcoat_specular": [ + 0.00002, + 0.00002, + 0.00002 + ], + "direct_brdf_cos": [ + 0.018387, + 0.004628, + 0.001882 + ], + "direct_pdf": 0.106465, + "white_furnace_reflectance": [ + 0.520357, + 0.14032, + 0.103009 + ] + }, + { + "id": "clearcoat-half-v1.0", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.5, + "perceptual_roughness": 0.35, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "clearcoat_factor": 0.5, + "clearcoat_perceptual_roughness": 0.2, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.034629, + 0.009096, + 0.003595 + ], + "direct_base_specular": [ + 0.013746, + 0.001965, + 0.001147 + ], + "direct_clearcoat_specular": [ + 0.000081, + 0.000081, + 0.000081 + ], + "direct_brdf_cos": [ + 0.024228, + 0.005571, + 0.002411 + ], + "direct_pdf": 0.11827, + "white_furnace_reflectance": [ + 0.521381, + 0.104399, + 0.063969 + ] + }, + { + "id": "clearcoat-specular-ior-combined-v0.1", + "base_color": [ + 0.12, + 0.35, + 0.8 + ], + "metallic": 0.15, + "perceptual_roughness": 0.42, + "ior": 1.76, + "specular_factor": 0.7, + "specular_color": [ + 1.2, + 0.7, + 0.3 + ], + "clearcoat_factor": 0.85, + "clearcoat_perceptual_roughness": 0.14, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.00732, + 0.021351, + 0.048801 + ], + "direct_base_specular": [ + 0.001325, + 0.001545, + 0.002453 + ], + "direct_clearcoat_specular": [ + 0.000026, + 0.000026, + 0.000026 + ], + "direct_brdf_cos": [ + 0.004336, + 0.011461, + 0.02564 + ], + "direct_pdf": 0.051895, + "white_furnace_reflectance": [ + 0.573842, + 0.614725, + 0.702369 + ] + }, + { + "id": "clearcoat-specular-ior-combined-v0.5", + "base_color": [ + 0.12, + 0.35, + 0.8 + ], + "metallic": 0.15, + "perceptual_roughness": 0.42, + "ior": 1.76, + "specular_factor": 0.7, + "specular_color": [ + 1.2, + 0.7, + 0.3 + ], + "clearcoat_factor": 0.85, + "clearcoat_perceptual_roughness": 0.14, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.021019, + 0.061306, + 0.140129 + ], + "direct_base_specular": [ + 0.001141, + 0.00133, + 0.002112 + ], + "direct_clearcoat_specular": [ + 0.000008, + 0.000008, + 0.000008 + ], + "direct_brdf_cos": [ + 0.011084, + 0.031322, + 0.071124 + ], + "direct_pdf": 0.130098, + "white_furnace_reflectance": [ + 0.19485, + 0.326611, + 0.604308 + ] + }, + { + "id": "clearcoat-specular-ior-combined-v1.0", + "base_color": [ + 0.12, + 0.35, + 0.8 + ], + "metallic": 0.15, + "perceptual_roughness": 0.42, + "ior": 1.76, + "specular_factor": 0.7, + "specular_color": [ + 1.2, + 0.7, + 0.3 + ], + "clearcoat_factor": 0.85, + "clearcoat_perceptual_roughness": 0.14, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.021808, + 0.063608, + 0.145389 + ], + "direct_base_specular": [ + 0.004249, + 0.004952, + 0.007864 + ], + "direct_clearcoat_specular": [ + 0.000033, + 0.000033, + 0.000033 + ], + "direct_brdf_cos": [ + 0.013045, + 0.034297, + 0.076643 + ], + "direct_pdf": 0.140195, + "white_furnace_reflectance": [ + 0.165241, + 0.304536, + 0.60028 + ] + } + ] +} diff --git a/tools/bloom-reference/reference/layered-pbr-v3.json b/tools/bloom-reference/reference/layered-pbr-v3.json new file mode 100644 index 00000000..3ffa079a --- /dev/null +++ b/tools/bloom-reference/reference/layered-pbr-v3.json @@ -0,0 +1,1546 @@ +{ + "schema": "bloom-layered-pbr-reference", + "version": 3, + "previous_contract_version": 2, + "model": "version-2 layered PBR + KHR sheen/anisotropy", + "sheen_distribution": "Charlie with alpha_g = perceptual roughness squared", + "sheen_visibility": "full fitted Charlie lambda; no Ashikhmin shortcut", + "sheen_layering": "max-channel reciprocal view/light directional-albedo scaling below clearcoat", + "sheen_albedo_lut": "128x128 R16F, 4096 Charlie-importance samples per texel", + "anisotropic_distribution": "Burley anisotropic GGX; alpha_t=mix(alpha,1,strength^2)", + "anisotropic_visibility": "height-correlated anisotropic Smith", + "anisotropy_frame": "counter-clockwise radians from glTF tangent; mirrored handedness retained", + "furnace_integration": "96x192 deterministic anisotropic GGX-VNDF, Charlie, clearcoat, and cosine samples", + "samples": [ + { + "id": "v2-default-v0.1", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.2, + "perceptual_roughness": 0.4, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.5, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.033813, + 0.004694, + 0.001797 + ], + "direct_base_specular": [ + 0.004507, + 0.001127, + 0.000892 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.000062, + 0.000062, + 0.000062 + ], + "direct_brdf_cos": [ + 0.019191, + 0.002941, + 0.001376 + ], + "direct_pdf": 0.040639, + "white_furnace_reflectance": [ + 0.540719, + 0.400489, + 0.388411 + ] + }, + { + "id": "v2-default-v0.5", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.2, + "perceptual_roughness": 0.4, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.5, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.101868, + 0.014141, + 0.005415 + ], + "direct_base_specular": [ + 0.002661, + 0.000665, + 0.000527 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.00002, + 0.00002, + 0.00002 + ], + "direct_brdf_cos": [ + 0.052274, + 0.007413, + 0.002981 + ], + "direct_pdf": 0.131862, + "white_furnace_reflectance": [ + 0.528696, + 0.14307, + 0.10827 + ] + }, + { + "id": "v2-default-v1.0", + "base_color": [ + 0.8, + 0.08, + 0.03 + ], + "metallic": 0.2, + "perceptual_roughness": 0.4, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.5, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.105716, + 0.014675, + 0.00562 + ], + "direct_base_specular": [ + 0.010022, + 0.002507, + 0.001985 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.000081, + 0.000081, + 0.000081 + ], + "direct_brdf_cos": [ + 0.057909, + 0.008632, + 0.003843 + ], + "direct_pdf": 0.141797, + "white_furnace_reflectance": [ + 0.52296, + 0.10957, + 0.072451 + ] + }, + { + "id": "velvet-smooth-v0.1", + "base_color": [ + 0.12, + 0.02, + 0.01 + ], + "metallic": 0.0, + "perceptual_roughness": 0.7, + "sheen_color": [ + 0.9, + 0.08, + 0.03 + ], + "sheen_perceptual_roughness": 0.08, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.008826, + 0.001471, + 0.000735 + ], + "direct_base_specular": [ + 0.003367, + 0.003367, + 0.003367 + ], + "direct_sheen_specular": [ + 0.000742, + 0.000066, + 0.000025 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.006468, + 0.002452, + 0.002064 + ], + "direct_pdf": 0.050608, + "white_furnace_reflectance": [ + 0.492944, + 0.094477, + 0.069457 + ] + }, + { + "id": "velvet-smooth-v0.5", + "base_color": [ + 0.12, + 0.02, + 0.01 + ], + "metallic": 0.0, + "perceptual_roughness": 0.7, + "sheen_color": [ + 0.9, + 0.08, + 0.03 + ], + "sheen_perceptual_roughness": 0.08, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.026414, + 0.004402, + 0.002201 + ], + "direct_base_specular": [ + 0.003767, + 0.003767, + 0.003767 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.01509, + 0.004085, + 0.002984 + ], + "direct_pdf": 0.111642, + "white_furnace_reflectance": [ + 0.115857, + 0.049825, + 0.043222 + ] + }, + { + "id": "velvet-smooth-v1.0", + "base_color": [ + 0.12, + 0.02, + 0.01 + ], + "metallic": 0.0, + "perceptual_roughness": 0.7, + "sheen_color": [ + 0.9, + 0.08, + 0.03 + ], + "sheen_perceptual_roughness": 0.08, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.026485, + 0.004414, + 0.002207 + ], + "direct_base_specular": [ + 0.007157, + 0.007157, + 0.007157 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.016821, + 0.005785, + 0.004682 + ], + "direct_pdf": 0.117397, + "white_furnace_reflectance": [ + 0.108553, + 0.041099, + 0.034353 + ] + }, + { + "id": "velvet-rough-v0.1", + "base_color": [ + 0.18, + 0.04, + 0.01 + ], + "metallic": 0.0, + "perceptual_roughness": 0.85, + "sheen_color": [ + 0.7, + 0.3, + 0.08 + ], + "sheen_perceptual_roughness": 0.8, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.012146, + 0.002699, + 0.000675 + ], + "direct_base_specular": [ + 0.004292, + 0.004292, + 0.004292 + ], + "direct_sheen_specular": [ + 0.214918, + 0.092108, + 0.024562 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.115678, + 0.04955, + 0.014765 + ], + "direct_pdf": 0.065866, + "white_furnace_reflectance": [ + 0.549567, + 0.249373, + 0.092091 + ] + }, + { + "id": "velvet-rough-v0.5", + "base_color": [ + 0.18, + 0.04, + 0.01 + ], + "metallic": 0.0, + "perceptual_roughness": 0.85, + "sheen_color": [ + 0.7, + 0.3, + 0.08 + ], + "sheen_perceptual_roughness": 0.8, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.029293, + 0.00651, + 0.001627 + ], + "direct_base_specular": [ + 0.004312, + 0.004312, + 0.004312 + ], + "direct_sheen_specular": [ + 0.116084, + 0.04975, + 0.013267 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.074845, + 0.030286, + 0.009603 + ], + "direct_pdf": 0.126726, + "white_furnace_reflectance": [ + 0.328084, + 0.134767, + 0.050946 + ] + }, + { + "id": "velvet-rough-v1.0", + "base_color": [ + 0.18, + 0.04, + 0.01 + ], + "metallic": 0.0, + "perceptual_roughness": 0.85, + "sheen_color": [ + 0.7, + 0.3, + 0.08 + ], + "sheen_perceptual_roughness": 0.8, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.029098, + 0.006466, + 0.001617 + ], + "direct_base_specular": [ + 0.004841, + 0.004841, + 0.004841 + ], + "direct_sheen_specular": [ + 0.036889, + 0.015809, + 0.004216 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.035414, + 0.013558, + 0.005337 + ], + "direct_pdf": 0.128017, + "white_furnace_reflectance": [ + 0.192053, + 0.071765, + 0.030422 + ] + }, + { + "id": "brushed-half-v0.1", + "base_color": [ + 0.9, + 0.45, + 0.12 + ], + "metallic": 1.0, + "perceptual_roughness": 0.28, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.5, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.016653, + 0.008326, + 0.00222 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.008326, + 0.004163, + 0.00111 + ], + "direct_pdf": 0.001673, + "white_furnace_reflectance": [ + 0.452217, + 0.242993, + 0.08956 + ] + }, + { + "id": "brushed-half-v0.5", + "base_color": [ + 0.9, + 0.45, + 0.12 + ], + "metallic": 1.0, + "perceptual_roughness": 0.28, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.5, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.018844, + 0.009422, + 0.002512 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.009422, + 0.004711, + 0.001256 + ], + "direct_pdf": 0.003245, + "white_furnace_reflectance": [ + 0.746006, + 0.380275, + 0.112073 + ] + }, + { + "id": "brushed-half-v1.0", + "base_color": [ + 0.9, + 0.45, + 0.12 + ], + "metallic": 1.0, + "perceptual_roughness": 0.28, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.5, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.011377, + 0.005689, + 0.001517 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.005688, + 0.002844, + 0.000759 + ], + "direct_pdf": 0.006552, + "white_furnace_reflectance": [ + 0.834041, + 0.41703, + 0.111223 + ] + }, + { + "id": "brushed-strong-v0.1", + "base_color": [ + 0.9, + 0.45, + 0.12 + ], + "metallic": 1.0, + "perceptual_roughness": 0.28, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 1.0, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.008494, + 0.004247, + 0.001133 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.004247, + 0.002123, + 0.000566 + ], + "direct_pdf": 0.000988, + "white_furnace_reflectance": [ + 0.735484, + 0.375329, + 0.111215 + ] + }, + { + "id": "brushed-strong-v0.5", + "base_color": [ + 0.9, + 0.45, + 0.12 + ], + "metallic": 1.0, + "perceptual_roughness": 0.28, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 1.0, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.006273, + 0.003137, + 0.000836 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.003137, + 0.001568, + 0.000418 + ], + "direct_pdf": 0.001754, + "white_furnace_reflectance": [ + 0.585853, + 0.294659, + 0.081118 + ] + }, + { + "id": "brushed-strong-v1.0", + "base_color": [ + 0.9, + 0.45, + 0.12 + ], + "metallic": 1.0, + "perceptual_roughness": 0.28, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 1.0, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.003143, + 0.001572, + 0.000419 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.001571, + 0.000786, + 0.00021 + ], + "direct_pdf": 0.002256, + "white_furnace_reflectance": [ + 0.47762, + 0.238824, + 0.063707 + ] + }, + { + "id": "brushed-rotated-45-v0.1", + "base_color": [ + 0.75, + 0.78, + 0.82 + ], + "metallic": 1.0, + "perceptual_roughness": 0.32, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.85, + "anisotropy_rotation": 0.7853982, + "clearcoat_factor": 0.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.012676, + 0.013183, + 0.01386 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.006338, + 0.006592, + 0.00693 + ], + "direct_pdf": 0.001528, + "white_furnace_reflectance": [ + 0.463076, + 0.480346, + 0.503373 + ] + }, + { + "id": "brushed-rotated-45-v0.5", + "base_color": [ + 0.75, + 0.78, + 0.82 + ], + "metallic": 1.0, + "perceptual_roughness": 0.32, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.85, + "anisotropy_rotation": 0.7853982, + "clearcoat_factor": 0.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.015863, + 0.016497, + 0.017343 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.007931, + 0.008249, + 0.008672 + ], + "direct_pdf": 0.004462, + "white_furnace_reflectance": [ + 0.521643, + 0.542156, + 0.56951 + ] + }, + { + "id": "brushed-rotated-45-v1.0", + "base_color": [ + 0.75, + 0.78, + 0.82 + ], + "metallic": 1.0, + "perceptual_roughness": 0.32, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.85, + "anisotropy_rotation": 0.7853982, + "clearcoat_factor": 0.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.825614, + 0.858637, + 0.902667 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.412807, + 0.429318, + 0.451334 + ], + "direct_pdf": 0.726699, + "white_furnace_reflectance": [ + 0.496179, + 0.516023, + 0.542486 + ] + }, + { + "id": "brushed-rotated-90-v0.1", + "base_color": [ + 0.75, + 0.78, + 0.82 + ], + "metallic": 1.0, + "perceptual_roughness": 0.32, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.85, + "anisotropy_rotation": 1.5707964, + "clearcoat_factor": 0.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.000559, + 0.000581, + 0.000611 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.000279, + 0.00029, + 0.000305 + ], + "direct_pdf": 0.000067, + "white_furnace_reflectance": [ + 0.064679, + 0.066346, + 0.068569 + ] + }, + { + "id": "brushed-rotated-90-v0.5", + "base_color": [ + 0.75, + 0.78, + 0.82 + ], + "metallic": 1.0, + "perceptual_roughness": 0.32, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.85, + "anisotropy_rotation": 1.5707964, + "clearcoat_factor": 0.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.000749, + 0.000779, + 0.000819 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.000375, + 0.00039, + 0.00041 + ], + "direct_pdf": 0.000167, + "white_furnace_reflectance": [ + 0.421478, + 0.437544, + 0.45896 + ] + }, + { + "id": "brushed-rotated-90-v1.0", + "base_color": [ + 0.75, + 0.78, + 0.82 + ], + "metallic": 1.0, + "perceptual_roughness": 0.32, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "sheen_perceptual_roughness": 0.0, + "anisotropy_strength": 0.85, + "anisotropy_rotation": 1.5707964, + "clearcoat_factor": 0.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.0079, + 0.008216, + 0.008637 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.00395, + 0.004108, + 0.004319 + ], + "direct_pdf": 0.006226, + "white_furnace_reflectance": [ + 0.496179, + 0.516023, + 0.542486 + ] + }, + { + "id": "fabric-anisotropic-v0.1", + "base_color": [ + 0.03, + 0.08, + 0.3 + ], + "metallic": 0.15, + "perceptual_roughness": 0.4, + "sheen_color": [ + 0.25, + 0.45, + 0.9 + ], + "sheen_perceptual_roughness": 0.45, + "anisotropy_strength": 0.7, + "anisotropy_rotation": 1.1, + "clearcoat_factor": 0.0, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.001423, + 0.003794, + 0.014229 + ], + "direct_base_specular": [ + 0.000225, + 0.000269, + 0.000462 + ], + "direct_sheen_specular": [ + 0.077406, + 0.139331, + 0.278662 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.039527, + 0.071697, + 0.146676 + ], + "direct_pdf": 0.063449, + "white_furnace_reflectance": [ + 0.164958, + 0.282931, + 0.564255 + ] + }, + { + "id": "fabric-anisotropic-v0.5", + "base_color": [ + 0.03, + 0.08, + 0.3 + ], + "metallic": 0.15, + "perceptual_roughness": 0.4, + "sheen_color": [ + 0.25, + 0.45, + 0.9 + ], + "sheen_perceptual_roughness": 0.45, + "anisotropy_strength": 0.7, + "anisotropy_rotation": 1.1, + "clearcoat_factor": 0.0, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.005246, + 0.013991, + 0.052465 + ], + "direct_base_specular": [ + 0.000508, + 0.000607, + 0.001043 + ], + "direct_sheen_specular": [ + 0.030375, + 0.054675, + 0.109349 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.018065, + 0.034636, + 0.081428 + ], + "direct_pdf": 0.115551, + "white_furnace_reflectance": [ + 0.089428, + 0.152387, + 0.359032 + ] + }, + { + "id": "fabric-anisotropic-v1.0", + "base_color": [ + 0.03, + 0.08, + 0.3 + ], + "metallic": 0.15, + "perceptual_roughness": 0.4, + "sheen_color": [ + 0.25, + 0.45, + 0.9 + ], + "sheen_perceptual_roughness": 0.45, + "anisotropy_strength": 0.7, + "anisotropy_rotation": 1.1, + "clearcoat_factor": 0.0, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.005361, + 0.014297, + 0.053613 + ], + "direct_base_specular": [ + 0.00852, + 0.010178, + 0.017473 + ], + "direct_sheen_specular": [ + 0.001791, + 0.003224, + 0.006448 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.007836, + 0.01385, + 0.038767 + ], + "direct_pdf": 0.101911, + "white_furnace_reflectance": [ + 0.048749, + 0.086359, + 0.244909 + ] + }, + { + "id": "coated-fabric-v0.1", + "base_color": [ + 0.2, + 0.03, + 0.01 + ], + "metallic": 0.0, + "perceptual_roughness": 0.65, + "sheen_color": [ + 0.8, + 0.2, + 0.08 + ], + "sheen_perceptual_roughness": 0.5, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.7, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.00742, + 0.001113, + 0.000371 + ], + "direct_base_specular": [ + 0.001473, + 0.001473, + 0.001473 + ], + "direct_sheen_specular": [ + 0.132899, + 0.033225, + 0.01329 + ], + "direct_clearcoat_specular": [ + 0.000057, + 0.000057, + 0.000057 + ], + "direct_brdf_cos": [ + 0.070925, + 0.017934, + 0.007596 + ], + "direct_pdf": 0.051009, + "white_furnace_reflectance": [ + 0.635931, + 0.450746, + 0.415027 + ] + }, + { + "id": "coated-fabric-v0.5", + "base_color": [ + 0.2, + 0.03, + 0.01 + ], + "metallic": 0.0, + "perceptual_roughness": 0.65, + "sheen_color": [ + 0.8, + 0.2, + 0.08 + ], + "sheen_perceptual_roughness": 0.5, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.7, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.034395, + 0.005159, + 0.00172 + ], + "direct_base_specular": [ + 0.002382, + 0.002382, + 0.002382 + ], + "direct_sheen_specular": [ + 0.095093, + 0.023773, + 0.009509 + ], + "direct_clearcoat_specular": [ + 0.000018, + 0.000018, + 0.000018 + ], + "direct_brdf_cos": [ + 0.065944, + 0.015666, + 0.006814 + ], + "direct_pdf": 0.123441, + "white_furnace_reflectance": [ + 0.30754, + 0.125684, + 0.096446 + ] + }, + { + "id": "coated-fabric-v1.0", + "base_color": [ + 0.2, + 0.03, + 0.01 + ], + "metallic": 0.0, + "perceptual_roughness": 0.65, + "sheen_color": [ + 0.8, + 0.2, + 0.08 + ], + "sheen_perceptual_roughness": 0.5, + "anisotropy_strength": 0.0, + "anisotropy_rotation": 0.0, + "clearcoat_factor": 0.7, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.03536, + 0.005304, + 0.001768 + ], + "direct_base_specular": [ + 0.005416, + 0.005416, + 0.005416 + ], + "direct_sheen_specular": [ + 0.009277, + 0.002319, + 0.000928 + ], + "direct_clearcoat_specular": [ + 0.000074, + 0.000074, + 0.000074 + ], + "direct_brdf_cos": [ + 0.025064, + 0.006557, + 0.004093 + ], + "direct_pdf": 0.116753, + "white_furnace_reflectance": [ + 0.18637, + 0.075453, + 0.061308 + ] + }, + { + "id": "all-v3-lobes-v0.1", + "base_color": [ + 0.05, + 0.2, + 0.6 + ], + "metallic": 0.35, + "perceptual_roughness": 0.36, + "sheen_color": [ + 0.2, + 0.55, + 0.9 + ], + "sheen_perceptual_roughness": 0.38, + "anisotropy_strength": 0.75, + "anisotropy_rotation": 0.63, + "clearcoat_factor": 0.65, + "n_dot_v": 0.1, + "direct_diffuse": [ + 0.001315, + 0.005259, + 0.015776 + ], + "direct_base_specular": [ + 0.00192, + 0.003184, + 0.007487 + ], + "direct_sheen_specular": [ + 0.037291, + 0.10255, + 0.167809 + ], + "direct_clearcoat_specular": [ + 0.00008, + 0.00008, + 0.00008 + ], + "direct_brdf_cos": [ + 0.020303, + 0.055536, + 0.095576 + ], + "direct_pdf": 0.057285, + "white_furnace_reflectance": [ + 0.395166, + 0.510963, + 0.661566 + ] + }, + { + "id": "all-v3-lobes-v0.5", + "base_color": [ + 0.05, + 0.2, + 0.6 + ], + "metallic": 0.35, + "perceptual_roughness": 0.36, + "sheen_color": [ + 0.2, + 0.55, + 0.9 + ], + "sheen_perceptual_roughness": 0.38, + "anisotropy_strength": 0.75, + "anisotropy_rotation": 0.63, + "clearcoat_factor": 0.65, + "n_dot_v": 0.5, + "direct_diffuse": [ + 0.006186, + 0.024743, + 0.074229 + ], + "direct_base_specular": [ + 0.00676, + 0.011213, + 0.026365 + ], + "direct_sheen_specular": [ + 0.01867, + 0.051342, + 0.084014 + ], + "direct_clearcoat_specular": [ + 0.000026, + 0.000026, + 0.000026 + ], + "direct_brdf_cos": [ + 0.015821, + 0.043662, + 0.092317 + ], + "direct_pdf": 0.100016, + "white_furnace_reflectance": [ + 0.124522, + 0.239406, + 0.499287 + ] + }, + { + "id": "all-v3-lobes-v1.0", + "base_color": [ + 0.05, + 0.2, + 0.6 + ], + "metallic": 0.35, + "perceptual_roughness": 0.36, + "sheen_color": [ + 0.2, + 0.55, + 0.9 + ], + "sheen_perceptual_roughness": 0.38, + "anisotropy_strength": 0.75, + "anisotropy_rotation": 0.63, + "clearcoat_factor": 0.65, + "n_dot_v": 1.0, + "direct_diffuse": [ + 0.006423, + 0.025691, + 0.077072 + ], + "direct_base_specular": [ + 0.025161, + 0.041723, + 0.098087 + ], + "direct_sheen_specular": [ + 0.0004, + 0.001101, + 0.001802 + ], + "direct_clearcoat_specular": [ + 0.000105, + 0.000105, + 0.000105 + ], + "direct_brdf_cos": [ + 0.016045, + 0.03431, + 0.088533 + ], + "direct_pdf": 0.106588, + "white_furnace_reflectance": [ + 0.085352, + 0.173982, + 0.425885 + ] + } + ] +} diff --git a/tools/bloom-reference/reference/layered-pbr-v4.json b/tools/bloom-reference/reference/layered-pbr-v4.json new file mode 100644 index 00000000..740eb2e2 --- /dev/null +++ b/tools/bloom-reference/reference/layered-pbr-v4.json @@ -0,0 +1,2705 @@ +{ + "schema": "bloom-layered-pbr-reference", + "version": 4, + "previous_contract_version": 3, + "model": "version-3 layered PBR + KHR_materials_iridescence", + "spectral_integration": "Belcour/Barla Fourier sensitivity, orders 0 through 2", + "interfaces": "air / dielectric thin film / dielectric-or-approximated-conductor base", + "diffuse_complement": "reciprocal view/light transmission from max-channel blended dielectric Fresnel", + "inactive_path": "factor zero or thickness zero is exactly the version-3 evaluator", + "color_space": "linear Rec.709, bounded to physical reflectance [0,1]", + "furnace_integration": "96x192 deterministic anisotropic GGX-VNDF, Charlie, clearcoat, and cosine samples", + "samples": [ + { + "id": "v3-inactive-v0.1", + "base_color": [ + 0.08, + 0.3, + 0.75 + ], + "metallic": 0.35, + "perceptual_roughness": 0.36, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.55, + "sheen_color": [ + 0.3, + 0.55, + 0.9 + ], + "anisotropy_strength": 0.7, + "n_dot_v": 0.1, + "raw_dielectric_thin_film_fresnel": [ + 0.662089, + 0.606352, + 0.523609 + ], + "raw_conductor_thin_film_fresnel": [ + 0.702686, + 0.739583, + 0.380778 + ], + "direct_diffuse": [ + 0.001846, + 0.006923, + 0.017308 + ], + "direct_base_specular": [ + 0.001871, + 0.00454, + 0.009998 + ], + "direct_sheen_specular": [ + 0.061194, + 0.112189, + 0.183583 + ], + "direct_clearcoat_specular": [ + 0.000068, + 0.000068, + 0.000068 + ], + "direct_brdf_cos": [ + 0.03249, + 0.06186, + 0.105478 + ], + "direct_pdf": 0.052151, + "white_furnace_reflectance": [ + 0.389383, + 0.497523, + 0.667259 + ] + }, + { + "id": "v3-inactive-v0.5", + "base_color": [ + 0.08, + 0.3, + 0.75 + ], + "metallic": 0.35, + "perceptual_roughness": 0.36, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.55, + "sheen_color": [ + 0.3, + 0.55, + 0.9 + ], + "anisotropy_strength": 0.7, + "n_dot_v": 0.5, + "raw_dielectric_thin_film_fresnel": [ + 0.045862, + 0.029107, + 0.0501 + ], + "raw_conductor_thin_film_fresnel": [ + 0.05856, + 0.107561, + 0.686039 + ], + "direct_diffuse": [ + 0.010263, + 0.038485, + 0.096213 + ], + "direct_base_specular": [ + 0.006194, + 0.015027, + 0.033093 + ], + "direct_sheen_specular": [ + 0.03015, + 0.055276, + 0.090451 + ], + "direct_clearcoat_specular": [ + 0.000022, + 0.000022, + 0.000022 + ], + "direct_brdf_cos": [ + 0.023315, + 0.054405, + 0.109889 + ], + "direct_pdf": 0.098881, + "white_furnace_reflectance": [ + 0.144489, + 0.304295, + 0.61185 + ] + }, + { + "id": "v3-inactive-v1.0", + "base_color": [ + 0.08, + 0.3, + 0.75 + ], + "metallic": 0.35, + "perceptual_roughness": 0.36, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 0.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.55, + "sheen_color": [ + 0.3, + 0.55, + 0.9 + ], + "anisotropy_strength": 0.7, + "n_dot_v": 1.0, + "raw_dielectric_thin_film_fresnel": [ + 0.009947, + 0.039985, + 0.018709 + ], + "raw_conductor_thin_film_fresnel": [ + 0.015807, + 0.301009, + 0.677863 + ], + "direct_diffuse": [ + 0.010695, + 0.040105, + 0.100262 + ], + "direct_base_specular": [ + 0.022956, + 0.055665, + 0.122567 + ], + "direct_sheen_specular": [ + 0.000916, + 0.00168, + 0.002749 + ], + "direct_clearcoat_specular": [ + 0.000089, + 0.000089, + 0.000089 + ], + "direct_brdf_cos": [ + 0.017328, + 0.048769, + 0.112834 + ], + "direct_pdf": 0.108402, + "white_furnace_reflectance": [ + 0.096266, + 0.246767, + 0.553476 + ] + }, + { + "id": "dielectric-thin-100nm-v0.1", + "base_color": [ + 0.55, + 0.55, + 0.55 + ], + "metallic": 0.0, + "perceptual_roughness": 0.3, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 100.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.1, + "raw_dielectric_thin_film_fresnel": [ + 0.568123, + 0.599209, + 0.559489 + ], + "raw_conductor_thin_film_fresnel": [ + 0.792066, + 0.736502, + 0.467696 + ], + "direct_diffuse": [ + 0.05977, + 0.05977, + 0.05977 + ], + "direct_base_specular": [ + 0.0, + 0.000252, + 0.000148 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.029885, + 0.030011, + 0.029959 + ], + "direct_pdf": 0.065502, + "white_furnace_reflectance": [ + 0.463693, + 0.497719, + 0.462565 + ] + }, + { + "id": "dielectric-thin-100nm-v0.5", + "base_color": [ + 0.55, + 0.55, + 0.55 + ], + "metallic": 0.0, + "perceptual_roughness": 0.3, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 100.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.5, + "raw_dielectric_thin_film_fresnel": [ + 0.014771, + 0.050831, + 0.029999 + ], + "raw_conductor_thin_film_fresnel": [ + 0.305423, + 0.4635, + 0.344246 + ], + "direct_diffuse": [ + 0.142046, + 0.142046, + 0.142046 + ], + "direct_base_specular": [ + 0.0, + 0.000089, + 0.000055 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.071023, + 0.071067, + 0.07105 + ], + "direct_pdf": 0.154008, + "white_furnace_reflectance": [ + 0.451986, + 0.485991, + 0.466066 + ] + }, + { + "id": "dielectric-thin-100nm-v1.0", + "base_color": [ + 0.55, + 0.55, + 0.55 + ], + "metallic": 0.0, + "perceptual_roughness": 0.3, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 100.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 1.0, + "raw_dielectric_thin_film_fresnel": [ + 0.0, + 0.017835, + 0.012456 + ], + "raw_conductor_thin_film_fresnel": [ + 0.257122, + 0.438132, + 0.411267 + ], + "direct_diffuse": [ + 0.146442, + 0.146442, + 0.146442 + ], + "direct_base_specular": [ + 0.0, + 0.000364, + 0.000198 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.073221, + 0.073403, + 0.07332 + ], + "direct_pdf": 0.157635, + "white_furnace_reflectance": [ + 0.451763, + 0.469474, + 0.463832 + ] + }, + { + "id": "dielectric-mid-400nm-v0.1", + "base_color": [ + 0.55, + 0.55, + 0.55 + ], + "metallic": 0.0, + "perceptual_roughness": 0.3, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.1, + "raw_dielectric_thin_film_fresnel": [ + 0.662089, + 0.606352, + 0.523609 + ], + "raw_conductor_thin_film_fresnel": [ + 0.894784, + 0.887647, + 0.252886 + ], + "direct_diffuse": [ + 0.050431, + 0.050431, + 0.050431 + ], + "direct_base_specular": [ + 0.0, + 0.000465, + 0.000438 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.025216, + 0.025448, + 0.025435 + ], + "direct_pdf": 0.05769, + "white_furnace_reflectance": [ + 0.515461, + 0.467275, + 0.409656 + ] + }, + { + "id": "dielectric-mid-400nm-v0.5", + "base_color": [ + 0.55, + 0.55, + 0.55 + ], + "metallic": 0.0, + "perceptual_roughness": 0.3, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.5, + "raw_dielectric_thin_film_fresnel": [ + 0.045862, + 0.029107, + 0.0501 + ], + "raw_conductor_thin_film_fresnel": [ + 0.474684, + 0.334131, + 0.462025 + ], + "direct_diffuse": [ + 0.142265, + 0.142265, + 0.142265 + ], + "direct_base_specular": [ + 0.000013, + 0.00018, + 0.000133 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.071139, + 0.071222, + 0.071199 + ], + "direct_pdf": 0.152493, + "white_furnace_reflectance": [ + 0.471774, + 0.457588, + 0.475846 + ] + }, + { + "id": "dielectric-mid-400nm-v1.0", + "base_color": [ + 0.55, + 0.55, + 0.55 + ], + "metallic": 0.0, + "perceptual_roughness": 0.3, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 1.0, + "raw_dielectric_thin_film_fresnel": [ + 0.009947, + 0.039985, + 0.018709 + ], + "raw_conductor_thin_film_fresnel": [ + 0.41117, + 0.551516, + 0.446539 + ], + "direct_diffuse": [ + 0.143249, + 0.143249, + 0.143249 + ], + "direct_base_specular": [ + 0.0, + 0.000568, + 0.000728 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.071625, + 0.071909, + 0.071989 + ], + "direct_pdf": 0.155679, + "white_furnace_reflectance": [ + 0.439781, + 0.470276, + 0.451801 + ] + }, + { + "id": "dielectric-thick-800nm-v0.1", + "base_color": [ + 0.55, + 0.55, + 0.55 + ], + "metallic": 0.0, + "perceptual_roughness": 0.3, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 800.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.1, + "raw_dielectric_thin_film_fresnel": [ + 0.654599, + 0.527167, + 0.634405 + ], + "raw_conductor_thin_film_fresnel": [ + 1.0, + 0.302151, + 0.83819 + ], + "direct_diffuse": [ + 0.049448, + 0.049448, + 0.049448 + ], + "direct_base_specular": [ + 0.000159, + 0.000297, + 0.00027 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.024804, + 0.024873, + 0.024859 + ], + "direct_pdf": 0.058004, + "white_furnace_reflectance": [ + 0.488303, + 0.424534, + 0.503972 + ] + }, + { + "id": "dielectric-thick-800nm-v0.5", + "base_color": [ + 0.55, + 0.55, + 0.55 + ], + "metallic": 0.0, + "perceptual_roughness": 0.3, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 800.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.5, + "raw_dielectric_thin_film_fresnel": [ + 0.0, + 0.088814, + 0.045734 + ], + "raw_conductor_thin_film_fresnel": [ + 0.178569, + 0.646447, + 0.435889 + ], + "direct_diffuse": [ + 0.130904, + 0.130904, + 0.130904 + ], + "direct_base_specular": [ + 0.000009, + 0.000137, + 0.000093 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.065457, + 0.065521, + 0.065499 + ], + "direct_pdf": 0.151721, + "white_furnace_reflectance": [ + 0.416463, + 0.485418, + 0.456133 + ] + }, + { + "id": "dielectric-thick-800nm-v1.0", + "base_color": [ + 0.55, + 0.55, + 0.55 + ], + "metallic": 0.0, + "perceptual_roughness": 0.3, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 800.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 1.0, + "raw_dielectric_thin_film_fresnel": [ + 0.0, + 0.034664, + 0.020762 + ], + "raw_conductor_thin_film_fresnel": [ + 0.326216, + 0.529558, + 0.456851 + ], + "direct_diffuse": [ + 0.138173, + 0.138173, + 0.138173 + ], + "direct_base_specular": [ + 0.00056, + 0.000243, + 0.000428 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.069366, + 0.069208, + 0.0693 + ], + "direct_pdf": 0.156351, + "white_furnace_reflectance": [ + 0.433061, + 0.464999, + 0.452283 + ] + }, + { + "id": "iridescence-half-strength-v0.1", + "base_color": [ + 0.65, + 0.18, + 0.05 + ], + "metallic": 0.0, + "perceptual_roughness": 0.45, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 0.5, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.1, + "raw_dielectric_thin_film_fresnel": [ + 0.662089, + 0.606352, + 0.523609 + ], + "raw_conductor_thin_film_fresnel": [ + 0.912794, + 0.670544, + 0.510706 + ], + "direct_diffuse": [ + 0.069595, + 0.019272, + 0.005353 + ], + "direct_base_specular": [ + 0.000983, + 0.001814, + 0.001766 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.035289, + 0.010543, + 0.003559 + ], + "direct_pdf": 0.061035, + "white_furnace_reflectance": [ + 0.389653, + 0.240885, + 0.189638 + ] + }, + { + "id": "iridescence-half-strength-v0.5", + "base_color": [ + 0.65, + 0.18, + 0.05 + ], + "metallic": 0.0, + "perceptual_roughness": 0.45, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 0.5, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.5, + "raw_dielectric_thin_film_fresnel": [ + 0.045862, + 0.029107, + 0.0501 + ], + "raw_conductor_thin_film_fresnel": [ + 0.585977, + 0.042231, + 0.052953 + ], + "direct_diffuse": [ + 0.157947, + 0.043739, + 0.01215 + ], + "direct_base_specular": [ + 0.000492, + 0.000885, + 0.000776 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.079219, + 0.022312, + 0.006463 + ], + "direct_pdf": 0.150422, + "white_furnace_reflectance": [ + 0.523825, + 0.176785, + 0.089743 + ] + }, + { + "id": "iridescence-half-strength-v1.0", + "base_color": [ + 0.65, + 0.18, + 0.05 + ], + "metallic": 0.0, + "perceptual_roughness": 0.45, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 0.5, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 1.0, + "raw_dielectric_thin_film_fresnel": [ + 0.009947, + 0.039985, + 0.018709 + ], + "raw_conductor_thin_film_fresnel": [ + 0.528916, + 0.180515, + 0.021591 + ], + "direct_diffuse": [ + 0.159189, + 0.044083, + 0.012245 + ], + "direct_base_specular": [ + 0.00161, + 0.002778, + 0.003107 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.0804, + 0.023431, + 0.007676 + ], + "direct_pdf": 0.155421, + "white_furnace_reflectance": [ + 0.506615, + 0.170919, + 0.067545 + ] + }, + { + "id": "film-ior-air-v0.1", + "base_color": [ + 0.55, + 0.55, + 0.55 + ], + "metallic": 0.0, + "perceptual_roughness": 0.3, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.0, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.1, + "raw_dielectric_thin_film_fresnel": [ + 1.0, + 0.781742, + 0.90238 + ], + "raw_conductor_thin_film_fresnel": [ + 1.0, + 0.876488, + 0.96808 + ], + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.000579, + 0.000512, + 0.000488 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.000289, + 0.000256, + 0.000244 + ], + "direct_pdf": 0.000293, + "white_furnace_reflectance": [ + 0.550395, + 0.470725, + 0.437272 + ] + }, + { + "id": "film-ior-air-v0.5", + "base_color": [ + 0.55, + 0.55, + 0.55 + ], + "metallic": 0.0, + "perceptual_roughness": 0.3, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.0, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.5, + "raw_dielectric_thin_film_fresnel": [ + 0.044855, + 0.092847, + 0.170385 + ], + "raw_conductor_thin_film_fresnel": [ + 0.504945, + 0.572077, + 0.666639 + ], + "direct_diffuse": [ + 0.108516, + 0.108516, + 0.108516 + ], + "direct_base_specular": [ + 0.000196, + 0.000177, + 0.000184 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.054356, + 0.054347, + 0.05435 + ], + "direct_pdf": 0.1417, + "white_furnace_reflectance": [ + 0.397695, + 0.441601, + 0.49722 + ] + }, + { + "id": "film-ior-air-v1.0", + "base_color": [ + 0.55, + 0.55, + 0.55 + ], + "metallic": 0.0, + "perceptual_roughness": 0.3, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.0, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 1.0, + "raw_dielectric_thin_film_fresnel": [ + 0.036664, + 0.035217, + 0.041575 + ], + "raw_conductor_thin_film_fresnel": [ + 0.548442, + 0.547764, + 0.550744 + ], + "direct_diffuse": [ + 0.124903, + 0.124903, + 0.124903 + ], + "direct_base_specular": [ + 0.000876, + 0.000763, + 0.000659 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.062889, + 0.062833, + 0.062781 + ], + "direct_pdf": 0.153486, + "white_furnace_reflectance": [ + 0.444362, + 0.442396, + 0.447633 + ] + }, + { + "id": "film-ior-high-v0.1", + "base_color": [ + 0.55, + 0.55, + 0.55 + ], + "metallic": 0.0, + "perceptual_roughness": 0.3, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 2.0, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.1, + "raw_dielectric_thin_film_fresnel": [ + 0.659182, + 0.693983, + 0.581592 + ], + "raw_conductor_thin_film_fresnel": [ + 0.675233, + 0.462573, + 0.850838 + ], + "direct_diffuse": [ + 0.035219, + 0.035219, + 0.035219 + ], + "direct_base_specular": [ + 0.00371, + 0.000475, + 0.002084 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.019464, + 0.017847, + 0.018651 + ], + "direct_pdf": 0.051415, + "white_furnace_reflectance": [ + 0.526472, + 0.527024, + 0.431197 + ] + }, + { + "id": "film-ior-high-v0.5", + "base_color": [ + 0.55, + 0.55, + 0.55 + ], + "metallic": 0.0, + "perceptual_roughness": 0.3, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 2.0, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.5, + "raw_dielectric_thin_film_fresnel": [ + 0.267494, + 0.154106, + 0.111026 + ], + "raw_conductor_thin_film_fresnel": [ + 0.027345, + 0.371209, + 0.496824 + ], + "direct_diffuse": [ + 0.084599, + 0.084599, + 0.084599 + ], + "direct_base_specular": [ + 0.001261, + 0.000168, + 0.000758 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.04293, + 0.042383, + 0.042678 + ], + "direct_pdf": 0.128241, + "white_furnace_reflectance": [ + 0.518542, + 0.39911, + 0.366305 + ] + }, + { + "id": "film-ior-high-v1.0", + "base_color": [ + 0.55, + 0.55, + 0.55 + ], + "metallic": 0.0, + "perceptual_roughness": 0.3, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 2.0, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 1.0, + "raw_dielectric_thin_film_fresnel": [ + 0.229898, + 0.039239, + 0.158108 + ], + "raw_conductor_thin_film_fresnel": [ + 0.078007, + 0.592756, + 0.258282 + ], + "direct_diffuse": [ + 0.088613, + 0.088613, + 0.088613 + ], + "direct_base_specular": [ + 0.005502, + 0.00074, + 0.002849 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.047057, + 0.044676, + 0.045731 + ], + "direct_pdf": 0.13585, + "white_furnace_reflectance": [ + 0.500515, + 0.304858, + 0.422634 + ] + }, + { + "id": "gold-conductor-film-v0.1", + "base_color": [ + 1.0, + 0.71, + 0.29 + ], + "metallic": 1.0, + "perceptual_roughness": 0.28, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.45, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.1, + "raw_dielectric_thin_film_fresnel": [ + 0.577917, + 0.586039, + 0.626008 + ], + "raw_conductor_thin_film_fresnel": [ + 0.999525, + 0.505674, + 0.830455 + ], + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.010791, + 0.007545, + 0.000783 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.005396, + 0.003772, + 0.000391 + ], + "direct_pdf": 0.000222, + "white_furnace_reflectance": [ + 0.88537, + 0.461777, + 0.61283 + ] + }, + { + "id": "gold-conductor-film-v0.5", + "base_color": [ + 1.0, + 0.71, + 0.29 + ], + "metallic": 1.0, + "perceptual_roughness": 0.28, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.45, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.5, + "raw_dielectric_thin_film_fresnel": [ + 0.047927, + 0.065078, + 0.076619 + ], + "raw_conductor_thin_film_fresnel": [ + 0.999726, + 0.652514, + 0.36766 + ], + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.00373, + 0.002557, + 0.000244 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.001865, + 0.001278, + 0.000122 + ], + "direct_pdf": 0.000529, + "white_furnace_reflectance": [ + 0.979045, + 0.640761, + 0.349244 + ] + }, + { + "id": "gold-conductor-film-v1.0", + "base_color": [ + 1.0, + 0.71, + 0.29 + ], + "metallic": 1.0, + "perceptual_roughness": 0.28, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.45, + "iridescence_thickness_nm": 400.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 1.0, + "raw_dielectric_thin_film_fresnel": [ + 0.04134, + 0.035816, + 0.028277 + ], + "raw_conductor_thin_film_fresnel": [ + 0.99991, + 0.651406, + 0.067639 + ], + "direct_diffuse": [ + 0.0, + 0.0, + 0.0 + ], + "direct_base_specular": [ + 0.015019, + 0.010679, + 0.001375 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.00751, + 0.005339, + 0.000688 + ], + "direct_pdf": 0.007545, + "white_furnace_reflectance": [ + 0.98945, + 0.650463, + 0.06642 + ] + }, + { + "id": "mixed-metal-film-v0.1", + "base_color": [ + 0.8, + 0.12, + 0.03 + ], + "metallic": 0.5, + "perceptual_roughness": 0.42, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 0.85, + "iridescence_ior": 1.35, + "iridescence_thickness_nm": 520.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.1, + "raw_dielectric_thin_film_fresnel": [ + 0.528907, + 0.61551, + 0.646456 + ], + "raw_conductor_thin_film_fresnel": [ + 0.335471, + 0.656921, + 0.637624 + ], + "direct_diffuse": [ + 0.040613, + 0.006092, + 0.001523 + ], + "direct_base_specular": [ + 0.016951, + 0.000621, + 0.001242 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.028782, + 0.003357, + 0.001383 + ], + "direct_pdf": 0.038303, + "white_furnace_reflectance": [ + 0.474632, + 0.278635, + 0.232186 + ] + }, + { + "id": "mixed-metal-film-v0.5", + "base_color": [ + 0.8, + 0.12, + 0.03 + ], + "metallic": 0.5, + "perceptual_roughness": 0.42, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 0.85, + "iridescence_ior": 1.35, + "iridescence_thickness_nm": 520.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.5, + "raw_dielectric_thin_film_fresnel": [ + 0.05312, + 0.08104, + 0.040104 + ], + "raw_conductor_thin_film_fresnel": [ + 0.74992, + 0.176975, + 0.044229 + ], + "direct_diffuse": [ + 0.094043, + 0.014106, + 0.003527 + ], + "direct_base_specular": [ + 0.007214, + 0.00035, + 0.000567 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.050628, + 0.007228, + 0.002047 + ], + "direct_pdf": 0.112936, + "white_furnace_reflectance": [ + 0.666815, + 0.142363, + 0.05465 + ] + }, + { + "id": "mixed-metal-film-v1.0", + "base_color": [ + 0.8, + 0.12, + 0.03 + ], + "metallic": 0.5, + "perceptual_roughness": 0.42, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 0.85, + "iridescence_ior": 1.35, + "iridescence_thickness_nm": 520.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 1.0, + "raw_dielectric_thin_film_fresnel": [ + 0.016573, + 0.017018, + 0.034755 + ], + "raw_conductor_thin_film_fresnel": [ + 0.707121, + 0.033768, + 0.027485 + ], + "direct_diffuse": [ + 0.097408, + 0.014611, + 0.003653 + ], + "direct_base_specular": [ + 0.028367, + 0.000982, + 0.001854 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.062887, + 0.007797, + 0.002753 + ], + "direct_pdf": 0.12998, + "white_furnace_reflectance": [ + 0.672667, + 0.071474, + 0.041426 + ] + }, + { + "id": "colored-specular-film-v0.1", + "base_color": [ + 0.15, + 0.4, + 0.8 + ], + "metallic": 0.0, + "perceptual_roughness": 0.38, + "ior": 1.76, + "specular_factor": 0.7, + "specular_color": [ + 1.2, + 0.6, + 0.25 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.25, + "iridescence_thickness_nm": 310.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.1, + "raw_dielectric_thin_film_fresnel": [ + 0.691399, + 0.614941, + 0.642847 + ], + "raw_conductor_thin_film_fresnel": [ + 0.790687, + 0.7146, + 0.857002 + ], + "direct_diffuse": [ + 0.01175, + 0.031332, + 0.062664 + ], + "direct_base_specular": [ + 0.001287, + 0.000292, + 0.000238 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.006518, + 0.015812, + 0.031451 + ], + "direct_pdf": 0.05176, + "white_furnace_reflectance": [ + 0.378355, + 0.348743, + 0.443338 + ] + }, + { + "id": "colored-specular-film-v0.5", + "base_color": [ + 0.15, + 0.4, + 0.8 + ], + "metallic": 0.0, + "perceptual_roughness": 0.38, + "ior": 1.76, + "specular_factor": 0.7, + "specular_color": [ + 1.2, + 0.6, + 0.25 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.25, + "iridescence_thickness_nm": 310.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.5, + "raw_dielectric_thin_film_fresnel": [ + 0.154096, + 0.064834, + 0.041015 + ], + "raw_conductor_thin_film_fresnel": [ + 0.282583, + 0.401645, + 0.753203 + ], + "direct_diffuse": [ + 0.03011, + 0.080293, + 0.160585 + ], + "direct_base_specular": [ + 0.000453, + 0.000099, + 0.000113 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.015281, + 0.040196, + 0.080349 + ], + "direct_pdf": 0.144532, + "white_furnace_reflectance": [ + 0.231659, + 0.314316, + 0.551436 + ] + }, + { + "id": "colored-specular-film-v1.0", + "base_color": [ + 0.15, + 0.4, + 0.8 + ], + "metallic": 0.0, + "perceptual_roughness": 0.38, + "ior": 1.76, + "specular_factor": 0.7, + "specular_color": [ + 1.2, + 0.6, + 0.25 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.25, + "iridescence_thickness_nm": 310.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 1.0, + "raw_dielectric_thin_film_fresnel": [ + 0.025973, + 0.005948, + 0.011664 + ], + "raw_conductor_thin_film_fresnel": [ + 0.082405, + 0.253139, + 0.752961 + ], + "direct_diffuse": [ + 0.034366, + 0.091642, + 0.183285 + ], + "direct_base_specular": [ + 0.00237, + 0.000571, + 0.000307 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.018368, + 0.046107, + 0.091796 + ], + "direct_pdf": 0.157161, + "white_furnace_reflectance": [ + 0.140497, + 0.3014, + 0.599567 + ] + }, + { + "id": "clearcoat-over-film-v0.1", + "base_color": [ + 0.7, + 0.08, + 0.02 + ], + "metallic": 0.1, + "perceptual_roughness": 0.5, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 450.0, + "clearcoat_factor": 0.8, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.1, + "raw_dielectric_thin_film_fresnel": [ + 0.610876, + 0.555246, + 0.571265 + ], + "raw_conductor_thin_film_fresnel": [ + 1.0, + 0.524434, + 0.577815 + ], + "direct_diffuse": [ + 0.0347, + 0.003966, + 0.000991 + ], + "direct_base_specular": [ + 0.002952, + 0.00129, + 0.000211 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.000041, + 0.000041, + 0.000041 + ], + "direct_brdf_cos": [ + 0.018846, + 0.002648, + 0.000622 + ], + "direct_pdf": 0.039991, + "white_furnace_reflectance": [ + 0.60283, + 0.498247, + 0.505667 + ] + }, + { + "id": "clearcoat-over-film-v0.5", + "base_color": [ + 0.7, + 0.08, + 0.02 + ], + "metallic": 0.1, + "perceptual_roughness": 0.5, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 450.0, + "clearcoat_factor": 0.8, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.5, + "raw_dielectric_thin_film_fresnel": [ + 0.012591, + 0.036479, + 0.087304 + ], + "raw_conductor_thin_film_fresnel": [ + 0.446902, + 0.037976, + 0.063802 + ], + "direct_diffuse": [ + 0.126759, + 0.014487, + 0.003622 + ], + "direct_base_specular": [ + 0.00303, + 0.001099, + 0.000158 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.000013, + 0.000013, + 0.000013 + ], + "direct_brdf_cos": [ + 0.064901, + 0.007799, + 0.001896 + ], + "direct_pdf": 0.139908, + "white_furnace_reflectance": [ + 0.495429, + 0.131922, + 0.114991 + ] + }, + { + "id": "clearcoat-over-film-v1.0", + "base_color": [ + 0.7, + 0.08, + 0.02 + ], + "metallic": 0.1, + "perceptual_roughness": 0.5, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 1.3, + "iridescence_thickness_nm": 450.0, + "clearcoat_factor": 0.8, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 1.0, + "raw_dielectric_thin_film_fresnel": [ + 0.046086, + 0.025048, + 0.005492 + ], + "raw_conductor_thin_film_fresnel": [ + 0.725042, + 0.04815, + 0.011522 + ], + "direct_diffuse": [ + 0.133635, + 0.015273, + 0.003818 + ], + "direct_base_specular": [ + 0.00809, + 0.004335, + 0.001036 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.000053, + 0.000053, + 0.000053 + ], + "direct_brdf_cos": [ + 0.070889, + 0.009831, + 0.002454 + ], + "direct_pdf": 0.148391, + "white_furnace_reflectance": [ + 0.536953, + 0.108023, + 0.049483 + ] + }, + { + "id": "all-v4-lobes-v0.1", + "base_color": [ + 0.05, + 0.2, + 0.6 + ], + "metallic": 0.35, + "perceptual_roughness": 0.36, + "ior": 1.7, + "specular_factor": 0.8, + "specular_color": [ + 1.1, + 0.7, + 0.4 + ], + "iridescence_factor": 0.9, + "iridescence_ior": 1.38, + "iridescence_thickness_nm": 560.0, + "clearcoat_factor": 0.65, + "sheen_color": [ + 0.2, + 0.55, + 0.9 + ], + "anisotropy_strength": 0.75, + "n_dot_v": 0.1, + "raw_dielectric_thin_film_fresnel": [ + 0.597674, + 0.631945, + 0.617433 + ], + "raw_conductor_thin_film_fresnel": [ + 0.594419, + 0.776201, + 0.520871 + ], + "direct_diffuse": [ + 0.000956, + 0.003825, + 0.011474 + ], + "direct_base_specular": [ + 0.000187, + 0.002572, + 0.006868 + ], + "direct_sheen_specular": [ + 0.037291, + 0.10255, + 0.167809 + ], + "direct_clearcoat_specular": [ + 0.00008, + 0.00008, + 0.00008 + ], + "direct_brdf_cos": [ + 0.019257, + 0.054513, + 0.093115 + ], + "direct_pdf": 0.051592, + "white_furnace_reflectance": [ + 0.396941, + 0.500253, + 0.642878 + ] + }, + { + "id": "all-v4-lobes-v0.5", + "base_color": [ + 0.05, + 0.2, + 0.6 + ], + "metallic": 0.35, + "perceptual_roughness": 0.36, + "ior": 1.7, + "specular_factor": 0.8, + "specular_color": [ + 1.1, + 0.7, + 0.4 + ], + "iridescence_factor": 0.9, + "iridescence_ior": 1.38, + "iridescence_thickness_nm": 560.0, + "clearcoat_factor": 0.65, + "sheen_color": [ + 0.2, + 0.55, + 0.9 + ], + "anisotropy_strength": 0.75, + "n_dot_v": 0.5, + "raw_dielectric_thin_film_fresnel": [ + 0.130439, + 0.049472, + 0.057843 + ], + "raw_conductor_thin_film_fresnel": [ + 0.11185, + 0.111567, + 0.400507 + ], + "direct_diffuse": [ + 0.005645, + 0.022582, + 0.067745 + ], + "direct_base_specular": [ + 0.000657, + 0.010185, + 0.023493 + ], + "direct_sheen_specular": [ + 0.01867, + 0.051342, + 0.084014 + ], + "direct_clearcoat_specular": [ + 0.000026, + 0.000026, + 0.000026 + ], + "direct_brdf_cos": [ + 0.012499, + 0.042067, + 0.087639 + ], + "direct_pdf": 0.100042, + "white_furnace_reflectance": [ + 0.120082, + 0.206064, + 0.473659 + ] + }, + { + "id": "all-v4-lobes-v1.0", + "base_color": [ + 0.05, + 0.2, + 0.6 + ], + "metallic": 0.35, + "perceptual_roughness": 0.36, + "ior": 1.7, + "specular_factor": 0.8, + "specular_color": [ + 1.1, + 0.7, + 0.4 + ], + "iridescence_factor": 0.9, + "iridescence_ior": 1.38, + "iridescence_thickness_nm": 560.0, + "clearcoat_factor": 0.65, + "sheen_color": [ + 0.2, + 0.55, + 0.9 + ], + "anisotropy_strength": 0.75, + "n_dot_v": 1.0, + "raw_dielectric_thin_film_fresnel": [ + 0.0, + 0.036428, + 0.030378 + ], + "raw_conductor_thin_film_fresnel": [ + 0.0, + 0.21107, + 0.476054 + ], + "direct_diffuse": [ + 0.0063, + 0.0252, + 0.075599 + ], + "direct_base_specular": [ + 0.003004, + 0.027063, + 0.092809 + ], + "direct_sheen_specular": [ + 0.0004, + 0.001101, + 0.001802 + ], + "direct_clearcoat_specular": [ + 0.000105, + 0.000105, + 0.000105 + ], + "direct_brdf_cos": [ + 0.004905, + 0.026734, + 0.085157 + ], + "direct_pdf": 0.102474, + "white_furnace_reflectance": [ + 0.052773, + 0.169542, + 0.41547 + ] + }, + { + "id": "zero-thickness-inactive-v0.1", + "base_color": [ + 0.3, + 0.6, + 0.9 + ], + "metallic": 0.2, + "perceptual_roughness": 0.4, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 2.0, + "iridescence_thickness_nm": 0.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.1, + "raw_dielectric_thin_film_fresnel": [ + 1.0, + 0.765295, + 0.821588 + ], + "raw_conductor_thin_film_fresnel": [ + 1.0, + 0.874931, + 0.969991 + ], + "direct_diffuse": [ + 0.023822, + 0.041555, + 0.053824 + ], + "direct_base_specular": [ + 0.003213, + 0.005308, + 0.007403 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.013517, + 0.023431, + 0.030613 + ], + "direct_pdf": 0.052372, + "white_furnace_reflectance": [ + 0.338642, + 0.42668, + 0.500074 + ] + }, + { + "id": "zero-thickness-inactive-v0.5", + "base_color": [ + 0.3, + 0.6, + 0.9 + ], + "metallic": 0.2, + "perceptual_roughness": 0.4, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 2.0, + "iridescence_thickness_nm": 0.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 0.5, + "raw_dielectric_thin_film_fresnel": [ + 0.284527, + 0.107902, + 0.145491 + ], + "raw_conductor_thin_film_fresnel": [ + 0.615212, + 0.629541, + 0.919658 + ], + "direct_diffuse": [ + 0.051804, + 0.090367, + 0.117048 + ], + "direct_base_specular": [ + 0.001369, + 0.002262, + 0.003156 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.026587, + 0.046315, + 0.060102 + ], + "direct_pdf": 0.128779, + "white_furnace_reflectance": [ + 0.269412, + 0.440812, + 0.575989 + ] + }, + { + "id": "zero-thickness-inactive-v1.0", + "base_color": [ + 0.3, + 0.6, + 0.9 + ], + "metallic": 0.2, + "perceptual_roughness": 0.4, + "ior": 1.5, + "specular_factor": 1.0, + "specular_color": [ + 1.0, + 1.0, + 1.0 + ], + "iridescence_factor": 1.0, + "iridescence_ior": 2.0, + "iridescence_thickness_nm": 0.0, + "clearcoat_factor": 0.0, + "sheen_color": [ + 0.0, + 0.0, + 0.0 + ], + "anisotropy_strength": 0.0, + "n_dot_v": 1.0, + "raw_dielectric_thin_film_fresnel": [ + 0.054091, + 0.040813, + 0.043639 + ], + "raw_conductor_thin_film_fresnel": [ + 0.310278, + 0.600345, + 0.900388 + ], + "direct_diffuse": [ + 0.052938, + 0.092346, + 0.11961 + ], + "direct_base_specular": [ + 0.005079, + 0.00839, + 0.011701 + ], + "direct_sheen_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_clearcoat_specular": [ + 0.0, + 0.0, + 0.0 + ], + "direct_brdf_cos": [ + 0.029008, + 0.050368, + 0.065655 + ], + "direct_pdf": 0.137504, + "white_furnace_reflectance": [ + 0.252142, + 0.431625, + 0.57371 + ] + } + ] +} diff --git a/tools/bloom-reference/src/angular_reference.rs b/tools/bloom-reference/src/angular_reference.rs new file mode 100644 index 00000000..2f4a6439 --- /dev/null +++ b/tools/bloom-reference/src/angular_reference.rs @@ -0,0 +1,256 @@ +//! Multi-view/multi-light comparison corpus for the complete layered model. + +use crate::layered_pbr::{ + evaluate_layered_brdf, BaseMaterial, LayeredBrdfEvaluation, LayeredMaterial, + IRIDESCENCE_REFERENCE_VERSION, +}; +use glam::Vec3; +use serde::Serialize; + +const ABSOLUTE_COMPONENT_TOLERANCE: f32 = 3.0e-5; + +#[derive(Serialize)] +struct AngularMaterial { + base_color: [f32; 3], + metallic: f32, + perceptual_roughness: f32, + ior: f32, + specular_factor: f32, + specular_color: [f32; 3], + clearcoat_factor: f32, + clearcoat_perceptual_roughness: f32, + sheen_color: [f32; 3], + sheen_perceptual_roughness: f32, + anisotropy_strength: f32, + anisotropy_rotation: f32, + iridescence_factor: f32, + iridescence_ior: f32, + iridescence_thickness_nm: f32, +} + +impl From for AngularMaterial { + fn from(material: LayeredMaterial) -> Self { + Self { + base_color: material.base.base_color.to_array(), + metallic: material.base.metallic, + perceptual_roughness: material.base.perceptual_roughness, + ior: material.ior, + specular_factor: material.specular_factor, + specular_color: material.specular_color.to_array(), + clearcoat_factor: material.clearcoat_factor, + clearcoat_perceptual_roughness: material.clearcoat_perceptual_roughness, + sheen_color: material.sheen_color.to_array(), + sheen_perceptual_roughness: material.sheen_perceptual_roughness, + anisotropy_strength: material.anisotropy_strength, + anisotropy_rotation: material.anisotropy_rotation, + iridescence_factor: material.iridescence_factor, + iridescence_ior: material.iridescence_ior, + iridescence_thickness_nm: material.iridescence_thickness_nm, + } + } +} + +#[derive(Serialize)] +struct AngularSample { + id: String, + lobe: &'static str, + material: AngularMaterial, + n_dot_v: f32, + view_azimuth_radians: f32, + n_dot_l: f32, + light_azimuth_radians: f32, + direct_diffuse: [f32; 3], + direct_base_specular: [f32; 3], + direct_sheen_specular: [f32; 3], + direct_clearcoat_specular: [f32; 3], + direct_brdf_cos: [f32; 3], + direct_pdf: f32, + reciprocity_max_abs_error: f32, +} + +#[derive(Serialize)] +struct Thresholds { + serialized_decimal_places: u32, + checked_in_regeneration: &'static str, + cpu_brdf_component_absolute: f32, + reciprocity_max_absolute: f32, +} + +#[derive(Serialize)] +pub(crate) struct AngularContractReport { + schema: &'static str, + corpus_version: u32, + material_contract_version: u32, + comparison_target: &'static str, + coordinate_convention: &'static str, + coverage: &'static str, + thresholds: Thresholds, + known_model_differences: [&'static str; 5], + maximum_reciprocity_error: f32, + samples: Vec, +} + +fn rounded(value: f32) -> f32 { + (value * 1_000_000.0).round() / 1_000_000.0 +} + +fn rounded_vec(value: Vec3) -> [f32; 3] { + [rounded(value.x), rounded(value.y), rounded(value.z)] +} + +fn direction(n_dot: f32, azimuth: f32) -> Vec3 { + let sin_theta = (1.0 - n_dot * n_dot).sqrt(); + Vec3::new(sin_theta * azimuth.cos(), sin_theta * azimuth.sin(), n_dot) +} + +fn maximum_component(value: Vec3) -> f32 { + value.x.max(value.y).max(value.z) +} + +fn brdf(evaluation: LayeredBrdfEvaluation) -> Vec3 { + evaluation.diffuse + + evaluation.base_specular + + evaluation.sheen_specular + + evaluation.clearcoat_specular +} + +fn scenarios() -> [(&'static str, LayeredMaterial); 7] { + let base = || { + LayeredMaterial::from_base(BaseMaterial { + base_color: Vec3::new(0.42, 0.18, 0.055), + metallic: 0.2, + perceptual_roughness: 0.38, + }) + }; + [ + ("base", base()), + ( + "specular-ior", + LayeredMaterial { + ior: 1.82, + specular_factor: 0.72, + specular_color: Vec3::new(1.2, 0.62, 0.28), + ..base() + }, + ), + ( + "clearcoat", + LayeredMaterial { + clearcoat_factor: 0.85, + clearcoat_perceptual_roughness: 0.17, + ..base() + }, + ), + ( + "sheen", + LayeredMaterial { + sheen_color: Vec3::new(0.25, 0.65, 0.9), + sheen_perceptual_roughness: 0.43, + ..base() + }, + ), + ( + "anisotropy", + LayeredMaterial { + anisotropy_strength: 0.82, + anisotropy_rotation: 0.61, + ..base() + }, + ), + ( + "iridescence", + LayeredMaterial { + iridescence_factor: 0.9, + iridescence_ior: 1.36, + iridescence_thickness_nm: 475.0, + ..base() + }, + ), + ( + "combined", + LayeredMaterial { + ior: 1.7, + specular_factor: 0.8, + specular_color: Vec3::new(1.1, 0.7, 0.4), + clearcoat_factor: 0.65, + clearcoat_perceptual_roughness: 0.2, + sheen_color: Vec3::new(0.2, 0.55, 0.9), + sheen_perceptual_roughness: 0.38, + anisotropy_strength: 0.75, + anisotropy_rotation: 0.63, + iridescence_factor: 0.9, + iridescence_ior: 1.38, + iridescence_thickness_nm: 560.0, + ..base() + }, + ), + ] +} + +pub(crate) fn build_angular_report() -> AngularContractReport { + let normal = Vec3::Z; + let views = [(0.2, 0.0), (0.5, 0.7), (0.85, 1.4)]; + let lights = [(0.25, 2.4), (0.55, 1.2), (0.9, 0.35)]; + let mut maximum_reciprocity_error = 0.0f32; + let mut samples = Vec::with_capacity(scenarios().len() * views.len() * lights.len()); + for (lobe, material) in scenarios() { + for (view_index, (n_dot_v, view_azimuth)) in views.into_iter().enumerate() { + let view = direction(n_dot_v, view_azimuth); + for (light_index, (n_dot_l, light_azimuth)) in lights.into_iter().enumerate() { + let light = direction(n_dot_l, light_azimuth); + let direct = evaluate_layered_brdf(material, normal, view, light); + let reverse = evaluate_layered_brdf(material, normal, light, view); + let reciprocity_error = maximum_component((brdf(direct) - brdf(reverse)).abs()); + maximum_reciprocity_error = maximum_reciprocity_error.max(reciprocity_error); + samples.push(AngularSample { + id: format!("{lobe}-v{view_index}-l{light_index}"), + lobe, + material: material.into(), + n_dot_v, + view_azimuth_radians: rounded(view_azimuth), + n_dot_l, + light_azimuth_radians: rounded(light_azimuth), + direct_diffuse: rounded_vec(direct.diffuse), + direct_base_specular: rounded_vec(direct.base_specular), + direct_sheen_specular: rounded_vec(direct.sheen_specular), + direct_clearcoat_specular: rounded_vec(direct.clearcoat_specular), + direct_brdf_cos: rounded_vec(direct.brdf_cos), + direct_pdf: rounded(direct.pdf), + reciprocity_max_abs_error: rounded(reciprocity_error), + }); + } + } + } + assert!( + maximum_reciprocity_error <= ABSOLUTE_COMPONENT_TOLERANCE, + "angular corpus reciprocity error {maximum_reciprocity_error} exceeds \ + {ABSOLUTE_COMPONENT_TOLERANCE}" + ); + AngularContractReport { + schema: "bloom-layered-pbr-angular-reference", + corpus_version: 1, + material_contract_version: IRIDESCENCE_REFERENCE_VERSION, + comparison_target: + "tools/bloom-reference::layered_pbr::evaluate_layered_brdf (linear RGB)", + coordinate_convention: + "+Z normal, +X tangent, right-handed; azimuth is counter-clockwise from +X", + coverage: + "base, specular/IOR, clearcoat, sheen, anisotropy, iridescence, and combined; \ + 3 view directions x 3 light directions per scenario", + thresholds: Thresholds { + serialized_decimal_places: 6, + checked_in_regeneration: "byte exact", + cpu_brdf_component_absolute: ABSOLUTE_COMPONENT_TOLERANCE, + reciprocity_max_absolute: ABSOLUTE_COMPONENT_TOLERANCE, + }, + known_model_differences: [ + "direct punctual-light BRDF only; environment IBL, SSR, exposure, and tone mapping are excluded", + "realtime finite-highlight compression is excluded and must be compared in image space", + "normal maps and their LEADR/Toksvig or derivative variance are evaluated by the motion corpus", + "path tracing is stochastic; transport comparison uses converged radiance tolerances, not byte equality", + "iridescence is the bounded Khronos Belcour/Barla Rec.709 approximation, not spectral conductor Fresnel", + ], + maximum_reciprocity_error: rounded(maximum_reciprocity_error), + samples, + } +} diff --git a/tools/bloom-reference/src/bin/generate_sheen_lut.rs b/tools/bloom-reference/src/bin/generate_sheen_lut.rs new file mode 100644 index 00000000..aefa2131 --- /dev/null +++ b/tools/bloom-reference/src/bin/generate_sheen_lut.rs @@ -0,0 +1,64 @@ +#[path = "../sheen_lut.rs"] +mod sheen_lut; + +use std::path::PathBuf; +use std::process::ExitCode; + +fn main() -> ExitCode { + let mut args = std::env::args().skip(1); + let mut output = None; + let mut size = sheen_lut::DEFAULT_LUT_SIZE; + let mut samples = sheen_lut::DEFAULT_SAMPLE_COUNT; + while let Some(argument) = args.next() { + match argument.as_str() { + "--out" => output = args.next().map(PathBuf::from), + "--size" => { + size = args + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or(0) + } + "--samples" => { + samples = args + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or(0) + } + _ => { + eprintln!("unknown argument: {argument}"); + return ExitCode::from(2); + } + } + } + let Some(output) = output else { + eprintln!("usage: generate_sheen_lut --out FILE [--size 128] [--samples 4096]"); + return ExitCode::from(2); + }; + if size < 2 || samples == 0 { + eprintln!("size must be at least 2 and samples must be non-zero"); + return ExitCode::from(2); + } + let values = sheen_lut::build_r16f_lut(size, samples); + let bytes = values + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::>(); + if let Some(parent) = output.parent() { + if let Err(error) = std::fs::create_dir_all(parent) { + eprintln!("create {}: {error}", parent.display()); + return ExitCode::FAILURE; + } + } + if let Err(error) = std::fs::write(&output, bytes) { + eprintln!("write {}: {error}", output.display()); + return ExitCode::FAILURE; + } + println!( + "wrote {}x{} R16F sheen directional-albedo LUT ({} spp) to {}", + size, + size, + samples, + output.display() + ); + ExitCode::SUCCESS +} diff --git a/tools/bloom-reference/src/brdf_reference.rs b/tools/bloom-reference/src/brdf_reference.rs new file mode 100644 index 00000000..10ce80df --- /dev/null +++ b/tools/bloom-reference/src/brdf_reference.rs @@ -0,0 +1,874 @@ +//! Deterministic parameter-matrix output for Bloom's layered-PBR contract. + +mod angular_reference; +mod layered_pbr; +mod sheen_lut; + +use angular_reference::build_angular_report; +use glam::Vec3; +use layered_pbr::{ + evaluate_base_brdf, evaluate_layered_brdf, integrate_layered_white_furnace, + integrate_white_furnace, iridescence_fresnel, BaseMaterial, LayeredMaterial, CLEARCOAT_IOR, + CLEARCOAT_SPECULAR_REFERENCE_VERSION, DIELECTRIC_F0, IRIDESCENCE_REFERENCE_VERSION, + LAYERED_PBR_REFERENCE_VERSION, MIN_PERCEPTUAL_ROUGHNESS, SHEEN_ANISOTROPY_REFERENCE_VERSION, +}; +use serde::Serialize; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Serialize)] +struct MatrixSample { + id: String, + base_color: [f32; 3], + metallic: f32, + perceptual_roughness: f32, + n_dot_v: f32, + direct_diffuse: [f32; 3], + direct_specular: [f32; 3], + direct_brdf_cos: [f32; 3], + direct_pdf: f32, + white_furnace_reflectance: [f32; 3], +} + +#[derive(Serialize)] +struct ContractReport { + schema: &'static str, + version: u32, + model: &'static str, + dielectric_f0: f32, + minimum_perceptual_roughness: f32, + visibility: &'static str, + diffuse: &'static str, + furnace_integration: &'static str, + samples: Vec, +} + +#[derive(Serialize)] +struct LayeredMatrixSample { + id: String, + base_color: [f32; 3], + metallic: f32, + perceptual_roughness: f32, + ior: f32, + specular_factor: f32, + specular_color: [f32; 3], + clearcoat_factor: f32, + clearcoat_perceptual_roughness: f32, + n_dot_v: f32, + direct_diffuse: [f32; 3], + direct_base_specular: [f32; 3], + direct_clearcoat_specular: [f32; 3], + direct_brdf_cos: [f32; 3], + direct_pdf: f32, + white_furnace_reflectance: [f32; 3], +} + +#[derive(Serialize)] +struct LayeredContractReport { + schema: &'static str, + version: u32, + base_contract_version: u32, + model: &'static str, + ior_zero_mode: &'static str, + specular_fresnel: &'static str, + diffuse_complement: &'static str, + clearcoat_ior: f32, + clearcoat_layering: &'static str, + minimum_perceptual_roughness: f32, + furnace_integration: &'static str, + samples: Vec, +} + +#[derive(Serialize)] +struct SheenAnisotropyMatrixSample { + id: String, + base_color: [f32; 3], + metallic: f32, + perceptual_roughness: f32, + sheen_color: [f32; 3], + sheen_perceptual_roughness: f32, + anisotropy_strength: f32, + anisotropy_rotation: f32, + clearcoat_factor: f32, + n_dot_v: f32, + direct_diffuse: [f32; 3], + direct_base_specular: [f32; 3], + direct_sheen_specular: [f32; 3], + direct_clearcoat_specular: [f32; 3], + direct_brdf_cos: [f32; 3], + direct_pdf: f32, + white_furnace_reflectance: [f32; 3], +} + +#[derive(Serialize)] +struct SheenAnisotropyContractReport { + schema: &'static str, + version: u32, + previous_contract_version: u32, + model: &'static str, + sheen_distribution: &'static str, + sheen_visibility: &'static str, + sheen_layering: &'static str, + sheen_albedo_lut: &'static str, + anisotropic_distribution: &'static str, + anisotropic_visibility: &'static str, + anisotropy_frame: &'static str, + furnace_integration: &'static str, + samples: Vec, +} + +#[derive(Serialize)] +struct IridescenceMatrixSample { + id: String, + base_color: [f32; 3], + metallic: f32, + perceptual_roughness: f32, + ior: f32, + specular_factor: f32, + specular_color: [f32; 3], + iridescence_factor: f32, + iridescence_ior: f32, + iridescence_thickness_nm: f32, + clearcoat_factor: f32, + sheen_color: [f32; 3], + anisotropy_strength: f32, + n_dot_v: f32, + raw_dielectric_thin_film_fresnel: [f32; 3], + raw_conductor_thin_film_fresnel: [f32; 3], + direct_diffuse: [f32; 3], + direct_base_specular: [f32; 3], + direct_sheen_specular: [f32; 3], + direct_clearcoat_specular: [f32; 3], + direct_brdf_cos: [f32; 3], + direct_pdf: f32, + white_furnace_reflectance: [f32; 3], +} + +#[derive(Serialize)] +struct IridescenceContractReport { + schema: &'static str, + version: u32, + previous_contract_version: u32, + model: &'static str, + spectral_integration: &'static str, + interfaces: &'static str, + diffuse_complement: &'static str, + inactive_path: &'static str, + color_space: &'static str, + furnace_integration: &'static str, + samples: Vec, +} + +fn rounded(value: f32) -> f32 { + (value * 1_000_000.0).round() / 1_000_000.0 +} + +fn rounded_vec(value: Vec3) -> [f32; 3] { + [rounded(value.x), rounded(value.y), rounded(value.z)] +} + +fn direction(n_dot: f32, azimuth: f32) -> Vec3 { + let sin_theta = (1.0 - n_dot * n_dot).sqrt(); + Vec3::new(sin_theta * azimuth.cos(), sin_theta * azimuth.sin(), n_dot) +} + +fn build_report() -> ContractReport { + let normal = Vec3::Z; + let light = direction(0.5, std::f32::consts::FRAC_PI_4); + let mut samples = Vec::new(); + for (color_name, base_color) in [ + ("neutral", Vec3::splat(0.18)), + ("red", Vec3::new(0.8, 0.08, 0.03)), + ] { + for metallic in [0.0, 1.0] { + for roughness in [0.04, 0.25, 0.5, 1.0] { + for n_dot_v in [0.1, 0.5, 1.0] { + let material = BaseMaterial { + base_color, + metallic, + perceptual_roughness: roughness, + }; + let view = direction(n_dot_v, 0.0); + let direct = evaluate_base_brdf(material, normal, view, light); + let furnace = integrate_white_furnace(material, n_dot_v, 96, 192); + samples.push(MatrixSample { + id: format!("{color_name}-m{metallic:.0}-r{roughness:.2}-v{n_dot_v:.1}"), + base_color: base_color.to_array(), + metallic, + perceptual_roughness: roughness, + n_dot_v, + direct_diffuse: rounded_vec(direct.diffuse), + direct_specular: rounded_vec(direct.specular), + direct_brdf_cos: rounded_vec(direct.brdf_cos), + direct_pdf: rounded(direct.pdf), + white_furnace_reflectance: rounded_vec(furnace), + }); + } + } + } + } + ContractReport { + schema: "bloom-layered-pbr-reference", + version: LAYERED_PBR_REFERENCE_VERSION, + model: "metallic-roughness / correlated GGX / Schlick / energy-normalized Burley", + dielectric_f0: DIELECTRIC_F0, + minimum_perceptual_roughness: MIN_PERCEPTUAL_ROUGHNESS, + visibility: "height-correlated Smith, including 1/(4 NdotV NdotL)", + diffuse: "energy-normalized Burley, reciprocal view/light Fresnel transmission, 1/pi", + furnace_integration: "96x192 deterministic GGX-VNDF and cosine samples", + samples, + } +} + +fn layered_scenarios() -> Vec<(&'static str, LayeredMaterial)> { + let material = |base_color, metallic, roughness| { + LayeredMaterial::from_base(BaseMaterial { + base_color, + metallic, + perceptual_roughness: roughness, + }) + }; + vec![ + ( + "base-neutral-dielectric", + material(Vec3::splat(0.18), 0.0, 0.5), + ), + ( + "base-red-mixed", + material(Vec3::new(0.8, 0.08, 0.03), 0.5, 0.25), + ), + ( + "base-gold-conductor", + material(Vec3::new(1.0, 0.71, 0.29), 1.0, 0.4), + ), + ( + "ior-water", + LayeredMaterial { + ior: 1.33, + ..material(Vec3::splat(0.7), 0.0, 0.2) + }, + ), + ( + "ior-diamond", + LayeredMaterial { + ior: 2.42, + ..material(Vec3::splat(0.7), 0.0, 0.2) + }, + ), + ( + "ior-zero-compatibility", + LayeredMaterial { + ior: 0.0, + specular_color: Vec3::new(0.9, 0.35, 0.1), + ..material(Vec3::new(0.4, 0.1, 0.03), 0.0, 0.35) + }, + ), + ( + "specular-disabled", + LayeredMaterial { + specular_factor: 0.0, + ..material(Vec3::new(0.5, 0.15, 0.04), 0.0, 0.55) + }, + ), + ( + "specular-half", + LayeredMaterial { + specular_factor: 0.5, + ..material(Vec3::new(0.5, 0.15, 0.04), 0.0, 0.55) + }, + ), + ( + "specular-colored", + LayeredMaterial { + specular_color: Vec3::new(1.5, 0.4, 0.2), + ..material(Vec3::new(0.5, 0.15, 0.04), 0.0, 0.55) + }, + ), + ( + "clearcoat-smooth", + LayeredMaterial { + clearcoat_factor: 1.0, + clearcoat_perceptual_roughness: 0.04, + ..material(Vec3::new(0.8, 0.08, 0.03), 0.0, 0.7) + }, + ), + ( + "clearcoat-rough", + LayeredMaterial { + clearcoat_factor: 1.0, + clearcoat_perceptual_roughness: 0.75, + ..material(Vec3::new(1.0, 0.71, 0.29), 1.0, 0.7) + }, + ), + ( + "clearcoat-half", + LayeredMaterial { + clearcoat_factor: 0.5, + clearcoat_perceptual_roughness: 0.2, + ..material(Vec3::new(0.8, 0.08, 0.03), 0.5, 0.35) + }, + ), + ( + "clearcoat-specular-ior-combined", + LayeredMaterial { + ior: 1.76, + specular_factor: 0.7, + specular_color: Vec3::new(1.2, 0.7, 0.3), + clearcoat_factor: 0.85, + clearcoat_perceptual_roughness: 0.14, + ..material(Vec3::new(0.12, 0.35, 0.8), 0.15, 0.42) + }, + ), + ] +} + +fn build_layered_report() -> LayeredContractReport { + let normal = Vec3::Z; + let light = direction(0.5, std::f32::consts::FRAC_PI_4); + let mut samples = Vec::new(); + for (name, material) in layered_scenarios() { + for n_dot_v in [0.1, 0.5, 1.0] { + let view = direction(n_dot_v, 0.0); + let direct = evaluate_layered_brdf(material, normal, view, light); + let furnace = integrate_layered_white_furnace(material, n_dot_v, 96, 192); + samples.push(LayeredMatrixSample { + id: format!("{name}-v{n_dot_v:.1}"), + base_color: material.base.base_color.to_array(), + metallic: material.base.metallic, + perceptual_roughness: material.base.perceptual_roughness, + ior: material.ior, + specular_factor: material.specular_factor, + specular_color: material.specular_color.to_array(), + clearcoat_factor: material.clearcoat_factor, + clearcoat_perceptual_roughness: material.clearcoat_perceptual_roughness, + n_dot_v, + direct_diffuse: rounded_vec(direct.diffuse), + direct_base_specular: rounded_vec(direct.base_specular), + direct_clearcoat_specular: rounded_vec(direct.clearcoat_specular), + direct_brdf_cos: rounded_vec(direct.brdf_cos), + direct_pdf: rounded(direct.pdf), + white_furnace_reflectance: rounded_vec(furnace), + }); + } + } + LayeredContractReport { + schema: "bloom-layered-pbr-reference", + version: CLEARCOAT_SPECULAR_REFERENCE_VERSION, + base_contract_version: LAYERED_PBR_REFERENCE_VERSION, + model: "version-1 base + KHR clearcoat/specular/IOR", + ior_zero_mode: "positive-infinity compatibility mode; dielectric F0=1", + specular_fresnel: "IOR F0 * color (clamped before factor), explicit factor F90", + diffuse_complement: "max-channel dielectric Fresnel at reciprocal view/light interfaces", + clearcoat_ior: CLEARCOAT_IOR, + clearcoat_layering: + "fixed-IOR GGX; reciprocal view/light transmission attenuates the full base", + minimum_perceptual_roughness: MIN_PERCEPTUAL_ROUGHNESS, + furnace_integration: "96x192 deterministic per-lobe GGX-VNDF and cosine samples", + samples, + } +} + +fn sheen_anisotropy_scenarios() -> Vec<(&'static str, LayeredMaterial)> { + let material = |base_color, metallic, roughness| { + LayeredMaterial::from_base(BaseMaterial { + base_color, + metallic, + perceptual_roughness: roughness, + }) + }; + vec![ + ( + "v2-default", + LayeredMaterial { + clearcoat_factor: 0.5, + clearcoat_perceptual_roughness: 0.2, + ..material(Vec3::new(0.8, 0.08, 0.03), 0.2, 0.4) + }, + ), + ( + "velvet-smooth", + LayeredMaterial { + sheen_color: Vec3::new(0.9, 0.08, 0.03), + sheen_perceptual_roughness: 0.08, + ..material(Vec3::new(0.12, 0.02, 0.01), 0.0, 0.7) + }, + ), + ( + "velvet-rough", + LayeredMaterial { + sheen_color: Vec3::new(0.7, 0.3, 0.08), + sheen_perceptual_roughness: 0.8, + ..material(Vec3::new(0.18, 0.04, 0.01), 0.0, 0.85) + }, + ), + ( + "brushed-half", + LayeredMaterial { + anisotropy_strength: 0.5, + anisotropy_rotation: 0.0, + ..material(Vec3::new(0.9, 0.45, 0.12), 1.0, 0.28) + }, + ), + ( + "brushed-strong", + LayeredMaterial { + anisotropy_strength: 1.0, + anisotropy_rotation: 0.0, + ..material(Vec3::new(0.9, 0.45, 0.12), 1.0, 0.28) + }, + ), + ( + "brushed-rotated-45", + LayeredMaterial { + anisotropy_strength: 0.85, + anisotropy_rotation: std::f32::consts::FRAC_PI_4, + ..material(Vec3::new(0.75, 0.78, 0.82), 1.0, 0.32) + }, + ), + ( + "brushed-rotated-90", + LayeredMaterial { + anisotropy_strength: 0.85, + anisotropy_rotation: std::f32::consts::FRAC_PI_2, + ..material(Vec3::new(0.75, 0.78, 0.82), 1.0, 0.32) + }, + ), + ( + "fabric-anisotropic", + LayeredMaterial { + sheen_color: Vec3::new(0.25, 0.45, 0.9), + sheen_perceptual_roughness: 0.45, + anisotropy_strength: 0.7, + anisotropy_rotation: 1.1, + ..material(Vec3::new(0.03, 0.08, 0.3), 0.15, 0.4) + }, + ), + ( + "coated-fabric", + LayeredMaterial { + sheen_color: Vec3::new(0.8, 0.2, 0.08), + sheen_perceptual_roughness: 0.5, + clearcoat_factor: 0.7, + clearcoat_perceptual_roughness: 0.18, + ..material(Vec3::new(0.2, 0.03, 0.01), 0.0, 0.65) + }, + ), + ( + "all-v3-lobes", + LayeredMaterial { + ior: 1.72, + specular_factor: 0.8, + specular_color: Vec3::new(1.1, 0.7, 0.4), + clearcoat_factor: 0.65, + clearcoat_perceptual_roughness: 0.2, + sheen_color: Vec3::new(0.2, 0.55, 0.9), + sheen_perceptual_roughness: 0.38, + anisotropy_strength: 0.75, + anisotropy_rotation: 0.63, + ..material(Vec3::new(0.05, 0.2, 0.6), 0.35, 0.36) + }, + ), + ] +} + +fn build_sheen_anisotropy_report() -> SheenAnisotropyContractReport { + let normal = Vec3::Z; + let light = direction(0.5, std::f32::consts::FRAC_PI_4); + let mut samples = Vec::new(); + for (name, material) in sheen_anisotropy_scenarios() { + for n_dot_v in [0.1, 0.5, 1.0] { + let view = direction(n_dot_v, 0.0); + let direct = evaluate_layered_brdf(material, normal, view, light); + let furnace = integrate_layered_white_furnace(material, n_dot_v, 96, 192); + samples.push(SheenAnisotropyMatrixSample { + id: format!("{name}-v{n_dot_v:.1}"), + base_color: material.base.base_color.to_array(), + metallic: material.base.metallic, + perceptual_roughness: material.base.perceptual_roughness, + sheen_color: material.sheen_color.to_array(), + sheen_perceptual_roughness: material.sheen_perceptual_roughness, + anisotropy_strength: material.anisotropy_strength, + anisotropy_rotation: material.anisotropy_rotation, + clearcoat_factor: material.clearcoat_factor, + n_dot_v, + direct_diffuse: rounded_vec(direct.diffuse), + direct_base_specular: rounded_vec(direct.base_specular), + direct_sheen_specular: rounded_vec(direct.sheen_specular), + direct_clearcoat_specular: rounded_vec(direct.clearcoat_specular), + direct_brdf_cos: rounded_vec(direct.brdf_cos), + direct_pdf: rounded(direct.pdf), + white_furnace_reflectance: rounded_vec(furnace), + }); + } + } + SheenAnisotropyContractReport { + schema: "bloom-layered-pbr-reference", + version: SHEEN_ANISOTROPY_REFERENCE_VERSION, + previous_contract_version: CLEARCOAT_SPECULAR_REFERENCE_VERSION, + model: "version-2 layered PBR + KHR sheen/anisotropy", + sheen_distribution: "Charlie with alpha_g = perceptual roughness squared", + sheen_visibility: "full fitted Charlie lambda; no Ashikhmin shortcut", + sheen_layering: + "max-channel reciprocal view/light directional-albedo scaling below clearcoat", + sheen_albedo_lut: "128x128 R16F, 4096 Charlie-importance samples per texel", + anisotropic_distribution: "Burley anisotropic GGX; alpha_t=mix(alpha,1,strength^2)", + anisotropic_visibility: "height-correlated anisotropic Smith", + anisotropy_frame: + "counter-clockwise radians from glTF tangent; mirrored handedness retained", + furnace_integration: + "96x192 deterministic anisotropic GGX-VNDF, Charlie, clearcoat, and cosine samples", + samples, + } +} + +fn iridescence_scenarios() -> Vec<(&'static str, LayeredMaterial)> { + let material = |base_color, metallic, roughness| { + LayeredMaterial::from_base(BaseMaterial { + base_color, + metallic, + perceptual_roughness: roughness, + }) + }; + vec![ + ( + "v3-inactive", + LayeredMaterial { + clearcoat_factor: 0.55, + clearcoat_perceptual_roughness: 0.2, + sheen_color: Vec3::new(0.3, 0.55, 0.9), + sheen_perceptual_roughness: 0.4, + anisotropy_strength: 0.7, + anisotropy_rotation: 0.63, + ..material(Vec3::new(0.08, 0.3, 0.75), 0.35, 0.36) + }, + ), + ( + "dielectric-thin-100nm", + LayeredMaterial { + iridescence_factor: 1.0, + iridescence_thickness_nm: 100.0, + ..material(Vec3::splat(0.55), 0.0, 0.3) + }, + ), + ( + "dielectric-mid-400nm", + LayeredMaterial { + iridescence_factor: 1.0, + iridescence_thickness_nm: 400.0, + ..material(Vec3::splat(0.55), 0.0, 0.3) + }, + ), + ( + "dielectric-thick-800nm", + LayeredMaterial { + iridescence_factor: 1.0, + iridescence_thickness_nm: 800.0, + ..material(Vec3::splat(0.55), 0.0, 0.3) + }, + ), + ( + "iridescence-half-strength", + LayeredMaterial { + iridescence_factor: 0.5, + iridescence_thickness_nm: 400.0, + ..material(Vec3::new(0.65, 0.18, 0.05), 0.0, 0.45) + }, + ), + ( + "film-ior-air", + LayeredMaterial { + iridescence_factor: 1.0, + iridescence_ior: 1.0, + iridescence_thickness_nm: 400.0, + ..material(Vec3::splat(0.55), 0.0, 0.3) + }, + ), + ( + "film-ior-high", + LayeredMaterial { + iridescence_factor: 1.0, + iridescence_ior: 2.0, + iridescence_thickness_nm: 400.0, + ..material(Vec3::splat(0.55), 0.0, 0.3) + }, + ), + ( + "gold-conductor-film", + LayeredMaterial { + iridescence_factor: 1.0, + iridescence_ior: 1.45, + iridescence_thickness_nm: 400.0, + ..material(Vec3::new(1.0, 0.71, 0.29), 1.0, 0.28) + }, + ), + ( + "mixed-metal-film", + LayeredMaterial { + iridescence_factor: 0.85, + iridescence_ior: 1.35, + iridescence_thickness_nm: 520.0, + ..material(Vec3::new(0.8, 0.12, 0.03), 0.5, 0.42) + }, + ), + ( + "colored-specular-film", + LayeredMaterial { + ior: 1.76, + specular_factor: 0.7, + specular_color: Vec3::new(1.2, 0.6, 0.25), + iridescence_factor: 1.0, + iridescence_ior: 1.25, + iridescence_thickness_nm: 310.0, + ..material(Vec3::new(0.15, 0.4, 0.8), 0.0, 0.38) + }, + ), + ( + "clearcoat-over-film", + LayeredMaterial { + clearcoat_factor: 0.8, + clearcoat_perceptual_roughness: 0.16, + iridescence_factor: 1.0, + iridescence_ior: 1.3, + iridescence_thickness_nm: 450.0, + ..material(Vec3::new(0.7, 0.08, 0.02), 0.1, 0.5) + }, + ), + ( + "all-v4-lobes", + LayeredMaterial { + ior: 1.7, + specular_factor: 0.8, + specular_color: Vec3::new(1.1, 0.7, 0.4), + clearcoat_factor: 0.65, + clearcoat_perceptual_roughness: 0.2, + sheen_color: Vec3::new(0.2, 0.55, 0.9), + sheen_perceptual_roughness: 0.38, + anisotropy_strength: 0.75, + anisotropy_rotation: 0.63, + iridescence_factor: 0.9, + iridescence_ior: 1.38, + iridescence_thickness_nm: 560.0, + ..material(Vec3::new(0.05, 0.2, 0.6), 0.35, 0.36) + }, + ), + ( + "zero-thickness-inactive", + LayeredMaterial { + iridescence_factor: 1.0, + iridescence_ior: 2.0, + iridescence_thickness_nm: 0.0, + ..material(Vec3::new(0.3, 0.6, 0.9), 0.2, 0.4) + }, + ), + ] +} + +fn build_iridescence_report() -> IridescenceContractReport { + let normal = Vec3::Z; + let light = direction(0.5, std::f32::consts::FRAC_PI_4); + let mut samples = Vec::new(); + for (name, material) in iridescence_scenarios() { + for n_dot_v in [0.1, 0.5, 1.0] { + let view = direction(n_dot_v, 0.0); + let direct = evaluate_layered_brdf(material, normal, view, light); + let furnace = integrate_layered_white_furnace(material, n_dot_v, 96, 192); + let dielectric_thin_film = iridescence_fresnel( + 1.0, + material.iridescence_ior, + n_dot_v, + material.iridescence_thickness_nm, + material.dielectric_f0(), + ); + let conductor_thin_film = iridescence_fresnel( + 1.0, + material.iridescence_ior, + n_dot_v, + material.iridescence_thickness_nm, + material.base.base_color, + ); + samples.push(IridescenceMatrixSample { + id: format!("{name}-v{n_dot_v:.1}"), + base_color: material.base.base_color.to_array(), + metallic: material.base.metallic, + perceptual_roughness: material.base.perceptual_roughness, + ior: material.ior, + specular_factor: material.specular_factor, + specular_color: material.specular_color.to_array(), + iridescence_factor: material.iridescence_factor, + iridescence_ior: material.iridescence_ior, + iridescence_thickness_nm: material.iridescence_thickness_nm, + clearcoat_factor: material.clearcoat_factor, + sheen_color: material.sheen_color.to_array(), + anisotropy_strength: material.anisotropy_strength, + n_dot_v, + raw_dielectric_thin_film_fresnel: rounded_vec(dielectric_thin_film), + raw_conductor_thin_film_fresnel: rounded_vec(conductor_thin_film), + direct_diffuse: rounded_vec(direct.diffuse), + direct_base_specular: rounded_vec(direct.base_specular), + direct_sheen_specular: rounded_vec(direct.sheen_specular), + direct_clearcoat_specular: rounded_vec(direct.clearcoat_specular), + direct_brdf_cos: rounded_vec(direct.brdf_cos), + direct_pdf: rounded(direct.pdf), + white_furnace_reflectance: rounded_vec(furnace), + }); + } + } + IridescenceContractReport { + schema: "bloom-layered-pbr-reference", + version: IRIDESCENCE_REFERENCE_VERSION, + previous_contract_version: SHEEN_ANISOTROPY_REFERENCE_VERSION, + model: "version-3 layered PBR + KHR_materials_iridescence", + spectral_integration: "Belcour/Barla Fourier sensitivity, orders 0 through 2", + interfaces: "air / dielectric thin film / dielectric-or-approximated-conductor base", + diffuse_complement: + "reciprocal view/light transmission from max-channel blended dielectric Fresnel", + inactive_path: "factor zero or thickness zero is exactly the version-3 evaluator", + color_space: "linear Rec.709, bounded to physical reflectance [0,1]", + furnace_integration: + "96x192 deterministic anisotropic GGX-VNDF, Charlie, clearcoat, and cosine samples", + samples, + } +} + +struct Options { + output: Option, + version: u32, + angular: bool, +} + +fn options() -> Result, String> { + let mut args = std::env::args().skip(1); + let mut output = None; + let mut version = LAYERED_PBR_REFERENCE_VERSION; + let mut angular = false; + while let Some(arg) = args.next() { + match arg.as_str() { + "--out" => { + let value = args.next().ok_or("--out requires a path")?; + output = Some(PathBuf::from(value)); + } + "--version" => { + let value = args.next().ok_or("--version requires 1, 2, 3, or 4")?; + version = value + .parse::() + .map_err(|_| "--version requires 1, 2, 3, or 4".to_owned())?; + if !matches!( + version, + LAYERED_PBR_REFERENCE_VERSION + | CLEARCOAT_SPECULAR_REFERENCE_VERSION + | SHEEN_ANISOTROPY_REFERENCE_VERSION + | IRIDESCENCE_REFERENCE_VERSION + ) { + return Err("--version requires 1, 2, 3, or 4".to_owned()); + } + } + "--angular" => angular = true, + "--help" | "-h" => { + println!( + "usage: bloom-brdf-reference [--version 1|2|3|4 | --angular] \ + [--out REPORT.json]" + ); + return Ok(None); + } + _ => return Err(format!("unknown argument: {arg}")), + } + } + Ok(Some(Options { + output, + version, + angular, + })) +} + +fn main() -> ExitCode { + let options = match options() { + Ok(Some(options)) => options, + Ok(None) => return ExitCode::SUCCESS, + Err(error) => { + eprintln!("error: {error}"); + return ExitCode::from(2); + } + }; + let encoded = if options.angular { + serde_json::to_string_pretty(&build_angular_report()) + } else { + match options.version { + LAYERED_PBR_REFERENCE_VERSION => serde_json::to_string_pretty(&build_report()), + CLEARCOAT_SPECULAR_REFERENCE_VERSION => { + serde_json::to_string_pretty(&build_layered_report()) + } + SHEEN_ANISOTROPY_REFERENCE_VERSION => { + serde_json::to_string_pretty(&build_sheen_anisotropy_report()) + } + IRIDESCENCE_REFERENCE_VERSION => { + serde_json::to_string_pretty(&build_iridescence_report()) + } + _ => unreachable!("version validated by argument parser"), + } + } + .expect("serialize BRDF report"); + if let Some(path) = options.output { + if let Err(error) = std::fs::write(&path, format!("{encoded}\n")) { + eprintln!("error writing {}: {error}", path.display()); + return ExitCode::from(1); + } + println!("wrote {}", path.display()); + } else { + println!("{encoded}"); + } + ExitCode::SUCCESS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checked_in_v1_matrix_matches_the_reference_evaluator() { + let encoded = serde_json::to_string_pretty(&build_report()).expect("serialize BRDF matrix"); + assert_eq!( + format!("{encoded}\n"), + include_str!("../reference/layered-pbr-v1.json") + ); + } + + #[test] + fn checked_in_v2_matrix_matches_the_reference_evaluator() { + let encoded = + serde_json::to_string_pretty(&build_layered_report()).expect("serialize BRDF matrix"); + assert_eq!( + format!("{encoded}\n"), + include_str!("../reference/layered-pbr-v2.json") + ); + } + + #[test] + fn checked_in_v3_matrix_matches_the_reference_evaluator() { + let encoded = serde_json::to_string_pretty(&build_sheen_anisotropy_report()) + .expect("serialize BRDF matrix"); + assert_eq!( + format!("{encoded}\n"), + include_str!("../reference/layered-pbr-v3.json") + ); + } + + #[test] + fn checked_in_v4_matrix_matches_the_reference_evaluator() { + let encoded = serde_json::to_string_pretty(&build_iridescence_report()) + .expect("serialize BRDF matrix"); + assert_eq!( + format!("{encoded}\n"), + include_str!("../reference/layered-pbr-v4.json") + ); + } + + #[test] + fn checked_in_angular_matrix_matches_the_reference_evaluator() { + let encoded = + serde_json::to_string_pretty(&build_angular_report()).expect("serialize BRDF matrix"); + assert_eq!( + format!("{encoded}\n"), + include_str!("../reference/layered-pbr-angular-v1.json") + ); + } +} diff --git a/tools/bloom-reference/src/layered_pbr.rs b/tools/bloom-reference/src/layered_pbr.rs new file mode 100644 index 00000000..81025152 --- /dev/null +++ b/tools/bloom-reference/src/layered_pbr.rs @@ -0,0 +1,1509 @@ +//! Versioned, side-effect-free reference evaluation for Bloom's layered PBR. +//! +//! Version 1 intentionally describes only the existing metallic/roughness base +//! layer. Later lobes must compose through this contract instead of creating +//! another unrelated shader formula. The deterministic corpus consumes this +//! module directly; realtime and transport paths are wired only after each +//! version has passed its independent energy and reciprocity gates. + +use glam::Vec3; + +#[allow(dead_code)] +pub const LAYERED_PBR_REFERENCE_VERSION: u32 = 1; +pub const CLEARCOAT_SPECULAR_REFERENCE_VERSION: u32 = 2; +pub const SHEEN_ANISOTROPY_REFERENCE_VERSION: u32 = 3; +pub const IRIDESCENCE_REFERENCE_VERSION: u32 = 4; +pub const DIELECTRIC_F0: f32 = 0.04; +pub const MIN_PERCEPTUAL_ROUGHNESS: f32 = 0.04; +pub const DEFAULT_DIELECTRIC_IOR: f32 = 1.5; +pub const CLEARCOAT_IOR: f32 = 1.5; +pub const DEFAULT_IRIDESCENCE_IOR: f32 = 1.3; +pub const DEFAULT_IRIDESCENCE_THICKNESS_NM: f32 = 400.0; +const SHEEN_ALBEDO_LUT_SIZE: usize = 128; +const SHEEN_ALBEDO_LUT: &[u8] = + include_bytes!("../../../native/shared/shaders/sheen_albedo_lut_r16f.bin"); + +#[derive(Clone, Copy, Debug)] +pub struct BaseMaterial { + pub base_color: Vec3, + pub metallic: f32, + pub perceptual_roughness: f32, +} + +impl BaseMaterial { + #[allow(dead_code)] + pub fn validated(self) -> Self { + Self { + base_color: self.base_color.clamp(Vec3::ZERO, Vec3::ONE), + metallic: self.metallic.clamp(0.0, 1.0), + perceptual_roughness: self + .perceptual_roughness + .clamp(MIN_PERCEPTUAL_ROUGHNESS, 1.0), + } + } + + pub fn f0(self) -> Vec3 { + Vec3::splat(DIELECTRIC_F0).lerp(self.base_color, self.metallic) + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct BaseBrdfEvaluation { + /// BRDF without the incident cosine. + pub diffuse: Vec3, + /// BRDF without the incident cosine. + pub specular: Vec3, + /// `(diffuse + specular) * NdotL`, ready for light transport. + pub brdf_cos: Vec3, + /// The base sampler's current mixture PDF, used by reference MIS. + pub pdf: f32, +} + +/// Version-2 clearcoat and dielectric-specular parameters layered over the +/// version-1 metallic/roughness base. +/// +/// `ior == 0` is the glTF specular-glossiness compatibility mode and maps to +/// unit dielectric F0. `specular_color` is intentionally not capped at one: +/// KHR_materials_specular permits larger factors and clamps the resulting F0. +#[derive(Clone, Copy, Debug)] +pub struct LayeredMaterial { + pub base: BaseMaterial, + pub ior: f32, + pub specular_factor: f32, + pub specular_color: Vec3, + pub clearcoat_factor: f32, + pub clearcoat_perceptual_roughness: f32, + pub sheen_color: Vec3, + pub sheen_perceptual_roughness: f32, + pub anisotropy_strength: f32, + pub anisotropy_rotation: f32, + /// Effective KHR_materials_iridescence strength after texture sampling. + pub iridescence_factor: f32, + /// Index of refraction of the dielectric thin-film layer. + pub iridescence_ior: f32, + /// Effective thin-film thickness after texture sampling, in nanometers. + pub iridescence_thickness_nm: f32, +} + +impl LayeredMaterial { + pub fn from_base(base: BaseMaterial) -> Self { + Self { + base, + ior: DEFAULT_DIELECTRIC_IOR, + specular_factor: 1.0, + specular_color: Vec3::ONE, + clearcoat_factor: 0.0, + clearcoat_perceptual_roughness: 0.0, + sheen_color: Vec3::ZERO, + sheen_perceptual_roughness: 0.0, + anisotropy_strength: 0.0, + anisotropy_rotation: 0.0, + iridescence_factor: 0.0, + iridescence_ior: DEFAULT_IRIDESCENCE_IOR, + iridescence_thickness_nm: DEFAULT_IRIDESCENCE_THICKNESS_NM, + } + } + + #[allow(dead_code)] + pub fn validated(self) -> Self { + let finite_non_negative = |value: f32, fallback: f32| { + if value.is_finite() { + value.max(0.0) + } else { + fallback + } + }; + let ior = if !self.ior.is_finite() { + DEFAULT_DIELECTRIC_IOR + } else if self.ior == 0.0 { + 0.0 + } else { + self.ior.max(1.0) + }; + let specular_color = Vec3::new( + finite_non_negative(self.specular_color.x, 1.0), + finite_non_negative(self.specular_color.y, 1.0), + finite_non_negative(self.specular_color.z, 1.0), + ); + Self { + base: self.base.validated(), + ior, + specular_factor: if self.specular_factor.is_finite() { + self.specular_factor.clamp(0.0, 1.0) + } else { + 1.0 + }, + specular_color, + clearcoat_factor: if self.clearcoat_factor.is_finite() { + self.clearcoat_factor.clamp(0.0, 1.0) + } else { + 0.0 + }, + clearcoat_perceptual_roughness: if self.clearcoat_perceptual_roughness.is_finite() { + self.clearcoat_perceptual_roughness.clamp(0.0, 1.0) + } else { + 0.0 + }, + sheen_color: self.sheen_color.clamp(Vec3::ZERO, Vec3::ONE), + sheen_perceptual_roughness: if self.sheen_perceptual_roughness.is_finite() { + self.sheen_perceptual_roughness.clamp(0.0, 1.0) + } else { + 0.0 + }, + anisotropy_strength: if self.anisotropy_strength.is_finite() { + self.anisotropy_strength.clamp(0.0, 1.0) + } else { + 0.0 + }, + anisotropy_rotation: if self.anisotropy_rotation.is_finite() { + self.anisotropy_rotation + } else { + 0.0 + }, + iridescence_factor: if self.iridescence_factor.is_finite() { + self.iridescence_factor.clamp(0.0, 1.0) + } else { + 0.0 + }, + iridescence_ior: if self.iridescence_ior.is_finite() { + self.iridescence_ior.max(1.0) + } else { + DEFAULT_IRIDESCENCE_IOR + }, + iridescence_thickness_nm: if self.iridescence_thickness_nm.is_finite() { + self.iridescence_thickness_nm.max(0.0) + } else { + DEFAULT_IRIDESCENCE_THICKNESS_NM + }, + } + } + + pub fn dielectric_f0(self) -> Vec3 { + (self.specular_color * ior_f0(self.ior)).min(Vec3::ONE) * self.specular_factor + } + + fn base_fresnel_v3(self, cos_theta: f32) -> Vec3 { + let dielectric = fresnel_schlick_f90( + cos_theta, + self.dielectric_f0(), + Vec3::splat(self.specular_factor), + ); + let conductor = fresnel_schlick(cos_theta, self.base.base_color); + dielectric.lerp(conductor, self.base.metallic) + } + + fn has_iridescence(self) -> bool { + self.iridescence_factor > 0.0 && self.iridescence_thickness_nm > 0.0 + } + + fn base_fresnel(self, cos_theta: f32) -> Vec3 { + let base = self.base_fresnel_v3(cos_theta); + if !self.has_iridescence() { + return base; + } + let dielectric = iridescence_fresnel( + 1.0, + self.iridescence_ior, + cos_theta, + self.iridescence_thickness_nm, + self.dielectric_f0(), + ); + let conductor = iridescence_fresnel( + 1.0, + self.iridescence_ior, + cos_theta, + self.iridescence_thickness_nm, + self.base.base_color, + ); + base.lerp( + dielectric.lerp(conductor, self.base.metallic), + self.iridescence_factor, + ) + } + + fn has_specular_ior_lobe(self) -> bool { + self.ior != DEFAULT_DIELECTRIC_IOR + || self.specular_factor != 1.0 + || self.specular_color != Vec3::ONE + } + + fn dielectric_transmission(self, cos_theta: f32) -> f32 { + let base = fresnel_schlick_f90( + cos_theta, + self.dielectric_f0(), + Vec3::splat(self.specular_factor), + ); + let fresnel = if self.has_iridescence() { + base.lerp( + iridescence_fresnel( + 1.0, + self.iridescence_ior, + cos_theta, + self.iridescence_thickness_nm, + self.dielectric_f0(), + ), + self.iridescence_factor, + ) + } else { + base + }; + (1.0 - fresnel.max_element()).clamp(0.0, 1.0) + } + + fn clearcoat_fresnel(self, cos_theta: f32) -> f32 { + self.clearcoat_factor * fresnel_schlick(cos_theta, Vec3::splat(ior_f0(CLEARCOAT_IOR))).x + } + + fn clearcoat_transmission(self, cos_theta: f32) -> f32 { + 1.0 - self.clearcoat_fresnel(cos_theta) + } + + fn clearcoat_roughness(self) -> f32 { + self.clearcoat_perceptual_roughness + .max(MIN_PERCEPTUAL_ROUGHNESS) + } + + fn sheen_roughness(self) -> f32 { + self.sheen_perceptual_roughness.max(1e-3) + } + + fn has_sheen(self) -> bool { + self.sheen_color.max_element() > 0.0 + } + + fn anisotropic_alpha(self) -> [f32; 2] { + let alpha = self.base.perceptual_roughness * self.base.perceptual_roughness; + [ + alpha + (1.0 - alpha) * self.anisotropy_strength.powi(2), + alpha, + ] + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct LayeredBrdfEvaluation { + /// Energy-compensated base diffuse BRDF, without the incident cosine. + pub diffuse: Vec3, + /// Energy-compensated base GGX BRDF, without the incident cosine. + pub base_specular: Vec3, + /// Clearcoat GGX BRDF, without the incident cosine. + pub clearcoat_specular: Vec3, + /// Charlie micro-fiber BRDF below clearcoat, without the incident cosine. + pub sheen_specular: Vec3, + /// Sum of every lobe multiplied by `NdotL`. + pub brdf_cos: Vec3, + /// Three-lobe mixture PDF used by the future reference transport path. + pub pdf: f32, +} + +/// Schlick's Fresnel approximation. `f0` is reflectance at normal +/// incidence; `cos_theta` is the view/half-vector dot product. +#[inline] +pub fn fresnel_schlick(cos_theta: f32, f0: Vec3) -> Vec3 { + let m = (1.0 - cos_theta).clamp(0.0, 1.0); + f0 + (Vec3::ONE - f0) * (m * m * m * m * m) +} + +/// Schlick Fresnel with an explicit grazing reflectance. This is required by +/// KHR_materials_specular: its scalar factor scales both dielectric F0 and +/// F90, while pure conductors remain unaffected. +#[inline] +pub fn fresnel_schlick_f90(cos_theta: f32, f0: Vec3, f90: Vec3) -> Vec3 { + let m = (1.0 - cos_theta).clamp(0.0, 1.0); + f0 + (f90 - f0) * (m * m * m * m * m) +} + +/// Dielectric normal-incidence reflectance for an air/material interface. +/// +/// glTF reserves IOR zero for specular-glossiness compatibility, where the +/// effective IOR is positive infinity and F0 is exactly one. +#[inline] +pub fn ior_f0(ior: f32) -> f32 { + if ior == 0.0 { + 1.0 + } else { + let ior = ior.max(1.0); + let ratio = (ior - 1.0) / (ior + 1.0); + ratio * ratio + } +} + +fn fresnel0_to_ior(f0: Vec3) -> Vec3 { + let value = f0.clamp(Vec3::ZERO, Vec3::splat(0.9999)); + let root = Vec3::new(value.x.sqrt(), value.y.sqrt(), value.z.sqrt()); + (Vec3::ONE + root) / (Vec3::ONE - root) +} + +fn ior_to_fresnel0(transmitted_ior: Vec3, incident_ior: f32) -> Vec3 { + let incident = Vec3::splat(incident_ior); + let ratio = (transmitted_ior - incident) / (transmitted_ior + incident); + ratio * ratio +} + +fn eval_iridescence_sensitivity(optical_path_difference_nm: f32, shift: Vec3) -> Vec3 { + let phase = std::f32::consts::TAU * optical_path_difference_nm * 1.0e-9; + let phase_squared = phase * phase; + let value = Vec3::new(5.4856e-13, 4.4201e-13, 5.2481e-13); + let position = Vec3::new(1.6810e6, 1.7953e6, 2.2084e6); + let variance = Vec3::new(4.3278e9, 9.3046e9, 6.6121e9); + let cosine = Vec3::new( + (position.x * phase + shift.x).cos(), + (position.y * phase + shift.y).cos(), + (position.z * phase + shift.z).cos(), + ); + let gaussian = Vec3::new( + (-phase_squared * variance.x).exp(), + (-phase_squared * variance.y).exp(), + (-phase_squared * variance.z).exp(), + ); + let variance_scale = Vec3::new( + (std::f32::consts::TAU * variance.x).sqrt(), + (std::f32::consts::TAU * variance.y).sqrt(), + (std::f32::consts::TAU * variance.z).sqrt(), + ); + let mut xyz = value * variance_scale * cosine * gaussian; + xyz.x += 9.7470e-14 + * (std::f32::consts::TAU * 4.5282e9).sqrt() + * (2.2399e6 * phase + shift.x).cos() + * (-4.5282e9 * phase_squared).exp(); + xyz /= 1.0685e-7; + + Vec3::new( + 3.240_454_2 * xyz.x - 0.969_266 * xyz.y + 0.055_643_4 * xyz.z, + -1.537_138_5 * xyz.x + 1.876_010_8 * xyz.y - 0.204_025_9 * xyz.z, + -0.498_531_4 * xyz.x + 0.041_556 * xyz.y + 1.057_225_2 * xyz.z, + ) +} + +/// Khronos' bounded Belcour/Barla thin-film Fresnel approximation. +/// +/// The result is expressed in linear Rec.709. The ratified glTF formulation +/// clamps negative out-of-gamut components; Bloom also caps the upper bound +/// because this value is a reflectance used by an energy-conserving BRDF. +pub fn iridescence_fresnel( + outside_ior: f32, + film_ior: f32, + cos_theta_1: f32, + thickness_nm: f32, + base_f0: Vec3, +) -> Vec3 { + let outside_ior = outside_ior.max(1.0e-4); + let cos_theta_1 = cos_theta_1.clamp(0.0, 1.0); + let thickness_nm = thickness_nm.max(0.0); + let transition = (thickness_nm / 0.03).clamp(0.0, 1.0); + let transition = transition * transition * (3.0 - 2.0 * transition); + let film_ior = outside_ior + (film_ior.max(1.0) - outside_ior) * transition; + + let sin_theta_2_squared = (outside_ior / film_ior).powi(2) * (1.0 - cos_theta_1 * cos_theta_1); + let cos_theta_2_squared = 1.0 - sin_theta_2_squared; + if cos_theta_2_squared < 0.0 { + return Vec3::ONE; + } + let cos_theta_2 = cos_theta_2_squared.sqrt(); + + let r0 = ior_f0(film_ior / outside_ior); + let r12 = fresnel_schlick(cos_theta_1, Vec3::splat(r0)).x; + let t121 = 1.0 - r12; + let phi12 = if film_ior < outside_ior { + std::f32::consts::PI + } else { + 0.0 + }; + let phi21 = std::f32::consts::PI - phi12; + + let base_ior = fresnel0_to_ior(base_f0); + let r1 = ior_to_fresnel0(base_ior, film_ior); + let r23 = fresnel_schlick(cos_theta_2, r1); + let phi23 = Vec3::new( + if base_ior.x < film_ior { + std::f32::consts::PI + } else { + 0.0 + }, + if base_ior.y < film_ior { + std::f32::consts::PI + } else { + 0.0 + }, + if base_ior.z < film_ior { + std::f32::consts::PI + } else { + 0.0 + }, + ); + + let optical_path_difference = 2.0 * film_ior * thickness_nm * cos_theta_2; + let phase_shift = Vec3::splat(phi21) + phi23; + let r123 = (r23 * r12).clamp(Vec3::splat(1.0e-5), Vec3::splat(0.9999)); + let mut cm = (r23 * (t121 * t121) / (Vec3::ONE - r123)) - Vec3::splat(t121); + let mut result = Vec3::splat(r12) + r23 * (t121 * t121) / (Vec3::ONE - r123); + let r123_amplitude = Vec3::new(r123.x.sqrt(), r123.y.sqrt(), r123.z.sqrt()); + for order in 1..=2 { + cm *= r123_amplitude; + result += + cm * eval_iridescence_sensitivity( + order as f32 * optical_path_difference, + order as f32 * phase_shift, + ) * 2.0; + } + result.clamp(Vec3::ZERO, Vec3::ONE) +} + +/// GGX (Trowbridge-Reitz) normal distribution. +#[inline] +pub fn d_ggx(n_dot_h: f32, alpha: f32) -> f32 { + let a2 = alpha * alpha; + let nh2 = n_dot_h * n_dot_h; + let denom = nh2 * (a2 - 1.0) + 1.0; + a2 / (std::f32::consts::PI * denom * denom) +} + +/// Height-correlated Smith visibility, including `1/(4 NdotV NdotL)`. +#[inline] +pub fn v_smith(n_dot_v: f32, n_dot_l: f32, alpha: f32) -> f32 { + let a2 = alpha * alpha; + let ggx_v = n_dot_l * (n_dot_v * n_dot_v * (1.0 - a2) + a2).sqrt(); + let ggx_l = n_dot_v * (n_dot_l * n_dot_l * (1.0 - a2) + a2).sqrt(); + 0.5 / (ggx_v + ggx_l + 1e-6) +} + +#[inline] +pub fn d_ggx_anisotropic(n_dot_h: f32, t_dot_h: f32, b_dot_h: f32, at: f32, ab: f32) -> f32 { + let at = at.max(0.001); + let ab = ab.max(0.001); + let a2 = at * ab; + let f = Vec3::new(ab * t_dot_h, at * b_dot_h, a2 * n_dot_h); + let w2 = a2 / f.length_squared().max(1e-12); + a2 * w2 * w2 / std::f32::consts::PI +} + +#[allow(clippy::too_many_arguments)] +#[inline] +pub fn v_smith_anisotropic( + n_dot_l: f32, + n_dot_v: f32, + t_dot_v: f32, + b_dot_v: f32, + t_dot_l: f32, + b_dot_l: f32, + at: f32, + ab: f32, +) -> f32 { + let ggx_v = n_dot_l * Vec3::new(at * t_dot_v, ab * b_dot_v, n_dot_v).length(); + let ggx_l = n_dot_v * Vec3::new(at * t_dot_l, ab * b_dot_l, n_dot_l).length(); + (0.5 / (ggx_v + ggx_l + 1e-6)).clamp(0.0, 1.0) +} + +#[inline] +pub fn sheen_directional_albedo(n_dot: f32, perceptual_roughness: f32) -> f32 { + crate::sheen_lut::sample_r16f_lut( + SHEEN_ALBEDO_LUT, + SHEEN_ALBEDO_LUT_SIZE, + n_dot, + perceptual_roughness, + ) +} + +fn default_tangent(normal: Vec3) -> Vec3 { + let candidate = Vec3::X - normal * normal.x; + if candidate.length_squared() > 1e-8 { + candidate.normalize() + } else { + Vec3::Y + } +} + +/// Energy-normalized Burley diffuse, including its `1/pi` normalization. +/// +/// The original Disney form can return more than one unit of directional +/// albedo for a rough surface at grazing view. The Frostbite normalization +/// fades to `1/1.51` at roughness one and keeps the lobe reciprocal. +#[inline] +pub fn burley_diffuse(n_dot_l: f32, n_dot_v: f32, l_dot_h: f32, perceptual_roughness: f32) -> f32 { + let fd90 = 0.5 + 2.0 * l_dot_h * l_dot_h * perceptual_roughness; + let light = 1.0 + (fd90 - 1.0) * (1.0 - n_dot_l).powi(5); + let view = 1.0 + (fd90 - 1.0) * (1.0 - n_dot_v).powi(5); + let energy_factor = 1.0 + (1.0 / 1.51 - 1.0) * perceptual_roughness; + light * view * energy_factor / std::f32::consts::PI +} + +/// Evaluate the version-1 base layer for one view/light pair. +/// +/// Inputs are expected to be normalized. Invalid/back-facing pairs return +/// zero instead of allowing a non-finite half vector into later lobes. +#[inline] +pub fn evaluate_base_brdf( + material: BaseMaterial, + normal: Vec3, + view: Vec3, + light: Vec3, +) -> BaseBrdfEvaluation { + let n_dot_v = normal.dot(view).max(0.0); + let n_dot_l = normal.dot(light).max(0.0); + if n_dot_l <= 0.0 || n_dot_v <= 0.0 { + return BaseBrdfEvaluation::default(); + } + let half_raw = view + light; + if half_raw.length_squared() <= 1e-12 { + return BaseBrdfEvaluation::default(); + } + let half = half_raw.normalize(); + let n_dot_h = normal.dot(half).max(0.0); + let v_dot_h = view.dot(half).max(0.0); + + let f0 = material.f0(); + let alpha = material.perceptual_roughness * material.perceptual_roughness; + let fresnel = fresnel_schlick(v_dot_h, f0); + let distribution = d_ggx(n_dot_h, alpha); + let visibility = v_smith(n_dot_v, n_dot_l, alpha); + let specular = fresnel * distribution * visibility; + + let diffuse_factor = burley_diffuse(n_dot_l, n_dot_v, v_dot_h, material.perceptual_roughness); + // Diffuse crosses the dielectric boundary twice. Gating it by both the + // view-side and light-side interface transmission is reciprocal and + // prevents grazing specular from stacking on top of full diffuse energy. + let view_transmission = Vec3::ONE - fresnel_schlick(n_dot_v, f0); + let light_transmission = Vec3::ONE - fresnel_schlick(n_dot_l, f0); + let diffuse_albedo = + material.base_color * (1.0 - material.metallic) * view_transmission * light_transmission; + let diffuse = diffuse_albedo * diffuse_factor; + let brdf_cos = (specular + diffuse) * n_dot_l; + + let specular_weight = f0.element_sum() / 3.0; + let diffuse_weight = (1.0 - specular_weight) * (1.0 - material.metallic); + let specular_probability = specular_weight / (specular_weight + diffuse_weight + 1e-6); + let diffuse_probability = 1.0 - specular_probability; + let specular_pdf = distribution * n_dot_h / (4.0 * v_dot_h + 1e-6); + let diffuse_pdf = n_dot_l / std::f32::consts::PI; + let pdf = (specular_probability * specular_pdf + diffuse_probability * diffuse_pdf).max(0.0); + + BaseBrdfEvaluation { + diffuse, + specular, + brdf_cos, + pdf, + } +} + +/// Evaluate the current layered contract with a deterministic tangent frame. +/// +/// Clearcoat uses a fixed IOR of 1.5. For direct lighting its Schlick term is +/// evaluated at the symmetric view/half angle, so attenuating the base by +/// `1 - clearcoatFactor * Fc` is reciprocal. This follows the common +/// Filament/glTF implementation shape while avoiding the view-only layering +/// shortcut that changes when view and light are exchanged. +#[inline] +pub fn evaluate_layered_brdf( + material: LayeredMaterial, + normal: Vec3, + view: Vec3, + light: Vec3, +) -> LayeredBrdfEvaluation { + evaluate_layered_brdf_with_tangent(material, normal, default_tangent(normal), view, light) +} + +/// Evaluate clearcoat, sheen, anisotropic GGX, and specular/IOR for one +/// view/light pair. `tangent` is re-orthogonalized against `normal`; the +/// material rotation is counter-clockwise in the resulting tangent plane. +#[inline] +pub fn evaluate_layered_brdf_with_tangent( + material: LayeredMaterial, + normal: Vec3, + tangent: Vec3, + view: Vec3, + light: Vec3, +) -> LayeredBrdfEvaluation { + let n_dot_v = normal.dot(view).max(0.0); + let n_dot_l = normal.dot(light).max(0.0); + if n_dot_l <= 0.0 || n_dot_v <= 0.0 { + return LayeredBrdfEvaluation::default(); + } + let half_raw = view + light; + if half_raw.length_squared() <= 1e-12 { + return LayeredBrdfEvaluation::default(); + } + let half = half_raw.normalize(); + let n_dot_h = normal.dot(half).max(0.0); + let v_dot_h = view.dot(half).max(0.0); + + let tangent = (tangent - normal * normal.dot(tangent)).normalize_or_zero(); + let tangent = if tangent.length_squared() > 0.0 { + tangent + } else { + default_tangent(normal) + }; + let bitangent = normal.cross(tangent).normalize(); + let (sin_rotation, cos_rotation) = material.anisotropy_rotation.sin_cos(); + let anisotropic_tangent = tangent * cos_rotation + bitangent * sin_rotation; + let anisotropic_bitangent = normal.cross(anisotropic_tangent).normalize(); + let [at, ab] = material.anisotropic_alpha(); + let base_distribution = if material.anisotropy_strength > 0.0 { + d_ggx_anisotropic( + n_dot_h, + anisotropic_tangent.dot(half), + anisotropic_bitangent.dot(half), + at, + ab, + ) + } else { + d_ggx(n_dot_h, ab) + }; + let base_visibility = if material.anisotropy_strength > 0.0 { + v_smith_anisotropic( + n_dot_l, + n_dot_v, + anisotropic_tangent.dot(view), + anisotropic_bitangent.dot(view), + anisotropic_tangent.dot(light), + anisotropic_bitangent.dot(light), + at, + ab, + ) + } else { + v_smith(n_dot_v, n_dot_l, ab) + }; + let (uncoated_diffuse, uncoated_base_specular) = if material.has_specular_ior_lobe() + || material.anisotropy_strength > 0.0 + || material.has_iridescence() + { + let base_fresnel = material.base_fresnel(v_dot_h); + let base_specular = base_fresnel * base_distribution * base_visibility; + let diffuse_factor = burley_diffuse( + n_dot_l, + n_dot_v, + v_dot_h, + material.base.perceptual_roughness, + ); + // Khronos specifies max(R,G,B) for colored dielectric Fresnel so the + // diffuse complement cannot create an inverse color. Applying the + // complement at both interfaces keeps the diffuse term reciprocal. + let diffuse_albedo = material.base.base_color + * (1.0 - material.base.metallic) + * material.dielectric_transmission(n_dot_v) + * material.dielectric_transmission(n_dot_l); + (diffuse_albedo * diffuse_factor, base_specular) + } else { + let base = evaluate_base_brdf(material.base, normal, view, light); + (base.diffuse, base.specular) + }; + + let (sheen_scale, uncoated_sheen) = if material.has_sheen() { + let sheen_roughness = material.sheen_roughness(); + let view_albedo = sheen_directional_albedo(n_dot_v, sheen_roughness); + let light_albedo = sheen_directional_albedo(n_dot_l, sheen_roughness); + let scale = (1.0 - material.sheen_color.max_element() * view_albedo.max(light_albedo)) + .clamp(0.0, 1.0); + let sheen_distribution = crate::sheen_lut::distribution_charlie(n_dot_h, sheen_roughness); + let sheen_visibility = + crate::sheen_lut::visibility_sheen(n_dot_l, n_dot_v, sheen_roughness); + ( + scale, + material.sheen_color * sheen_distribution * sheen_visibility, + ) + } else { + (1.0, Vec3::ZERO) + }; + + let clearcoat_fresnel = material.clearcoat_fresnel(v_dot_h); + // A real top interface is crossed in both directions. The two symmetric + // transmission factors prevent rough/grazing base energy from stacking + // under a second white-furnace lobe. This is also the direct-light + // counterpart of Filament's squared clearcoat attenuation for IBL. + let base_attenuation = + material.clearcoat_transmission(n_dot_v) * material.clearcoat_transmission(n_dot_l); + let clearcoat_alpha = material.clearcoat_roughness() * material.clearcoat_roughness(); + let clearcoat_distribution = d_ggx(n_dot_h, clearcoat_alpha); + let clearcoat_visibility = v_smith(n_dot_v, n_dot_l, clearcoat_alpha); + let clearcoat_specular = + Vec3::splat(clearcoat_fresnel * clearcoat_distribution * clearcoat_visibility); + + let diffuse = uncoated_diffuse * sheen_scale * base_attenuation; + let base_specular = uncoated_base_specular * sheen_scale * base_attenuation; + let sheen_specular = uncoated_sheen * base_attenuation; + let brdf_cos = (diffuse + base_specular + sheen_specular + clearcoat_specular) * n_dot_l; + + let base_specular_weight = material.base_fresnel(n_dot_v).element_sum() / 3.0; + let diffuse_weight = material.dielectric_transmission(n_dot_v) * (1.0 - material.base.metallic); + let clearcoat_weight = material.clearcoat_fresnel(n_dot_v); + let sheen_weight = material.sheen_color.element_sum() / 3.0; + let weight_sum = base_specular_weight + diffuse_weight + clearcoat_weight + sheen_weight + 1e-6; + let base_specular_probability = base_specular_weight / weight_sum; + let diffuse_probability = diffuse_weight / weight_sum; + let clearcoat_probability = clearcoat_weight / weight_sum; + let sheen_probability = sheen_weight / weight_sum; + let base_specular_pdf = base_distribution * n_dot_h / (4.0 * v_dot_h + 1e-6); + let diffuse_pdf = n_dot_l / std::f32::consts::PI; + let clearcoat_pdf = clearcoat_distribution * n_dot_h / (4.0 * v_dot_h + 1e-6); + let sheen_pdf = crate::sheen_lut::distribution_charlie(n_dot_h, material.sheen_roughness()) + * n_dot_h + / (4.0 * v_dot_h + 1e-6); + let pdf = (base_specular_probability * base_specular_pdf + + diffuse_probability * diffuse_pdf + + clearcoat_probability * clearcoat_pdf + + sheen_probability * sheen_pdf) + .max(0.0); + + LayeredBrdfEvaluation { + diffuse, + base_specular, + clearcoat_specular, + sheen_specular, + brdf_cos, + pdf, + } +} + +fn reflect(incoming: Vec3, normal: Vec3) -> Vec3 { + incoming - normal * (2.0 * incoming.dot(normal)) +} + +fn smith_g1(n_dot_x: f32, alpha: f32) -> f32 { + let a2 = alpha * alpha; + let inner = ((1.0 - a2) * n_dot_x * n_dot_x + a2).sqrt(); + 2.0 * n_dot_x / (n_dot_x + inner + 1e-6) +} + +fn smith_g1_anisotropic(direction: Vec3, at: f32, ab: f32) -> f32 { + let n_dot = direction.z.max(0.0); + if n_dot <= 0.0 { + return 0.0; + } + let projected = Vec3::new(at * direction.x, ab * direction.y, n_dot).length(); + 2.0 * n_dot / (n_dot + projected + 1e-6) +} + +/// Heitz visible-normal GGX sampling in the local z-up frame. +fn sample_ggx_vndf(view: Vec3, alpha: f32, sample: [f32; 2]) -> Vec3 { + sample_ggx_vndf_anisotropic(view, alpha, alpha, sample) +} + +/// Heitz visible-normal GGX sampling with independent tangent/bitangent +/// alpha roughness. +fn sample_ggx_vndf_anisotropic(view: Vec3, at: f32, ab: f32, sample: [f32; 2]) -> Vec3 { + let stretched_view = Vec3::new(at * view.x, ab * view.y, view.z).normalize(); + let lensq = stretched_view.x * stretched_view.x + stretched_view.y * stretched_view.y; + let tangent = if lensq > 0.0 { + Vec3::new(-stretched_view.y, stretched_view.x, 0.0) / lensq.sqrt() + } else { + Vec3::X + }; + let bitangent = stretched_view.cross(tangent); + let radius = sample[0].sqrt(); + let phi = 2.0 * std::f32::consts::PI * sample[1]; + let tangent_x = radius * phi.cos(); + let mut tangent_y = radius * phi.sin(); + let blend = 0.5 * (1.0 + stretched_view.z); + tangent_y = (1.0 - blend) * (1.0 - tangent_x * tangent_x).max(0.0).sqrt() + blend * tangent_y; + let stretched_normal = tangent_x * tangent + + tangent_y * bitangent + + (1.0 - tangent_x * tangent_x - tangent_y * tangent_y) + .max(0.0) + .sqrt() + * stretched_view; + Vec3::new( + at * stretched_normal.x, + ab * stretched_normal.y, + stretched_normal.z.max(0.0), + ) + .normalize() +} + +fn sample_charlie_half(perceptual_roughness: f32, sample: [f32; 2]) -> Vec3 { + let alpha = perceptual_roughness.max(1e-3).powi(2); + let sin_theta = sample[0].powf(alpha / (2.0 * alpha + 1.0)); + let cos_theta = (1.0 - sin_theta * sin_theta).max(0.0).sqrt(); + let phi = std::f32::consts::TAU * sample[1]; + Vec3::new(sin_theta * phi.cos(), sin_theta * phi.sin(), cos_theta) +} + +fn sample_cosine_hemisphere(sample: [f32; 2]) -> Vec3 { + let radius = sample[0].sqrt(); + let phi = 2.0 * std::f32::consts::PI * sample[1]; + Vec3::new( + radius * phi.cos(), + radius * phi.sin(), + (1.0 - sample[0]).max(0.0).sqrt(), + ) +} + +/// Deterministic importance-sampled integration under a unit white furnace. +/// +/// Specular uses the GGX visible-normal distribution, so mirror-like lobes do +/// not alias into false energy gain. Diffuse uses cosine sampling. The two +/// lobe integrals are accumulated separately and then summed; this is a +/// qualification oracle, never a runtime shading path. +pub fn integrate_white_furnace( + material: BaseMaterial, + n_dot_v: f32, + sample_rows: u32, + sample_columns: u32, +) -> Vec3 { + assert!(sample_rows > 0 && sample_columns > 0); + let n_dot_v = n_dot_v.clamp(1e-4, 1.0); + let view = Vec3::new((1.0 - n_dot_v * n_dot_v).sqrt(), 0.0, n_dot_v); + let normal = Vec3::Z; + let alpha = material.perceptual_roughness * material.perceptual_roughness; + let sample_count = (sample_rows * sample_columns) as f32; + let mut diffuse_sum = Vec3::ZERO; + let mut specular_sum = Vec3::ZERO; + for row in 0..sample_rows { + for column in 0..sample_columns { + let sample = [ + (row as f32 + 0.5) / sample_rows as f32, + (column as f32 + 0.5) / sample_columns as f32, + ]; + + let diffuse_light = sample_cosine_hemisphere(sample); + let diffuse = evaluate_base_brdf(material, normal, view, diffuse_light).diffuse; + diffuse_sum += diffuse * std::f32::consts::PI; + + let half = sample_ggx_vndf(view, alpha, sample); + let specular_light = reflect(-view, half); + let n_dot_l = specular_light.z; + if n_dot_l > 0.0 { + // For a GGX VNDF sample, `BRDF * cos / PDF` cancels D + // analytically to `F * G2/G1`. Using that stable form is + // essential for the near-delta roughness floor. + let g1_view = smith_g1(n_dot_v, alpha); + if g1_view > 0.0 { + let g2 = v_smith(n_dot_v, n_dot_l, alpha) * (4.0 * n_dot_v * n_dot_l); + let fresnel = fresnel_schlick(view.dot(half).max(0.0), material.f0()); + specular_sum += fresnel * g2 / g1_view; + } + } + } + } + (diffuse_sum + specular_sum) / sample_count +} + +/// Deterministic unit-white-furnace integration for the version-2 model. +/// +/// Each GGX lobe uses its own visible-normal sampler. The stable +/// `F * G2/G1` estimator avoids losing or over-counting the near-delta +/// clearcoat peak; diffuse remains cosine sampled. +pub fn integrate_layered_white_furnace( + material: LayeredMaterial, + n_dot_v: f32, + sample_rows: u32, + sample_columns: u32, +) -> Vec3 { + assert!(sample_rows > 0 && sample_columns > 0); + let n_dot_v = n_dot_v.clamp(1e-4, 1.0); + let view = Vec3::new((1.0 - n_dot_v * n_dot_v).sqrt(), 0.0, n_dot_v); + let normal = Vec3::Z; + let [at, ab] = material.anisotropic_alpha(); + let base_alpha = material.base.perceptual_roughness * material.base.perceptual_roughness; + let (sin_rotation, cos_rotation) = material.anisotropy_rotation.sin_cos(); + let tangent = Vec3::new(cos_rotation, sin_rotation, 0.0); + let bitangent = Vec3::new(-sin_rotation, cos_rotation, 0.0); + let to_local = |direction: Vec3| { + Vec3::new( + tangent.dot(direction), + bitangent.dot(direction), + direction.z, + ) + }; + let to_world = + |direction: Vec3| tangent * direction.x + bitangent * direction.y + normal * direction.z; + let local_view = to_local(view); + let clearcoat_alpha = material.clearcoat_roughness() * material.clearcoat_roughness(); + let sample_count = (sample_rows * sample_columns) as f32; + let mut diffuse_sum = Vec3::ZERO; + let mut base_specular_sum = Vec3::ZERO; + let mut sheen_sum = Vec3::ZERO; + let mut clearcoat_sum = Vec3::ZERO; + for row in 0..sample_rows { + for column in 0..sample_columns { + let sample = [ + (row as f32 + 0.5) / sample_rows as f32, + (column as f32 + 0.5) / sample_columns as f32, + ]; + + let diffuse_light = sample_cosine_hemisphere(sample); + let diffuse = evaluate_layered_brdf(material, normal, view, diffuse_light).diffuse; + diffuse_sum += diffuse * std::f32::consts::PI; + + let base_half = if material.anisotropy_strength > 0.0 { + to_world(sample_ggx_vndf_anisotropic(local_view, at, ab, sample)) + } else { + sample_ggx_vndf(view, base_alpha, sample) + }; + let base_light = reflect(-view, base_half); + let base_n_dot_l = base_light.z; + if base_n_dot_l > 0.0 { + let g1_view = if material.anisotropy_strength > 0.0 { + smith_g1_anisotropic(local_view, at, ab) + } else { + smith_g1(n_dot_v, base_alpha) + }; + if g1_view > 0.0 { + let local_light = to_local(base_light); + let g2 = if material.anisotropy_strength > 0.0 { + v_smith_anisotropic( + base_n_dot_l, + n_dot_v, + local_view.x, + local_view.y, + local_light.x, + local_light.y, + at, + ab, + ) + } else { + v_smith(n_dot_v, base_n_dot_l, base_alpha) + } * (4.0 * n_dot_v * base_n_dot_l); + let v_dot_h = view.dot(base_half).max(0.0); + let base_attenuation = material.clearcoat_transmission(n_dot_v) + * material.clearcoat_transmission(base_n_dot_l); + let sheen_scale = if material.has_sheen() { + let view_albedo = + sheen_directional_albedo(n_dot_v, material.sheen_roughness()); + let light_albedo = + sheen_directional_albedo(base_n_dot_l, material.sheen_roughness()); + (1.0 - material.sheen_color.max_element() * view_albedo.max(light_albedo)) + .clamp(0.0, 1.0) + } else { + 1.0 + }; + base_specular_sum += material.base_fresnel(v_dot_h) * g2 / g1_view + * base_attenuation + * sheen_scale; + } + } + + if material.has_sheen() { + let sheen_half = sample_charlie_half(material.sheen_roughness(), sample); + let sheen_light = reflect(-view, sheen_half); + let sheen_n_dot_l = sheen_light.z; + let v_dot_h = view.dot(sheen_half).max(0.0); + if sheen_n_dot_l > 0.0 && v_dot_h > 0.0 && sheen_half.z > 0.0 { + let visibility = crate::sheen_lut::visibility_sheen( + sheen_n_dot_l, + n_dot_v, + material.sheen_roughness(), + ); + let estimator = visibility * sheen_n_dot_l * 4.0 * v_dot_h / sheen_half.z; + let coat_attenuation = material.clearcoat_transmission(n_dot_v) + * material.clearcoat_transmission(sheen_n_dot_l); + sheen_sum += material.sheen_color * estimator * coat_attenuation; + } + } + + if material.clearcoat_factor > 0.0 { + let clearcoat_half = sample_ggx_vndf(view, clearcoat_alpha, sample); + let clearcoat_light = reflect(-view, clearcoat_half); + let clearcoat_n_dot_l = clearcoat_light.z; + if clearcoat_n_dot_l > 0.0 { + let g1_view = smith_g1(n_dot_v, clearcoat_alpha); + if g1_view > 0.0 { + let g2 = v_smith(n_dot_v, clearcoat_n_dot_l, clearcoat_alpha) + * (4.0 * n_dot_v * clearcoat_n_dot_l); + let fresnel = material.clearcoat_fresnel(view.dot(clearcoat_half).max(0.0)); + clearcoat_sum += Vec3::splat(fresnel * g2 / g1_view); + } + } + } + } + } + (diffuse_sum + base_specular_sum + sheen_sum + clearcoat_sum) / sample_count +} + +#[cfg(test)] +mod tests { + use super::*; + + fn direction(n_dot: f32, azimuth: f32) -> Vec3 { + let sin_theta = (1.0 - n_dot * n_dot).sqrt(); + Vec3::new(sin_theta * azimuth.cos(), sin_theta * azimuth.sin(), n_dot) + } + + #[test] + fn base_brdf_is_reciprocal_and_finite_across_parameter_matrix() { + let normal = Vec3::Z; + for base_color in [Vec3::splat(0.18), Vec3::new(0.8, 0.08, 0.03)] { + for metallic in [0.0, 0.5, 1.0] { + for roughness in [0.04, 0.25, 0.5, 1.0] { + let material = BaseMaterial { + base_color, + metallic, + perceptual_roughness: roughness, + }; + for n_dot_v in [0.1, 0.5, 1.0] { + let view = direction(n_dot_v, 0.0); + let light = direction(0.35, 1.1); + let forward = evaluate_base_brdf(material, normal, view, light); + let reverse = evaluate_base_brdf(material, normal, light, view); + assert!(forward.brdf_cos.is_finite() && forward.pdf.is_finite()); + assert!(forward.diffuse.min_element() >= 0.0); + assert!(forward.specular.min_element() >= 0.0); + let forward_brdf = forward.diffuse + forward.specular; + let reverse_brdf = reverse.diffuse + reverse.specular; + assert!( + forward_brdf.abs_diff_eq(reverse_brdf, 2e-5), + "{material:?}: {forward_brdf:?} != {reverse_brdf:?}" + ); + } + } + } + } + } + + #[test] + fn white_furnace_has_no_unbounded_energy_gain() { + let mut maximum = Vec3::ZERO; + let mut maximum_case = None; + for base_color in [Vec3::ONE, Vec3::new(1.0, 0.1, 0.02)] { + for metallic in [0.0, 0.5, 1.0] { + for roughness in [0.04, 0.25, 0.5, 1.0] { + let material = BaseMaterial { + base_color, + metallic, + perceptual_roughness: roughness, + }; + for n_dot_v in [0.1, 0.25, 0.5, 1.0] { + let reflected = integrate_white_furnace(material, n_dot_v, 96, 192); + assert!(reflected.is_finite()); + assert!(reflected.min_element() >= 0.0); + if reflected.max_element() > maximum.max_element() { + maximum_case = Some((material, n_dot_v)); + } + maximum = maximum.max(reflected); + } + } + } + } + assert!( + maximum.max_element() <= 1.02, + "white-furnace gain: {maximum:?} at {maximum_case:?}" + ); + } + + #[test] + fn invalid_parameters_have_one_documented_sanitization() { + let material = BaseMaterial { + base_color: Vec3::new(-1.0, 0.5, 2.0), + metallic: 4.0, + perceptual_roughness: 0.0, + } + .validated(); + assert_eq!(material.base_color, Vec3::new(0.0, 0.5, 1.0)); + assert_eq!(material.metallic, 1.0); + assert_eq!(material.perceptual_roughness, MIN_PERCEPTUAL_ROUGHNESS); + } + + #[test] + fn version_two_defaults_are_exactly_the_version_one_base_model() { + let normal = Vec3::Z; + for base_color in [Vec3::splat(0.18), Vec3::new(0.8, 0.08, 0.03)] { + for metallic in [0.0, 0.5, 1.0] { + for roughness in [0.04, 0.25, 0.5, 1.0] { + let base = BaseMaterial { + base_color, + metallic, + perceptual_roughness: roughness, + }; + let layered = LayeredMaterial::from_base(base); + for n_dot_v in [0.1, 0.5, 1.0] { + let view = direction(n_dot_v, 0.0); + let light = direction(0.35, 1.1); + let old = evaluate_base_brdf(base, normal, view, light); + let new = evaluate_layered_brdf(layered, normal, view, light); + assert!(old.diffuse.abs_diff_eq(new.diffuse, 2e-6)); + assert!(old.specular.abs_diff_eq(new.base_specular, 2e-6)); + assert_eq!(new.clearcoat_specular, Vec3::ZERO); + assert!(old.brdf_cos.abs_diff_eq(new.brdf_cos, 2e-6)); + } + } + } + } + } + + #[test] + fn layered_brdf_is_reciprocal_finite_and_non_negative() { + let normal = Vec3::Z; + for ior in [0.0, 1.0, 1.33, 1.5, 2.42] { + for specular_factor in [0.0, 0.35, 1.0] { + for clearcoat_factor in [0.0, 0.5, 1.0] { + let material = LayeredMaterial { + base: BaseMaterial { + base_color: Vec3::new(0.8, 0.08, 0.03), + metallic: 0.25, + perceptual_roughness: 0.4, + }, + ior, + specular_factor, + specular_color: Vec3::new(1.4, 0.5, 0.2), + clearcoat_factor, + clearcoat_perceptual_roughness: 0.18, + sheen_color: Vec3::ZERO, + sheen_perceptual_roughness: 0.0, + anisotropy_strength: 0.0, + anisotropy_rotation: 0.0, + iridescence_factor: 0.0, + iridescence_ior: DEFAULT_IRIDESCENCE_IOR, + iridescence_thickness_nm: DEFAULT_IRIDESCENCE_THICKNESS_NM, + }; + let view = direction(0.21, 0.3); + let light = direction(0.63, 1.4); + let forward = evaluate_layered_brdf(material, normal, view, light); + let reverse = evaluate_layered_brdf(material, normal, light, view); + let forward_brdf = forward.diffuse + + forward.base_specular + + forward.sheen_specular + + forward.clearcoat_specular; + let reverse_brdf = reverse.diffuse + + reverse.base_specular + + reverse.sheen_specular + + reverse.clearcoat_specular; + assert!(forward_brdf.is_finite() && forward.pdf.is_finite()); + assert!(forward_brdf.min_element() >= 0.0); + assert!( + forward_brdf.abs_diff_eq(reverse_brdf, 3e-5), + "{material:?}: {forward_brdf:?} != {reverse_brdf:?}" + ); + } + } + } + } + + #[test] + fn specular_and_ior_do_not_modify_a_pure_conductor() { + let normal = Vec3::Z; + let view = direction(0.35, 0.0); + let light = direction(0.7, 1.0); + let base = BaseMaterial { + base_color: Vec3::new(0.9, 0.45, 0.1), + metallic: 1.0, + perceptual_roughness: 0.3, + }; + let first = evaluate_layered_brdf( + LayeredMaterial { + ior: 1.0, + specular_factor: 0.0, + specular_color: Vec3::ZERO, + ..LayeredMaterial::from_base(base) + }, + normal, + view, + light, + ); + let second = evaluate_layered_brdf( + LayeredMaterial { + ior: 2.42, + specular_factor: 1.0, + specular_color: Vec3::new(4.0, 0.2, 2.0), + ..LayeredMaterial::from_base(base) + }, + normal, + view, + light, + ); + assert!(first.brdf_cos.abs_diff_eq(second.brdf_cos, 2e-6)); + } + + #[test] + fn layered_white_furnace_has_no_unexplained_energy_gain() { + let mut maximum = Vec3::ZERO; + let mut maximum_case = None; + for metallic in [0.0, 0.5, 1.0] { + for roughness in [0.04, 0.25, 0.75, 1.0] { + for (ior, specular_factor, specular_color) in [ + (1.0, 1.0, Vec3::ONE), + (1.33, 1.0, Vec3::ONE), + (1.5, 0.35, Vec3::ONE), + (1.5, 1.0, Vec3::new(1.5, 0.4, 0.2)), + (2.42, 1.0, Vec3::ONE), + ] { + for (clearcoat_factor, clearcoat_roughness) in + [(0.0, 0.0), (0.5, 0.04), (1.0, 0.2), (1.0, 0.75)] + { + let material = LayeredMaterial { + base: BaseMaterial { + base_color: Vec3::ONE, + metallic, + perceptual_roughness: roughness, + }, + ior, + specular_factor, + specular_color, + clearcoat_factor, + clearcoat_perceptual_roughness: clearcoat_roughness, + sheen_color: Vec3::ZERO, + sheen_perceptual_roughness: 0.0, + anisotropy_strength: 0.0, + anisotropy_rotation: 0.0, + iridescence_factor: 0.0, + iridescence_ior: DEFAULT_IRIDESCENCE_IOR, + iridescence_thickness_nm: DEFAULT_IRIDESCENCE_THICKNESS_NM, + }; + for n_dot_v in [0.1, 0.25, 0.5, 1.0] { + let reflected = + integrate_layered_white_furnace(material, n_dot_v, 64, 128); + assert!(reflected.is_finite()); + assert!(reflected.min_element() >= 0.0); + if reflected.max_element() > maximum.max_element() { + maximum_case = Some((material, n_dot_v, reflected)); + } + maximum = maximum.max(reflected); + } + } + } + } + } + assert!( + maximum.max_element() <= 1.02, + "layered white-furnace gain: {maximum:?} at {maximum_case:?}" + ); + } + + #[test] + fn layered_validation_preserves_ior_zero_and_clamps_only_bounded_factors() { + let material = LayeredMaterial { + base: BaseMaterial { + base_color: Vec3::new(-1.0, 0.5, 2.0), + metallic: 3.0, + perceptual_roughness: 0.0, + }, + ior: 0.0, + specular_factor: 4.0, + specular_color: Vec3::new(-1.0, 2.0, f32::NAN), + clearcoat_factor: -1.0, + clearcoat_perceptual_roughness: 2.0, + sheen_color: Vec3::new(-1.0, 0.5, 2.0), + sheen_perceptual_roughness: 2.0, + anisotropy_strength: -1.0, + anisotropy_rotation: f32::NAN, + iridescence_factor: 2.0, + iridescence_ior: f32::NAN, + iridescence_thickness_nm: -1.0, + } + .validated(); + assert_eq!(material.ior, 0.0); + assert_eq!(material.specular_factor, 1.0); + assert_eq!(material.specular_color, Vec3::new(0.0, 2.0, 1.0)); + assert_eq!(material.clearcoat_factor, 0.0); + assert_eq!(material.clearcoat_perceptual_roughness, 1.0); + assert_eq!(material.sheen_color, Vec3::new(0.0, 0.5, 1.0)); + assert_eq!(material.sheen_perceptual_roughness, 1.0); + assert_eq!(material.anisotropy_strength, 0.0); + assert_eq!(material.anisotropy_rotation, 0.0); + assert_eq!(material.iridescence_factor, 1.0); + assert_eq!(material.iridescence_ior, DEFAULT_IRIDESCENCE_IOR); + assert_eq!(material.iridescence_thickness_nm, 0.0); + assert_eq!(material.base.base_color, Vec3::new(0.0, 0.5, 1.0)); + } + + #[test] + fn sheen_lut_tracks_the_full_charlie_directional_albedo_oracle() { + for (n_dot_v, roughness) in [ + (0.08, 0.08), + (0.2, 0.35), + (0.5, 0.5), + (0.85, 0.75), + (1.0, 1.0), + ] { + let table = sheen_directional_albedo(n_dot_v, roughness); + let integrated = crate::sheen_lut::directional_albedo(n_dot_v, roughness, 65_536); + assert!( + (table - integrated).abs() <= 0.012, + "Charlie E mismatch at ({n_dot_v}, {roughness}): {table} vs {integrated}" + ); + } + } + + #[test] + fn sheen_and_anisotropic_ggx_are_reciprocal_finite_and_rotation_periodic() { + let normal = Vec3::Z; + let tangent = Vec3::X; + for sheen_roughness in [0.04, 0.35, 1.0] { + for anisotropy_strength in [0.0, 0.45, 1.0] { + for rotation in [0.0, 0.7, std::f32::consts::FRAC_PI_2] { + let material = LayeredMaterial { + sheen_color: Vec3::new(0.8, 0.25, 0.05), + sheen_perceptual_roughness: sheen_roughness, + anisotropy_strength, + anisotropy_rotation: rotation, + ..LayeredMaterial::from_base(BaseMaterial { + base_color: Vec3::new(0.3, 0.08, 0.02), + metallic: 0.65, + perceptual_roughness: 0.32, + }) + }; + let view = direction(0.23, 0.2); + let light = direction(0.61, 1.3); + let forward = + evaluate_layered_brdf_with_tangent(material, normal, tangent, view, light); + let reverse = + evaluate_layered_brdf_with_tangent(material, normal, tangent, light, view); + let sum = |value: LayeredBrdfEvaluation| { + value.diffuse + + value.base_specular + + value.sheen_specular + + value.clearcoat_specular + }; + assert!(sum(forward).is_finite() && forward.pdf.is_finite()); + assert!(sum(forward).min_element() >= 0.0); + assert!( + sum(forward).abs_diff_eq(sum(reverse), 4e-5), + "{material:?}: {:?} != {:?}", + sum(forward), + sum(reverse) + ); + let periodic = evaluate_layered_brdf_with_tangent( + LayeredMaterial { + anisotropy_rotation: rotation + std::f32::consts::TAU, + ..material + }, + normal, + tangent, + view, + light, + ); + assert!(sum(forward).abs_diff_eq(sum(periodic), 4e-5)); + } + } + } + } + + #[test] + fn sheen_and_anisotropy_white_furnace_stay_bounded() { + let mut maximum = Vec3::ZERO; + let mut maximum_case = None; + for metallic in [0.0, 0.5, 1.0] { + for roughness in [0.12, 0.4, 0.8] { + for sheen_roughness in [0.08, 0.4, 1.0] { + for anisotropy_strength in [0.0, 0.65, 1.0] { + let material = LayeredMaterial { + sheen_color: Vec3::new(1.0, 0.45, 0.12), + sheen_perceptual_roughness: sheen_roughness, + anisotropy_strength, + anisotropy_rotation: 0.73, + ..LayeredMaterial::from_base(BaseMaterial { + base_color: Vec3::ONE, + metallic, + perceptual_roughness: roughness, + }) + }; + for n_dot_v in [0.1, 0.4, 1.0] { + let reflected = + integrate_layered_white_furnace(material, n_dot_v, 64, 128); + assert!(reflected.is_finite() && reflected.min_element() >= 0.0); + if reflected.max_element() > maximum.max_element() { + maximum_case = Some((material, n_dot_v, reflected)); + } + maximum = maximum.max(reflected); + } + } + } + } + } + assert!( + maximum.max_element() <= 1.03, + "sheen/anisotropy white-furnace gain: {maximum:?} at {maximum_case:?}" + ); + } + + #[test] + fn inactive_iridescence_is_exactly_the_version_three_model() { + let normal = Vec3::Z; + let view = direction(0.27, 0.2); + let light = direction(0.64, 1.4); + let v3 = LayeredMaterial { + ior: 1.72, + specular_factor: 0.7, + specular_color: Vec3::new(1.2, 0.6, 0.3), + clearcoat_factor: 0.6, + clearcoat_perceptual_roughness: 0.22, + sheen_color: Vec3::new(0.2, 0.5, 0.9), + sheen_perceptual_roughness: 0.4, + anisotropy_strength: 0.75, + anisotropy_rotation: 0.61, + ..LayeredMaterial::from_base(BaseMaterial { + base_color: Vec3::new(0.08, 0.3, 0.75), + metallic: 0.35, + perceptual_roughness: 0.36, + }) + }; + let expected = evaluate_layered_brdf(v3, normal, view, light); + for inactive in [ + LayeredMaterial { + iridescence_factor: 0.0, + iridescence_ior: 2.2, + iridescence_thickness_nm: 800.0, + ..v3 + }, + LayeredMaterial { + iridescence_factor: 1.0, + iridescence_ior: 2.2, + iridescence_thickness_nm: 0.0, + ..v3 + }, + ] { + let actual = evaluate_layered_brdf(inactive, normal, view, light); + assert_eq!(actual.diffuse, expected.diffuse); + assert_eq!(actual.base_specular, expected.base_specular); + assert_eq!(actual.sheen_specular, expected.sheen_specular); + assert_eq!(actual.clearcoat_specular, expected.clearcoat_specular); + assert_eq!(actual.brdf_cos, expected.brdf_cos); + assert_eq!(actual.pdf, expected.pdf); + } + } + + #[test] + fn thin_film_fresnel_is_finite_bounded_and_spectrally_varying() { + let base_f0 = Vec3::splat(0.04); + let mut colors = Vec::new(); + for thickness_nm in [100.0, 250.0, 400.0, 800.0] { + for cos_theta in [0.12, 0.45, 0.9] { + let value = iridescence_fresnel(1.0, 1.3, cos_theta, thickness_nm, base_f0); + assert!(value.is_finite()); + assert!(value.min_element() >= 0.0); + assert!(value.max_element() <= 1.0); + colors.push(value); + } + } + let spread = colors + .iter() + .flat_map(|left| colors.iter().map(move |right| (*left - *right).length())) + .fold(0.0_f32, f32::max); + assert!(spread > 0.1, "thin film did not produce spectral variation"); + } + + #[test] + fn iridescent_layer_is_reciprocal_and_white_furnace_bounded() { + let normal = Vec3::Z; + let view = direction(0.19, 0.4); + let light = direction(0.68, 1.3); + let sum = |value: LayeredBrdfEvaluation| { + value.diffuse + value.base_specular + value.sheen_specular + value.clearcoat_specular + }; + let mut maximum = Vec3::ZERO; + for metallic in [0.0, 0.5, 1.0] { + for thickness_nm in [100.0, 400.0, 800.0] { + let material = LayeredMaterial { + iridescence_factor: 1.0, + iridescence_ior: 1.3, + iridescence_thickness_nm: thickness_nm, + ..LayeredMaterial::from_base(BaseMaterial { + base_color: Vec3::new(0.92, 0.55, 0.12), + metallic, + perceptual_roughness: 0.35, + }) + }; + let forward = evaluate_layered_brdf(material, normal, view, light); + let reverse = evaluate_layered_brdf(material, normal, light, view); + assert!(sum(forward).is_finite() && sum(forward).min_element() >= 0.0); + assert!(sum(forward).abs_diff_eq(sum(reverse), 4e-5)); + for n_dot_v in [0.1, 0.5, 1.0] { + let reflected = integrate_layered_white_furnace(material, n_dot_v, 64, 128); + assert!(reflected.is_finite() && reflected.min_element() >= 0.0); + maximum = maximum.max(reflected); + } + } + } + assert!( + maximum.max_element() <= 1.03, + "iridescence white-furnace gain: {maximum:?}" + ); + } +} diff --git a/tools/bloom-reference/src/main.rs b/tools/bloom-reference/src/main.rs index f935f1ee..04922d9f 100644 --- a/tools/bloom-reference/src/main.rs +++ b/tools/bloom-reference/src/main.rs @@ -76,7 +76,8 @@ struct ReferenceDefaults { /// as a unit. fn load_spec(path: &Path) -> Result<(ViewSpec, PathBuf), String> { let text = std::fs::read_to_string(path).map_err(|e| format!("read {:?}: {e}", path))?; - let spec: ViewSpec = serde_json::from_str(&text).map_err(|e| format!("parse {:?}: {e}", path))?; + let spec: ViewSpec = + serde_json::from_str(&text).map_err(|e| format!("parse {:?}: {e}", path))?; let base_dir = path .parent() .map(|p| p.to_path_buf()) @@ -285,9 +286,8 @@ impl Scene { let w = tex.width as i32; let h = tex.height as i32; let fetch = |x: i32, y: i32| -> (f32, f32, f32) { - let idx = ((y.rem_euclid(h) as usize) * tex.width as usize - + (x.rem_euclid(w) as usize)) - * 4; + let idx = + ((y.rem_euclid(h) as usize) * tex.width as usize + (x.rem_euclid(w) as usize)) * 4; ( srgb_u8_to_linear(tex.pixels[idx]), srgb_u8_to_linear(tex.pixels[idx + 1]), @@ -350,9 +350,8 @@ impl Scene { let w = tex.width as i32; let h = tex.height as i32; let fetch = |x: i32, y: i32| -> (f32, f32, f32) { - let idx = ((y.rem_euclid(h) as usize) * tex.width as usize - + (x.rem_euclid(w) as usize)) - * 4; + let idx = + ((y.rem_euclid(h) as usize) * tex.width as usize + (x.rem_euclid(w) as usize)) * 4; let r = tex.pixels[idx] as f32 / 255.0; let g = tex.pixels[idx + 1] as f32 / 255.0; let b = tex.pixels[idx + 2] as f32 / 255.0; @@ -457,11 +456,10 @@ fn load_scene(path: &Path) -> Result { let tex_idx = info.texture().index(); texture_to_image.get(tex_idx).copied() }); - let metallic_roughness_texture = - pbr.metallic_roughness_texture().and_then(|info| { - let tex_idx = info.texture().index(); - texture_to_image.get(tex_idx).copied() - }); + let metallic_roughness_texture = pbr.metallic_roughness_texture().and_then(|info| { + let tex_idx = info.texture().index(); + texture_to_image.get(tex_idx).copied() + }); let emissive_texture = m.emissive_texture().and_then(|info| { let tex_idx = info.texture().index(); texture_to_image.get(tex_idx).copied() @@ -533,6 +531,144 @@ fn load_scene(path: &Path) -> Result { }) } +fn push_reference_quad( + triangles: &mut Vec, + points: [Vec3; 4], + normal: Vec3, + material_index: u32, +) { + let tangent_dir = (points[1] - points[0]).normalize_or_zero(); + let tangent = tangent_dir.extend(1.0); + let uv = [Vec2::ZERO, Vec2::X, Vec2::ONE, Vec2::Y]; + for [a, b, c] in [[0usize, 1, 2], [0, 2, 3]] { + triangles.push(Triangle { + v0: points[a], + v1: points[b], + v2: points[c], + n0: normal, + n1: normal, + n2: normal, + t0: tangent, + t1: tangent, + t2: tangent, + uv0: uv[a], + uv1: uv[b], + uv2: uv[c], + material_index, + }); + } +} + +fn push_reference_box(triangles: &mut Vec, min: Vec3, max: Vec3, material_index: u32) { + push_reference_quad( + triangles, + [ + Vec3::new(max.x, min.y, min.z), + Vec3::new(max.x, max.y, min.z), + Vec3::new(max.x, max.y, max.z), + Vec3::new(max.x, min.y, max.z), + ], + Vec3::X, + material_index, + ); + push_reference_quad( + triangles, + [ + Vec3::new(min.x, min.y, max.z), + Vec3::new(min.x, max.y, max.z), + Vec3::new(min.x, max.y, min.z), + Vec3::new(min.x, min.y, min.z), + ], + Vec3::NEG_X, + material_index, + ); + push_reference_quad( + triangles, + [ + Vec3::new(min.x, max.y, min.z), + Vec3::new(min.x, max.y, max.z), + Vec3::new(max.x, max.y, max.z), + Vec3::new(max.x, max.y, min.z), + ], + Vec3::Y, + material_index, + ); + push_reference_quad( + triangles, + [ + Vec3::new(min.x, min.y, max.z), + Vec3::new(min.x, min.y, min.z), + Vec3::new(max.x, min.y, min.z), + Vec3::new(max.x, min.y, max.z), + ], + Vec3::NEG_Y, + material_index, + ); + push_reference_quad( + triangles, + [ + Vec3::new(max.x, min.y, max.z), + Vec3::new(max.x, max.y, max.z), + Vec3::new(min.x, max.y, max.z), + Vec3::new(min.x, min.y, max.z), + ], + Vec3::Z, + material_index, + ); + push_reference_quad( + triangles, + [ + Vec3::new(min.x, min.y, min.z), + Vec3::new(min.x, max.y, min.z), + Vec3::new(max.x, max.y, min.z), + Vec3::new(max.x, min.y, min.z), + ], + Vec3::NEG_Z, + material_index, + ); +} + +/// Procedural mirror of native/shared/tests/golden_render.rs::build_pt_scene. +/// Keeping this scene asset-free makes the GPU oracle independently +/// sanity-checkable on any machine that can run the CPU reference tracer. +fn load_pt_golden_scene() -> Scene { + let colors = [ + [0.55, 0.50, 0.45, 1.0], + [0.85, 0.20, 0.15, 1.0], + [0.20, 0.65, 0.90, 1.0], + [0.90, 0.80, 0.20, 1.0], + ]; + let materials = colors + .into_iter() + .map(|base_color_factor| Material { + base_color_factor, + metallic: 0.0, + roughness: 0.8, + ..Material::default_material() + }) + .collect(); + let mut triangles = Vec::with_capacity(7 * 12); + push_reference_box( + &mut triangles, + Vec3::new(-8.0, -0.2, -8.0), + Vec3::new(8.0, 0.0, 8.0), + 0, + ); + for i in 0..6u32 { + let angle = i as f32 / 6.0 * std::f32::consts::TAU; + let center = Vec3::new(angle.cos() * 2.4, 0.5, angle.sin() * 2.4); + let half = Vec3::new(0.5, if i % 2 == 0 { 0.5 } else { 1.0 }, 0.5); + push_reference_box(&mut triangles, center - half, center + half, 1 + i % 3); + } + Scene { + triangles, + materials, + textures: Vec::new(), + bbox_min: Vec3::new(-8.0, -0.2, -8.0), + bbox_max: Vec3::new(8.0, 1.5, 8.0), + } +} + fn walk_node( node: &gltf::Node, parent_transform: Mat4, @@ -649,6 +785,10 @@ fn walk_node( // Path-tracing core (ray/BVH/camera/RNG/BRDF/environment/lights/ // integrator) lives in tracer.rs (2000-line file policy). +#[allow(dead_code)] +mod layered_pbr; +#[allow(dead_code)] +mod sheen_lut; mod tracer; use tracer::*; @@ -740,7 +880,9 @@ fn render( struct Args { scene_path: String, + builtin_scene: Option, out_path: String, + metadata_path: Option, env_path: Option, env_intensity: f32, sun_direction: Option, @@ -756,7 +898,9 @@ struct Args { fn parse_args() -> Result { let mut scene_path: Option = None; + let mut builtin_scene: Option = None; let mut out_path: Option = None; + let mut metadata_path: Option = None; let mut env_path: Option = None; let mut env_intensity: f32 = 1.0; let mut sun_direction: Option = Some(Vec3::new(0.4, 0.8, 0.3).normalize()); @@ -776,7 +920,9 @@ fn parse_args() -> Result { match arg.as_str() { "--spec" => spec_path = iter.next(), "--scene" => scene_path = iter.next(), + "--builtin" => builtin_scene = iter.next(), "--out" => out_path = iter.next(), + "--metadata" => metadata_path = iter.next(), "--env" => env_path = iter.next(), "--env-intensity" => { env_intensity = iter @@ -874,7 +1020,9 @@ fn parse_args() -> Result { println!(" camera, env, sun, resolution. CLI flags below"); println!(" override individual spec fields."); println!(" --scene PATH glTF/GLB file to render"); + println!(" --builtin NAME built-in scene (pt-golden)"); println!(" --out PATH output PNG path (required)"); + println!(" --metadata PATH write reproducibility metadata JSON"); println!(" --env PATH HDR (.hdr) environment map"); println!(" --env-intensity F env map multiplier (default 1.0)"); println!(" --sun-dir X Y Z sun direction toward light"); @@ -954,8 +1102,18 @@ fn parse_args() -> Result { } Ok(Args { - scene_path: scene_path.ok_or("--scene or --spec is required")?, + scene_path: match (&scene_path, &builtin_scene) { + (Some(path), None) => path.clone(), + (None, Some(name)) if name == "pt-golden" => format!(""), + (None, Some(name)) => return Err(format!("unknown built-in scene: {name}")), + (Some(_), Some(_)) => { + return Err("use either --scene or --builtin, not both".to_owned()) + } + (None, None) => return Err("--scene, --spec, or --builtin is required".to_owned()), + }, + builtin_scene, out_path: out_path.ok_or("--out is required")?, + metadata_path, env_path, env_intensity, sun_direction, @@ -980,7 +1138,12 @@ fn main() -> ExitCode { }; let load_start = Instant::now(); - let scene = match load_scene(Path::new(&args.scene_path)) { + let scene_result = if args.builtin_scene.as_deref() == Some("pt-golden") { + Ok(load_pt_golden_scene()) + } else { + load_scene(Path::new(&args.scene_path)) + }; + let scene = match scene_result { Ok(s) => s, Err(e) => { eprintln!("error loading {}: {}", args.scene_path, e); @@ -997,11 +1160,7 @@ fn main() -> ExitCode { let bvh_start = Instant::now(); let bvh = build_bvh(&scene.triangles); - println!( - "bvh: {} nodes ({:?})", - bvh.nodes.len(), - bvh_start.elapsed() - ); + println!("bvh: {} nodes ({:?})", bvh.nodes.len(), bvh_start.elapsed()); let environment = match &args.env_path { Some(p) => { @@ -1018,8 +1177,13 @@ fn main() -> ExitCode { } } None => { - println!("env: procedural (no --env supplied)"); - Environment::procedural() + if args.builtin_scene.as_deref() == Some("pt-golden") { + println!("env: Bloom PT golden analytic sky"); + Environment::bloom_pt_golden() + } else { + println!("env: procedural (no --env supplied)"); + Environment::procedural() + } } }; @@ -1068,13 +1232,10 @@ fn main() -> ExitCode { seed: args.seed, }, ); + let render_elapsed = render_start.elapsed(); println!( "render: {}x{} @ {}spp, {} bounces, {:?}", - args.width, - args.height, - args.spp, - args.max_bounces, - render_start.elapsed() + args.width, args.height, args.spp, args.max_bounces, render_elapsed ); let img = image::RgbImage::from_raw(args.width, args.height, pixels) @@ -1084,5 +1245,90 @@ fn main() -> ExitCode { return ExitCode::from(1); } println!("wrote {}", args.out_path); + if let Some(path) = &args.metadata_path { + let camera = args.camera_override.as_ref().map(|camera| { + serde_json::json!({ + "position": camera.position, + "target": camera.target, + "up": camera.up, + "fov_y_deg": camera.fov_y_deg, + }) + }); + let metadata = serde_json::json!({ + "renderer": "bloom-reference", + "scene": args.scene_path, + "width": args.width, + "height": args.height, + "spp": args.spp, + "max_bounces": args.max_bounces, + "seed": args.seed, + "triangles": scene.triangles.len(), + "camera": camera, + "sun": args.sun_direction.map(|direction| serde_json::json!({ + "direction_to_light": direction.to_array(), + "color": args.sun_color.to_array(), + "intensity": args.sun_intensity, + })), + "environment": if args.builtin_scene.as_deref() == Some("pt-golden") + && args.env_path.is_none() + { + "bloom-pt-golden-analytic" + } else { + args.env_path.as_deref().unwrap_or("procedural") + }, + "render_seconds": render_elapsed.as_secs_f64(), + "output": args.out_path, + }); + let encoded = serde_json::to_vec_pretty(&metadata).expect("serialize reference metadata"); + if let Err(e) = std::fs::write(path, encoded) { + eprintln!("error writing metadata {}: {}", path, e); + return ExitCode::from(1); + } + println!("wrote {path}"); + } ExitCode::SUCCESS } + +#[cfg(test)] +mod pt_golden_scene_tests { + use super::*; + + #[test] + fn built_in_scene_matches_gpu_golden_topology_and_materials() { + let scene = load_pt_golden_scene(); + assert_eq!(scene.triangles.len(), 84); // seven boxes, twelve triangles each + assert_eq!(scene.materials.len(), 4); + assert_eq!(scene.bbox_min, Vec3::new(-8.0, -0.2, -8.0)); + assert_eq!(scene.bbox_max, Vec3::new(8.0, 1.5, 8.0)); + + let mut triangles_by_material = [0usize; 4]; + for triangle in &scene.triangles { + triangles_by_material[triangle.material_index as usize] += 1; + } + assert_eq!(triangles_by_material, [12, 24, 24, 24]); + assert!(scene.materials.iter().all(|material| { + material.metallic == 0.0 && (material.roughness - 0.8).abs() < f32::EPSILON + })); + } + + #[test] + fn built_in_sky_matches_gpu_gradient() { + let sky = Environment::bloom_pt_golden(); + for direction in [Vec3::Y, Vec3::X, Vec3::NEG_Y] { + let expected = 0.3 * (0.45 + (1.35 - 0.45) * (direction.y * 0.5 + 0.5)); + let sampled = sky.sample(direction); + assert!( + (sampled.x - expected).abs() < 0.002, + "{direction:?}: {sampled:?}" + ); + assert!( + (sampled.y - expected).abs() < 0.002, + "{direction:?}: {sampled:?}" + ); + assert!( + (sampled.z - expected).abs() < 0.002, + "{direction:?}: {sampled:?}" + ); + } + } +} diff --git a/tools/bloom-reference/src/sheen_lut.rs b/tools/bloom-reference/src/sheen_lut.rs new file mode 100644 index 00000000..dfe7ddf3 --- /dev/null +++ b/tools/bloom-reference/src/sheen_lut.rs @@ -0,0 +1,124 @@ +//! Deterministic directional-albedo oracle for the Khronos Charlie sheen BRDF. +//! +//! The checked R16F table is generated from these equations and consumed by +//! both the CPU contract and Bloom's lazy realtime layered-material path. + +#![allow(dead_code)] + +use rayon::prelude::*; + +pub const DEFAULT_LUT_SIZE: usize = 128; +pub const DEFAULT_SAMPLE_COUNT: u32 = 4096; + +fn radical_inverse_vdc(mut bits: u32) -> f32 { + bits = bits.rotate_right(16); + bits = ((bits & 0x5555_5555) << 1) | ((bits & 0xAAAA_AAAA) >> 1); + bits = ((bits & 0x3333_3333) << 2) | ((bits & 0xCCCC_CCCC) >> 2); + bits = ((bits & 0x0F0F_0F0F) << 4) | ((bits & 0xF0F0_F0F0) >> 4); + bits = ((bits & 0x00FF_00FF) << 8) | ((bits & 0xFF00_FF00) >> 8); + bits as f32 * 2.328_306_4e-10 +} + +fn lambda_numeric_helper(x: f32, alpha_g: f32) -> f32 { + let one_minus_alpha_sq = (1.0 - alpha_g) * (1.0 - alpha_g); + let mix = |a: f32, b: f32| a + (b - a) * one_minus_alpha_sq; + let a = mix(21.5473, 25.3245); + let b = mix(3.82987, 3.32435); + let c = mix(0.19823, 0.16801); + let d = mix(-1.97760, -1.27393); + let e = mix(-4.32054, -4.85967); + a / (1.0 + b * x.max(0.0).powf(c)) + d * x + e +} + +fn lambda_sheen(cos_theta: f32, alpha_g: f32) -> f32 { + let cosine = cos_theta.abs().clamp(0.0, 1.0); + if cosine < 0.5 { + lambda_numeric_helper(cosine, alpha_g).exp() + } else { + (2.0 * lambda_numeric_helper(0.5, alpha_g) - lambda_numeric_helper(1.0 - cosine, alpha_g)) + .exp() + } +} + +pub fn visibility_sheen(n_dot_l: f32, n_dot_v: f32, perceptual_roughness: f32) -> f32 { + let n_dot_l = n_dot_l.max(1e-6); + let n_dot_v = n_dot_v.max(1e-6); + let alpha_g = perceptual_roughness.max(1e-3).powi(2); + 1.0 / ((1.0 + lambda_sheen(n_dot_v, alpha_g) + lambda_sheen(n_dot_l, alpha_g)) + * (4.0 * n_dot_v * n_dot_l)) +} + +pub fn distribution_charlie(n_dot_h: f32, perceptual_roughness: f32) -> f32 { + let alpha_g = perceptual_roughness.max(1e-3).powi(2); + let inverse_alpha = 1.0 / alpha_g; + let sin2_h = (1.0 - n_dot_h.clamp(0.0, 1.0).powi(2)).max(0.0); + (2.0 + inverse_alpha) * sin2_h.powf(0.5 * inverse_alpha) / (2.0 * std::f32::consts::PI) +} + +/// Integrate `E(NdotV, roughness)` by importance-sampling the normalized +/// Charlie microfacet-normal distribution. The estimator cancels `D` +/// analytically and remains stable for the grazing, near-delta cloth lobe. +pub fn directional_albedo(n_dot_v: f32, perceptual_roughness: f32, sample_count: u32) -> f32 { + let n_dot_v = n_dot_v.clamp(1e-4, 1.0); + let roughness = perceptual_roughness.clamp(1e-3, 1.0); + let alpha_g = roughness * roughness; + let view = [(1.0 - n_dot_v * n_dot_v).sqrt(), 0.0, n_dot_v]; + let mut sum = 0.0; + for index in 0..sample_count { + let u = (index as f32 + 0.5) / sample_count as f32; + let v = radical_inverse_vdc(index); + let sin_theta = u.powf(alpha_g / (2.0 * alpha_g + 1.0)); + let cos_theta = (1.0 - sin_theta * sin_theta).max(0.0).sqrt(); + let phi = std::f32::consts::TAU * v; + let half = [sin_theta * phi.cos(), sin_theta * phi.sin(), cos_theta]; + let v_dot_h = (view[0] * half[0] + view[1] * half[1] + view[2] * half[2]).max(0.0); + if v_dot_h <= 0.0 || cos_theta <= 0.0 { + continue; + } + let light = [ + 2.0 * v_dot_h * half[0] - view[0], + 2.0 * v_dot_h * half[1] - view[1], + 2.0 * v_dot_h * half[2] - view[2], + ]; + let n_dot_l = light[2]; + if n_dot_l <= 0.0 { + continue; + } + // p(H)=D(H)NdotH and p(L)=p(H)/(4 VdotH). + sum += visibility_sheen(n_dot_l, n_dot_v, roughness) * n_dot_l * 4.0 * v_dot_h / cos_theta; + } + (sum / sample_count.max(1) as f32).clamp(0.0, 1.0) +} + +pub fn build_r16f_lut(size: usize, sample_count: u32) -> Vec { + (0..size * size) + .into_par_iter() + .map(|index| { + let x = index % size; + let y = index / size; + let n_dot_v = (x as f32 + 0.5) / size as f32; + let roughness = (y as f32 + 0.5) / size as f32; + half::f16::from_f32(directional_albedo(n_dot_v, roughness, sample_count)).to_bits() + }) + .collect() +} + +pub fn sample_r16f_lut(bytes: &[u8], size: usize, n_dot_v: f32, roughness: f32) -> f32 { + assert_eq!(bytes.len(), size * size * 2); + let coordinate = |value: f32| value.clamp(0.0, 1.0) * size as f32 - 0.5; + let x = coordinate(n_dot_v); + let y = coordinate(roughness); + let x0 = x.floor().clamp(0.0, (size - 1) as f32) as usize; + let y0 = y.floor().clamp(0.0, (size - 1) as f32) as usize; + let x1 = (x0 + 1).min(size - 1); + let y1 = (y0 + 1).min(size - 1); + let tx = (x - x.floor()).clamp(0.0, 1.0); + let ty = (y - y.floor()).clamp(0.0, 1.0); + let load = |x: usize, y: usize| { + let offset = (y * size + x) * 2; + half::f16::from_bits(u16::from_le_bytes([bytes[offset], bytes[offset + 1]])).to_f32() + }; + let top = load(x0, y0) + (load(x1, y0) - load(x0, y0)) * tx; + let bottom = load(x0, y1) + (load(x1, y1) - load(x0, y1)) * tx; + top + (bottom - top) * ty +} diff --git a/tools/bloom-reference/src/tracer.rs b/tools/bloom-reference/src/tracer.rs index 0b7cee8a..2052d717 100644 --- a/tools/bloom-reference/src/tracer.rs +++ b/tools/bloom-reference/src/tracer.rs @@ -2,6 +2,9 @@ //! GGX/Burley BRDF, environment sampling, punctual lights, and the //! integrator. Split from main.rs (2000-line file policy). +use crate::layered_pbr::{ + burley_diffuse, d_ggx, evaluate_base_brdf, fresnel_schlick, v_smith, BaseMaterial, +}; use crate::*; // ============================================================ @@ -62,7 +65,12 @@ pub(crate) fn intersect_triangle(ray: &Ray, tri: &Triangle, max_t: f32) -> Optio /// Slab-based ray vs AABB. Returns `Some((t_near, t_far))` when the ray /// intersects the box in front of the origin; used by the BVH walk to /// decide which children to descend into. -pub(crate) fn intersect_aabb(ray: &Ray, bounds_min: Vec3, bounds_max: Vec3, max_t: f32) -> Option { +pub(crate) fn intersect_aabb( + ray: &Ray, + bounds_min: Vec3, + bounds_max: Vec3, + max_t: f32, +) -> Option { let t1 = (bounds_min - ray.origin) * ray.inv_direction; let t2 = (bounds_max - ray.origin) * ray.inv_direction; let t_min = t1.min(t2); @@ -334,7 +342,13 @@ pub(crate) struct Camera { } impl Camera { - pub(crate) fn looking_at(position: Vec3, target: Vec3, up_hint: Vec3, fov_y_degrees: f32, aspect: f32) -> Self { + pub(crate) fn looking_at( + position: Vec3, + target: Vec3, + up_hint: Vec3, + fov_y_degrees: f32, + aspect: f32, + ) -> Self { let forward = (target - position).normalize(); let right = forward.cross(up_hint).normalize(); let up = right.cross(forward).normalize(); @@ -423,38 +437,11 @@ pub(crate) fn seed_for(pixel: UVec2, sample: u32, global_seed: u64) -> u64 { // BRDF (GGX + Burley diffuse, metalness-aware) // ============================================================ -/// Schlick's Fresnel approximation. `f0` is reflectance at normal -/// incidence; `cos_theta` is dot(surface_normal, view_dir). -pub(crate) fn fresnel_schlick(cos_theta: f32, f0: Vec3) -> Vec3 { - let m = (1.0 - cos_theta).clamp(0.0, 1.0); - f0 + (Vec3::ONE - f0) * (m * m * m * m * m) -} - -/// GGX (Trowbridge-Reitz) normal distribution. -pub(crate) fn d_ggx(n_dot_h: f32, alpha: f32) -> f32 { - let a2 = alpha * alpha; - let nh2 = n_dot_h * n_dot_h; - let denom = nh2 * (a2 - 1.0) + 1.0; - a2 / (std::f32::consts::PI * denom * denom) -} - -/// Smith visibility term for GGX with height-correlated formulation. -/// Closer to the ground truth than the separable G1*G2, and only -/// marginally more expensive. -pub(crate) fn v_smith(n_dot_v: f32, n_dot_l: f32, alpha: f32) -> f32 { - let a2 = alpha * alpha; - let ggx_v = n_dot_l * ((n_dot_v * (1.0 - a2) + a2) * n_dot_v).sqrt(); - let ggx_l = n_dot_v * ((n_dot_l * (1.0 - a2) + a2) * n_dot_l).sqrt(); - 0.5 / (ggx_v + ggx_l + 1e-6) -} - -/// Burley (Disney) diffuse term. Tracks the view-dependent darkening -/// near grazing angles for rough dielectrics that Lambert gets wrong. -pub(crate) fn burley_diffuse(n_dot_l: f32, n_dot_v: f32, l_dot_h: f32, roughness: f32) -> f32 { - let fd90 = 0.5 + 2.0 * l_dot_h * l_dot_h * roughness; - let light = 1.0 + (fd90 - 1.0) * (1.0 - n_dot_l).powi(5); - let view = 1.0 + (fd90 - 1.0) * (1.0 - n_dot_v).powi(5); - light * view / std::f32::consts::PI +fn specular_probability(f0: Vec3, n_dot_v: f32, metallic: f32) -> f32 { + let f_view = fresnel_schlick(n_dot_v, f0); + let specular_weight = (f_view.x + f_view.y + f_view.z) / 3.0; + let diffuse_weight = (1.0 - specular_weight) * (1.0 - metallic); + (specular_weight / (specular_weight + diffuse_weight + 1e-6)).clamp(0.05, 0.95) } #[derive(Clone, Copy)] @@ -495,8 +482,7 @@ pub(crate) fn surface_from_hit(scene: &Scene, ray: &Ray, hit: &Hit) -> SurfaceSa // has no tangents (length 0), skip normal mapping entirely. let tangent_interp = tri.t0 * w + tri.t1 * u + tri.t2 * v; let tangent_xyz = Vec3::new(tangent_interp.x, tangent_interp.y, tangent_interp.z); - let shading_normal = if material.normal_texture.is_some() - && tangent_xyz.length_squared() > 1e-8 + let shading_normal = if material.normal_texture.is_some() && tangent_xyz.length_squared() > 1e-8 { let t = tangent_xyz.normalize(); // Re-orthogonalize the tangent against the normal (Gram-Schmidt) @@ -554,7 +540,12 @@ pub(crate) fn sample_cosine_hemisphere(rand: Vec2) -> Vec3 { /// visible from the view direction. pub(crate) fn sample_ggx_vndf(view_tangent: Vec3, alpha: f32, rand: Vec2) -> Vec3 { // Transform view to hemisphere of ellipsoid. - let vh = Vec3::new(alpha * view_tangent.x, alpha * view_tangent.y, view_tangent.z).normalize(); + let vh = Vec3::new( + alpha * view_tangent.x, + alpha * view_tangent.y, + view_tangent.z, + ) + .normalize(); let lensq = vh.x * vh.x + vh.y * vh.y; let t1 = if lensq > 0.0 { Vec3::new(-vh.y, vh.x, 0.0) / lensq.sqrt() @@ -586,8 +577,9 @@ pub(crate) struct BrdfSample { } /// Sample an outgoing direction from the BRDF at a surface point. -/// Uses a simple metal/dielectric split: metals are pure specular, -/// dielectrics pick diffuse vs specular by Fresnel-at-normal weight. +/// Uses a metal/dielectric split with view-angle Fresnel probabilities. +/// The bounded probabilities keep finite-spp grazing paths stable while the +/// reciprocal interface factors below preserve an unbiased estimator. pub(crate) fn sample_brdf(surface: &SurfaceSample, view_world: Vec3, rng: &mut Rng) -> BrdfSample { let n = surface.normal; let alpha = surface.roughness * surface.roughness; @@ -599,14 +591,10 @@ pub(crate) fn sample_brdf(surface: &SurfaceSample, view_world: Vec3, rng: &mut R // f0: dielectrics use 0.04; metals use the base color as f0. let f0 = Vec3::splat(0.04).lerp(surface.base_color, surface.metallic); - // Decide diffuse vs specular lobe. Weighting by luminance of the - // Fresnel-at-normal-incidence gives a reasonable importance - // distribution without a second sample. Pure metals have ~zero - // diffuse so this collapses naturally. - let spec_weight = (f0.x + f0.y + f0.z) / 3.0; - let diff_weight = (1.0 - spec_weight) * (1.0 - surface.metallic); - let total = spec_weight + diff_weight + 1e-6; - let p_spec = spec_weight / total; + // Grazing Fresnel can approach one even for a 4%-F0 dielectric. + // Selecting from the actual view angle avoids rare 20x-25x specular + // weights while the lower bound keeps the estimator robust at endpoints. + let p_spec = specular_probability(f0, v_tangent.z.max(0.0), surface.metallic); let pick_spec = rng.next_f32() < p_spec; let rand = rng.next_vec2(); @@ -665,10 +653,13 @@ pub(crate) fn sample_brdf(surface: &SurfaceSample, view_world: Vec3, rng: &mut R let h_tangent = (v_tangent + l_tangent).normalize_or_zero(); let l_dot_h = l_tangent.dot(h_tangent).max(0.0); - // Diffuse color is zero for metals; for dielectrics we scale - // base color by (1 - Fresnel-at-normal) so total reflectance - // stays ≤ 1. - let diffuse_albedo = surface.base_color * (1.0 - surface.metallic) * (Vec3::ONE - f0); + // Diffuse crosses the dielectric interface on entry and exit. + // Both directional transmission factors are required for reciprocity + // and to keep grazing specular from stacking on full diffuse energy. + let view_transmission = Vec3::ONE - fresnel_schlick(n_dot_v, f0); + let light_transmission = Vec3::ONE - fresnel_schlick(n_dot_l, f0); + let diffuse_albedo = + surface.base_color * (1.0 - surface.metallic) * view_transmission * light_transmission; let fd = burley_diffuse(n_dot_l, n_dot_v, l_dot_h, surface.roughness); // BRDF · cos / PDF, with PDF = cos / pi, collapses to // BRDF · pi. Burley already divides by pi, so multiply here. @@ -829,8 +820,8 @@ impl EnvDistribution { } // Reconstruct pixel weight / total. let row_base = y as usize * (w + 1); - let px_p_given_row = self.conditional[row_base + x as usize + 1] - - self.conditional[row_base + x as usize]; + let px_p_given_row = + self.conditional[row_base + x as usize + 1] - self.conditional[row_base + x as usize]; let row_total = self.marginal[y as usize + 1] - self.marginal[y as usize]; row_total * px_p_given_row } @@ -909,7 +900,11 @@ impl Environment { let v = (y as f32 + 0.5) / h as f32; let theta = v * std::f32::consts::PI; // 0 at +Y, PI at -Y let phi = (u - 0.5) * 2.0 * std::f32::consts::PI; - let dir = Vec3::new(theta.sin() * phi.cos(), theta.cos(), theta.sin() * phi.sin()); + let dir = Vec3::new( + theta.sin() * phi.cos(), + theta.cos(), + theta.sin() * phi.sin(), + ); let horizon = Vec3::new(0.95, 0.85, 0.7); let zenith = Vec3::new(0.4, 0.55, 0.85); let sky = horizon.lerp(zenith, (0.5 * (dir.y + 1.0)).clamp(0.0, 1.0)); @@ -927,6 +922,33 @@ impl Environment { } } + /// Analytic sky used by Bloom's GPU path-tracing golden: default ambient + /// is white at intensity 0.3, modulated from 0.45 at the nadir to 1.35 at + /// the zenith. It deliberately excludes a sun disc because the directional + /// light is sampled separately in both renderers. + pub(crate) fn bloom_pt_golden() -> Self { + let w = 256u32; + let h = 128u32; + let mut pixels = Vec::with_capacity((w * h) as usize); + for y in 0..h { + let v = (y as f32 + 0.5) / h as f32; + let dir_y = (v * std::f32::consts::PI).cos(); + let t = (dir_y * 0.5 + 0.5).clamp(0.0, 1.0); + let radiance = 0.3 * (0.45 + (1.35 - 0.45) * t); + for _ in 0..w { + pixels.push(Vec3::splat(radiance)); + } + } + let distribution = EnvDistribution::build(&pixels, w, h); + Self { + pixels, + width: w, + height: h, + intensity: 1.0, + distribution, + } + } + /// Sample the environment in a given direction. Uses the standard /// equirectangular convention: theta=0 at +Y (top), phi=0 at +Z, /// phi rotating toward +X. Bilinear-filtered. @@ -1046,7 +1068,13 @@ pub(crate) struct SunLight { /// `origin` going toward `direction_to_light` within `max_distance`. /// The origin should already be offset along the surface normal to /// avoid self-intersection. -pub(crate) fn visible(ray_origin: Vec3, direction_to_light: Vec3, max_distance: f32, scene: &Scene, bvh: &Bvh) -> bool { +pub(crate) fn visible( + ray_origin: Vec3, + direction_to_light: Vec3, + max_distance: f32, + scene: &Scene, + bvh: &Bvh, +) -> bool { let ray = Ray::new(ray_origin, direction_to_light); // We only need to know if ANY triangle is hit before max_distance; // a specialized "any-hit" traversal would be faster than @@ -1086,28 +1114,24 @@ pub(crate) fn evaluate_brdf(surface: &SurfaceSample, view: Vec3, light: Vec3) -> let f0 = Vec3::splat(0.04).lerp(surface.base_color, surface.metallic); let alpha = surface.roughness * surface.roughness; - - // Specular: F * D * Vsmith. Vsmith is the height-correlated form - // that already includes the 1/(4·N·V·N·L) term. - let f = fresnel_schlick(v_dot_h, f0); let d = d_ggx(n_dot_h, alpha); - let vsmith = v_smith(n_dot_v, n_dot_l, alpha); - let specular = f * d * vsmith; - - // Diffuse: Burley (already 1/pi-normalized). Scale by (1 - F) and - // (1 - metallic) for energy conservation. - let fd = burley_diffuse(n_dot_l, n_dot_v, v_dot_h, surface.roughness); - let diffuse_albedo = surface.base_color * (1.0 - surface.metallic) * (Vec3::ONE - f); - let diffuse = diffuse_albedo * fd; - - let brdf_cos = (specular + diffuse) * n_dot_l; + // Lobe evaluation comes from the named layered-PBR base contract. Keep + // only the sampler-specific PDF here: its view-angle probabilities reduce + // finite-spp grazing variance without changing the evaluated response. + let evaluation = evaluate_base_brdf( + BaseMaterial { + base_color: surface.base_color, + metallic: surface.metallic, + perceptual_roughness: surface.roughness, + }, + n, + view, + light, + ); // Rough approximation of the BRDF sampler's PDF for MIS. Uses the // same spec/diff split heuristic as `sample_brdf`. - let spec_weight = (f0.x + f0.y + f0.z) / 3.0; - let diff_weight = (1.0 - spec_weight) * (1.0 - surface.metallic); - let total = spec_weight + diff_weight + 1e-6; - let p_spec = spec_weight / total; + let p_spec = specular_probability(f0, n_dot_v, surface.metallic); let p_diff = 1.0 - p_spec; // Spec PDF (GGX VNDF): D * G1(V) * max(0, V·H) / (4 * N·V * V·H). @@ -1117,7 +1141,7 @@ pub(crate) fn evaluate_brdf(surface: &SurfaceSample, view: Vec3, light: Vec3) -> let pdf_diff = n_dot_l / std::f32::consts::PI; let pdf = p_spec * pdf_spec + p_diff * pdf_diff; - (brdf_cos, pdf.max(0.0)) + (evaluation.brdf_cos, pdf.max(0.0)) } /// Balance heuristic for MIS — weights a sample from strategy A by @@ -1184,7 +1208,13 @@ pub(crate) fn trace_path( let l = sun.direction_to_light; let n_dot_l = surface.normal.dot(l); if n_dot_l > 0.0 - && visible(shadow_origin, l, f32::INFINITY, scenario.scene, scenario.bvh) + && visible( + shadow_origin, + l, + f32::INFINITY, + scenario.scene, + scenario.bvh, + ) { let (brdf_cos, _pdf_brdf) = evaluate_brdf(&surface, view, l); radiance += throughput * brdf_cos * sun.color * sun.intensity; @@ -1194,8 +1224,7 @@ pub(crate) fn trace_path( // --- NEE B: env-map importance sample. Pick a direction from // the env luminance CDF, test visibility, add (brdf·cos·L) // / env_pdf with the MIS balance weight vs the BRDF PDF. - let (env_dir, env_radiance, env_pdf) = - scenario.environment.sample_importance(rng); + let (env_dir, env_radiance, env_pdf) = scenario.environment.sample_importance(rng); if env_pdf > 0.0 { let n_dot_l = surface.normal.dot(env_dir); if n_dot_l > 0.0 @@ -1250,3 +1279,56 @@ pub(crate) fn trace_path( radiance.clamp(Vec3::ZERO, Vec3::splat(50.0)) } +#[cfg(test)] +mod base_transport_contract_tests { + use super::*; + + fn surface(base_color: Vec3, metallic: f32, roughness: f32) -> SurfaceSample { + SurfaceSample { + position: Vec3::ZERO, + normal: Vec3::Z, + base_color, + metallic, + roughness, + emissive: Vec3::ZERO, + occlusion: 1.0, + } + } + + #[test] + fn grazing_rough_conductor_stays_bounded_in_white_furnace() { + let material = surface(Vec3::ONE, 1.0, 0.8); + let n_dot_v = 0.1_f32; + let view = Vec3::new((1.0 - n_dot_v * n_dot_v).sqrt(), 0.0, n_dot_v); + let theta_steps = 128_u32; + let phi_steps = 256_u32; + let mut reflected = Vec3::ZERO; + for y in 0..theta_steps { + let n_dot_l = (y as f32 + 0.5) / theta_steps as f32; + let radius = (1.0 - n_dot_l * n_dot_l).sqrt(); + for x in 0..phi_steps { + let phi = std::f32::consts::TAU * (x as f32 + 0.5) / phi_steps as f32; + let light = Vec3::new(radius * phi.cos(), radius * phi.sin(), n_dot_l); + reflected += evaluate_brdf(&material, view, light).0; + } + } + reflected *= std::f32::consts::TAU / (theta_steps * phi_steps) as f32; + assert!( + reflected.max_element() <= 1.02, + "grazing rough conductor gained energy: {reflected:?}" + ); + } + + #[test] + fn dielectric_brdf_is_reciprocal_after_two_interface_attenuation() { + let material = surface(Vec3::new(0.8, 0.3, 0.1), 0.0, 0.65); + let view = Vec3::new(0.8, 0.0, 0.6).normalize(); + let light = Vec3::new(-0.3, 0.4, 0.85).normalize(); + let forward = evaluate_brdf(&material, view, light).0 / light.z; + let reverse = evaluate_brdf(&material, light, view).0 / view.z; + assert!( + forward.abs_diff_eq(reverse, 2e-5), + "{forward:?} != {reverse:?}" + ); + } +} diff --git a/tools/check-ci-contract.js b/tools/check-ci-contract.js new file mode 100755 index 00000000..ef91d1ba --- /dev/null +++ b/tools/check-ci-contract.js @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// Prevent local and PR command inventories from silently diverging. + +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const root = path.resolve(__dirname, ".."); +const read = (relative) => + fs.readFileSync(path.join(root, relative), "utf8").replace(/\r\n/g, "\n"); + +const expectedLanes = new Map([ + ["quick", ["contracts", "lint", "shared-tests", "wasm-check", "quality-contract", "example-inventory"]], + ["full", ["contracts", "lint", "shared-tests", "wasm-check", "quality-contract", "example-inventory", "host-build", "wasm-build"]], + ["web", ["wasm-check", "wasm-build", "browser-smoke"]], + ["cross", ["target-check"]], + ["hardware", ["example-compile", "quality-check", "quality-faults", "quality-run"]], +]); + +const listing = spawnSync("bash", ["scripts/ci-check.sh", "--list"], { + cwd: root, + encoding: "utf8", +}); +if (listing.status !== 0) { + console.error(listing.stderr || "ci-check.sh --list failed"); + process.exit(1); +} + +const actualLanes = new Map( + listing.stdout.trim().split("\n").map((line) => { + const [lane, components] = line.split("\t"); + return [lane, components.split(/\s+/)]; + }), +); + +let failures = 0; +for (const [lane, expected] of expectedLanes) { + const actual = actualLanes.get(lane); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + console.error(`FAIL ${lane} lane: expected ${expected.join(" ")}, got ${(actual || []).join(" ")}`); + failures += 1; + } +} + +const testWorkflow = read(".github/workflows/test.yml"); +const qualityWorkflow = read(".github/workflows/quality.yml"); +const releaseWorkflow = read(".github/workflows/release.yml"); +const failureAction = read(".github/actions/upload-ci-failure/action.yml"); +const workflowCommands = [ + "./scripts/ci-check.sh --quick --component shared-tests", + "./scripts/ci-check.sh --quick --component contracts", + "./scripts/ci-check.sh --quick --component lint", + "./scripts/ci-check.sh --full --component host-build", + "./scripts/ci-check.sh --web --component wasm-check", + "./scripts/ci-check.sh --web --component wasm-build", + "./scripts/ci-check.sh --web --component browser-smoke", + "./scripts/ci-check.sh --cross --component target-check", + "./scripts/ci-check.sh --quick --component quality-contract", + "./scripts/ci-check.sh --hardware --component example-compile", + "./scripts/ci-check.sh --hardware --component quality-check", + "./scripts/ci-check.sh --hardware --component quality-faults", + "./scripts/ci-check.sh --hardware --component quality-run", +]; + +for (const command of workflowCommands) { + const workflow = command.includes("quality-") || command.includes("example-compile") + ? qualityWorkflow + : testWorkflow; + if (!workflow.includes(command)) { + console.error(`FAIL workflow does not delegate through: ${command}`); + failures += 1; + } +} + +for (const [name, workflow] of [ + ["test.yml", testWorkflow], + ["quality.yml", qualityWorkflow], +]) { + if (/continue-on-error\s*:\s*true/.test(workflow)) { + console.error(`FAIL ${name} contains an advisory required step`); + failures += 1; + } +} + +for (const evidencePath of [ + "target/ci", + "native/shared/tests/golden/**/*.actual.png", + "tools/quality/out", +]) { + if (!failureAction.includes(evidencePath)) { + console.error(`FAIL failure evidence action omits: ${evidencePath}`); + failures += 1; + } +} +if ((testWorkflow.match(/uses: \.\/\.github\/actions\/upload-ci-failure/g) || []).length !== 9) { + console.error("FAIL every Tests job must upload failure evidence"); + failures += 1; +} +if (!qualityWorkflow.includes("uses: ./.github/actions/upload-ci-failure")) { + console.error("FAIL quality contract job must upload failure evidence"); + failures += 1; +} +for (const requiredReleaseText of [ + "resolve-release:", + "SHA: ${{ needs.resolve-release.outputs.sha }}", + "ref: ${{ needs.resolve-release.outputs.sha }}", + 'if [ "$type" != "commit" ]', +]) { + if (!releaseWorkflow.includes(requiredReleaseText)) { + console.error(`FAIL release workflow omits exact-ref contract: ${requiredReleaseText}`); + failures += 1; + } +} +if (releaseWorkflow.includes("bypassing test gate")) { + console.error("FAIL manual releases may not bypass the exact-SHA test gate"); + failures += 1; +} + +const duplicatedTestCommands = [ + "node tools/validate-ffi.js", + "node tools/check-file-lines.js", + "cargo fmt --check", + "cargo clippy --release", + "cargo test --release", + "cargo check --target wasm32-unknown-unknown", + "wasm-pack build --release --target web", +]; +for (const command of duplicatedTestCommands) { + if (testWorkflow.includes(command)) { + console.error(`FAIL test.yml duplicates ci-check.sh command: ${command}`); + failures += 1; + } +} + +console.log(`${failures} failures`); +process.exit(failures === 0 ? 0 : 1); diff --git a/tools/ci/compile_examples.py b/tools/ci/compile_examples.py new file mode 100644 index 00000000..6d1d7d37 --- /dev/null +++ b/tools/ci/compile_examples.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Validate and compile every canonical Perry example.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[2] +MANIFEST_PATH = Path(__file__).with_name("examples.json") +REPORT_SCHEMA = "bloom-example-compile-v1" + + +def load_inventory() -> tuple[list[str], list[str]]: + failures: list[str] = [] + try: + manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return [], [f"cannot read {MANIFEST_PATH}: {exc}"] + if manifest.get("schema") != "bloom-canonical-examples-v1": + failures.append("example manifest has an unknown schema") + examples = manifest.get("examples") + if not isinstance(examples, list) or not all( + isinstance(item, str) and item for item in examples + ): + return [], failures + ["examples must be a non-empty string array"] + if examples != sorted(set(examples)): + failures.append("examples must be sorted and unique") + discovered = sorted( + str(path.parent.relative_to(REPO_ROOT)).replace(os.sep, "/") + for path in (REPO_ROOT / "examples").glob("*/package.json") + if (path.parent / "main.ts").is_file() + ) + missing = sorted(set(discovered) - set(examples)) + stale = sorted(set(examples) - set(discovered)) + if missing: + failures.append(f"unlisted canonical examples: {missing}") + if stale: + failures.append(f"listed examples missing package.json/main.ts: {stale}") + for relative in examples: + directory = (REPO_ROOT / relative).resolve() + try: + directory.relative_to(REPO_ROOT / "examples") + except ValueError: + failures.append(f"example escapes examples/: {relative}") + return examples, failures + + +def ensure_engine_dependency(directory: Path) -> None: + dependency = directory / "node_modules" / "bloom" + if dependency.exists(): + return + dependency.parent.mkdir(parents=True, exist_ok=True) + try: + dependency.symlink_to(REPO_ROOT, target_is_directory=True) + except OSError: + npm = shutil.which("npm") + if npm is None: + raise RuntimeError("npm is required when directory symlinks are unavailable") + subprocess.run( + [ + npm, + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--package-lock=false", + ], + cwd=directory, + check=True, + ) + + +def write_report(path: Path, report: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true", help="validate inventory only") + parser.add_argument( + "--out", + default=str(REPO_ROOT / "target" / "ci" / "examples"), + ) + args = parser.parse_args() + examples, failures = load_inventory() + if failures: + for failure in failures: + print(f"FAIL {failure}", file=sys.stderr) + return 1 + print(f"canonical examples: {len(examples)}") + if args.check: + print("PASS: canonical example inventory is complete") + return 0 + + perry = shutil.which("perry") + if perry is None: + print("FAIL perry is required to compile canonical examples", file=sys.stderr) + return 2 + + out_dir = Path(args.out).resolve() + bin_dir = out_dir / "bin" + log_dir = out_dir / "logs" + bin_dir.mkdir(parents=True, exist_ok=True) + log_dir.mkdir(parents=True, exist_ok=True) + records: list[dict[str, Any]] = [] + started = time.perf_counter() + for relative in examples: + directory = REPO_ROOT / relative + name = directory.name + output = bin_dir / name + print(f"[example] {relative}", flush=True) + ensure_engine_dependency(directory) + case_started = time.perf_counter() + result = subprocess.run( + [perry, "compile", "main.ts", "-o", str(output), "--no-link"], + cwd=directory, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + (log_dir / f"{name}.stdout.log").write_text(result.stdout, encoding="utf-8") + (log_dir / f"{name}.stderr.log").write_text(result.stderr, encoding="utf-8") + record = { + "example": relative, + "status": "pass" if result.returncode == 0 else "fail", + "mode": "codegen-no-link", + "exit_code": result.returncode, + "duration_ms": round((time.perf_counter() - case_started) * 1000, 3), + "stdout": f"logs/{name}.stdout.log", + "stderr": f"logs/{name}.stderr.log", + } + records.append(record) + print(f"[example] {relative}: {record['status']}", flush=True) + + failed = [record["example"] for record in records if record["status"] != "pass"] + report = { + "schema": REPORT_SCHEMA, + "status": "fail" if failed else "pass", + "duration_ms": round((time.perf_counter() - started) * 1000, 3), + "perry": subprocess.run( + [perry, "--version"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ).stdout.strip(), + "examples": records, + "failures": failed, + } + write_report(out_dir / "result.json", report) + if failed: + print(f"FAIL: {len(failed)} canonical example(s) failed: {failed}", file=sys.stderr) + return 1 + print(f"PASS: all {len(records)} canonical examples compiled") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ci/examples.json b/tools/ci/examples.json new file mode 100644 index 00000000..f640879b --- /dev/null +++ b/tools/ci/examples.json @@ -0,0 +1,25 @@ +{ + "schema": "bloom-canonical-examples-v1", + "examples": [ + "examples/bistro", + "examples/dungeon-crawl", + "examples/intel-sponza", + "examples/isometric-rpg", + "examples/kart-racer", + "examples/pbr-spheres", + "examples/perry-embed", + "examples/pong", + "examples/quality-masked", + "examples/quality-motion", + "examples/quality-stress", + "examples/quality-transparency", + "examples/renderer-test", + "examples/space-blaster", + "examples/sponza", + "examples/test-gltf-watch", + "examples/test-scene-watch", + "examples/test3d", + "examples/voxel-sandbox", + "examples/world-viewer" + ] +} diff --git a/tools/ci/web_smoke.py b/tools/ci/web_smoke.py new file mode 100644 index 00000000..79eb4aaa --- /dev/null +++ b/tools/ci/web_smoke.py @@ -0,0 +1,524 @@ +#!/usr/bin/env python3 +"""Boot Bloom WebGPU in a real browser and verify a presented known-color frame.""" + +from __future__ import annotations + +import argparse +import base64 +import http.server +import json +import os +import secrets +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time +import urllib.error +import urllib.request +import zlib +from pathlib import Path +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[2] +REPORT_SCHEMA = "bloom-web-browser-smoke-v1" + +SMOKE_HTML = """ + + + + + + +
BOOTING
+ + + + +""" + + +class QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, _format: str, *_args: object) -> None: + pass + + +def browser_path(explicit: str | None) -> str | None: + if explicit: + return explicit + for candidate in ( + shutil.which("google-chrome"), + shutil.which("google-chrome-stable"), + shutil.which("chromium"), + shutil.which("chromium-browser"), + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + ): + if candidate and Path(candidate).is_file(): + return candidate + return None + + +def receive_exact(stream: socket.socket, length: int) -> bytes: + chunks = bytearray() + while len(chunks) < length: + chunk = stream.recv(length - len(chunks)) + if not chunk: + raise ConnectionError("browser closed the DevTools connection") + chunks.extend(chunk) + return bytes(chunks) + + +class DevTools: + def __init__(self, url: str): + from urllib.parse import urlparse + + parsed = urlparse(url) + self.stream = socket.create_connection((parsed.hostname, parsed.port), timeout=10) + key = base64.b64encode(secrets.token_bytes(16)).decode("ascii") + target = parsed.path + (f"?{parsed.query}" if parsed.query else "") + request = ( + f"GET {target} HTTP/1.1\r\n" + f"Host: {parsed.hostname}:{parsed.port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "Origin: http://127.0.0.1\r\n\r\n" + ) + self.stream.sendall(request.encode("ascii")) + response = bytearray() + while b"\r\n\r\n" not in response: + response.extend(self.stream.recv(4096)) + if not response.startswith(b"HTTP/1.1 101"): + raise ConnectionError(f"DevTools WebSocket handshake failed: {response[:200]!r}") + self.next_id = 1 + + def close(self) -> None: + self.stream.close() + + def send_json(self, value: dict[str, Any]) -> None: + payload = json.dumps(value, separators=(",", ":")).encode("utf-8") + mask = secrets.token_bytes(4) + length = len(payload) + header = bytearray([0x81]) + if length < 126: + header.append(0x80 | length) + elif length < 65536: + header.append(0x80 | 126) + header.extend(struct.pack(">H", length)) + else: + header.append(0x80 | 127) + header.extend(struct.pack(">Q", length)) + header.extend(mask) + header.extend(byte ^ mask[index % 4] for index, byte in enumerate(payload)) + self.stream.sendall(header) + + def receive_json(self) -> dict[str, Any]: + first, second = receive_exact(self.stream, 2) + opcode = first & 0x0F + length = second & 0x7F + if length == 126: + length = struct.unpack(">H", receive_exact(self.stream, 2))[0] + elif length == 127: + length = struct.unpack(">Q", receive_exact(self.stream, 8))[0] + if second & 0x80: + mask = receive_exact(self.stream, 4) + payload = bytes( + byte ^ mask[index % 4] + for index, byte in enumerate(receive_exact(self.stream, length)) + ) + else: + payload = receive_exact(self.stream, length) + if opcode == 8: + raise ConnectionError("browser closed the DevTools target") + if opcode != 1: + return self.receive_json() + return json.loads(payload.decode("utf-8")) + + def call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + call_id = self.next_id + self.next_id += 1 + self.send_json({"id": call_id, "method": method, "params": params or {}}) + while True: + message = self.receive_json() + if message.get("id") == call_id: + if "error" in message: + raise RuntimeError(f"{method} failed: {message['error']}") + return message.get("result", {}) + + +def paeth(a: int, b: int, c: int) -> int: + estimate = a + b - c + distances = (abs(estimate - a), abs(estimate - b), abs(estimate - c)) + return (a, b, c)[distances.index(min(distances))] + + +def png_channel_means(path: Path) -> tuple[float, float, float]: + payload = path.read_bytes() + if payload[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError("browser screenshot is not a PNG") + offset = 8 + width = height = color_type = bit_depth = interlace = None + compressed = bytearray() + while offset < len(payload): + length = struct.unpack(">I", payload[offset : offset + 4])[0] + kind = payload[offset + 4 : offset + 8] + data = payload[offset + 8 : offset + 8 + length] + offset += 12 + length + if kind == b"IHDR": + width, height, bit_depth, color_type, _, _, interlace = struct.unpack( + ">IIBBBBB", data + ) + elif kind == b"IDAT": + compressed.extend(data) + elif kind == b"IEND": + break + if not width or not height or bit_depth != 8 or color_type not in (2, 6) or interlace: + raise ValueError("unsupported browser screenshot PNG layout") + channels = 3 if color_type == 2 else 4 + stride = width * channels + raw = zlib.decompress(bytes(compressed)) + rows: list[bytearray] = [] + cursor = 0 + previous = bytearray(stride) + for _ in range(height): + filter_kind = raw[cursor] + cursor += 1 + encoded = raw[cursor : cursor + stride] + cursor += stride + row = bytearray(stride) + for index, value in enumerate(encoded): + left = row[index - channels] if index >= channels else 0 + up = previous[index] + upper_left = previous[index - channels] if index >= channels else 0 + if filter_kind == 0: + decoded = value + elif filter_kind == 1: + decoded = value + left + elif filter_kind == 2: + decoded = value + up + elif filter_kind == 3: + decoded = value + ((left + up) // 2) + elif filter_kind == 4: + decoded = value + paeth(left, up, upper_left) + else: + raise ValueError(f"unsupported PNG filter {filter_kind}") + row[index] = decoded & 0xFF + rows.append(row) + previous = row + totals = [0, 0, 0] + samples = 0 + # Ignore a thin browser-edge band and average the presented canvas. + for row in rows[max(1, height // 20) : height - max(1, height // 20)]: + for index in range(max(1, width // 20) * channels, (width - max(1, width // 20)) * channels, channels): + totals[0] += row[index] + totals[1] += row[index + 1] + totals[2] += row[index + 2] + samples += 1 + return tuple(total / samples for total in totals) # type: ignore[return-value] + + +def write_report(path: Path, report: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def free_local_port() -> int: + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + return int(probe.getsockname()[1]) + + +def devtools_target(port: int, url: str, deadline: float) -> str | None: + endpoint = f"http://127.0.0.1:{port}/json/list" + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(endpoint, timeout=1) as response: + targets = json.load(response) + for target in targets: + if target.get("type") == "page" and target.get("url") == url: + return str(target["webSocketDebuggerUrl"]) + except (OSError, urllib.error.URLError, json.JSONDecodeError): + pass + time.sleep(0.1) + return None + + +def evaluated_value(devtools: DevTools, expression: str) -> Any: + result = devtools.call( + "Runtime.evaluate", + { + "expression": expression, + "returnByValue": True, + "awaitPromise": True, + }, + ) + return result.get("result", {}).get("value") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--browser") + parser.add_argument( + "--out", + default=str(REPO_ROOT / "target" / "ci" / "web-smoke"), + ) + parser.add_argument("--timeout", type=float, default=45.0) + args = parser.parse_args() + out_dir = Path(args.out).resolve() + out_dir.mkdir(parents=True, exist_ok=True) + package_dir = REPO_ROOT / "native" / "web" / "pkg" + required = [package_dir / "bloom_web.js", package_dir / "bloom_web_bg.wasm"] + missing = [str(path) for path in required if not path.is_file()] + if missing: + write_report( + out_dir / "result.json", + {"schema": REPORT_SCHEMA, "status": "fail", "failures": [f"missing {missing}"]}, + ) + print(f"FAIL: wasm-pack output missing: {missing}") + return 1 + browser = browser_path(args.browser) + if browser is None: + print("FAIL: Chrome/Chromium is required for the web browser smoke") + return 2 + + site = out_dir / "site" + if site.exists(): + shutil.rmtree(site) + site.mkdir() + shutil.copytree(package_dir, site / "pkg") + shutil.copy2(REPO_ROOT / "native" / "web" / "bloom_glue.js", site) + shutil.copy2(REPO_ROOT / "native" / "web" / "jolt_bridge.js", site) + (site / "index.html").write_text(SMOKE_HTML, encoding="utf-8") + + handler = lambda *handler_args, **handler_kwargs: QuietHandler( + *handler_args, directory=str(site), **handler_kwargs + ) + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + url = f"http://127.0.0.1:{server.server_port}/index.html" + screenshot = out_dir / "frame.png" + screenshot.unlink(missing_ok=True) + profile = tempfile.mkdtemp(prefix="bloom-web-smoke-") + debug_port = free_local_port() + command = [browser, "--headless=new"] + command.extend( + [ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu-sandbox", + "--enable-unsafe-webgpu", + "--ignore-gpu-blocklist", + ] + ) + if sys.platform.startswith("linux"): + # Hosted Linux runners have no display GPU. These are Chromium's own + # WebGPU SwiftShader test switches: explicitly select the software + # adapter and initialize ANGLE for canvas/compositor interop. + command.extend( + [ + "--use-webgpu-adapter=swiftshader", + "--use-gpu-in-tests", + "--enable-accelerated-2d-canvas", + ] + ) + command.extend( + [ + "--remote-allow-origins=*", + f"--remote-debugging-port={debug_port}", + "--window-size=320,240", + f"--user-data-dir={profile}", + url, + ] + ) + started = time.perf_counter() + stdout_log = out_dir / "browser.stdout.log" + stderr_log = out_dir / "browser.stderr.log" + process: subprocess.Popen[bytes] | None = None + devtools: DevTools | None = None + marker = "pending" + browser_error: str | None = None + adapter_info: dict[str, Any] | None = None + frame_signature: str | None = None + failures: list[str] = [] + try: + with stdout_log.open("wb") as stdout, stderr_log.open("wb") as stderr: + process = subprocess.Popen( + command, + stdout=stdout, + stderr=stderr, + ) + deadline = time.monotonic() + args.timeout + target = devtools_target(debug_port, url, deadline) + if target is None: + failures.append("DevTools did not expose the Bloom smoke page") + else: + devtools = DevTools(target) + devtools.call("Page.enable") + devtools.call("Runtime.enable") + while time.monotonic() < deadline: + marker = str( + evaluated_value( + devtools, + 'document.documentElement.dataset.bloomSmoke || "pending"', + ) + ) + if marker in ("pass", "fail"): + break + time.sleep(0.1) + raw_adapter_info = evaluated_value( + devtools, + """(() => { + const adapter = globalThis.__bloomSmokeAdapter; + if (!adapter) return null; + const info = adapter.info ?? {}; + return { + vendor: info.vendor ?? "", + architecture: info.architecture ?? "", + device: info.device ?? "", + description: info.description ?? "", + backend: info.backend ?? "", + type: info.type ?? "", + }; + })()""", + ) + if isinstance(raw_adapter_info, dict): + adapter_info = raw_adapter_info + if marker == "fail": + browser_error = str( + evaluated_value( + devtools, + 'document.documentElement.dataset.bloomError || "unknown error"', + ) + ) + failures.append(f"Bloom browser frame failed: {browser_error}") + elif marker != "pass": + failures.append("browser timed out before completing a Bloom frame") + else: + raw_frame_signature = evaluated_value( + devtools, + "document.documentElement.dataset.bloomFrame || null", + ) + if isinstance(raw_frame_signature, str): + frame_signature = raw_frame_signature + if frame_signature != "direct-2d-clear-rgba-32-112-224-255": + failures.append("browser did not report the submitted known frame") + capture = devtools.call( + "Page.captureScreenshot", + {"format": "png", "fromSurface": True}, + ) + screenshot.write_bytes(base64.b64decode(capture["data"])) + except (ConnectionError, OSError, RuntimeError, ValueError) as exc: + failures.append(f"browser automation failed: {exc}") + finally: + if devtools is not None: + devtools.close() + if process is not None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + server.shutdown() + server.server_close() + thread.join(timeout=2) + shutil.rmtree(profile, ignore_errors=True) + + if process is not None and process.returncode not in (0, -9, -15): + failures.append(f"browser exited {process.returncode}") + means: tuple[float, float, float] | None = None + if marker == "pass" and not screenshot.is_file(): + failures.append("DevTools did not produce a screenshot") + elif screenshot.is_file(): + try: + means = png_channel_means(screenshot) + red, green, blue = means + if not (blue > green + 30 and green > red + 30 and blue > 180): + failures.append( + f"browser screenshot is not the known blue frame: {means}" + ) + except (OSError, ValueError, zlib.error) as exc: + failures.append(f"cannot validate browser screenshot: {exc}") + report = { + "schema": REPORT_SCHEMA, + "status": "fail" if failures else "pass", + "duration_ms": round((time.perf_counter() - started) * 1000, 3), + "browser": browser, + "command": command, + "url": url, + "dom_marker": marker, + "browser_error": browser_error, + "adapter_info": adapter_info, + "screenshot": "frame.png" if screenshot.is_file() else None, + "frame_signature": frame_signature, + "compositor_screenshot_rgb_means": means, + "stdout": stdout_log.name, + "stderr": stderr_log.name, + "failures": failures, + } + write_report(out_dir / "result.json", report) + if failures: + print("FAIL: web browser smoke") + for failure in failures: + print(f" - {failure}") + return 1 + print(f"PASS: browser presented known frame; RGB means={means}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/file-lines-baseline.json b/tools/file-lines-baseline.json index de6fc9b5..47082658 100644 --- a/tools/file-lines-baseline.json +++ b/tools/file-lines-baseline.json @@ -1,3 +1,8 @@ { - "native/shared/src/renderer/mod.rs": 12556 + "native/shared/src/physics_jolt.rs": 3858, + "native/shared/src/renderer/material_system.rs": 2335, + "native/shared/src/renderer/mod.rs": 14774, + "native/shared/src/scene.rs": 2902, + "native/shared/tests/golden_render.rs": 2253, + "native/web/src/lib.rs": 2351 } diff --git a/tools/quality/.gitignore b/tools/quality/.gitignore new file mode 100644 index 00000000..ea93687f --- /dev/null +++ b/tools/quality/.gitignore @@ -0,0 +1,2 @@ +out/ +__pycache__/ diff --git a/tools/quality/README.md b/tools/quality/README.md new file mode 100644 index 00000000..b5d44c73 --- /dev/null +++ b/tools/quality/README.md @@ -0,0 +1,439 @@ +# Bloom renderer qualification + +This directory is the canonical visual-correctness and steady-state GPU/CPU +qualification workflow for Bloom. It turns a renderer change into reviewable +evidence: versioned scene inputs, fixed cameras and timesteps, native adapter +metadata, final and intermediate images, perceptual diffs, per-pass GPU +timestamps, and explicit pass/fail results. + +It is a regression oracle, not a claim that the current images have reached +the project's quality target. In particular, the initial corpus deliberately +records visible material, temporal, and post-processing defects so later work +can prove that it improves them without breaking another scene. + +## Commands + +From the repository root: + +```sh +# Fast local gate: PBR spheres, Damaged Helmet, and Sponza. +python3 tools/quality/run.py run quick + +# Full nine-case hardware qualification. +python3 tools/quality/run.py run full \ + --machine-class apple-m1-max-metal + +# Explore on an unqualified machine without changing the process exit code. +python3 tools/quality/run.py run full \ + --report-only \ + --out tools/quality/out/local-full +``` + +`run` is strict by default and exits non-zero for a missing asset/baseline, +capture error, missing intermediate, visual threshold failure, or applicable +hard performance budget. `--report-only` records those same failures in +`result.json`; it only makes the process exit zero for local investigation. +It never turns a failure into a recorded pass. + +Useful focused commands: + +```sh +python3 tools/quality/run.py check +python3 tools/quality/run.py run full --case bistro-exterior --report-only +python3 tools/quality/run.py faults +python3 -m unittest tools/quality/test_run.py -v +``` + +The output directory contains: + +- `result.json`: the authoritative machine-readable result; +- `summary.md` and `summary.html`: compact human summaries; +- `cases//final.png`: the exact offscreen final output; +- `cases//intermediates/*.png`: named render-graph evidence; +- `metrics.json`, `heatmap.png`, and `comparison.png` when a baseline exists; +- complete prepare/build/capture/diff command records, including stdout, + stderr, duration, exit status, and timeout state. + +`tools/quality/out/` is ignored. CI uploads it as an artifact; do not commit +run directories. + +## Determinism and measurement contract + +Each case in `scenes.toml` versions its assets, camera, resolution, quality +tier, render scale, warm-up count, measured count, seed, timestep, required +features, thresholds, and performance budgets. + +The scene executable uses `bloom/quality`'s `QualityRun`: + +1. run a fixed-step warm-up with shader compilation excluded; +2. reset profiling and run the measured frames uncapped; +3. stop profiling before any readback; +4. request the final and named-intermediate capture; +5. serialize native telemetry and exit. + +Headless qualification uses an exact-size offscreen target. It does not depend +on Retina/window scaling, desktop compositing, or vsync. The engine reports +`uncapped`, `present_mode`, warm-up exclusion, shader-compilation exclusion, +and GPU timestamp availability. Hard performance runners reject telemetry +that cannot prove these properties. + +The measured window is intentionally sustained (240–300 frames after +120–180 warm-up frames). Shorter windows were rejected because scheduler +spikes dominated p95. Capture and PNG encoding happen after measurement. + +The full corpus also includes two focused composition/silhouette cases: + +- `weighted-transparency` exercises 96 intersecting imported BLEND layers; +- `masked-alpha-coverage` exercises 48 imported MASK cards across projected + mip sizes, with deterministic object motion and cutout shadow casters. + +The weighted fixture also accepts `--sorted` and `--refractive` for focused +backend validation of the two other temporal-reactive writers. These flags do +not alter the versioned default corpus. See +[`docs/temporal-reactive-coverage.md`](../../docs/temporal-reactive-coverage.md). + +The `--refractive` route also exercises lazy transmitted directional shadows +when directional shadows are enabled. Native telemetry reports +`renderer_paths.transmitted_shadows` with the route's enable/active state, +`nearest-layer-rgb-depth` representation, fixed map resolution, exact +persistent bytes when allocated, and submitted caster count. Set +`BLOOM_TRANSMITTED_SHADOWS=0` for the exact visual/performance A/B control. +The ordinary no-transmission plan must have no transmitted-shadow graph +resources or pass. See +[`docs/transmitted-shadows.md`](../../docs/transmitted-shadows.md). + +When SSGI and a retained transmission instance are both present, native +telemetry also reports `renderer_paths.transparent_gi`: enable/active state, +the bounded `one-layer-colored-continuation` representation, exact additional +persistent bytes (zero), and retained instance count. Set +`BLOOM_TRANSPARENT_GI=0` for the opaque-GI A/B control. Opaque scenes must not +create or select the lazy hardware/SDF/WSRC pipeline specializations. See +[`docs/transparent-gi.md`](../../docs/transparent-gi.md). + +`examples/quality-transparency/main.ts` accepts `--transparent-gi` as an +unversioned focused stress route. It replaces the 96 immediate draws with 96 +moving retained physical-transmission nodes and disables their independent +directional-shadow contribution. This makes an environment-variable on/off +run measure the GI specialization while keeping camera refraction, transforms, +Mesh-Cards, TLAS rebuilds, and scene composition identical. The flag does not +alter the versioned default corpus. + +The same fixture accepts `--reflection-hierarchy` as an unversioned focused +glass-reflection oracle. It creates an explicit horizontal planar probe and a +smooth imported-transmission floor reflecting a rotating Damaged Helmet. +Native telemetry reports `renderer_paths.refractive_reflections`, including +the planar/screen-space/environment source order, fixed march bounds, the +lazy 160-byte uniform, and zero additional graph passes/images. Set +`BLOOM_REFRACTIVE_REFLECTIONS=0` for the exact environment-only A/B control. +The flag does not alter the versioned default corpus. See +[`docs/refractive-reflections.md`](../../docs/refractive-reflections.md). + +`renderer_paths.physical_texture_uv` reports supported UV sets, lazy +TEXCOORD_1-pipeline initialization, the unchanged 96-byte ordinary vertex +stride, the 8-byte UV1 sidecar stride, and zero graph/image cost. + +`renderer_paths.transparency` also reports the +`global-depth-source-stable-id` conventional sorted-interleaving contract, +the number of lazy attachment-compatible custom pipelines initialized so far, +and the invariant zero additional draws/graph passes. Weighted OIT remains an +imported aggregate resolved before custom commands. +`BLOOM_SORTED_INTERLEAVING=0` restores the prior list boundary for exact A/B. + +`renderer_paths.steady_state_uploads.lighting` reports the last frame's +lighting-buffer `write_count`, actual `byte_count`, and `full_buffer_bytes`. +Lighting setters update one CPU snapshot; the renderer compares that snapshot +once before submission and emits at most three aligned dirty ranges (fixed and +directional fields, point lights, and view/shadow/frame data). This makes the +former repeated full-buffer upload observable without adding a readback or GPU +pass. + +`renderer_paths.steady_state_resources.bind_group_creations` reports the last +frame's total bind-group creations and a fixed, named count for every recurring +core-frame site. The counter storage is a twelve-element integer array: it +performs no allocation and lets qualification distinguish true steady-state +churn from initialization, resize, or resource-generation rebuilds. +Final-composite bindings are cached across the exact Cartesian product of +eight possible source views and two exposure-history slots. Resize invalidates +all sixteen entries before replacing any referenced render-target view, so a +warmed stable path reports `final_composite: 0` without stale-view reuse. +Scene-compose bindings likewise use distinct slots for the cleared SSR +fallback and both SSR history views. A warmed stable path therefore also +reports `scene_compose: 0`; SSR toggles and path-tracing ownership select a +different complete binding instead of mutating or incompletely keying one. +SSR temporal bindings are also cached separately for the two alternating +previous-history inputs. The optional diagnostics pass consumes the same +cached binding, and resize invalidates both entries before history, raw SSR, +or velocity views are replaced. +Ordinary TAA uses the same two-slot history-keyed cache and reports `taa: 0` +after warmup. Reactive TAA remains a separately named counter and uses two +history slots keyed by both compiled plan ID and transient-pool rebuild epoch, +because its coverage view belongs to that compiled transient generation. It +also reports `taa_reactive: 0` after warmup and after a resize rebuild settles. +The non-TAA half-resolution upscale binding is a single persistent slot, +invalidated before resize replaces its composed input. A dedicated real-GPU +half-resolution test proves scene pixels are produced and `upscale: 0` is +restored after warmup. +DoF, motion blur, SSS, and CAS use lazy bind-group arrays indexed by the exact +upstream color target selected by the post-FX chain (including both TAA +history slots). Resize clears every array before replacing those views. A +forced full-chain GPU test renders geometry and hard-gates all four named +creation counters to zero after warmup. +Auto exposure uses sixteen lazy entries for the same eight composite-source +identities crossed with both previous-exposure slots. The forced full-chain +test enables exposure adaptation and also hard-gates `auto_exposure: 0` after +both ping-pong bindings are warm. +Each user post-pass owns two lazy bindings for the LDR A/B input parity and +drops them before resize replaces color/depth views. A two-pass copy stack +proves geometry preservation and `custom_post_pass: 0` both before and after a +resize cycle. With every named core site covered, official post-warmup quality +artifacts now fail the contract unless total bind-group creation is zero. + +`examples/quality-transparency/main.ts` accepts `--sorted-interleaving` as an +unversioned focused ordering oracle. It forces conventional sorted composition +and pairs the 96 imported BLEND layers with 96 custom-material layers at +alternating depths while TAA is active. This makes the global order and lazy +reactive-compatible custom pipeline observable; the environment kill switch +above supplies the identical-scene legacy control. The flag does not alter the +versioned default corpus. + +### Native evidence + +The native renderer reports the adapter name, vendor/device IDs, device type, +driver fields when exposed by wgpu, backend, capability tier, semantic feature +set, actual SSGI trace backend, and path-tracing availability. Empty driver +strings are valid on Metal because the backend does not expose them; they are +recorded rather than invented. Runtime evidence also reports +`ray_scene_preparation` as `disabled`, `ssgi`, `pt`, or `ssgi+pt`; this makes +the shared acceleration/card prefix observable without conflating it with +SSGI-only baking. + +Every accepted native telemetry artifact must also contain the complete +`adapter.renderer_capabilities` and `adapter.device_negotiation` snapshots. +The quality runner validates tier identity, granted features/limits, selected +system paths, active platform profile, chosen request, fallback cause, and +requested device limits before comparing images or timings. Missing or +inconsistent capability evidence fails the case rather than producing an +unqualified performance result. Each run also writes the same evidence as the +named top-level `capabilities.json` artifact referenced by `result.json`; the +hardware workflows upload the entire result directory on both success and +failure. + +The one debug-capture API snapshots existing render-graph products: + +- `hdr-scene`: RGBA16F scene output, converted for review with the same + ACES-style display curve used by the capture helper; +- `ssgi`: RGBA16F resolved indirect diffuse output, accompanied by raw HDR + finite/luminance metrics; +- `scene-depth`: Depth32F normalized to the finite range of that capture for + diagnostic visibility (not a metric-preserving linear-depth encoding); +- `shadow-cascade-0`, `shadow-cascade-1`, `shadow-cascade-2`: Depth32F shadow + maps normalized per cascade. + +Active temporal systems add capture-only evidence beside those physical graph +products. Realtime PT emits trace-resolution rejection reason, motion, +reprojected UV, and variance/history confidence in one temporary compute pass. +The normal renderer creates none of those resources; runtime telemetry reports +their exact temporary byte/pass contract and release state. + +The manifest makes the complete TAA, SSR, and SSGI capture set mandatory for +every High-preset corpus case. A missing diagnostic therefore fails +qualification instead of silently producing an incomplete evidence bundle. +Lower presets require only the graph products that remain valid for their +disabled feature set. Realtime PT uses the same named-artifact contract when a +PT corpus case is enabled. + +The API is dormant during ordinary rendering and throughout the measured +window. It reuses textures already marked `COPY_SRC` and records copies in a +keyed terminal render-graph pass during the post-measurement screenshot +submission. It creates no normal-frame pass, allocation, bind group, or +readback. + +To retain the exact normal and capture plans beside a qualification result, +set `BLOOM_GRAPH_DUMP_DIR` to an absolute directory. Each distinct plan emits +deterministic JSON and DOT once. The schema, cache counters, lifetime fields, +and aliasing constraints are described in +[`docs/compiled-render-graph.md`](../../docs/compiled-render-graph.md). + +## Reproducibility + +Run the same suite twice without changing source, configuration, machine +power mode, or background GPU load, then compare the bundles: + +```sh +python3 tools/quality/run.py run quick \ + --report-only --out tools/quality/out/repro-a +python3 tools/quality/run.py run quick \ + --report-only --out tools/quality/out/repro-b +python3 tools/quality/run.py repro-check \ + --first tools/quality/out/repro-a/result.json \ + --second tools/quality/out/repro-b/result.json +``` + +`repro-check` requires identical stable metadata and artifact sets. It hashes +every final/intermediate PNG and applies tighter-than-regression image metrics +when hardware ray-query sampling is not byte-identical. It compares CPU/GPU +mean and p95 against the versioned `[reproducibility]` bounds. + +The current same-machine contract is: + +- final/intermediate SSIM at least `0.999`, luminance RMSE at most `0.002`, + mean OKLab and edge deltas at most `0.001`; +- CPU/GPU mean: 15% relative noise, with small absolute allowances of + 0.35 ms CPU and 1.0 ms GPU; +- CPU p95: 25% or 1.0 ms; +- GPU p95: 50% or 12 ms because OS/GPU scheduling can move a small number of + frames across the 95th-percentile boundary. + +The wider p95 reproducibility envelope does not weaken hard budgets: a +qualified machine still compares the observed absolute p95 to the case budget. +If a reproducibility run fails, remove background load and retry; do not raise +bounds without attaching multiple-run evidence. + +## Machine classes and fallbacks + +Hard budgets apply only when `--machine-class` selects the class declared by +that case. The runner verifies the native adapter/backend against the selected +class, so a label or environment variable cannot impersonate a qualified GPU. + +Defined classes: + +- `apple-m1-max-metal`: Metal high-end RT/bindless baseline; +- `nvidia-rtx4080-vulkan`: Vulkan discrete high-end baseline, including VRAM; +- `apple-m1-metal-constrained`: constrained preset/render-scale gate. + +Runs without a machine class still record timings and visual failures, but +performance is report-only. VRAM must come from the hardware runner when wgpu +cannot report it: + +```sh +export BLOOM_QUALITY_VRAM_PEAK_MB=2450 +# or case-specific, with punctuation converted to underscores: +export BLOOM_QUALITY_VRAM_PEAK_MB_BISTRO_EXTERIOR=6120 +``` + +Required features are evaluated again from the native runtime probe. Sponza +and Bistro record their declared software GI fallback when ray query is not +available; `feature_decision` says `native`, `fallback`, or `unsupported`, and +telemetry records the path actually used. + +## Baseline governance + +A normal run never creates or overwrites a baseline. Create a review bundle: + +```sh +python3 tools/quality/run.py baseline-review \ + --result tools/quality/out/local-full/result.json \ + --out tools/quality/out/baseline-review \ + --reason "Explain the renderer change and expected visual effect" +``` + +The bundle contains the proposed image, current image when present, available +diff/heatmap/metrics, all named intermediates, timing evidence, source commit, +manifest hash, and reason. For an initial baseline it records +`baseline_state: absent`. + +After a human reviews the final and intermediate images, install explicitly: + +```sh +python3 tools/quality/run.py baseline-install \ + --review tools/quality/out/baseline-review/review.json \ + --approved-by "Reviewer Name" \ + --receipt tools/quality/out/baseline-review/installation.json +``` + +Installation is restricted to `tools/quality/baselines/`. It refuses a stale +review if the baseline changed after the bundle was made, and it refuses to +replace a newly-created target when the review expected no baseline. Do not +use an agent/service identity for `--approved-by`; this is the independent +visual-review boundary. + +A baseline PR must provide: + +- why the pixels should change; +- before/after/comparison/heatmap (or explicit initial-baseline status); +- SSIM, RMSE, OKLab, and edge deltas; +- CPU/GPU p95 deltas on the applicable machine class; +- the human reviewer and installation receipt. + +Backend-specific baselines require a documented, reproducible raster or +floating-point difference that cannot be normalized. Portable baselines are +the default. + +## Seeded negative controls + +`python3 tools/quality/run.py faults` asks `bloom-diff` to create five +deterministic corruptions from approved baselines and succeeds only when every +one is rejected: + +- BRDF energy; +- shadow placement; +- GI leakage; +- motion history; +- texture orientation. + +This proves that passing thresholds still detect the failure classes the +corpus is intended to guard. Fault images, metrics, and commands are retained +under `tools/quality/out/faults`. + +During initial bring-up, before any baseline has received human approval, the +detector itself can be demonstrated against a complete result bundle: + +```sh +python3 tools/quality/run.py faults \ + --source-result tools/quality/out/local-full/result.json \ + --out tools/quality/out/bootstrap-faults +``` + +That result is explicitly labelled `unapproved-result-bundle`; it proves fault +detection but does not substitute for approved-baseline CI. Scheduled hardware +CI intentionally omits `--source-result`. + +## Corpus notes + +- PBR spheres: high and constrained BRDF/material-lobe contracts. +- Damaged Helmet: canonical glTF textures, UVs, tangent normal map, + metallic-roughness, occlusion, and emissive behavior. +- Sponza: interior GI, cascaded shadows, alpha leakage, and temporal output. +- Bistro: exterior sun/sky and representative large-scene materials. +- Skinned alpha motion: looping Fox deformation in front of textured, + alpha-tested Sponza foliage. +- Draw/light stress: 10,240 meshes plus many lights. +- Weighted transparency: 96 imported BLEND layers in 12 animated, + intersecting cells; records OIT accumulation/resolve cost and stability. + +The checked-out Bistro source has 2,909 mesh-node instances and 551 unique +meshes. Bloom's current `ModelData` ABI duplicates vertex arrays per instance; +loading the literal scene consumed about 19 GB RSS and exceeded the 256 MB +per-buffer limit when building ray-query geometry. Until GPU instancing lands, +`prepare_bistro.py` deterministically selects 96 largest camera-visible unique +meshes, preserves their authored world transforms/materials/textures, and +records source/derived hashes. It reads `bistro.bin` from the pinned Git +revision into the case output and never edits the working-tree asset. The +10k-mesh stress case separately covers draw pressure. Do not describe the +current Bistro case as full 2,909-instance streaming coverage. + +## CI and migration + +`.github/workflows/quality.yml` runs contract tests on hosted CI and runs the +full suite plus negative controls on labelled hardware runners. Evidence is +uploaded with `if: always()`, and `summary.md` is appended to the job summary. + +This workflow supersedes `tools/validate.sh` as the regression gate. +`validate.sh` remains a legacy, macOS-only four-camera Helmet exploration tool; +its cached reference, `sips` resize, embedded cameras, vsync/warm-up behavior, +and report-only output are not a qualification contract. + +Existing tools are reused: + +- `bloom-reference` remains the deterministic CPU/path-traced reference + generator and PT oracle; +- `bloom-diff` is the authoritative visual metric/fault engine; +- `tools/cycles_reference` remains useful for offline artistic/reference + studies; +- their approved outputs enter qualification only through an explicit + baseline review. + +When adding a case, update `scenes.toml`, pin every asset with hash/revision +plus source/license, implement the same `QualityRun` CLI contract, capture at +least final/HDR/depth evidence, add it to the appropriate workflow list, and +add or update a negative control when it represents a new failure class. diff --git a/tools/quality/build_example.py b/tools/quality/build_example.py new file mode 100644 index 00000000..1e4f02b8 --- /dev/null +++ b/tools/quality/build_example.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Prepare a Perry example and compile it reproducibly for qualification.""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + + +def run(argv: list[str], cwd: Path) -> None: + print("+ " + " ".join(argv), flush=True) + result = subprocess.run(argv, cwd=cwd, check=False) + if result.returncode != 0: + raise SystemExit(result.returncode) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("directory") + parser.add_argument("--source", default="main.ts") + parser.add_argument("--output", default="main") + args = parser.parse_args() + directory = Path(args.directory).resolve() + if not (directory / "package.json").is_file(): + print(f"missing package.json: {directory}", file=sys.stderr) + return 2 + if not (directory / "node_modules/bloom").exists(): + npm = shutil.which("npm") + if npm is None: + print("npm is required to prepare the example", file=sys.stderr) + return 2 + run( + [ + npm, + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--package-lock=false", + ], + directory, + ) + perry = shutil.which("perry") + if perry is None: + print("perry is required to compile qualification scenes", file=sys.stderr) + return 2 + run([perry, "compile", args.source, "-o", args.output], directory) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/quality/prepare_bistro.py b/tools/quality/prepare_bistro.py new file mode 100644 index 00000000..f89dc340 --- /dev/null +++ b/tools/quality/prepare_bistro.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +"""Build a bounded, deterministic Bistro qualification glTF. + +The source asset references 551 unique meshes from 2,909 mesh nodes. Bloom's +current ModelData ABI owns vertices per mesh entry rather than an instance +table, so loading the source literally expands repeated geometry to ~19 GB. +This preparer retains the first authored world transform for every unique +mesh. It preserves the complete material/texture set and exterior scale while +bounding geometry to one copy per source mesh until native mesh instancing +lands. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import os +import subprocess +from pathlib import Path +from typing import Any + + +def multiply(a: list[float], b: list[float]) -> list[float]: + """Column-major 4x4 multiplication matching the glTF matrix layout.""" + out = [0.0] * 16 + for column in range(4): + for row in range(4): + out[column * 4 + row] = sum( + a[k * 4 + row] * b[column * 4 + k] for k in range(4) + ) + return out + + +def local_matrix(node: dict[str, Any]) -> list[float]: + matrix = node.get("matrix") + if isinstance(matrix, list) and len(matrix) == 16: + return [float(value) for value in matrix] + translation = node.get("translation", [0.0, 0.0, 0.0]) + rotation = node.get("rotation", [0.0, 0.0, 0.0, 1.0]) + scale = node.get("scale", [1.0, 1.0, 1.0]) + x, y, z, w = (float(value) for value in rotation) + length = math.sqrt(x * x + y * y + z * z + w * w) + if length > 0.0: + x, y, z, w = x / length, y / length, z / length, w / length + sx, sy, sz = (float(value) for value in scale) + tx, ty, tz = (float(value) for value in translation) + return [ + (1.0 - 2.0 * (y * y + z * z)) * sx, + (2.0 * (x * y + z * w)) * sx, + (2.0 * (x * z - y * w)) * sx, + 0.0, + (2.0 * (x * y - z * w)) * sy, + (1.0 - 2.0 * (x * x + z * z)) * sy, + (2.0 * (y * z + x * w)) * sy, + 0.0, + (2.0 * (x * z + y * w)) * sz, + (2.0 * (y * z - x * w)) * sz, + (1.0 - 2.0 * (x * x + y * y)) * sz, + 0.0, + tx, + ty, + tz, + 1.0, + ] + + +IDENTITY = [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, +] + + +def first_mesh_transforms(document: dict[str, Any]) -> dict[int, list[float]]: + nodes = document["nodes"] + first: dict[int, list[float]] = {} + + def visit(index: int, parent: list[float], stack: set[int]) -> None: + if index in stack: + raise ValueError(f"cycle in glTF node graph at node {index}") + node = nodes[index] + world = multiply(parent, local_matrix(node)) + mesh = node.get("mesh") + if isinstance(mesh, int) and mesh not in first: + first[mesh] = world + next_stack = set(stack) + next_stack.add(index) + for child in node.get("children", []): + visit(int(child), world, next_stack) + + for scene in document.get("scenes", []): + for root in scene.get("nodes", []): + visit(int(root), IDENTITY, set()) + return first + + +def mesh_bounds( + document: dict[str, Any], + mesh: dict[str, Any], + transform: list[float], +) -> tuple[list[float], float]: + minimum = [math.inf, math.inf, math.inf] + maximum = [-math.inf, -math.inf, -math.inf] + for primitive in mesh.get("primitives", []): + position = primitive.get("attributes", {}).get("POSITION") + if not isinstance(position, int): + continue + accessor = document["accessors"][position] + if "min" not in accessor or "max" not in accessor: + continue + for axis in range(3): + minimum[axis] = min(minimum[axis], float(accessor["min"][axis])) + maximum[axis] = max(maximum[axis], float(accessor["max"][axis])) + if not math.isfinite(minimum[0]): + return [transform[12], transform[13], transform[14]], 0.0 + corners: list[list[float]] = [] + for mask in range(8): + point = [ + maximum[axis] if mask & (1 << axis) else minimum[axis] + for axis in range(3) + ] + corners.append( + [ + sum(transform[column * 4 + row] * point[column] for column in range(3)) + + transform[12 + row] + for row in range(3) + ] + ) + world_min = [min(point[axis] for point in corners) for axis in range(3)] + world_max = [max(point[axis] for point in corners) for axis in range(3)] + center = [(world_min[axis] + world_max[axis]) * 0.5 for axis in range(3)] + radius = math.sqrt( + sum(((world_max[axis] - world_min[axis]) * 0.5) ** 2 for axis in range(3)) + ) + return center, radius + + +def qualification_meshes( + document: dict[str, Any], + transforms: dict[int, list[float]], + maximum: int, +) -> list[int]: + # Use the authored glTF camera. Its -Z axis is camera-forward. + camera_node = next( + (node for node in document["nodes"] if isinstance(node.get("camera"), int)), + None, + ) + if camera_node is None: + return sorted(transforms)[:maximum] + camera = local_matrix(camera_node) + position = camera[12:15] + forward = [-camera[8], -camera[9], -camera[10]] + right = camera[0:3] + up = camera[4:7] + ranked: list[tuple[float, int]] = [] + for mesh_index, mesh in enumerate(document.get("meshes", [])): + center, radius = mesh_bounds(document, mesh, transforms[mesh_index]) + relative = [center[axis] - position[axis] for axis in range(3)] + depth = sum(relative[axis] * forward[axis] for axis in range(3)) + horizontal = sum(relative[axis] * right[axis] for axis in range(3)) + vertical = sum(relative[axis] * up[axis] for axis in range(3)) + # Generous frustum around the source camera. Bounding-sphere overlap + # retains large architecture whose center lies outside the view. + visible = ( + depth + radius > 0.0 + and depth - radius < 200.0 + and abs(horizontal) < depth * 1.3 + radius + and abs(vertical) < depth * 0.8 + radius + ) + if visible: + ranked.append((radius / max(depth, 0.1), mesh_index)) + ranked.sort(key=lambda item: (-item[0], item[1])) + selected = sorted(mesh_index for _, mesh_index in ranked[:maximum]) + if not selected: + raise ValueError("Bistro camera selection produced no meshes") + return selected + + +def texture_indices(value: Any) -> set[int]: + found: set[int] = set() + if isinstance(value, dict): + for key, child in value.items(): + if key == "index" and isinstance(child, int): + found.add(child) + else: + found.update(texture_indices(child)) + elif isinstance(value, list): + for child in value: + found.update(texture_indices(child)) + return found + + +def remap_texture_indices(value: Any, mapping: dict[int, int]) -> None: + if isinstance(value, dict): + for key, child in value.items(): + if key == "index" and isinstance(child, int): + value[key] = mapping[child] + else: + remap_texture_indices(child, mapping) + elif isinstance(value, list): + for child in value: + remap_texture_indices(child, mapping) + + +def rewrite_uri(uri: str, source_dir: Path, output_dir: Path) -> str: + if uri.startswith("data:"): + return uri + source = (source_dir / uri).resolve() + return os.path.relpath(source, output_dir).replace(os.sep, "/") + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("source") + parser.add_argument("output") + parser.add_argument("--metadata") + parser.add_argument("--max-meshes", type=int, default=96) + parser.add_argument("--revision") + args = parser.parse_args() + source = Path(args.source).resolve() + output = Path(args.output).resolve() + document = json.loads(source.read_text(encoding="utf-8")) + transforms = first_mesh_transforms(document) + source_mesh_count = len(document.get("meshes", [])) + if len(transforms) != source_mesh_count: + missing = sorted(set(range(source_mesh_count)) - set(transforms)) + raise SystemExit(f"source scene does not reference every mesh: {missing[:16]}") + + selected_meshes = qualification_meshes( + document, transforms, max(1, args.max_meshes) + ) + source_meshes = document["meshes"] + document["meshes"] = [source_meshes[index] for index in selected_meshes] + mesh_mapping = { + source_index: derived_index + for derived_index, source_index in enumerate(selected_meshes) + } + used_materials = sorted( + { + primitive["material"] + for mesh in document["meshes"] + for primitive in mesh.get("primitives", []) + if isinstance(primitive.get("material"), int) + } + ) + material_mapping = { + source_index: derived_index + for derived_index, source_index in enumerate(used_materials) + } + source_materials = document.get("materials", []) + document["materials"] = [source_materials[index] for index in used_materials] + for mesh in document["meshes"]: + for primitive in mesh.get("primitives", []): + if isinstance(primitive.get("material"), int): + primitive["material"] = material_mapping[primitive["material"]] + used_textures = sorted(texture_indices(document["materials"])) + texture_mapping = { + source_index: derived_index + for derived_index, source_index in enumerate(used_textures) + } + remap_texture_indices(document["materials"], texture_mapping) + source_textures = document.get("textures", []) + document["textures"] = [source_textures[index] for index in used_textures] + used_images = sorted( + { + texture["source"] + for texture in document["textures"] + if isinstance(texture.get("source"), int) + } + ) + image_mapping = { + source_index: derived_index + for derived_index, source_index in enumerate(used_images) + } + for texture in document["textures"]: + if isinstance(texture.get("source"), int): + texture["source"] = image_mapping[texture["source"]] + extensions = texture.get("extensions", {}) + for extension in extensions.values(): + if isinstance(extension, dict) and isinstance(extension.get("source"), int): + source_index = extension["source"] + if source_index in image_mapping: + extension["source"] = image_mapping[source_index] + else: + extension.pop("source") + source_images = document.get("images", []) + document["images"] = [source_images[index] for index in used_images] + + mesh_count = len(selected_meshes) + document["nodes"] = [ + { + "name": f"quality_source_mesh_{source_index}", + "mesh": mesh_mapping[source_index], + "matrix": transforms[source_index], + } + for source_index in selected_meshes + ] + document["scenes"] = [ + { + "name": "Bloom deterministic unique-mesh Bistro subset", + "nodes": list(range(mesh_count)), + } + ] + document["scene"] = 0 + document.pop("animations", None) + document.pop("skins", None) + document.pop("cameras", None) + source_dir = source.parent + output.parent.mkdir(parents=True, exist_ok=True) + pinned_buffers: list[dict[str, str]] = [] + for buffer_index, buffer in enumerate(document.get("buffers", [])): + uri = buffer.get("uri") + if isinstance(uri, str): + if args.revision and not uri.startswith("data:"): + try: + payload = subprocess.check_output( + ["git", "-C", str(source_dir), "show", f"{args.revision}:{uri}"] + ) + except subprocess.CalledProcessError as exc: + raise SystemExit( + f"cannot read pinned Bistro buffer {args.revision}:{uri}" + ) from exc + pinned_name = f"pinned-buffer-{buffer_index}{Path(uri).suffix}" + pinned_path = output.parent / pinned_name + pinned_path.write_bytes(payload) + buffer["uri"] = pinned_name + pinned_buffers.append( + { + "source": f"{args.revision}:{uri}", + "sha256": hashlib.sha256(payload).hexdigest(), + } + ) + else: + buffer["uri"] = rewrite_uri(uri, source_dir, output.parent) + for image in document.get("images", []): + uri = image.get("uri") + if isinstance(uri, str): + image["uri"] = rewrite_uri(uri, source_dir, output.parent) + document.setdefault("asset", {})["generator"] = ( + "Bloom tools/quality/prepare_bistro.py unique-mesh qualification subset" + ) + output.write_text( + json.dumps(document, separators=(",", ":"), ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + metadata_path = Path(args.metadata).resolve() if args.metadata else output.with_suffix(".json") + metadata = { + "schema": "bloom-quality-derived-asset-v1", + "source": str(source), + "source_sha256": sha256(source), + "source_revision": args.revision, + "pinned_buffers": pinned_buffers, + "output": str(output), + "output_sha256": sha256(output), + "source_mesh_nodes": sum( + 1 for node in json.loads(source.read_text(encoding="utf-8"))["nodes"] + if "mesh" in node + ), + "derived_mesh_nodes": mesh_count, + "derived_materials": len(document["materials"]), + "derived_textures": len(document["textures"]), + "derived_images": len(document["images"]), + "policy": ( + "largest projected authored meshes in source camera, first world " + "transform per unique mesh" + ), + } + metadata_path.write_text( + json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print( + f"prepared Bistro subset: {metadata['source_mesh_nodes']} -> " + f"{metadata['derived_mesh_nodes']} mesh nodes" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/quality/run.py b/tools/quality/run.py new file mode 100644 index 00000000..e39a4ba6 --- /dev/null +++ b/tools/quality/run.py @@ -0,0 +1,2253 @@ +#!/usr/bin/env python3 +"""Deterministic Bloom visual/performance qualification runner. + +The runner deliberately owns orchestration only. Scene executables own pixels +and per-pass telemetry; bloom-diff owns image metrics. That keeps qualification +code out of production frame paths and makes every artifact reproducible from +the versioned manifest. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import hashlib +import html +import json +import os +import platform +import shlex +import shutil +import statistics +import subprocess +import sys +import time +import tomllib +from pathlib import Path +from typing import Any, Iterable, Mapping, Sequence + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parent.parent +DEFAULT_MANIFEST = SCRIPT_DIR / "scenes.toml" +RESULT_SCHEMA = "bloom-quality-result-v1" +CAPABILITY_SNAPSHOT_SCHEMA = "bloom-renderer-capability-snapshot-v1" +REVIEW_SCHEMA = "bloom-quality-baseline-review-v1" +INSTALL_SCHEMA = "bloom-quality-baseline-install-v1" +REPRO_SCHEMA = "bloom-quality-reproducibility-v1" +ALLOWED_SUITES = {"quick", "full"} +ALLOWED_STATUSES = {"pass", "fail", "skip", "error"} +KNOWN_INTERMEDIATE_NAMES = { + "hdr-scene", + "scene-depth", + "shadow-cascade-0", + "shadow-cascade-1", + "shadow-cascade-2", + "ssgi", + "ssgi-rejection-reason", + "ssgi-temporal-confidence", + "ssr", + "ssr-raw", + "ssr-rejection-reason", + "ssr-temporal-confidence", + "taa-motion", + "taa-rejection-reason", + "taa-reprojected-uv", + "taa-temporal-confidence", + "pt-motion", + "pt-rejection-reason", + "pt-reprojected-uv", + "pt-temporal-confidence", +} +STABLE_CASE_METADATA_KEYS = ( + "id", + "description", + "quality_tier", + "resolution", + "render_scale", + "seed", + "fixed_timestep", + "warmup_frames", + "measured_frames", + "camera", + "settings", + "assets", + "feature_decision", + "reference_target", +) +STABLE_TELEMETRY_METADATA_KEYS = ( + "schema", + "fixed_timestep", + "warmup_frames", + "measured_frames", + "quality_preset", + "render_scale", + "present_mode", + "present_mode_code", + "uncapped", + "gpu_timestamps_available", + "warmup_excluded", + "shader_compilation_excluded", + "adapter", + "renderer_paths", +) +RENDERER_CAPABILITY_TIERS = {"baseline", "modern", "high-end"} +RENDERER_CAPABILITY_PATH_KEYS = { + "materials", + "geometry", + "shadows", + "gi", + "reflections", + "anti_aliasing", + "textures", + "path_tracing", +} +RENDERER_CAPABILITY_FEATURE_KEYS = { + "texture_binding_array", + "non_uniform_indexing", + "indirect_first_instance", + "ray_query", +} +RENDERER_CAPABILITY_LIMIT_KEYS = { + "max_binding_array_elements_per_shader_stage", + "max_binding_array_sampler_elements_per_shader_stage", + "max_texture_array_layers", + "max_sampled_textures_per_shader_stage", + "max_samplers_per_shader_stage", + "max_bind_groups", + "max_color_attachments", +} +DEVICE_NEGOTIATION_LIMIT_KEYS = { + "max_bind_groups", + "max_color_attachments", + "max_sampled_textures_per_shader_stage", + "max_samplers_per_shader_stage", + "max_storage_buffers_per_shader_stage", + "max_uniform_buffer_binding_size", + "max_binding_array_elements_per_shader_stage", + "max_binding_array_sampler_elements_per_shader_stage", +} +STEADY_STATE_BIND_GROUP_SITES = { + "scene_compose", + "ssr_temporal", + "upscale", + "taa", + "taa_reactive", + "depth_of_field", + "motion_blur", + "subsurface_scattering", + "contrast_adaptive_sharpen", + "auto_exposure", + "final_composite", + "custom_post_pass", +} + + +class QualityError(RuntimeError): + pass + + +@dataclasses.dataclass(frozen=True) +class CommandResult: + argv: list[str] + cwd: str + returncode: int + duration_ms: float + stdout: str + stderr: str + timed_out: bool + + +def canonical_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def capability_snapshot_failures(adapter: Mapping[str, Any]) -> list[str]: + """Validate the capability evidence embedded in every native quality run.""" + failures: list[str] = [] + capability = adapter.get("renderer_capabilities") + if not isinstance(capability, dict): + return ["adapter did not include renderer_capabilities snapshot"] + + for key in ("detected", "selected"): + if capability.get(key) not in RENDERER_CAPABILITY_TIERS: + failures.append(f"renderer_capabilities.{key} is not a known tier") + for key in ("requested", "forced"): + value = capability.get(key) + if value is not None and value not in RENDERER_CAPABILITY_TIERS: + failures.append(f"renderer_capabilities.{key} is not null or a known tier") + if adapter.get("capability_tier") != capability.get("selected"): + failures.append("adapter capability_tier does not match selected renderer tier") + forced = capability.get("forced") + if forced is not None and forced != capability.get("selected"): + failures.append("forced renderer tier does not match selected renderer tier") + if capability.get("diagnostic") is not None and not isinstance( + capability.get("diagnostic"), str + ): + failures.append("renderer_capabilities.diagnostic is not null or text") + + available = capability.get("available") + if not isinstance(available, dict): + failures.append("renderer_capabilities.available is missing") + else: + features = available.get("features") + if not isinstance(features, dict): + failures.append("renderer_capabilities.available.features is missing") + else: + for key in RENDERER_CAPABILITY_FEATURE_KEYS: + if not isinstance(features.get(key), bool): + failures.append( + f"renderer_capabilities.available.features.{key} is not boolean" + ) + limits = available.get("limits") + if not isinstance(limits, dict): + failures.append("renderer_capabilities.available.limits is missing") + else: + for key in RENDERER_CAPABILITY_LIMIT_KEYS: + value = limits.get(key) + if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0: + failures.append( + f"renderer_capabilities.available.limits.{key} is invalid" + ) + + paths = capability.get("paths") + if not isinstance(paths, dict): + failures.append("renderer_capabilities.paths is missing") + else: + for key in RENDERER_CAPABILITY_PATH_KEYS: + value = paths.get(key) + if not isinstance(value, str) or not value: + failures.append(f"renderer_capabilities.paths.{key} is missing") + + negotiation = adapter.get("device_negotiation") + if not isinstance(negotiation, dict): + failures.append("adapter did not include device_negotiation snapshot") + return failures + for key in ("preferred_tier", "selected_tier"): + if negotiation.get(key) not in RENDERER_CAPABILITY_TIERS: + failures.append(f"device_negotiation.{key} is not a known tier") + if negotiation.get("selected_tier") != capability.get("selected"): + failures.append("device negotiation tier does not match selected renderer tier") + if negotiation.get("profile") not in {"native-full", "folded-mobile"}: + failures.append("device_negotiation.profile is invalid") + if not isinstance(negotiation.get("selected_request"), str) or not negotiation.get( + "selected_request" + ): + failures.append("device_negotiation.selected_request is missing") + if negotiation.get("fallback_cause") is not None and not isinstance( + negotiation.get("fallback_cause"), str + ): + failures.append("device_negotiation.fallback_cause is not null or text") + if not isinstance(negotiation.get("required_features"), str): + failures.append("device_negotiation.required_features is missing") + limits = negotiation.get("required_limits") + if not isinstance(limits, dict): + failures.append("device_negotiation.required_limits is missing") + else: + for key in DEVICE_NEGOTIATION_LIMIT_KEYS: + value = limits.get(key) + if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0: + failures.append(f"device_negotiation.required_limits.{key} is invalid") + return failures + + +def repo_path(raw: str, *, must_exist: bool = False) -> Path: + path = (REPO_ROOT / raw).resolve() + try: + path.relative_to(REPO_ROOT) + except ValueError as exc: + raise QualityError(f"path escapes repository: {raw!r}") from exc + if must_exist and not path.exists(): + raise QualityError(f"required path does not exist: {raw}") + return path + + +def git_text(*args: str) -> str: + result = subprocess.run( + ["git", *args], + cwd=REPO_ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + ) + # Preserve the leading status column from `git status --short`; stripping + # it truncates the first dirty path (` M docs/...` became `ocs/...`). + return result.stdout.rstrip() if result.returncode == 0 else "unknown" + + +def stable_environment(adapter: Mapping[str, Any] | None) -> dict[str, Any]: + dirty_paths = [ + line[3:] + for line in git_text("status", "--short", "--untracked-files=all").splitlines() + if len(line) >= 4 + ] + return { + "git_commit": git_text("rev-parse", "HEAD"), + "git_dirty": bool(dirty_paths), + "git_dirty_paths": sorted(dirty_paths), + "build_profile": "release", + "os": platform.system().lower(), + "os_release": platform.release(), + "architecture": platform.machine().lower(), + "python": platform.python_version(), + "adapter": dict(adapter or {"availability": "not-reported"}), + } + + +def capability_snapshot_artifact( + environment: Mapping[str, Any], machine_class: str | None +) -> dict[str, Any]: + adapter = environment.get("adapter") + return { + "schema": CAPABILITY_SNAPSHOT_SCHEMA, + "git_commit": environment.get("git_commit"), + "machine_class": machine_class, + "adapter": dict(adapter) if isinstance(adapter, Mapping) else { + "availability": "not-reported" + }, + } + + +def write_capability_snapshot_artifact( + out_dir: Path, environment: Mapping[str, Any], machine_class: str | None +) -> str: + name = "capabilities.json" + (out_dir / name).write_text( + json.dumps( + capability_snapshot_artifact(environment, machine_class), + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + return name + + +def load_adapter(path: Path | None) -> dict[str, Any] | None: + if path is None: + env_path = os.environ.get("BLOOM_QUALITY_ADAPTER_JSON", "") + path = Path(env_path) if env_path else None + if path is None: + return None + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise QualityError(f"cannot read adapter metadata {path}: {exc}") from exc + if not isinstance(value, dict): + raise QualityError(f"adapter metadata must be a JSON object: {path}") + return value + + +def load_manifest(path: Path) -> tuple[dict[str, Any], str]: + try: + raw = path.read_bytes() + manifest = tomllib.loads(raw.decode("utf-8")) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: + raise QualityError(f"cannot load manifest {path}: {exc}") from exc + if manifest.get("schema_version") != 1: + raise QualityError("quality manifest schema_version must be 1") + validate_manifest(manifest) + return manifest, hashlib.sha256(raw).hexdigest() + + +def list_of_strings(value: Any, where: str, *, allow_empty: bool = False) -> list[str]: + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise QualityError(f"{where} must be an array of strings") + if not allow_empty and not value: + raise QualityError(f"{where} must not be empty") + return value + + +def validate_manifest(manifest: Mapping[str, Any]) -> None: + workflows = manifest.get("workflow") + if not isinstance(workflows, dict): + raise QualityError("manifest requires [workflow]") + cases = manifest.get("case") + if not isinstance(cases, list) or not cases: + raise QualityError("manifest requires at least one [[case]]") + ids: set[str] = set() + case_by_id: dict[str, Mapping[str, Any]] = {} + for index, case in enumerate(cases): + if not isinstance(case, dict): + raise QualityError(f"case {index} must be a table") + case_id = case.get("id") + if not isinstance(case_id, str) or not case_id: + raise QualityError(f"case {index} requires a non-empty id") + if case_id in ids: + raise QualityError(f"duplicate case id: {case_id}") + ids.add(case_id) + case_by_id[case_id] = case + resolution = case.get("resolution") + if ( + not isinstance(resolution, list) + or len(resolution) != 2 + or not all(isinstance(v, int) and v > 0 for v in resolution) + ): + raise QualityError(f"{case_id}.resolution must be two positive integers") + for scalar in ("warmup_frames", "measured_frames", "seed"): + if not isinstance(case.get(scalar), int) or case[scalar] < 0: + raise QualityError(f"{case_id}.{scalar} must be a non-negative integer") + if not isinstance(case.get("fixed_timestep"), (int, float)) or case["fixed_timestep"] <= 0: + raise QualityError(f"{case_id}.fixed_timestep must be positive") + if ( + not isinstance(case.get("render_scale"), (int, float)) + or case["render_scale"] <= 0 + ): + raise QualityError(f"{case_id}.render_scale must be positive") + if not isinstance(case.get("quality_tier"), str) or not case["quality_tier"]: + raise QualityError(f"{case_id}.quality_tier must be a non-empty string") + required_intermediates = list_of_strings( + case.get("required_intermediates"), + f"{case_id}.required_intermediates", + ) + unknown_intermediates = set(required_intermediates) - KNOWN_INTERMEDIATE_NAMES + if unknown_intermediates: + raise QualityError( + f"{case_id}.required_intermediates contains unknown names: " + f"{sorted(unknown_intermediates)}" + ) + capture = case.get("capture") + if not isinstance(capture, dict): + raise QualityError(f"{case_id} requires [case.capture]") + list_of_strings(capture.get("command"), f"{case_id}.capture.command") + if "prepare" in capture: + list_of_strings(capture["prepare"], f"{case_id}.capture.prepare") + if "build" in capture: + list_of_strings(capture["build"], f"{case_id}.capture.build") + reference = case.get("reference") + if not isinstance(reference, dict) or not isinstance(reference.get("path"), str): + raise QualityError(f"{case_id} requires case.reference.path") + for key in ("kind", "generation"): + if not isinstance(reference.get(key), str) or not reference[key]: + raise QualityError(f"{case_id}.reference.{key} must be non-empty") + thresholds = case.get("thresholds") + if not isinstance(thresholds, dict): + raise QualityError(f"{case_id} requires [case.thresholds]") + if not any( + key in thresholds + for key in ("min_ssim", "max_rmse", "max_oklab_delta", "max_edge_delta") + ): + raise QualityError(f"{case_id} must configure at least one visual threshold") + camera = case.get("camera") + if not isinstance(camera, dict): + raise QualityError(f"{case_id} requires a versioned camera table") + for vector in ("position", "target", "up"): + values = camera.get(vector) + if ( + not isinstance(values, list) + or len(values) != 3 + or not all(isinstance(v, (int, float)) for v in values) + ): + raise QualityError(f"{case_id}.camera.{vector} must have three numbers") + if not isinstance(camera.get("fov_y_degrees"), (int, float)): + raise QualityError(f"{case_id}.camera.fov_y_degrees must be numeric") + if not isinstance(camera.get("animation"), str) or not camera["animation"]: + raise QualityError(f"{case_id}.camera.animation must be non-empty") + if not isinstance(case.get("settings"), dict): + raise QualityError(f"{case_id} requires [case.settings]") + budgets = case.get("budgets") + if not isinstance(budgets, dict): + raise QualityError(f"{case_id} requires [case.budgets]") + for key in ( + "machine_class", + "max_cpu_frame_p95_ms", + "max_gpu_frame_p95_ms", + "max_vram_peak_mb", + ): + if key not in budgets: + raise QualityError(f"{case_id}.budgets.{key} is required") + assets = case.get("assets") + if not isinstance(assets, list) or not assets: + raise QualityError(f"{case_id}.assets must be a non-empty array") + for asset_index, asset in enumerate(assets): + if not isinstance(asset, dict): + raise QualityError(f"{case_id}.assets[{asset_index}] must be a table") + for key in ("path", "source", "license"): + if not isinstance(asset.get(key), str) or not asset[key]: + raise QualityError( + f"{case_id}.assets[{asset_index}].{key} must be non-empty" + ) + if not isinstance(asset.get("sha256"), str) and not isinstance( + asset.get("revision"), str + ): + raise QualityError( + f"{case_id}.assets[{asset_index}] requires sha256 or revision" + ) + machine_classes = manifest.get("machine_class") + if not isinstance(machine_classes, list) or not machine_classes: + raise QualityError("manifest requires at least one [[machine_class]]") + machine_ids: set[str] = set() + for item in machine_classes: + if not isinstance(item, dict) or not isinstance(item.get("id"), str): + raise QualityError("every machine class requires an id") + if item["id"] in machine_ids: + raise QualityError(f"duplicate machine class id: {item['id']}") + machine_ids.add(item["id"]) + for key in ("description", "os", "backend", "gpu"): + if not isinstance(item.get(key), str) or not item[key]: + raise QualityError(f"machine class {item['id']}.{key} is required") + list_of_strings(item.get("hard_metrics"), f"{item['id']}.hard_metrics") + for case in cases: + machine_id = case["budgets"]["machine_class"] + if machine_id not in machine_ids: + raise QualityError( + f"{case['id']} references unknown machine class {machine_id!r}" + ) + for suite in ALLOWED_SUITES: + selected = list_of_strings(workflows.get(suite), f"workflow.{suite}") + missing = sorted(set(selected) - ids) + if missing: + raise QualityError(f"workflow.{suite} references missing cases: {missing}") + if not set(workflows["quick"]).issubset(set(workflows["full"])): + raise QualityError("workflow.quick must be a subset of workflow.full") + reproducibility = manifest.get("reproducibility") + if not isinstance(reproducibility, dict): + raise QualityError("manifest requires [reproducibility] noise bounds") + for key in ( + "min_ssim", + "max_rmse", + "max_oklab_delta", + "max_edge_delta", + "max_cpu_mean_relative_delta", + "max_cpu_mean_absolute_delta_ms", + "max_gpu_mean_relative_delta", + "max_gpu_mean_absolute_delta_ms", + "max_cpu_p95_relative_delta", + "max_cpu_p95_absolute_delta_ms", + "max_gpu_p95_relative_delta", + "max_gpu_p95_absolute_delta_ms", + ): + value = reproducibility.get(key) + if not isinstance(value, (int, float)) or value < 0: + raise QualityError(f"reproducibility.{key} must be a non-negative number") + fault_controls = manifest.get("negative_control", []) + if not isinstance(fault_controls, list): + raise QualityError("negative_control must be an array of tables") + expected_faults = { + "brdf-energy", + "shadow-placement", + "gi-leakage", + "motion-history", + "texture-orientation", + } + configured_faults = {item.get("fault") for item in fault_controls if isinstance(item, dict)} + missing_faults = expected_faults - configured_faults + if missing_faults: + raise QualityError(f"negative controls missing: {sorted(missing_faults)}") + for item in fault_controls: + if not isinstance(item, dict) or item.get("case") not in case_by_id: + raise QualityError(f"negative control references an unknown case: {item!r}") + + +def verify_assets(case: Mapping[str, Any]) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for asset in case.get("assets", []): + if not isinstance(asset, dict): + raise QualityError(f"{case['id']}: asset entry must be a table") + raw_path = asset.get("path") + expected = asset.get("sha256") + revision = asset.get("revision") + if not isinstance(raw_path, str) or ( + not isinstance(expected, str) and not isinstance(revision, str) + ): + raise QualityError( + f"{case['id']}: asset requires path plus sha256 or revision" + ) + path = repo_path(raw_path) + if not path.exists(): + source = asset.get("source") + records.append( + { + "path": raw_path, + "expected_sha256": expected, + "expected_revision": revision, + "status": "missing", + "source": source, + "license": asset.get("license", "unspecified"), + } + ) + continue + if isinstance(revision, str): + if not path.is_dir(): + status = "not-a-directory" + actual_revision = None + dirty = None + else: + probe = subprocess.run( + ["git", "-C", str(path), "rev-parse", "HEAD"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + ) + actual_revision = probe.stdout.strip() if probe.returncode == 0 else None + dirty_probe = subprocess.run( + ["git", "-C", str(path), "status", "--porcelain"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + ) + dirty = bool(dirty_probe.stdout.strip()) if dirty_probe.returncode == 0 else None + status = ( + "pass" + if actual_revision == revision + and (dirty is False or bool(asset.get("allow_dirty", False))) + else "revision-mismatch-or-dirty" + ) + records.append( + { + "path": raw_path, + "expected_revision": revision, + "actual_revision": actual_revision, + "dirty": dirty, + "status": status, + "source": asset.get("source"), + "license": asset.get("license", "unspecified"), + } + ) + continue + actual = sha256_file(path) + records.append( + { + "path": raw_path, + "expected_sha256": expected, + "actual_sha256": actual, + "status": "pass" if actual == expected else "hash-mismatch", + "source": asset.get("source"), + "license": asset.get("license", "unspecified"), + } + ) + return records + + +def placeholders(case: Mapping[str, Any], case_dir: Path) -> dict[str, str]: + width, height = case["resolution"] + camera = case["camera"] + pos = camera["position"] + target = camera["target"] + return { + "repo": str(REPO_ROOT), + "case_dir": str(case_dir), + "candidate": str(case_dir / "final.png"), + "telemetry": str(case_dir / "telemetry.json"), + "intermediates": str(case_dir / "intermediates"), + "width": str(width), + "height": str(height), + "warmup_frames": str(case["warmup_frames"]), + "measured_frames": str(case["measured_frames"]), + "seed": str(case["seed"]), + "fixed_timestep": format(float(case["fixed_timestep"]), ".12g"), + "render_scale": format(float(case.get("render_scale", 1.0)), ".6g"), + "quality_tier": str(case.get("quality_tier", "high")), + "camera_px": str(pos[0]), + "camera_py": str(pos[1]), + "camera_pz": str(pos[2]), + "camera_tx": str(target[0]), + "camera_ty": str(target[1]), + "camera_tz": str(target[2]), + "camera_fov": str(camera["fov_y_degrees"]), + } + + +def expand_argv(argv: Sequence[str], values: Mapping[str, str], where: str) -> list[str]: + try: + return [part.format_map(values) for part in argv] + except KeyError as exc: + raise QualityError(f"{where} uses unknown placeholder {exc}") from exc + + +def run_command( + argv: Sequence[str], + cwd: Path, + timeout_seconds: float, + env: Mapping[str, str] | None = None, +) -> CommandResult: + started = time.perf_counter() + timed_out = False + try: + proc = subprocess.run( + list(argv), + cwd=cwd, + env=dict(os.environ, **(dict(env or {}))), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout_seconds, + check=False, + ) + returncode = proc.returncode + stdout = proc.stdout + stderr = proc.stderr + except subprocess.TimeoutExpired as exc: + timed_out = True + returncode = 124 + stdout = exc.stdout if isinstance(exc.stdout, str) else "" + stderr = exc.stderr if isinstance(exc.stderr, str) else "" + stderr += f"\nquality runner: timed out after {timeout_seconds:.1f}s\n" + return CommandResult( + argv=list(argv), + cwd=str(cwd), + returncode=returncode, + duration_ms=(time.perf_counter() - started) * 1000.0, + stdout=stdout, + stderr=stderr, + timed_out=timed_out, + ) + + +def command_record(result: CommandResult) -> dict[str, Any]: + return dataclasses.asdict(result) + + +def read_json(path: Path, what: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise QualityError(f"cannot read {what} {path}: {exc}") from exc + if not isinstance(value, dict): + raise QualityError(f"{what} must be a JSON object: {path}") + return value + + +def png_dimensions(path: Path) -> tuple[int, int]: + try: + header = path.read_bytes()[:24] + except OSError as exc: + raise QualityError(f"cannot read PNG {path}: {exc}") from exc + if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR": + raise QualityError(f"artifact is not a valid PNG: {path}") + return ( + int.from_bytes(header[16:20], "big"), + int.from_bytes(header[20:24], "big"), + ) + + +def steady_state_renderer_failures(renderer_paths: Mapping[str, Any]) -> list[str]: + failures: list[str] = [] + uploads = renderer_paths.get("steady_state_uploads") + lighting = uploads.get("lighting") if isinstance(uploads, dict) else None + if not isinstance(lighting, dict): + failures.append("renderer paths did not report steady-state lighting uploads") + else: + write_count = lighting.get("write_count") + byte_count = lighting.get("byte_count") + full_bytes = lighting.get("full_buffer_bytes") + if ( + not isinstance(write_count, int) + or isinstance(write_count, bool) + or not 0 <= write_count <= 3 + ): + failures.append("steady-state lighting write_count is not an integer in [0, 3]") + if ( + not isinstance(full_bytes, int) + or isinstance(full_bytes, bool) + or full_bytes <= 0 + ): + failures.append("steady-state lighting full_buffer_bytes is not positive") + if ( + not isinstance(byte_count, int) + or isinstance(byte_count, bool) + or byte_count < 0 + or ( + isinstance(full_bytes, int) + and not isinstance(full_bytes, bool) + and byte_count > full_bytes + ) + ): + failures.append("steady-state lighting byte_count is invalid") + + resources = renderer_paths.get("steady_state_resources") + bind_groups = ( + resources.get("bind_group_creations") if isinstance(resources, dict) else None + ) + if not isinstance(bind_groups, dict): + failures.append("renderer paths did not report steady-state bind-group creations") + return failures + total = bind_groups.get("total") + sites = bind_groups.get("sites") + if not isinstance(total, int) or isinstance(total, bool) or total < 0: + failures.append("steady-state bind-group total is not a non-negative integer") + if not isinstance(sites, dict): + failures.append("steady-state bind-group sites are unavailable") + return failures + if set(sites) != STEADY_STATE_BIND_GROUP_SITES: + failures.append("steady-state bind-group site set is incomplete or unknown") + return failures + counts = list(sites.values()) + if any( + not isinstance(count, int) or isinstance(count, bool) or count < 0 + for count in counts + ): + failures.append("steady-state bind-group site counts must be non-negative integers") + elif isinstance(total, int) and not isinstance(total, bool) and sum(counts) != total: + failures.append("steady-state bind-group total does not match named sites") + elif total != 0: + failures.append("steady-state bind-group creation remained after warm-up") + + graph_compiles = resources.get("graph_compiles") + if ( + not isinstance(graph_compiles, int) + or isinstance(graph_compiles, bool) + or graph_compiles < 0 + ): + failures.append("steady-state graph compile count is not a non-negative integer") + elif graph_compiles != 0: + failures.append("render graph recompiled after warm-up") + + pipelines = resources.get("pipeline_creations") + pipeline_first_use = ( + pipelines.get("first_use") if isinstance(pipelines, dict) else None + ) + if ( + not isinstance(pipeline_first_use, int) + or isinstance(pipeline_first_use, bool) + or pipeline_first_use < 0 + ): + failures.append("steady-state pipeline first-use count is invalid") + elif pipeline_first_use != 0: + failures.append("pipeline creation remained after warm-up") + + encoders = resources.get("command_encoder_creations") + if not isinstance(encoders, dict): + failures.append("renderer paths did not report command-encoder creations") + else: + encoder_total = encoders.get("total") + encoder_sites = encoders.get("sites") + if encoder_sites != {"frame_submission": encoder_total}: + failures.append("command-encoder total does not match the frame-submission site") + if encoder_total != 1: + failures.append("steady frame must create exactly one submission encoder") + + physical = resources.get("transient_physical_creations") + if not isinstance(physical, dict): + failures.append("renderer paths did not report transient physical creations") + else: + for kind in ("textures", "buffers"): + count = physical.get(kind) + if not isinstance(count, int) or isinstance(count, bool) or count < 0: + failures.append( + f"steady-state transient physical {kind} count is invalid" + ) + elif count != 0: + failures.append( + f"transient physical {kind} were created after warm-up" + ) + return failures + + +def telemetry_contract_failures( + case: Mapping[str, Any], telemetry: Mapping[str, Any] | None +) -> list[str]: + if telemetry is None: + return ["capture did not produce telemetry.json"] + failures: list[str] = [] + if telemetry.get("schema") != "bloom-quality-telemetry-v1": + failures.append(f"unsupported telemetry schema {telemetry.get('schema')!r}") + for key in ("warmup_frames", "measured_frames"): + if telemetry.get(key) != case.get(key): + failures.append( + f"telemetry {key} {telemetry.get(key)!r} != requested {case.get(key)!r}" + ) + observed_step = telemetry.get("fixed_timestep") + if not isinstance(observed_step, (int, float)) or abs( + float(observed_step) - float(case["fixed_timestep"]) + ) > 1e-6: + failures.append( + f"telemetry fixed_timestep {observed_step!r} != requested " + f"{case['fixed_timestep']!r}" + ) + expected_preset = case.get("settings", {}).get("quality_preset") + if expected_preset is not None and telemetry.get("quality_preset") != expected_preset: + failures.append( + f"telemetry quality_preset {telemetry.get('quality_preset')!r} " + f"!= requested {expected_preset!r}" + ) + observed_scale = telemetry.get("render_scale") + if not isinstance(observed_scale, (int, float)) or abs( + float(observed_scale) - float(case["render_scale"]) + ) > 1e-6: + failures.append( + f"telemetry render_scale {observed_scale!r} != requested " + f"{case['render_scale']!r}" + ) + if not telemetry.get("uncapped", False): + failures.append("telemetry did not prove uncapped execution") + if not telemetry.get("warmup_excluded", False): + failures.append("telemetry did not prove warm-up exclusion") + if not telemetry.get("shader_compilation_excluded", False): + failures.append("telemetry did not prove shader-compilation exclusion") + adapter = telemetry.get("adapter") + if not isinstance(adapter, dict) or adapter.get("availability") != "reported": + failures.append("telemetry did not report the native adapter") + else: + failures.extend(capability_snapshot_failures(adapter)) + renderer_paths = telemetry.get("renderer_paths") + if not isinstance(renderer_paths, dict): + failures.append("telemetry did not report active renderer paths") + else: + failures.extend(steady_state_renderer_failures(renderer_paths)) + for key in ("cpu_frame_mean_ms", "cpu_frame_p95_ms", "measurement_wall_ms"): + value = telemetry.get(key) + if not isinstance(value, (int, float)) or value < 0: + failures.append(f"telemetry {key} is unavailable") + if telemetry.get("gpu_timestamps_available", False): + for key in ("gpu_frame_mean_ms", "gpu_frame_p95_ms"): + value = telemetry.get(key) + if not isinstance(value, (int, float)) or value < 0: + failures.append(f"telemetry {key} is unavailable despite GPU timestamps") + passes = telemetry.get("passes") + if not isinstance(passes, list) or not passes: + failures.append("telemetry did not report per-pass timings") + return failures + + +def selected_machine_class( + manifest: Mapping[str, Any], machine_id: str | None +) -> dict[str, Any] | None: + if machine_id is None: + return None + for item in manifest.get("machine_class", []): + if isinstance(item, dict) and item.get("id") == machine_id: + return dict(item) + raise QualityError(f"unknown machine class: {machine_id}") + + +def effective_features(adapter: Mapping[str, Any] | None) -> set[str]: + from_env = { + feature.strip() + for feature in os.environ.get("BLOOM_QUALITY_FEATURES", "").split(",") + if feature.strip() + } + if adapter: + values = adapter.get("features", []) + if isinstance(values, list): + from_env.update(str(value) for value in values) + return from_env + + +def feature_decision(case: Mapping[str, Any], features: set[str]) -> dict[str, Any]: + required = set(case.get("required_features", [])) + forbidden = set(case.get("forbidden_features", [])) + missing = sorted(required - features) + present_forbidden = sorted(forbidden & features) + if not missing and not present_forbidden: + return { + "status": "native", + "required": sorted(required), + "forbidden": sorted(forbidden), + "missing": [], + "present_forbidden": [], + "fallback": None, + } + fallback = case.get("fallback") + if isinstance(fallback, dict): + return { + "status": "fallback", + "required": sorted(required), + "forbidden": sorted(forbidden), + "missing": missing, + "present_forbidden": present_forbidden, + "fallback": dict(fallback), + } + return { + "status": "unsupported", + "required": sorted(required), + "forbidden": sorted(forbidden), + "missing": missing, + "present_forbidden": present_forbidden, + "fallback": None, + } + + +def pending_feature_decision(case: Mapping[str, Any]) -> dict[str, Any]: + return { + "status": "runtime-probe", + "required": sorted(set(case.get("required_features", []))), + "forbidden": sorted(set(case.get("forbidden_features", []))), + "missing": [], + "present_forbidden": [], + "fallback": None, + } + + +def performance_failures( + case: Mapping[str, Any], + telemetry: Mapping[str, Any] | None, + machine: Mapping[str, Any] | None, +) -> list[str]: + if machine is None or not machine.get("hard_gate", False): + return [] + budgets = case.get("budgets", {}) + required_class = budgets.get("machine_class") + if required_class and required_class != machine.get("id"): + return [] + if telemetry is None: + return ["hard-gated machine produced no telemetry.json"] + failures: list[str] = [] + adapter = telemetry.get("adapter") + if not isinstance(adapter, dict) or adapter.get("availability") != "reported": + failures.append("hard-gated machine did not report native adapter metadata") + else: + expected_backend = str(machine.get("backend", "")).lower() + actual_backend = str(adapter.get("backend", "")).lower() + if expected_backend and actual_backend != expected_backend: + failures.append( + f"adapter backend {actual_backend!r} != machine class {expected_backend!r}" + ) + expected_gpu = str(machine.get("gpu", "")).lower() + actual_gpu = str(adapter.get("name", "")).lower() + if expected_gpu and expected_gpu not in actual_gpu: + failures.append( + f"adapter {adapter.get('name')!r} != machine class GPU {machine.get('gpu')!r}" + ) + hard_metrics = set(machine.get("hard_metrics", ["cpu", "gpu", "vram"])) + mappings = ( + ("cpu", "cpu_frame_p95_ms", "max_cpu_frame_p95_ms"), + ("gpu", "gpu_frame_p95_ms", "max_gpu_frame_p95_ms"), + ("vram", "vram_peak_mb", "max_vram_peak_mb"), + ) + for metric, measured_key, budget_key in mappings: + if metric not in hard_metrics: + continue + limit = budgets.get(budget_key) + if limit is None: + continue + measured = telemetry.get(measured_key) + if measured is None: + failures.append(f"{measured_key} unavailable for hard budget {budget_key}") + elif float(measured) > float(limit): + failures.append(f"{measured_key} {float(measured):.4f} > {float(limit):.4f}") + if not telemetry.get("uncapped", False): + failures.append("performance capture did not prove an uncapped/headless present mode") + if not telemetry.get("warmup_excluded", False): + failures.append("telemetry did not prove warm-up exclusion") + if not telemetry.get("shader_compilation_excluded", False): + failures.append("telemetry did not prove shader-compilation exclusion") + if not telemetry.get("gpu_timestamps_available", False): + failures.append("hard-gated machine did not report GPU timestamps") + return failures + + +def diff_command( + diff_bin: Path, + case: Mapping[str, Any], + reference: Path, + candidate: Path, + case_dir: Path, + report_only: bool, + seeded_fault: str | None = None, +) -> list[str]: + thresholds = case["thresholds"] + argv = [ + str(diff_bin), + "--reference", + str(reference), + "--candidate", + str(candidate), + "--heatmap", + str(case_dir / "heatmap.png"), + "--composite", + str(case_dir / "comparison.png"), + "--metrics-json", + str(case_dir / "metrics.json"), + "--tolerance", + str(thresholds.get("pixel_tolerance", 0.02)), + ] + for key, flag in ( + ("min_ssim", "--min-ssim"), + ("max_rmse", "--max-rmse"), + ("max_oklab_delta", "--max-oklab-delta"), + ("max_edge_delta", "--max-edge-delta"), + ): + if key in thresholds: + argv.extend([flag, str(thresholds[key])]) + mask = thresholds.get("mask") + if mask: + argv.extend(["--mask", str(repo_path(str(mask), must_exist=True))]) + if seeded_fault: + argv.extend( + [ + "--seed-fault", + seeded_fault, + "--fault-output", + str(case_dir / f"fault-{seeded_fault}.png"), + ] + ) + if report_only: + argv.append("--report-only") + return argv + + +def build_diff_tool(timeout_seconds: float) -> tuple[Path, CommandResult | None]: + binary_name = "bloom-diff.exe" if os.name == "nt" else "bloom-diff" + binary = REPO_ROOT / "tools/bloom-diff/target/release" / binary_name + manifest = REPO_ROOT / "tools/bloom-diff/Cargo.toml" + result = run_command( + ["cargo", "build", "--release", "--manifest-path", str(manifest)], + REPO_ROOT, + timeout_seconds, + ) + if result.returncode != 0 or not binary.exists(): + raise QualityError( + "failed to build bloom-diff:\n" + + result.stdout[-4000:] + + "\n" + + result.stderr[-4000:] + ) + return binary, result + + +def run_case( + case: Mapping[str, Any], + out_dir: Path, + diff_bin: Path, + features: set[str], + features_known: bool, + machine: Mapping[str, Any] | None, + report_only: bool, + timeout_seconds: float, + built_commands: set[tuple[str, ...]], +) -> dict[str, Any]: + case_id = str(case["id"]) + case_dir = out_dir / "cases" / case_id + case_dir.mkdir(parents=True, exist_ok=True) + values = placeholders(case, case_dir) + assets = verify_assets(case) + decision = ( + feature_decision(case, features) + if features_known + else pending_feature_decision(case) + ) + record: dict[str, Any] = { + "id": case_id, + "description": case.get("description", ""), + "quality_tier": case.get("quality_tier", "high"), + "resolution": case["resolution"], + "render_scale": case.get("render_scale", 1.0), + "seed": case["seed"], + "fixed_timestep": case["fixed_timestep"], + "warmup_frames": case["warmup_frames"], + "measured_frames": case["measured_frames"], + "camera": case["camera"], + "settings": case.get("settings", {}), + "assets": assets, + "feature_decision": decision, + "commands": [], + "artifacts": {}, + "reference_target": str(case["reference"]["path"]), + "status": "error", + "failures": [], + } + asset_failures = [ + f"asset {item['path']}: {item['status']}" + for item in assets + if item["status"] != "pass" + ] + if asset_failures: + record["failures"].extend(asset_failures) + record["status"] = "fail" + return record + if decision["status"] == "unsupported": + record["status"] = "skip" + record["failures"].append( + f"unsupported features: missing={decision['missing']} " + f"forbidden={decision['present_forbidden']}" + ) + return record + capture = case["capture"] + cwd = repo_path(capture.get("working_dir", "."), must_exist=True) + prepare_argv_raw = capture.get("prepare") + if prepare_argv_raw: + prepare_argv = expand_argv( + prepare_argv_raw, values, f"{case_id}.capture.prepare" + ) + prepare_result = run_command(prepare_argv, cwd, timeout_seconds) + record["commands"].append( + {"kind": "prepare", **command_record(prepare_result)} + ) + if prepare_result.returncode != 0: + record["status"] = "error" + record["failures"].append( + f"asset preparation failed with exit {prepare_result.returncode}" + ) + return record + build_argv_raw = capture.get("build") + if build_argv_raw: + build_argv = expand_argv(build_argv_raw, values, f"{case_id}.capture.build") + build_key = tuple([str(cwd), *build_argv]) + if build_key not in built_commands: + build_result = run_command(build_argv, cwd, timeout_seconds) + record["commands"].append({"kind": "build", **command_record(build_result)}) + if build_result.returncode != 0: + record["status"] = "error" + record["failures"].append(f"build failed with exit {build_result.returncode}") + return record + built_commands.add(build_key) + candidate = Path(values["candidate"]) + telemetry_path = Path(values["telemetry"]) + intermediates = Path(values["intermediates"]) + intermediates.mkdir(parents=True, exist_ok=True) + capture_argv = expand_argv(capture["command"], values, f"{case_id}.capture.command") + env = { + "BLOOM_HEADLESS": "1", + "BLOOM_HEADLESS_PIXEL_EXACT": "1", + "BLOOM_NO_FULLSCREEN": "1", + "BLOOM_QUALITY": "1", + "BLOOM_QUALITY_CASE": case_id, + "BLOOM_QUALITY_SEED": str(case["seed"]), + "BLOOM_QUALITY_FIXED_TIMESTEP": str(case["fixed_timestep"]), + "BLOOM_QUALITY_WARMUP_FRAMES": str(case["warmup_frames"]), + "BLOOM_QUALITY_MEASURED_FRAMES": str(case["measured_frames"]), + "BLOOM_QUALITY_TELEMETRY": str(telemetry_path), + "BLOOM_QUALITY_INTERMEDIATES": str(intermediates), + "RUST_BACKTRACE": "1", + } + capture_result = run_command(capture_argv, cwd, timeout_seconds, env) + record["commands"].append({"kind": "capture", **command_record(capture_result)}) + if capture_result.returncode != 0: + record["status"] = "error" + record["failures"].append(f"capture failed with exit {capture_result.returncode}") + return record + if not candidate.exists(): + record["status"] = "error" + record["failures"].append(f"capture did not produce {candidate}") + return record + candidate_dimensions = png_dimensions(candidate) + if candidate_dimensions != tuple(case["resolution"]): + record["failures"].append( + f"candidate dimensions {candidate_dimensions} != requested " + f"{tuple(case['resolution'])}" + ) + telemetry = read_json(telemetry_path, "telemetry") if telemetry_path.exists() else None + if telemetry is not None and isinstance(telemetry.get("adapter"), dict): + runtime_features = effective_features(telemetry["adapter"]) + decision = feature_decision(case, runtime_features) + record["feature_decision"] = decision + if decision["status"] == "unsupported": + record["status"] = "skip" + record["failures"].append( + f"runtime adapter unsupported: missing={decision['missing']} " + f"forbidden={decision['present_forbidden']}" + ) + return record + if telemetry is not None and telemetry.get("vram_peak_mb") is None: + case_key = "".join( + character if character.isalnum() else "_" + for character in case_id.upper() + ) + external_vram = os.environ.get( + f"BLOOM_QUALITY_VRAM_PEAK_MB_{case_key}", + os.environ.get("BLOOM_QUALITY_VRAM_PEAK_MB"), + ) + if external_vram: + try: + telemetry["vram_peak_mb"] = float(external_vram) + telemetry["vram_measurement_source"] = "hardware-runner" + except ValueError: + record["failures"].append( + f"invalid external VRAM measurement {external_vram!r}" + ) + record["telemetry"] = telemetry + record["failures"].extend(telemetry_contract_failures(case, telemetry)) + record["artifacts"]["candidate"] = str(candidate.relative_to(out_dir)) + intermediate_files = sorted( + str(path.relative_to(out_dir)) for path in intermediates.glob("*.png") if path.is_file() + ) + record["artifacts"]["intermediates"] = intermediate_files + required_intermediates = set(case.get("required_intermediates", ["final"])) + produced_names = {"final"} | {Path(path).stem for path in intermediate_files} + missing_intermediates = sorted(required_intermediates - produced_names) + if missing_intermediates: + record["failures"].append(f"missing named intermediates: {missing_intermediates}") + reference = repo_path(case["reference"]["path"]) + if not reference.exists(): + record["status"] = "fail" + record["failures"].append(f"approved baseline missing: {case['reference']['path']}") + return record + diff_argv = diff_command( + diff_bin, case, reference, candidate, case_dir, report_only=False + ) + diff_result = run_command(diff_argv, REPO_ROOT, timeout_seconds) + record["commands"].append({"kind": "diff", **command_record(diff_result)}) + metrics_path = case_dir / "metrics.json" + if metrics_path.exists(): + metrics = read_json(metrics_path, "diff metrics") + record["metrics"] = metrics.get("metrics", {}) + record["visual_passed"] = bool(metrics.get("passed", False)) + record["artifacts"].update( + { + "reference": os.path.relpath(reference, out_dir), + "metrics": str(metrics_path.relative_to(out_dir)), + "heatmap": str((case_dir / "heatmap.png").relative_to(out_dir)), + "comparison": str((case_dir / "comparison.png").relative_to(out_dir)), + } + ) + else: + record["visual_passed"] = False + record["failures"].append("bloom-diff did not produce metrics.json") + if diff_result.returncode != 0: + record["failures"].append(f"visual thresholds failed (exit {diff_result.returncode})") + record["failures"].extend( + performance_failures(case, telemetry, machine) + ) + record["failures"].extend( + f"missing named intermediate {item}" for item in missing_intermediates + ) + record["status"] = "pass" if not record["failures"] else "fail" + if report_only and record["status"] == "fail": + record["report_only_failure"] = True + return record + + +def case_summary_row(case: Mapping[str, Any]) -> list[str]: + metrics = case.get("metrics", {}) + telemetry = case.get("telemetry") or {} + return [ + str(case["id"]), + str(case["status"]).upper(), + f"{float(metrics.get('ssim_luminance', 0.0)):.5f}" if metrics else "-", + f"{float(metrics.get('mean_oklab_delta', 0.0)):.5f}" if metrics else "-", + f"{float(metrics.get('mean_edge_delta', 0.0)):.5f}" if metrics else "-", + ( + f"{float(telemetry['cpu_frame_p95_ms']):.3f}" + if telemetry.get("cpu_frame_p95_ms") is not None + else "-" + ), + ( + f"{float(telemetry['gpu_frame_p95_ms']):.3f}" + if telemetry.get("gpu_frame_p95_ms") is not None + else "-" + ), + "; ".join(str(item) for item in case.get("failures", [])), + ] + + +def markdown_table(headers: Sequence[str], rows: Iterable[Sequence[str]]) -> str: + escaped_rows = [ + [cell.replace("|", "\\|").replace("\n", " ") for cell in row] for row in rows + ] + lines = [ + "| " + " | ".join(headers) + " |", + "| " + " | ".join("---" for _ in headers) + " |", + ] + lines.extend("| " + " | ".join(row) + " |" for row in escaped_rows) + return "\n".join(lines) + + +def write_summaries(result: Mapping[str, Any], out_dir: Path) -> None: + rows = [case_summary_row(case) for case in result["cases"]] + headers = [ + "Case", + "Status", + "SSIM", + "OKLab Δ", + "Edge Δ", + "CPU p95 ms", + "GPU p95 ms", + "Notes", + ] + md = ( + f"# Bloom quality qualification: {result['suite']}\n\n" + f"Overall: **{str(result['status']).upper()}** \n" + f"Commit: `{result['environment']['git_commit']}` \n" + f"Manifest: `{result['manifest_sha256']}` \n" + f"Machine class: `{result.get('machine_class') or 'report-only'}`\n\n" + + markdown_table(headers, rows) + + "\n" + ) + (out_dir / "summary.md").write_text(md, encoding="utf-8") + html_rows = "\n".join( + "" + + "".join(f"{html.escape(cell)}" for cell in row) + + "" + for row in rows + ) + html_doc = f""" + +Bloom quality qualification + +

Bloom quality qualification: {html.escape(str(result["suite"]))}

+

Overall: {html.escape(str(result["status"]).upper())}

+

Commit {html.escape(str(result["environment"]["git_commit"]))}

+{"".join(f"" for h in headers)} +{html_rows}
{html.escape(h)}
+""" + (out_dir / "summary.html").write_text(html_doc, encoding="utf-8") + + +def stable_result_metadata(result: Mapping[str, Any]) -> dict[str, Any]: + cases: list[dict[str, Any]] = [] + for case in result.get("cases", []): + if not isinstance(case, dict): + continue + stable_case = { + key: case.get(key) + for key in STABLE_CASE_METADATA_KEYS + } + telemetry = case.get("telemetry") + stable_case["telemetry"] = ( + { + key: telemetry.get(key) + for key in STABLE_TELEMETRY_METADATA_KEYS + } + if isinstance(telemetry, dict) + else None + ) + cases.append(stable_case) + return { + "schema": result.get("schema"), + "manifest_path": result.get("manifest_path"), + "manifest_sha256": result.get("manifest_sha256"), + "suite": result.get("suite"), + "machine_class": result.get("machine_class"), + "report_only": result.get("report_only"), + "environment": result.get("environment"), + "features": result.get("features"), + "cases": cases, + } + + +def result_artifact(result_path: Path, raw: Any, what: str) -> Path: + if not isinstance(raw, str) or not raw: + raise QualityError(f"{what} artifact path is missing") + source_dir = result_path.parent.resolve() + path = (source_dir / raw).resolve() + try: + path.relative_to(source_dir) + except ValueError as exc: + raise QualityError(f"{what} artifact escapes its result directory") from exc + if not path.is_file(): + raise QualityError(f"{what} artifact does not exist: {path}") + return path + + +def artifact_map( + result_path: Path, case: Mapping[str, Any] +) -> dict[str, Path]: + artifacts = case.get("artifacts") + if not isinstance(artifacts, dict): + raise QualityError(f"{case.get('id')}: result has no artifacts") + mapped = { + "final": result_artifact( + result_path, artifacts.get("candidate"), f"{case.get('id')} final" + ) + } + intermediates = artifacts.get("intermediates", []) + if not isinstance(intermediates, list): + raise QualityError(f"{case.get('id')}: intermediates must be an array") + for raw in intermediates: + path = result_artifact( + result_path, raw, f"{case.get('id')} intermediate" + ) + name = path.stem + if name in mapped: + raise QualityError(f"{case.get('id')}: duplicate artifact name {name!r}") + mapped[name] = path + return mapped + + +def reproducibility_diff_command( + diff_bin: Path, + reference: Path, + candidate: Path, + config: Mapping[str, Any], + output_dir: Path, + name: str, +) -> list[str]: + return [ + str(diff_bin), + "--reference", + str(reference), + "--candidate", + str(candidate), + "--heatmap", + str(output_dir / f"{name}-heatmap.png"), + "--composite", + str(output_dir / f"{name}-comparison.png"), + "--metrics-json", + str(output_dir / f"{name}-metrics.json"), + "--tolerance", + str(config.get("pixel_tolerance", 0.002)), + "--min-ssim", + str(config["min_ssim"]), + "--max-rmse", + str(config["max_rmse"]), + "--max-oklab-delta", + str(config["max_oklab_delta"]), + "--max-edge-delta", + str(config["max_edge_delta"]), + ] + + +def timing_delta( + first: Mapping[str, Any], + second: Mapping[str, Any], + metric: str, + absolute_limit: float, + relative_limit: float, +) -> dict[str, Any]: + first_value = first.get(metric) + second_value = second.get(metric) + if not isinstance(first_value, (int, float)) or not isinstance( + second_value, (int, float) + ): + return { + "metric": metric, + "first": first_value, + "second": second_value, + "passed": False, + "failure": "metric unavailable", + } + absolute_delta = abs(float(first_value) - float(second_value)) + denominator = max(abs(float(first_value)), abs(float(second_value)), 1e-9) + relative_delta = absolute_delta / denominator + passed = absolute_delta <= absolute_limit or relative_delta <= relative_limit + return { + "metric": metric, + "first": float(first_value), + "second": float(second_value), + "absolute_delta_ms": absolute_delta, + "relative_delta": relative_delta, + "absolute_limit_ms": absolute_limit, + "relative_limit": relative_limit, + "passed": passed, + } + + +def check_reproducibility(args: argparse.Namespace) -> int: + manifest, manifest_hash = load_manifest(Path(args.manifest).resolve()) + config = manifest["reproducibility"] + first_path = Path(args.first).resolve() + second_path = Path(args.second).resolve() + first = read_json(first_path, "first quality result") + second = read_json(second_path, "second quality result") + for label, result in (("first", first), ("second", second)): + if result.get("schema") != RESULT_SCHEMA: + raise QualityError( + f"{label} result has unsupported schema {result.get('schema')!r}" + ) + if result.get("manifest_sha256") != manifest_hash: + raise QualityError( + f"{label} result was not produced by the selected manifest" + ) + out_dir = Path(args.out).resolve() + if out_dir.exists(): + try: + out_dir.relative_to(REPO_ROOT) + except ValueError as exc: + raise QualityError( + "refusing to replace a reproducibility output outside the repository" + ) from exc + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True) + failures: list[str] = [] + metadata_equal = stable_result_metadata(first) == stable_result_metadata(second) + if not metadata_equal: + failures.append("stable metadata differs") + (out_dir / "metadata-first.json").write_text( + json.dumps(stable_result_metadata(first), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + (out_dir / "metadata-second.json").write_text( + json.dumps(stable_result_metadata(second), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + first_cases = { + str(case.get("id")): case + for case in first.get("cases", []) + if isinstance(case, dict) + } + second_cases = { + str(case.get("id")): case + for case in second.get("cases", []) + if isinstance(case, dict) + } + if set(first_cases) != set(second_cases): + failures.append("case sets differ") + diff_bin, build_result = build_diff_tool(args.timeout) + case_records: list[dict[str, Any]] = [] + timing_specs = ( + ( + "cpu_frame_mean_ms", + "max_cpu_mean_absolute_delta_ms", + "max_cpu_mean_relative_delta", + ), + ( + "gpu_frame_mean_ms", + "max_gpu_mean_absolute_delta_ms", + "max_gpu_mean_relative_delta", + ), + ( + "cpu_frame_p95_ms", + "max_cpu_p95_absolute_delta_ms", + "max_cpu_p95_relative_delta", + ), + ( + "gpu_frame_p95_ms", + "max_gpu_p95_absolute_delta_ms", + "max_gpu_p95_relative_delta", + ), + ) + for case_id in sorted(set(first_cases) & set(second_cases)): + first_case = first_cases[case_id] + second_case = second_cases[case_id] + case_dir = out_dir / case_id + case_dir.mkdir() + first_artifacts = artifact_map(first_path, first_case) + second_artifacts = artifact_map(second_path, second_case) + artifact_records: list[dict[str, Any]] = [] + if set(first_artifacts) != set(second_artifacts): + failures.append(f"{case_id}: artifact sets differ") + for name in sorted(set(first_artifacts) & set(second_artifacts)): + reference = first_artifacts[name] + candidate = second_artifacts[name] + first_sha = sha256_file(reference) + second_sha = sha256_file(candidate) + artifact_record: dict[str, Any] = { + "name": name, + "first_sha256": first_sha, + "second_sha256": second_sha, + "identical_bytes": first_sha == second_sha, + "passed": True, + } + if first_sha != second_sha: + safe_name = "".join( + character if character.isalnum() or character in "-_" else "_" + for character in name + ) + argv = reproducibility_diff_command( + diff_bin, reference, candidate, config, case_dir, safe_name + ) + diff_result = run_command(argv, REPO_ROOT, args.timeout) + metrics_path = case_dir / f"{safe_name}-metrics.json" + artifact_record["command"] = command_record(diff_result) + artifact_record["metrics"] = ( + read_json(metrics_path, "reproducibility metrics").get("metrics", {}) + if metrics_path.exists() + else None + ) + artifact_record["passed"] = diff_result.returncode == 0 + if not artifact_record["passed"]: + failures.append(f"{case_id}: unstable pixels in {name}") + artifact_records.append(artifact_record) + first_telemetry = first_case.get("telemetry") + second_telemetry = second_case.get("telemetry") + timing_records: list[dict[str, Any]] = [] + if isinstance(first_telemetry, dict) and isinstance(second_telemetry, dict): + for metric, absolute_key, relative_key in timing_specs: + timing_records.append( + timing_delta( + first_telemetry, + second_telemetry, + metric, + float(config[absolute_key]), + float(config[relative_key]), + ) + ) + else: + timing_records.append( + { + "metric": "telemetry", + "passed": False, + "failure": "telemetry unavailable", + } + ) + for timing in timing_records: + if not timing["passed"]: + failures.append( + f"{case_id}: unstable or missing timing {timing['metric']}" + ) + case_records.append( + { + "id": case_id, + "artifacts": artifact_records, + "timing": timing_records, + "passed": all(item["passed"] for item in artifact_records) + and all(item["passed"] for item in timing_records), + } + ) + report = { + "schema": REPRO_SCHEMA, + "manifest_sha256": manifest_hash, + "first_result_sha256": sha256_file(first_path), + "second_result_sha256": sha256_file(second_path), + "metadata_identical": metadata_equal, + "noise_bounds": dict(config), + "build": command_record(build_result) if build_result else None, + "cases": case_records, + "failures": failures, + "passed": not failures, + } + (out_dir / "result.json").write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(f"[reproducibility] {'PASS' if not failures else 'FAIL'}") + for failure in failures: + print(f" - {failure}") + print(f"[reproducibility] result: {out_dir / 'result.json'}") + return 0 if not failures else 1 + + +def execute_suite(args: argparse.Namespace) -> int: + manifest_path = Path(args.manifest).resolve() + manifest, manifest_hash = load_manifest(manifest_path) + adapter = load_adapter(Path(args.adapter_json).resolve() if args.adapter_json else None) + machine = selected_machine_class(manifest, args.machine_class) + selected_ids = list(manifest["workflow"][args.suite]) + if args.case: + requested = set(args.case) + missing = requested - set(selected_ids) + if missing: + raise QualityError( + f"--case entries are not part of suite {args.suite}: {sorted(missing)}" + ) + selected_ids = [case_id for case_id in selected_ids if case_id in requested] + case_by_id = {case["id"]: case for case in manifest["case"]} + out_dir = Path(args.out).resolve() + if out_dir.exists(): + if args.keep: + pass + else: + try: + out_dir.relative_to(REPO_ROOT) + except ValueError as exc: + raise QualityError( + "refusing to replace an output directory outside the repository; use --keep" + ) from exc + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + diff_bin, build_result = build_diff_tool(args.timeout) + started = time.perf_counter() + features = effective_features(adapter) + features_known = adapter is not None or bool( + os.environ.get("BLOOM_QUALITY_FEATURES", "").strip() + ) + built_commands: set[tuple[str, ...]] = set() + cases: list[dict[str, Any]] = [] + for case_id in selected_ids: + print(f"[quality] {case_id}", flush=True) + cases.append( + run_case( + case_by_id[case_id], + out_dir, + diff_bin, + features, + features_known, + machine, + args.report_only, + args.timeout, + built_commands, + ) + ) + print(f"[quality] {case_id}: {cases[-1]['status']}", flush=True) + hard_fail = any(case["status"] in ("fail", "error") for case in cases) + observed_adapter = adapter + if observed_adapter is None: + observed_adapter = next( + ( + dict(case["telemetry"]["adapter"]) + for case in cases + if isinstance(case.get("telemetry"), dict) + and isinstance(case["telemetry"].get("adapter"), dict) + ), + None, + ) + observed_features = effective_features(observed_adapter) + environment = stable_environment(observed_adapter) + machine_class = machine.get("id") if machine else None + capability_snapshot_path = write_capability_snapshot_artifact( + out_dir, environment, machine_class + ) + result = { + "schema": RESULT_SCHEMA, + "manifest_path": os.path.relpath(manifest_path, REPO_ROOT), + "manifest_sha256": manifest_hash, + "suite": args.suite, + "machine_class": machine_class, + "report_only": bool(args.report_only), + "environment": environment, + "features": sorted(observed_features or features), + "artifacts": {"capability_snapshot": capability_snapshot_path}, + "build": command_record(build_result) if build_result else None, + "cases": cases, + "status": "fail" if hard_fail else "pass", + "duration_ms": round((time.perf_counter() - started) * 1000.0, 3), + } + (out_dir / "result.json").write_text( + json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + write_summaries(result, out_dir) + print(f"[quality] result: {out_dir / 'result.json'}") + print(f"[quality] summary: {out_dir / 'summary.html'}") + return 0 if args.report_only or not hard_fail else 1 + + +def check_manifest(args: argparse.Namespace) -> int: + manifest, digest = load_manifest(Path(args.manifest).resolve()) + failures: list[str] = [] + for case in manifest["case"]: + for record in verify_assets(case): + if record["status"] != "pass": + failures.append(f"{case['id']}: {record['path']}: {record['status']}") + reference = repo_path(case["reference"]["path"]) + if not reference.exists(): + failures.append(f"{case['id']}: missing baseline {case['reference']['path']}") + print(f"manifest schema: 1") + print(f"manifest sha256: {digest}") + print(f"cases: {len(manifest['case'])}") + if failures: + print("FAIL:") + for failure in failures: + print(f" - {failure}") + return 1 + print("PASS: manifest, asset hashes, and approved baselines are complete") + return 0 + + +def run_negative_controls(args: argparse.Namespace) -> int: + manifest, manifest_hash = load_manifest(Path(args.manifest).resolve()) + out_dir = Path(args.out).resolve() + if out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True) + diff_bin, _ = build_diff_tool(args.timeout) + case_by_id = {case["id"]: case for case in manifest["case"]} + source_result_path = ( + Path(args.source_result).resolve() if args.source_result else None + ) + source_cases: dict[str, Mapping[str, Any]] = {} + source_result_sha256 = None + if source_result_path is not None: + source_result = read_json(source_result_path, "negative-control source result") + if source_result.get("schema") != RESULT_SCHEMA: + raise QualityError("negative-control source has an unsupported schema") + if source_result.get("manifest_sha256") != manifest_hash: + raise QualityError( + "negative-control source was not produced by the selected manifest" + ) + source_cases = { + str(case.get("id")): case + for case in source_result.get("cases", []) + if isinstance(case, dict) + } + source_result_sha256 = sha256_file(source_result_path) + records: list[dict[str, Any]] = [] + for control in manifest["negative_control"]: + fault = control["fault"] + case = case_by_id[control["case"]] + if source_result_path is not None: + source_case = source_cases.get(str(case["id"])) + if source_case is None: + raise QualityError( + f"negative-control source has no case {case['id']!r}" + ) + artifacts = artifact_map(source_result_path, source_case) + reference = artifacts["final"] + reference_source = "unapproved-result-bundle" + else: + reference = repo_path(case["reference"]["path"], must_exist=True) + reference_source = "approved-baseline" + control_dir = out_dir / fault + control_dir.mkdir(parents=True) + argv = diff_command( + diff_bin, + case, + reference, + reference, + control_dir, + report_only=False, + seeded_fault=fault, + ) + result = run_command(argv, REPO_ROOT, args.timeout) + detected = result.returncode == 1 + records.append( + { + "fault": fault, + "case": case["id"], + "detected": detected, + "reference_source": reference_source, + "reference_sha256": sha256_file(reference), + "command": command_record(result), + "metrics": ( + read_json(control_dir / "metrics.json", "fault metrics") + if (control_dir / "metrics.json").exists() + else None + ), + } + ) + print(f"[negative-control] {fault}: {'DETECTED' if detected else 'MISSED'}") + passed = all(record["detected"] for record in records) + result = { + "schema": "bloom-quality-negative-controls-v1", + "passed": passed, + "manifest_sha256": manifest_hash, + "source_result_sha256": source_result_sha256, + "controls": records, + } + (out_dir / "result.json").write_text( + json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 if passed else 1 + + +def write_baseline_review_summaries( + review: Mapping[str, Any], review_dir: Path +) -> None: + rows: list[list[str]] = [] + sections: list[str] = [] + for entry in review.get("entries", []): + timing = entry.get("timing") or {} + metrics = entry.get("metric_deltas") or {} + rows.append( + [ + str(entry["case"]), + str(entry["baseline_state"]), + f"{float(metrics['ssim_luminance']):.6f}" + if metrics.get("ssim_luminance") is not None + else "initial", + f"{float(timing['cpu_frame_p95_ms']):.3f}" + if timing.get("cpu_frame_p95_ms") is not None + else "-", + f"{float(timing['gpu_frame_p95_ms']):.3f}" + if timing.get("gpu_frame_p95_ms") is not None + else "-", + f"[after]({entry['proposed_image']})", + ] + ) + images: list[tuple[str, str]] = [("Proposed", entry["proposed_image"])] + if entry.get("before_image"): + images.insert(0, ("Current", entry["before_image"])) + for key, label in (("comparison", "Comparison"), ("heatmap", "Heatmap")): + raw = entry.get("evidence", {}).get(key) + if raw: + images.append((label, raw)) + image_html = "".join( + "
" + + html.escape(label) + + "
" + for label, path in images + ) + intermediate_html = "".join( + "
" + + html.escape(Path(path).stem) + + "
" + for path in entry.get("intermediates", []) + ) + sections.append( + "

" + + html.escape(str(entry["case"])) + + "

Baseline state: " + + html.escape(str(entry["baseline_state"])) + + " · CPU p95 " + + html.escape(str(timing.get("cpu_frame_p95_ms", "-"))) + + " ms · GPU p95 " + + html.escape(str(timing.get("gpu_frame_p95_ms", "-"))) + + " ms

" + + image_html + + "
Named graph intermediates" + + "
" + + intermediate_html + + "
" + ) + markdown = ( + "# Bloom baseline review\n\n" + f"Reason: {review['reason']}\n\n" + f"Source commit: `{review['source_commit']}` \n" + f"Manifest: `{review['manifest_sha256']}`\n\n" + + markdown_table( + ["Case", "Baseline", "SSIM", "CPU p95 ms", "GPU p95 ms", "Image"], + rows, + ) + + "\n\n" + + str(review["installation"]) + + "\n" + ) + (review_dir / "review.md").write_text(markdown, encoding="utf-8") + html_doc = """ + +Bloom baseline review + +

Bloom baseline review

+

Reason: """ + html.escape(str(review["reason"])) + """

+

Source commit """ + html.escape(str(review["source_commit"])) + """
+Manifest """ + html.escape(str(review["manifest_sha256"])) + """

+""" + "".join(sections) + """ +

""" + html.escape(str(review["installation"])) + """

+""" + (review_dir / "review.html").write_text(html_doc, encoding="utf-8") + + +def baseline_review(args: argparse.Namespace) -> int: + result_path = Path(args.result).resolve() + result = read_json(result_path, "quality result") + if result.get("schema") != RESULT_SCHEMA: + raise QualityError(f"unsupported result schema: {result.get('schema')!r}") + review_dir = Path(args.out).resolve() + if review_dir.exists(): + raise QualityError(f"review output already exists: {review_dir}") + review_dir.mkdir(parents=True) + requested = set(args.case or [case["id"] for case in result["cases"]]) + entries: list[dict[str, Any]] = [] + for case in result["cases"]: + if case["id"] not in requested: + continue + artifacts = case.get("artifacts", {}) + candidate_raw = artifacts.get("candidate") + if not candidate_raw: + raise QualityError(f"{case['id']} has no candidate evidence") + candidate = result_artifact( + result_path, candidate_raw, f"{case['id']} candidate" + ) + current_baseline = case.get("reference_target") + if not isinstance(current_baseline, str): + raise QualityError(f"{case['id']} has no baseline target") + reference = repo_path(current_baseline) + baseline_root = repo_path("tools/quality/baselines") + try: + reference.relative_to(baseline_root) + except ValueError as exc: + raise QualityError( + f"{case['id']}: baseline target is outside tools/quality/baselines" + ) from exc + target = review_dir / case["id"] + target.mkdir() + shutil.copy2(candidate, target / "after.png") + before_image = None + if reference.exists(): + shutil.copy2(reference, target / "before.png") + before_image = f"{case['id']}/before.png" + review_intermediates: list[str] = [] + intermediate_dir = target / "intermediates" + for raw in artifacts.get("intermediates", []): + source = result_artifact( + result_path, raw, f"{case['id']} intermediate" + ) + intermediate_dir.mkdir(exist_ok=True) + destination = intermediate_dir / source.name + shutil.copy2(source, destination) + review_intermediates.append( + f"{case['id']}/intermediates/{destination.name}" + ) + review_evidence: dict[str, str] = {} + for key in ("heatmap", "comparison", "metrics"): + raw = artifacts.get(key) + if raw: + source = result_artifact( + result_path, raw, f"{case['id']} {key}" + ) + destination = target / source.name + shutil.copy2(source, destination) + review_evidence[key] = f"{case['id']}/{destination.name}" + entries.append( + { + "case": case["id"], + "reason": args.reason, + "current_baseline": current_baseline, + "proposed_image": f"{case['id']}/after.png", + "before_image": before_image, + "baseline_state": "present" if before_image else "absent", + "intermediates": review_intermediates, + "evidence": review_evidence, + "metric_deltas": case.get("metrics", {}), + "timing": case.get("telemetry"), + } + ) + missing = requested - {entry["case"] for entry in entries} + if missing: + raise QualityError(f"requested cases absent from result: {sorted(missing)}") + review = { + "schema": REVIEW_SCHEMA, + "source_result_sha256": sha256_file(result_path), + "source_commit": result["environment"]["git_commit"], + "manifest_sha256": result["manifest_sha256"], + "reason": args.reason, + "entries": entries, + "installation": ( + "This bundle never writes baselines. After human image review, run " + "`tools/quality/run.py baseline-install --review REVIEW/review.json " + "--approved-by NAME` in a separate, explicit change." + ), + } + (review_dir / "review.json").write_text( + json.dumps(review, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + write_baseline_review_summaries(review, review_dir) + print(f"baseline review bundle: {review_dir}") + return 0 + + +def baseline_install(args: argparse.Namespace) -> int: + review_path = Path(args.review).resolve() + review = read_json(review_path, "baseline review") + if review.get("schema") != REVIEW_SCHEMA: + raise QualityError(f"unsupported review schema: {review.get('schema')!r}") + if not args.approved_by.strip(): + raise QualityError("--approved-by must name the human reviewer") + review_dir = review_path.parent + requested = set(args.case or [entry["case"] for entry in review.get("entries", [])]) + installed: list[dict[str, Any]] = [] + for entry in review.get("entries", []): + if not isinstance(entry, dict) or entry.get("case") not in requested: + continue + case_id = str(entry["case"]) + proposed_raw = entry.get("proposed_image") + target_raw = entry.get("current_baseline") + if not isinstance(proposed_raw, str) or not isinstance(target_raw, str): + raise QualityError(f"{case_id}: review entry is missing paths") + proposed = (review_dir / proposed_raw).resolve() + if not proposed.is_file(): + raise QualityError(f"{case_id}: proposed image missing: {proposed}") + target = repo_path(target_raw) + baseline_root = repo_path("tools/quality/baselines") + try: + target.relative_to(baseline_root) + except ValueError as exc: + raise QualityError( + f"{case_id}: baseline target is outside tools/quality/baselines" + ) from exc + before_raw = entry.get("before_image") + if isinstance(before_raw, str): + before = (review_dir / before_raw).resolve() + if not before.is_file() or not target.is_file(): + raise QualityError(f"{case_id}: existing baseline evidence is incomplete") + if sha256_file(before) != sha256_file(target): + raise QualityError( + f"{case_id}: baseline changed since review; create a new bundle" + ) + elif target.exists(): + raise QualityError( + f"{case_id}: review expected no baseline but target now exists" + ) + before_sha = sha256_file(target) if target.exists() else None + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(proposed, target) + installed.append( + { + "case": case_id, + "target": os.path.relpath(target, REPO_ROOT), + "before_sha256": before_sha, + "after_sha256": sha256_file(target), + } + ) + missing = requested - {entry["case"] for entry in installed} + if missing: + raise QualityError(f"requested cases absent from review: {sorted(missing)}") + receipt = { + "schema": INSTALL_SCHEMA, + "review_sha256": sha256_file(review_path), + "approved_by": args.approved_by.strip(), + "installed": installed, + } + receipt_path = Path(args.receipt).resolve() if args.receipt else review_dir / "installation.json" + receipt_path.write_text( + json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(f"installed {len(installed)} approved baseline(s)") + print(f"installation receipt: {receipt_path}") + return 0 + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser( + description="Bloom deterministic visual and GPU performance qualification" + ) + root.add_argument("--manifest", default=str(DEFAULT_MANIFEST)) + sub = root.add_subparsers(dest="command", required=True) + + run = sub.add_parser("run", help="run a manifest suite") + run.add_argument("suite", choices=sorted(ALLOWED_SUITES)) + run.add_argument("--out", default=str(SCRIPT_DIR / "out/latest")) + run.add_argument("--case", action="append") + run.add_argument("--machine-class") + run.add_argument("--adapter-json") + run.add_argument("--report-only", action="store_true") + run.add_argument("--keep", action="store_true") + run.add_argument("--timeout", type=float, default=900.0) + run.set_defaults(func=execute_suite) + + check = sub.add_parser("check", help="validate manifest, assets, and baselines") + check.set_defaults(func=check_manifest) + + faults = sub.add_parser("faults", help="prove all seeded negative controls fail") + faults.add_argument("--out", default=str(SCRIPT_DIR / "out/faults")) + faults.add_argument("--timeout", type=float, default=300.0) + faults.add_argument( + "--source-result", + help=( + "bootstrap detector validation from an unapproved result bundle; " + "normal CI must omit this and use approved baselines" + ), + ) + faults.set_defaults(func=run_negative_controls) + + repro = sub.add_parser( + "repro-check", + help="compare two result bundles against documented determinism/noise bounds", + ) + repro.add_argument("--first", required=True) + repro.add_argument("--second", required=True) + repro.add_argument("--out", default=str(SCRIPT_DIR / "out/reproducibility")) + repro.add_argument("--timeout", type=float, default=300.0) + repro.set_defaults(func=check_reproducibility) + + review = sub.add_parser( + "baseline-review", help="write a review bundle; never overwrite a baseline" + ) + review.add_argument("--result", required=True) + review.add_argument("--out", required=True) + review.add_argument("--reason", required=True) + review.add_argument("--case", action="append") + review.set_defaults(func=baseline_review) + + install = sub.add_parser( + "baseline-install", + help="install a human-approved review bundle with stale-baseline protection", + ) + install.add_argument("--review", required=True) + install.add_argument("--approved-by", required=True) + install.add_argument("--case", action="append") + install.add_argument("--receipt") + install.set_defaults(func=baseline_install) + return root + + +def main() -> int: + args = parser().parse_args() + try: + return int(args.func(args)) + except QualityError as exc: + print(f"quality error: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/quality/scenes.toml b/tools/quality/scenes.toml new file mode 100644 index 00000000..682c244b --- /dev/null +++ b/tools/quality/scenes.toml @@ -0,0 +1,834 @@ +schema_version = 1 + +[workflow] +quick = ["pbr-spheres-high", "damaged-helmet", "sponza-interior"] +full = [ + "pbr-spheres-high", + "pbr-spheres-constrained", + "damaged-helmet", + "sponza-interior", + "bistro-exterior", + "skinned-alpha-motion", + "draw-light-stress", + "weighted-transparency", + "masked-alpha-coverage", +] + +[reproducibility] +# Pixel bounds are deliberately much tighter than the regression thresholds: +# deterministic raster cases are byte-identical, while the hardware ray-query +# cases may differ by a handful of sub-LSB temporal samples. +pixel_tolerance = 0.002 +min_ssim = 0.999 +max_rmse = 0.002 +max_oklab_delta = 0.001 +max_edge_delta = 0.001 +# Mean timings are the stable throughput signal. P95 remains the hard budget +# signal, but receives a wider same-machine noise envelope because OS/GPU +# scheduling can move a few frames into or out of the 95th percentile. +max_cpu_mean_relative_delta = 0.15 +max_cpu_mean_absolute_delta_ms = 0.35 +max_gpu_mean_relative_delta = 0.15 +max_gpu_mean_absolute_delta_ms = 1.0 +max_cpu_p95_relative_delta = 0.25 +max_cpu_p95_absolute_delta_ms = 1.0 +max_gpu_p95_relative_delta = 0.50 +max_gpu_p95_absolute_delta_ms = 12.0 + +[[machine_class]] +id = "apple-m1-max-metal" +description = "High-end Apple Silicon desktop/laptop baseline, Metal" +hard_gate = true +hard_metrics = ["cpu", "gpu"] +os = "macos" +backend = "metal" +gpu = "Apple M1 Max" + +[[machine_class]] +id = "nvidia-rtx4080-vulkan" +description = "High-end discrete desktop baseline, Vulkan" +hard_gate = true +hard_metrics = ["cpu", "gpu", "vram"] +os = "linux" +backend = "vulkan" +gpu = "NVIDIA GeForce RTX 4080" + +[[machine_class]] +id = "apple-m1-metal-constrained" +description = "Constrained quality tier executed on Apple M1-class Metal" +hard_gate = true +hard_metrics = ["cpu", "gpu"] +os = "macos" +backend = "metal" +gpu = "Apple M1" + +[[case]] +id = "pbr-spheres-high" +description = "BRDF energy, Fresnel, metallic/roughness lobes, and IBL orientation" +resolution = [512, 512] +warmup_frames = 120 +measured_frames = 300 +seed = 0 +fixed_timestep = 0.016666666667 +render_scale = 1.0 +quality_tier = "high" +required_intermediates = [ + "hdr-scene", + "scene-depth", + "ssr", + "ssr-raw", + "ssr-rejection-reason", + "ssr-temporal-confidence", + "ssgi", + "ssgi-rejection-reason", + "ssgi-temporal-confidence", + "shadow-cascade-0", + "shadow-cascade-1", + "shadow-cascade-2", + "taa-rejection-reason", + "taa-motion", + "taa-reprojected-uv", + "taa-temporal-confidence", +] + +[case.camera] +position = [0.0, 0.0, 7.0] +target = [0.0, 0.0, 0.0] +up = [0.0, 1.0, 0.0] +fov_y_degrees = 45.0 +animation = "still" + +[case.settings] +quality_preset = 3 +taa = true +auto_exposure = false +present_mode = "auto-no-vsync" + +[case.capture] +working_dir = "examples/pbr-spheres" +build = ["python3", "{repo}/tools/quality/build_example.py", "{repo}/examples/pbr-spheres"] +command = [ + "./main", + "--camera", "{camera_px}", "{camera_py}", "{camera_pz}", + "{camera_tx}", "{camera_ty}", "{camera_tz}", "{camera_fov}", + "--res", "{width}", "{height}", + "--quality-preset", "3", "--render-scale", "{render_scale}", + "--quality-run", "{warmup_frames}", "{measured_frames}", "{fixed_timestep}", + "{candidate}", "{telemetry}", "{intermediates}", +] + +[case.reference] +path = "tools/quality/baselines/portable/pbr-spheres-high.png" +kind = "approved-realtime" +generation = "tools/quality/run.py baseline-review" + +[case.thresholds] +pixel_tolerance = 0.02 +min_ssim = 0.985 +max_rmse = 0.025 +max_oklab_delta = 0.025 +max_edge_delta = 0.025 + +[case.budgets] +machine_class = "apple-m1-max-metal" +max_cpu_frame_p95_ms = 5.0 +max_gpu_frame_p95_ms = 20.0 +max_vram_peak_mb = 1600 + +[[case.assets]] +path = "examples/renderer-test/assets/outdoor.hdr" +sha256 = "fd94c84997b8a3c353b62c2125a9b44e19509956986a126e472684432a02d798" +source = "https://polyhaven.com/a/venice_sunset" +license = "CC0 1.0" + +[[case]] +id = "pbr-spheres-constrained" +description = "Same BRDF contract at the constrained preset and 0.75 render scale" +resolution = [512, 512] +warmup_frames = 120 +measured_frames = 300 +seed = 0 +fixed_timestep = 0.016666666667 +render_scale = 0.75 +quality_tier = "constrained" +required_intermediates = [ + "hdr-scene", + "scene-depth", + "shadow-cascade-0", + "shadow-cascade-1", + "shadow-cascade-2", +] + +[case.camera] +position = [0.0, 0.0, 7.0] +target = [0.0, 0.0, 0.0] +up = [0.0, 1.0, 0.0] +fov_y_degrees = 45.0 +animation = "still" + +[case.settings] +quality_preset = 1 +taa = false +auto_exposure = false +present_mode = "auto-no-vsync" + +[case.capture] +working_dir = "examples/pbr-spheres" +build = ["python3", "{repo}/tools/quality/build_example.py", "{repo}/examples/pbr-spheres"] +command = [ + "./main", + "--camera", "{camera_px}", "{camera_py}", "{camera_pz}", + "{camera_tx}", "{camera_ty}", "{camera_tz}", "{camera_fov}", + "--res", "{width}", "{height}", + "--quality-preset", "1", "--render-scale", "{render_scale}", + "--quality-run", "{warmup_frames}", "{measured_frames}", "{fixed_timestep}", + "{candidate}", "{telemetry}", "{intermediates}", +] + +[case.reference] +path = "tools/quality/baselines/portable/pbr-spheres-constrained.png" +kind = "approved-realtime" +generation = "tools/quality/run.py baseline-review" + +[case.thresholds] +pixel_tolerance = 0.025 +min_ssim = 0.975 +max_rmse = 0.035 +max_oklab_delta = 0.035 +max_edge_delta = 0.035 + +[case.budgets] +machine_class = "apple-m1-metal-constrained" +max_cpu_frame_p95_ms = 5.0 +max_gpu_frame_p95_ms = 8.0 +max_vram_peak_mb = 1000 + +[[case.assets]] +path = "examples/renderer-test/assets/outdoor.hdr" +sha256 = "fd94c84997b8a3c353b62c2125a9b44e19509956986a126e472684432a02d798" +source = "https://polyhaven.com/a/venice_sunset" +license = "CC0 1.0" + +[[case]] +id = "damaged-helmet" +description = "Canonical glTF UV orientation, tangent normal map, MR/occlusion/emissive fidelity" +resolution = [512, 512] +warmup_frames = 120 +measured_frames = 300 +seed = 0 +fixed_timestep = 0.016666666667 +render_scale = 1.0 +quality_tier = "high" +required_intermediates = [ + "hdr-scene", + "scene-depth", + "ssr", + "ssr-raw", + "ssr-rejection-reason", + "ssr-temporal-confidence", + "ssgi", + "ssgi-rejection-reason", + "ssgi-temporal-confidence", + "shadow-cascade-0", + "shadow-cascade-1", + "shadow-cascade-2", + "taa-rejection-reason", + "taa-motion", + "taa-reprojected-uv", + "taa-temporal-confidence", +] + +[case.camera] +position = [1.8, 1.2, 2.4] +target = [0.0, 0.0, 0.0] +up = [0.0, 1.0, 0.0] +fov_y_degrees = 45.0 +animation = "still" + +[case.settings] +quality_preset = 3 +taa = true +auto_exposure = false +present_mode = "auto-no-vsync" + +[case.capture] +working_dir = "examples/renderer-test" +build = ["python3", "{repo}/tools/quality/build_example.py", "{repo}/examples/renderer-test"] +command = [ + "./main", + "--camera", "{camera_px}", "{camera_py}", "{camera_pz}", + "{camera_tx}", "{camera_ty}", "{camera_tz}", "{camera_fov}", + "--res", "{width}", "{height}", + "--quality-preset", "3", "--render-scale", "{render_scale}", + "--quality-run", "{warmup_frames}", "{measured_frames}", "{fixed_timestep}", + "{candidate}", "{telemetry}", "{intermediates}", +] + +[case.reference] +path = "tools/quality/baselines/portable/damaged-helmet.png" +kind = "approved-realtime" +generation = "tools/bloom-reference --spec examples/renderer-test/specs/helmet.json" + +[case.thresholds] +pixel_tolerance = 0.02 +min_ssim = 0.985 +max_rmse = 0.025 +max_oklab_delta = 0.025 +max_edge_delta = 0.025 + +[case.budgets] +machine_class = "apple-m1-max-metal" +max_cpu_frame_p95_ms = 6.0 +max_gpu_frame_p95_ms = 12.0 +max_vram_peak_mb = 1800 + +[[case.assets]] +path = "examples/renderer-test/assets/DamagedHelmet.glb" +sha256 = "a1e3b04de97b11de564ce6e53b95f02954a297f0008183ac63a4f5974f6b32d8" +source = "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/main/Models/DamagedHelmet" +license = "CC BY-NC 4.0 original; CC BY 4.0 glTF rebuild" + +[[case.assets]] +path = "examples/renderer-test/assets/outdoor.hdr" +sha256 = "fd94c84997b8a3c353b62c2125a9b44e19509956986a126e472684432a02d798" +source = "https://polyhaven.com/a/venice_sunset" +license = "CC0 1.0" + +[[case]] +id = "sponza-interior" +description = "Interior GI, shadow placement, cutout leakage, and temporal stability" +resolution = [800, 450] +warmup_frames = 180 +measured_frames = 300 +seed = 0 +fixed_timestep = 0.016666666667 +render_scale = 1.0 +quality_tier = "high" +required_features = ["ray-query"] +required_intermediates = [ + "hdr-scene", + "scene-depth", + "ssr", + "ssr-raw", + "ssr-rejection-reason", + "ssr-temporal-confidence", + "ssgi", + "ssgi-rejection-reason", + "ssgi-temporal-confidence", + "shadow-cascade-0", + "shadow-cascade-1", + "shadow-cascade-2", + "taa-rejection-reason", + "taa-motion", + "taa-reprojected-uv", + "taa-temporal-confidence", +] + +[case.fallback] +name = "software-wsrc" +reason = "hardware ray query unavailable" +settings = ["screen-space GI", "software world-space radiance cache"] + +[case.camera] +position = [0.0, 2.0, 0.0] +target = [0.0, 2.0, -100.0] +up = [0.0, 1.0, 0.0] +fov_y_degrees = 60.0 +animation = "still" + +[case.settings] +quality_preset = 3 +taa = true +auto_exposure = true +present_mode = "auto-no-vsync" + +[case.capture] +working_dir = "examples/sponza" +build = ["python3", "{repo}/tools/quality/build_example.py", "{repo}/examples/sponza"] +command = [ + "./main", "--yaw", "0", "--taa", "1", + "--quality-preset", "3", "--render-scale", "{render_scale}", + "--quality-run", "{warmup_frames}", "{measured_frames}", "{fixed_timestep}", + "{candidate}", "{telemetry}", "{intermediates}", +] + +[case.reference] +path = "tools/quality/baselines/portable/sponza-interior.png" +kind = "approved-realtime-plus-cycles-review" +generation = "tools/cycles_reference/render.sh" + +[case.thresholds] +pixel_tolerance = 0.025 +min_ssim = 0.975 +max_rmse = 0.035 +max_oklab_delta = 0.035 +max_edge_delta = 0.035 + +[case.budgets] +machine_class = "apple-m1-max-metal" +max_cpu_frame_p95_ms = 10.0 +max_gpu_frame_p95_ms = 55.0 +max_vram_peak_mb = 3200 + +[[case.assets]] +path = "examples/sponza/assets/Sponza.glb" +sha256 = "a72339e2b02bf597cdf72d3147014eb60df947d88b7b48e1f2ebf0cb111405bc" +source = "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/main/Models/Sponza" +license = "Cryengine Limited License Agreement" + +[[case.assets]] +path = "examples/sponza/assets/outdoor.hdr" +sha256 = "fd94c84997b8a3c353b62c2125a9b44e19509956986a126e472684432a02d798" +source = "https://polyhaven.com/a/venice_sunset" +license = "CC0 1.0" + +[[case]] +id = "bistro-exterior" +description = "World scale, sun/sky, exterior materials, texture residency, and streaming pressure" +resolution = [800, 450] +warmup_frames = 180 +measured_frames = 240 +seed = 0 +fixed_timestep = 0.016666666667 +render_scale = 1.0 +quality_tier = "high" +required_features = ["ray-query"] +required_intermediates = [ + "hdr-scene", + "scene-depth", + "ssr", + "ssr-raw", + "ssr-rejection-reason", + "ssr-temporal-confidence", + "ssgi", + "ssgi-rejection-reason", + "ssgi-temporal-confidence", + "shadow-cascade-0", + "shadow-cascade-1", + "shadow-cascade-2", + "taa-rejection-reason", + "taa-motion", + "taa-reprojected-uv", + "taa-temporal-confidence", +] + +[case.fallback] +name = "software-wsrc" +reason = "hardware ray query unavailable" +settings = ["screen-space GI", "software world-space radiance cache"] + +[case.camera] +position = [-26.43, 3.16, 11.17] +target = [69.34, -0.04, 39.75] +up = [0.0, 1.0, 0.0] +fov_y_degrees = 60.0 +animation = "still" + +[case.settings] +quality_preset = 3 +taa = true +auto_exposure = false +present_mode = "auto-no-vsync" + +[case.capture] +working_dir = "examples/bistro" +prepare = [ + "python3", "{repo}/tools/quality/prepare_bistro.py", + "{repo}/examples/bistro/assets/bistro.gltf", + "{case_dir}/bistro-quality.gltf", + "--metadata", "{case_dir}/bistro-quality.json", + "--revision", "7c9f9f9ac0915024ccf3dddbccd8bfc643a42607", +] +build = ["python3", "{repo}/tools/quality/build_example.py", "{repo}/examples/bistro"] +command = [ + "./main", "--scene", "{case_dir}/bistro-quality.gltf", + "--yaw", "-1.8608027466", "--taa", "1", + "--quality-preset", "3", "--render-scale", "{render_scale}", + "--quality-run", "{warmup_frames}", "{measured_frames}", "{fixed_timestep}", + "{candidate}", "{telemetry}", "{intermediates}", +] + +[case.reference] +path = "tools/quality/baselines/portable/bistro-exterior.png" +kind = "approved-realtime-plus-cycles-review" +generation = "tools/cycles_reference/render.sh" + +[case.thresholds] +pixel_tolerance = 0.025 +min_ssim = 0.97 +max_rmse = 0.04 +max_oklab_delta = 0.04 +max_edge_delta = 0.04 + +[case.budgets] +machine_class = "nvidia-rtx4080-vulkan" +max_cpu_frame_p95_ms = 12.0 +max_gpu_frame_p95_ms = 24.0 +max_vram_peak_mb = 7000 + +[[case.assets]] +path = "examples/bistro/assets" +revision = "7c9f9f9ac0915024ccf3dddbccd8bfc643a42607" +allow_dirty = true +source = "https://github.com/zeux/niagara_bistro" +license = "MIT" + +[[case.assets]] +path = "examples/bistro/assets/bistro.gltf" +sha256 = "96138eb738a85631802f03e8755157bc7661d53a2ea60f2f960fb04957ef2529" +source = "https://github.com/zeux/niagara_bistro" +license = "MIT" + +[[case.assets]] +path = "examples/bistro/assets/outdoor.hdr" +sha256 = "fd94c84997b8a3c353b62c2125a9b44e19509956986a126e472684432a02d798" +source = "https://polyhaven.com/a/venice_sunset" +license = "CC0 1.0" + +[[case]] +id = "skinned-alpha-motion" +description = "Moving skinned Fox in front of a textured alpha-tested Sponza foliage backdrop" +resolution = [800, 450] +warmup_frames = 120 +measured_frames = 240 +seed = 0 +fixed_timestep = 0.016666666667 +render_scale = 1.0 +quality_tier = "high" +required_intermediates = [ + "hdr-scene", + "scene-depth", + "ssr", + "ssr-raw", + "ssr-rejection-reason", + "ssr-temporal-confidence", + "ssgi", + "ssgi-rejection-reason", + "ssgi-temporal-confidence", + "shadow-cascade-0", + "shadow-cascade-1", + "shadow-cascade-2", + "taa-rejection-reason", + "taa-motion", + "taa-reprojected-uv", + "taa-temporal-confidence", +] + +[case.camera] +position = [4.86, 1.45, 2.2] +target = [4.86, 1.2, -1.6] +up = [0.0, 1.0, 0.0] +fov_y_degrees = 48.0 +animation = "Fox Walk clip at fixed 1/60 s" + +[case.settings] +quality_preset = 3 +taa = true +auto_exposure = false +present_mode = "auto-no-vsync" + +[case.capture] +working_dir = "examples/quality-motion" +build = ["python3", "{repo}/tools/quality/build_example.py", "{repo}/examples/quality-motion"] +command = [ + "./main", "--quality-preset", "3", "--render-scale", "{render_scale}", + "--quality-run", "{warmup_frames}", "{measured_frames}", "{fixed_timestep}", + "{candidate}", "{telemetry}", "{intermediates}", +] + +[case.reference] +path = "tools/quality/baselines/portable/skinned-alpha-motion.png" +kind = "approved-realtime-motion" +generation = "tools/quality/run.py baseline-review" + +[case.thresholds] +pixel_tolerance = 0.025 +min_ssim = 0.97 +max_rmse = 0.04 +max_oklab_delta = 0.04 +max_edge_delta = 0.04 + +[case.budgets] +machine_class = "apple-m1-max-metal" +max_cpu_frame_p95_ms = 10.0 +max_gpu_frame_p95_ms = 30.0 +max_vram_peak_mb = 3200 + +[[case.assets]] +path = "examples/test-gltf-watch/assets/Fox.glb" +sha256 = "d97044e701822bac5a62696459b27d7b375aada5de8574ed4362edbba94771f7" +source = "https://github.com/KhronosGroup/glTF-Sample-Models/tree/master/2.0/Fox" +license = "CC0 geometry; CC BY 4.0 rigging and animation" + +[[case.assets]] +path = "examples/sponza/assets/Sponza.glb" +sha256 = "a72339e2b02bf597cdf72d3147014eb60df947d88b7b48e1f2ebf0cb111405bc" +source = "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/main/Models/Sponza" +license = "Cryengine Limited License Agreement" + +[[case.assets]] +path = "examples/renderer-test/assets/outdoor.hdr" +sha256 = "fd94c84997b8a3c353b62c2125a9b44e19509956986a126e472684432a02d798" +source = "https://polyhaven.com/a/venice_sunset" +license = "CC0 1.0" + +[[case]] +id = "draw-light-stress" +description = "10,240 independently submitted meshes and 192 clustered point lights" +resolution = [1280, 720] +warmup_frames = 180 +measured_frames = 240 +seed = 0 +fixed_timestep = 0.016666666667 +render_scale = 1.0 +quality_tier = "stress" +required_intermediates = [ + "hdr-scene", + "scene-depth", + "ssr", + "ssr-raw", + "ssr-rejection-reason", + "ssr-temporal-confidence", + "ssgi", + "ssgi-rejection-reason", + "ssgi-temporal-confidence", + "shadow-cascade-0", + "shadow-cascade-1", + "shadow-cascade-2", + "taa-rejection-reason", + "taa-motion", + "taa-reprojected-uv", + "taa-temporal-confidence", +] + +[case.camera] +position = [0.0, 38.0, 52.0] +target = [0.0, 0.0, 0.0] +up = [0.0, 1.0, 0.0] +fov_y_degrees = 58.0 +animation = "still" + +[case.settings] +quality_preset = 3 +taa = true +draw_count = 10240 +point_light_count = 192 +present_mode = "auto-no-vsync" + +[case.capture] +working_dir = "examples/quality-stress" +build = ["python3", "{repo}/tools/quality/build_example.py", "{repo}/examples/quality-stress"] +command = [ + "./main", "--quality-preset", "3", "--render-scale", "{render_scale}", + "--quality-run", "{warmup_frames}", "{measured_frames}", "{fixed_timestep}", + "{candidate}", "{telemetry}", "{intermediates}", +] + +[case.reference] +path = "tools/quality/baselines/portable/draw-light-stress.png" +kind = "approved-realtime-stress" +generation = "tools/quality/run.py baseline-review" + +[case.thresholds] +pixel_tolerance = 0.025 +min_ssim = 0.97 +max_rmse = 0.04 +max_oklab_delta = 0.04 +max_edge_delta = 0.04 + +[case.budgets] +machine_class = "nvidia-rtx4080-vulkan" +max_cpu_frame_p95_ms = 20.0 +max_gpu_frame_p95_ms = 33.0 +max_vram_peak_mb = 7000 + +[[case.assets]] +path = "examples/renderer-test/assets/outdoor.hdr" +sha256 = "fd94c84997b8a3c353b62c2125a9b44e19509956986a126e472684432a02d798" +source = "https://polyhaven.com/a/venice_sunset" +license = "CC0 1.0" + +[[case]] +id = "weighted-transparency" +description = "96 imported BLEND layers in 12 animated intersecting cells using weighted OIT" +resolution = [960, 540] +warmup_frames = 120 +measured_frames = 240 +seed = 0 +fixed_timestep = 0.016666666667 +render_scale = 1.0 +quality_tier = "high" +required_intermediates = [ + "hdr-scene", + "scene-depth", + "ssr", + "ssr-raw", + "ssr-rejection-reason", + "ssr-temporal-confidence", + "ssgi", + "ssgi-rejection-reason", + "ssgi-temporal-confidence", + "shadow-cascade-0", + "shadow-cascade-1", + "shadow-cascade-2", + "taa-rejection-reason", + "taa-motion", + "taa-reprojected-uv", + "taa-temporal-confidence", +] + +[case.camera] +position = [0.0, 0.0, 9.0] +target = [0.0, 0.0, 0.0] +up = [0.0, 1.0, 0.0] +fov_y_degrees = 48.0 +animation = "96 layers rotate deterministically at fixed 1/60 s" + +[case.settings] +quality_preset = 3 +taa = true +auto_exposure = false +transparency_composition = "weighted" +transparent_draw_count = 96 +present_mode = "auto-no-vsync" + +[case.capture] +working_dir = "examples/quality-transparency" +build = [ + "python3", + "{repo}/tools/quality/build_example.py", + "{repo}/examples/quality-transparency", +] +command = [ + "./main", "--quality-preset", "3", "--render-scale", "{render_scale}", + "--quality-run", "{warmup_frames}", "{measured_frames}", "{fixed_timestep}", + "{candidate}", "{telemetry}", "{intermediates}", +] + +[case.reference] +path = "tools/quality/baselines/portable/weighted-transparency.png" +kind = "approved-realtime-motion" +generation = "tools/quality/run.py baseline-review" + +[case.thresholds] +pixel_tolerance = 0.025 +min_ssim = 0.97 +max_rmse = 0.04 +max_oklab_delta = 0.04 +max_edge_delta = 0.04 + +[case.budgets] +machine_class = "apple-m1-max-metal" +max_cpu_frame_p95_ms = 12.0 +max_gpu_frame_p95_ms = 30.0 +max_vram_peak_mb = 3400 + +[[case.assets]] +path = "examples/quality-transparency/assets/transparent-quad.gltf" +sha256 = "80c559c0a8a100f55fb5a26b331fa239751e0a1e66cc3b369d86bdff1d00acae" +source = "Bloom Engine generated qualification fixture" +license = "CC0 1.0" + +[[case]] +id = "masked-alpha-coverage" +description = "48 moving imported MASK cards spanning six projected mip ranges with cutout shadows" +resolution = [960, 540] +warmup_frames = 120 +measured_frames = 240 +seed = 0 +fixed_timestep = 0.016666666667 +render_scale = 1.0 +quality_tier = "high" +required_intermediates = [ + "hdr-scene", + "scene-depth", + "ssr", + "ssr-raw", + "ssr-rejection-reason", + "ssr-temporal-confidence", + "ssgi", + "ssgi-rejection-reason", + "ssgi-temporal-confidence", + "shadow-cascade-0", + "shadow-cascade-1", + "shadow-cascade-2", + "taa-rejection-reason", + "taa-motion", + "taa-reprojected-uv", + "taa-temporal-confidence", +] + +[case.camera] +position = [0.0, 5.0, 10.0] +target = [0.0, -0.3, -4.0] +up = [0.0, 1.0, 0.0] +fov_y_degrees = 52.0 +animation = "six separated LOD bands of cutout cards sway deterministically at fixed 1/60 s" + +[case.settings] +quality_preset = 3 +taa = true +auto_exposure = false +shadows = true +masked_draw_count = 48 +masked_alpha_minification = "coverage-mips-bayer-4x4" +sample_count = 1 +alpha_to_coverage = false +present_mode = "auto-no-vsync" + +[case.capture] +working_dir = "examples/quality-masked" +build = [ + "python3", + "{repo}/tools/quality/build_example.py", + "{repo}/examples/quality-masked", +] +command = [ + "./main", "--quality-preset", "3", "--render-scale", "{render_scale}", + "--quality-run", "{warmup_frames}", "{measured_frames}", "{fixed_timestep}", + "{candidate}", "{telemetry}", "{intermediates}", +] + +[case.reference] +path = "tools/quality/baselines/portable/masked-alpha-coverage.png" +kind = "approved-realtime-motion" +generation = "tools/quality/run.py baseline-review" + +[case.thresholds] +pixel_tolerance = 0.025 +min_ssim = 0.97 +max_rmse = 0.04 +max_oklab_delta = 0.04 +max_edge_delta = 0.04 + +[case.budgets] +machine_class = "apple-m1-max-metal" +max_cpu_frame_p95_ms = 12.0 +max_gpu_frame_p95_ms = 30.0 +max_vram_peak_mb = 3400 + +[[case.assets]] +path = "examples/quality-masked/assets/masked-card.gltf" +sha256 = "6e442fa8754905ec25d82f42d6180aef31bbd111ef08a4e1f91dd642fad8a110" +source = "Bloom Engine generated qualification fixture" +license = "CC0 1.0" + +[[negative_control]] +fault = "brdf-energy" +case = "pbr-spheres-high" + +[[negative_control]] +fault = "shadow-placement" +case = "sponza-interior" + +[[negative_control]] +fault = "gi-leakage" +case = "sponza-interior" + +[[negative_control]] +fault = "motion-history" +case = "skinned-alpha-motion" + +[[negative_control]] +fault = "texture-orientation" +case = "damaged-helmet" diff --git a/tools/quality/test_run.py b/tools/quality/test_run.py new file mode 100644 index 00000000..5f7fd573 --- /dev/null +++ b/tools/quality/test_run.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +"""Unit tests for qualification orchestration and baseline governance.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +MODULE_PATH = Path(__file__).with_name("run.py") +SPEC = importlib.util.spec_from_file_location("bloom_quality_run", MODULE_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"cannot import {MODULE_PATH}") +quality = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = quality +SPEC.loader.exec_module(quality) + + +class BaselineGovernanceTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name).resolve() + self.baseline = ( + self.root + / "tools" + / "quality" + / "baselines" + / "portable" + / "sample.png" + ) + self.run_dir = self.root / "run" + self.candidate = self.run_dir / "cases" / "sample" / "final.png" + self.intermediate = ( + self.run_dir + / "cases" + / "sample" + / "intermediates" + / "hdr-scene.png" + ) + self.candidate.parent.mkdir(parents=True) + self.intermediate.parent.mkdir(parents=True) + self.candidate.write_bytes(b"candidate-png") + self.intermediate.write_bytes(b"intermediate-png") + self.result_path = self.run_dir / "result.json" + self.result_path.write_text( + json.dumps( + { + "schema": quality.RESULT_SCHEMA, + "manifest_sha256": "manifest", + "environment": {"git_commit": "commit"}, + "cases": [ + { + "id": "sample", + "reference_target": ( + "tools/quality/baselines/portable/sample.png" + ), + "artifacts": { + "candidate": "cases/sample/final.png", + "intermediates": [ + "cases/sample/intermediates/hdr-scene.png" + ], + }, + "metrics": {"ssim_luminance": 1.0}, + "telemetry": {"cpu_frame_p95_ms": 1.0}, + } + ], + } + ), + encoding="utf-8", + ) + self.review_dir = self.root / "review" + self.repo_patch = mock.patch.object(quality, "REPO_ROOT", self.root) + self.repo_patch.start() + + def tearDown(self) -> None: + self.repo_patch.stop() + self.temp.cleanup() + + def make_review(self) -> Path: + args = argparse.Namespace( + result=str(self.result_path), + out=str(self.review_dir), + reason="unit-test review", + case=None, + ) + self.assertEqual(quality.baseline_review(args), 0) + return self.review_dir / "review.json" + + def test_initial_review_never_writes_then_explicit_install_does(self) -> None: + review_path = self.make_review() + self.assertFalse(self.baseline.exists()) + self.assertTrue((self.review_dir / "review.md").is_file()) + self.assertTrue((self.review_dir / "review.html").is_file()) + review = quality.read_json(review_path, "review") + entry = review["entries"][0] + self.assertEqual(entry["baseline_state"], "absent") + self.assertIsNone(entry["before_image"]) + self.assertEqual( + entry["intermediates"], + ["sample/intermediates/hdr-scene.png"], + ) + + receipt = self.root / "receipt.json" + args = argparse.Namespace( + review=str(review_path), + approved_by="Human Reviewer", + case=None, + receipt=str(receipt), + ) + self.assertEqual(quality.baseline_install(args), 0) + self.assertEqual(self.baseline.read_bytes(), b"candidate-png") + installed = quality.read_json(receipt, "receipt") + self.assertEqual(installed["approved_by"], "Human Reviewer") + self.assertIsNone(installed["installed"][0]["before_sha256"]) + + def test_stale_existing_baseline_is_rejected_without_overwrite(self) -> None: + self.baseline.parent.mkdir(parents=True) + self.baseline.write_bytes(b"reviewed-baseline") + review_path = self.make_review() + self.baseline.write_bytes(b"changed-after-review") + args = argparse.Namespace( + review=str(review_path), + approved_by="Human Reviewer", + case=None, + receipt=None, + ) + with self.assertRaisesRegex( + quality.QualityError, "baseline changed since review" + ): + quality.baseline_install(args) + self.assertEqual(self.baseline.read_bytes(), b"changed-after-review") + + def test_baseline_target_outside_governed_tree_is_rejected(self) -> None: + result = quality.read_json(self.result_path, "result") + result["cases"][0]["reference_target"] = "outside.png" + self.result_path.write_text(json.dumps(result), encoding="utf-8") + args = argparse.Namespace( + result=str(self.result_path), + out=str(self.review_dir), + reason="invalid target", + case=None, + ) + with self.assertRaisesRegex( + quality.QualityError, "outside tools/quality/baselines" + ): + quality.baseline_review(args) + self.assertFalse((self.root / "outside.png").exists()) + + def test_candidate_artifact_escape_is_rejected(self) -> None: + escaped = self.root / "escaped.png" + escaped.write_bytes(b"not-result-evidence") + result = quality.read_json(self.result_path, "result") + result["cases"][0]["artifacts"]["candidate"] = "../escaped.png" + self.result_path.write_text(json.dumps(result), encoding="utf-8") + args = argparse.Namespace( + result=str(self.result_path), + out=str(self.review_dir), + reason="invalid artifact", + case=None, + ) + with self.assertRaisesRegex(quality.QualityError, "artifact escapes"): + quality.baseline_review(args) + + +class ReproducibilityTests(unittest.TestCase): + def test_checked_in_manifest_satisfies_contract(self) -> None: + manifest, digest = quality.load_manifest(MODULE_PATH.with_name("scenes.toml")) + self.assertEqual(manifest["schema_version"], 1) + self.assertEqual(len(manifest["case"]), 9) + self.assertEqual(len(digest), 64) + temporal_evidence = { + "ssr", + "ssr-raw", + "ssr-rejection-reason", + "ssr-temporal-confidence", + "ssgi", + "ssgi-rejection-reason", + "ssgi-temporal-confidence", + "taa-rejection-reason", + "taa-motion", + "taa-reprojected-uv", + "taa-temporal-confidence", + } + for case in manifest["case"]: + required = set(case["required_intermediates"]) + if case["settings"]["quality_preset"] >= 3: + self.assertTrue( + temporal_evidence <= required, + f"{case['id']} must retain capture-only temporal evidence", + ) + else: + self.assertTrue( + temporal_evidence.isdisjoint(required), + f"{case['id']} cannot require disabled temporal systems", + ) + + def test_stable_metadata_ignores_commands_and_duration(self) -> None: + common = { + "schema": quality.RESULT_SCHEMA, + "manifest_path": "tools/quality/scenes.toml", + "manifest_sha256": "manifest", + "suite": "quick", + "machine_class": None, + "report_only": True, + "environment": {"git_commit": "commit"}, + "features": ["ray-query"], + "cases": [ + { + "id": "sample", + "description": "sample", + "commands": [{"duration_ms": 1.0}], + "telemetry": { + "schema": "telemetry", + "fixed_timestep": 1 / 60, + "cpu_frame_mean_ms": 1.0, + }, + } + ], + "duration_ms": 10.0, + } + changed = json.loads(json.dumps(common)) + changed["duration_ms"] = 999.0 + changed["cases"][0]["commands"][0]["duration_ms"] = 500.0 + changed["cases"][0]["telemetry"]["cpu_frame_mean_ms"] = 2.0 + self.assertEqual( + quality.stable_result_metadata(common), + quality.stable_result_metadata(changed), + ) + + def test_timing_delta_accepts_absolute_or_relative_bound(self) -> None: + absolute = quality.timing_delta( + {"metric": 1.0}, {"metric": 1.2}, "metric", 0.25, 0.01 + ) + relative = quality.timing_delta( + {"metric": 10.0}, {"metric": 11.0}, "metric", 0.1, 0.10 + ) + rejected = quality.timing_delta( + {"metric": 1.0}, {"metric": 2.0}, "metric", 0.25, 0.10 + ) + self.assertTrue(absolute["passed"]) + self.assertTrue(relative["passed"]) + self.assertFalse(rejected["passed"]) + + def test_capability_snapshot_contract_accepts_complete_native_evidence(self) -> None: + tier = "modern" + adapter = { + "availability": "reported", + "capability_tier": tier, + "renderer_capabilities": { + "detected": tier, + "selected": tier, + "requested": None, + "forced": None, + "diagnostic": None, + "available": { + "features": { + key: False + for key in quality.RENDERER_CAPABILITY_FEATURE_KEYS + }, + "limits": { + key: 16 + for key in quality.RENDERER_CAPABILITY_LIMIT_KEYS + }, + }, + "paths": { + key: f"test-{key}" + for key in quality.RENDERER_CAPABILITY_PATH_KEYS + }, + }, + "device_negotiation": { + "preferred_tier": tier, + "selected_tier": tier, + "profile": "native-full", + "selected_request": "bloom_device_preferred", + "fallback_cause": None, + "required_features": "Features(0x0)", + "required_limits": { + key: 16 + for key in quality.DEVICE_NEGOTIATION_LIMIT_KEYS + }, + }, + } + self.assertEqual(quality.capability_snapshot_failures(adapter), []) + + def test_named_capability_artifact_preserves_result_context(self) -> None: + adapter = { + "availability": "reported", + "name": "Test GPU", + "renderer_capabilities": {"selected": "modern"}, + "device_negotiation": {"selected_request": "preferred"}, + } + artifact = quality.capability_snapshot_artifact( + { + "git_commit": "abc123", + "adapter": adapter, + }, + "test-machine", + ) + self.assertEqual(artifact["schema"], quality.CAPABILITY_SNAPSHOT_SCHEMA) + self.assertEqual(artifact["git_commit"], "abc123") + self.assertEqual(artifact["machine_class"], "test-machine") + self.assertEqual(artifact["adapter"], adapter) + with tempfile.TemporaryDirectory() as directory: + name = quality.write_capability_snapshot_artifact( + Path(directory), + {"git_commit": "abc123", "adapter": adapter}, + "test-machine", + ) + self.assertEqual(name, "capabilities.json") + self.assertEqual( + json.loads((Path(directory) / name).read_text(encoding="utf-8")), + artifact, + ) + + def test_capability_snapshot_contract_rejects_missing_or_inconsistent_data( + self, + ) -> None: + self.assertIn( + "adapter did not include renderer_capabilities snapshot", + quality.capability_snapshot_failures({"availability": "reported"}), + ) + adapter = { + "capability_tier": "high-end", + "renderer_capabilities": { + "detected": "modern", + "selected": "modern", + "requested": None, + "forced": None, + "diagnostic": None, + "available": {"features": {}, "limits": {}}, + "paths": {}, + }, + "device_negotiation": None, + } + failures = quality.capability_snapshot_failures(adapter) + self.assertTrue(any("does not match selected renderer tier" in item for item in failures)) + self.assertTrue(any("device_negotiation snapshot" in item for item in failures)) + + def test_steady_state_renderer_contract_accepts_named_bounded_counts(self) -> None: + sites = {key: 0 for key in quality.STEADY_STATE_BIND_GROUP_SITES} + renderer_paths = { + "steady_state_uploads": { + "lighting": { + "write_count": 2, + "byte_count": 128, + "full_buffer_bytes": 8768, + } + }, + "steady_state_resources": { + "bind_group_creations": {"total": 0, "sites": sites}, + "graph_compiles": 0, + "pipeline_creations": {"first_use": 0}, + "command_encoder_creations": { + "total": 1, + "sites": {"frame_submission": 1}, + }, + "transient_physical_creations": {"textures": 0, "buffers": 0}, + }, + } + self.assertEqual(quality.steady_state_renderer_failures(renderer_paths), []) + + resources = renderer_paths["steady_state_resources"] + sites["final_composite"] = 1 + resources["bind_group_creations"]["total"] = 1 + resources["graph_compiles"] = 1 + resources["pipeline_creations"]["first_use"] = 1 + resources["command_encoder_creations"]["total"] = 2 + resources["transient_physical_creations"]["textures"] = 1 + failures = quality.steady_state_renderer_failures(renderer_paths) + self.assertIn( + "steady-state bind-group creation remained after warm-up", failures + ) + self.assertIn("render graph recompiled after warm-up", failures) + self.assertIn("pipeline creation remained after warm-up", failures) + self.assertIn( + "command-encoder total does not match the frame-submission site", failures + ) + self.assertIn("steady frame must create exactly one submission encoder", failures) + self.assertIn( + "transient physical textures were created after warm-up", failures + ) + + def test_telemetry_contract_rejects_vsync_and_wrong_frame_count(self) -> None: + case = { + "fixed_timestep": 1 / 60, + "warmup_frames": 120, + "measured_frames": 300, + "render_scale": 1.0, + "settings": {"quality_preset": 3}, + } + telemetry = { + "schema": "bloom-quality-telemetry-v1", + "fixed_timestep": 1 / 60, + "warmup_frames": 120, + "measured_frames": 299, + "quality_preset": 3, + "render_scale": 1.0, + "uncapped": False, + "warmup_excluded": True, + "shader_compilation_excluded": True, + "gpu_timestamps_available": True, + "adapter": {"availability": "reported"}, + "renderer_paths": {}, + "cpu_frame_mean_ms": 1.0, + "cpu_frame_p95_ms": 1.2, + "gpu_frame_mean_ms": 2.0, + "gpu_frame_p95_ms": 2.4, + "measurement_wall_ms": 100.0, + "passes": [{"label": "render_total"}], + } + failures = quality.telemetry_contract_failures(case, telemetry) + self.assertTrue(any("measured_frames" in item for item in failures)) + self.assertTrue(any("uncapped" in item for item in failures)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/render-perf/Cargo.lock b/tools/render-perf/Cargo.lock new file mode 100644 index 00000000..5475b9ff --- /dev/null +++ b/tools/render-perf/Cargo.lock @@ -0,0 +1,1513 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +dependencies = [ + "serde", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bloom-render-perf" +version = "0.1.0" +dependencies = [ + "bloom-shared", + "pollster", + "wgpu", +] + +[[package]] +name = "bloom-shared" +version = "0.1.0" +dependencies = [ + "bytemuck", + "cmake", + "earcutr", + "fontdue", + "half", + "image", + "lewton", + "libc", + "log", + "raw-window-handle", + "web-sys", + "wgpu", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "earcutr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" +dependencies = [ + "itertools", + "num-traits", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fontdue" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b" +dependencies = [ + "hashbrown 0.15.5", + "ttf-parser", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "serde", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "lewton" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030" +dependencies = [ + "byteorder", + "ogg", + "tinyvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash", + "serde", + "spirv", + "thiserror", + "unicode-ident", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "ogg" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e" +dependencies = [ + "byteorder", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" +dependencies = [ + "arrayvec", + "bitflags", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "macro_rules_attribute", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "ron", + "rustc-hash", + "serde", + "smallvec", + "thiserror", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags", + "block2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "ndk-sys", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-metal", + "objc2-quartz-core", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "thiserror", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" +dependencies = [ + "bitflags", + "bytemuck", + "js-sys", + "log", + "raw-window-handle", + "serde", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/tools/render-perf/Cargo.toml b/tools/render-perf/Cargo.toml new file mode 100644 index 00000000..0e70bfd5 --- /dev/null +++ b/tools/render-perf/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "bloom-render-perf" +version = "0.1.0" +edition = "2021" +description = "Controlled native renderer submit-time and upload-volume qualification" + +[[bin]] +name = "bloom-render-perf" +path = "src/main.rs" + +[dependencies] +bloom-shared = { path = "../../native/shared", default-features = false } +pollster = "0.4" +# Trace is enabled only in this qualification tool. Normal engine builds keep +# wgpu API tracing disabled and pay none of its serialization/file-I/O cost. +wgpu = { version = "29", features = ["trace"] } + +[profile.release] +opt-level = 3 +debug = false +lto = "thin" +codegen-units = 1 diff --git a/tools/render-perf/README.md b/tools/render-perf/README.md new file mode 100644 index 00000000..053fafb1 --- /dev/null +++ b/tools/render-perf/README.md @@ -0,0 +1,45 @@ +# Bloom renderer performance qualification + +`bloom-render-perf` runs a fixed Ultra static scene through the production +headless renderer. Its primary P50/P95/P99 `cpu_render_submit_ms` measurement +covers the complete fixed renderer frame: `begin_frame`, renderer draw/light +submission, and `end_frame`, before any FPS cap. This includes upload work done +by renderer API setters instead of starting the clock after that work. +`cpu_prepare_ms` and `cpu_end_frame_ms` are also emitted as diagnostics. The +tool disables Bloom's unrelated default audio/physics/model-loading features +so comparison worktrees do not depend on optional submodules and both +revisions compile the same renderer-only workload. + +Device creation uses the engine's production bounded negotiation and fallback +path. Every report embeds the complete adapter, renderer-tier/path, granted +feature/limit, selected device request, and fallback-cause snapshot under +`adapter`; a performance number without that capability evidence is not a +qualified result. + +The optional `--trace-dir` mode enables wgpu's API trace only in this tool and +sums every traced buffer/texture upload between the final measured submits. +Never use trace-mode timings as performance evidence: the report marks them as +including trace I/O. Run an untraced command for timing and a separate short +traced command for upload volume. + +When the tool is cherry-picked onto an older comparison revision, set +`BLOOM_RENDER_PERF_ENGINE_REVISION` to that engine commit so the JSON preserves +the actual code-under-test identity rather than the instrumentation commit. + +```sh +cargo run --release --manifest-path tools/render-perf/Cargo.toml -- \ + --width 1920 --height 1080 --warmup 180 --frames 300 \ + --out tools/quality/out/render-perf/1080p.json + +cargo run --release --manifest-path tools/render-perf/Cargo.toml -- \ + --width 1920 --height 1080 --warmup 32 --frames 32 \ + --trace-dir tools/quality/out/render-perf/trace-1080p \ + --out tools/quality/out/render-perf/1080p-uploads.json +``` + +The workload defaults to `--quality-preset 4`. Use +`--quality-preset 0..4` to measure a complete tier, or add +`--render-scale 0.15..1.0` after the preset to compare renderer revisions at +an identical shading resolution. Reports record both the requested preset and +the effective scale, plus the renderer-path/resource snapshot taken after the +measured window. diff --git a/tools/render-perf/src/main.rs b/tools/render-perf/src/main.rs new file mode 100644 index 00000000..33b63e4f --- /dev/null +++ b/tools/render-perf/src/main.rs @@ -0,0 +1,561 @@ +use bloom_shared::engine::EngineState; +use bloom_shared::models::MaterialAlphaMode; +use bloom_shared::renderer::device_negotiation::{ + request_device_with_fallback_and_trace, DeviceRequestOptions, DeviceRequestProfile, +}; +use bloom_shared::renderer::{Renderer, Vertex3D}; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +#[derive(Clone, Debug)] +struct Config { + width: u32, + height: u32, + warmup_frames: u32, + measured_frames: u32, + quality_preset: u32, + render_scale: Option, + reactive_transparency: bool, + profile_passes: bool, + trace_dir: Option, + output: PathBuf, +} + +#[derive(Clone, Copy, Debug, Default)] +struct Percentiles { + mean: f64, + p50: f64, + p95: f64, + p99: f64, + max: f64, +} + +#[derive(Clone, Copy, Debug, Default)] +struct UploadStats { + submit_count: usize, + measured_submit_count: usize, + buffer_total_bytes: u64, + texture_total_bytes: u64, + per_frame: Percentiles, +} + +#[derive(Clone, Copy, Debug, Default)] +struct FrameTiming { + render_submit_ms: f64, + prepare_ms: f64, + end_frame_ms: f64, +} + +fn parse_u32(value: Option, flag: &str) -> Result { + value + .ok_or_else(|| format!("missing value for {flag}"))? + .parse() + .map_err(|_| format!("{flag} must be an unsigned integer")) +} + +fn parse_f32(value: Option, flag: &str) -> Result { + value + .ok_or_else(|| format!("missing value for {flag}"))? + .parse() + .map_err(|_| format!("{flag} must be a number")) +} + +fn config() -> Result { + let mut width = 1920; + let mut height = 1080; + let mut warmup_frames = 180; + let mut measured_frames = 300; + let mut quality_preset = 4; + let mut render_scale = None; + let mut reactive_transparency = false; + let mut profile_passes = false; + let mut trace_dir = None; + let mut output = None; + let mut args = std::env::args().skip(1); + while let Some(flag) = args.next() { + match flag.as_str() { + "--width" => width = parse_u32(args.next(), "--width")?, + "--height" => height = parse_u32(args.next(), "--height")?, + "--warmup" => warmup_frames = parse_u32(args.next(), "--warmup")?, + "--frames" => measured_frames = parse_u32(args.next(), "--frames")?, + "--quality-preset" => { + quality_preset = parse_u32(args.next(), "--quality-preset")?.min(4); + } + "--render-scale" => { + render_scale = Some(parse_f32(args.next(), "--render-scale")?.clamp(0.15, 1.0)); + } + "--reactive-transparency" => reactive_transparency = true, + "--profile-passes" => profile_passes = true, + "--trace-dir" => { + trace_dir = Some(PathBuf::from( + args.next() + .ok_or_else(|| "missing value for --trace-dir".to_owned())?, + )); + } + "--out" => { + output = Some(PathBuf::from( + args.next() + .ok_or_else(|| "missing value for --out".to_owned())?, + )); + } + _ => return Err(format!("unknown argument {flag}")), + } + } + if width == 0 || height == 0 || warmup_frames == 0 || measured_frames < 2 { + return Err("width/height/warmup must be positive and frames must be >= 2".to_owned()); + } + Ok(Config { + width, + height, + warmup_frames, + measured_frames, + quality_preset, + render_scale, + reactive_transparency, + profile_passes, + trace_dir, + output: output.ok_or_else(|| "--out is required".to_owned())?, + }) +} + +fn percentile(sorted: &[f64], quantile: f64) -> f64 { + let index = ((sorted.len() - 1) as f64 * quantile).ceil() as usize; + sorted[index] +} + +fn percentiles(values: impl IntoIterator) -> Percentiles { + let mut sorted: Vec = values.into_iter().collect(); + if sorted.is_empty() { + return Percentiles::default(); + } + sorted.sort_by(|a, b| a.total_cmp(b)); + Percentiles { + mean: sorted.iter().sum::() / sorted.len() as f64, + p50: percentile(&sorted, 0.50), + p95: percentile(&sorted, 0.95), + p99: percentile(&sorted, 0.99), + max: *sorted.last().expect("non-empty percentile input"), + } +} + +fn draw_static_ultra_scene(engine: &mut EngineState) { + let renderer = &mut engine.renderer; + renderer.set_clear_color(2.0, 2.0, 4.0, 255.0); + renderer.begin_mode_3d( + 0.0, 8.0, 7.0, // eye + 0.0, 0.0, 0.0, // target + 0.0, 1.0, 0.0, 55.0, 0.0, + ); + renderer.set_ambient_light(20.0, 24.0, 32.0, 0.12); + renderer.set_directional_light(-0.5, -1.0, -0.3, 255.0, 242.0, 230.0, 1.2); + renderer.draw_plane(0.0, 0.0, 0.0, 14.0, 14.0, 110.0, 110.0, 110.0, 255.0); + renderer.draw_cube(0.0, 0.8, 0.0, 1.6, 1.6, 1.6, 210.0, 105.0, 35.0, 255.0); + for i in 0..40u32 { + let t = i as f32 / 40.0 * std::f32::consts::TAU; + renderer.add_point_light( + t.cos() * 4.0, + 1.2, + t.sin() * 4.0, + 3.5, + 0.5 + 0.5 * t.cos(), + 0.5 + 0.5 * (t + 2.094).cos(), + 0.5 + 0.5 * (t + 4.189).cos(), + 1.6, + ); + } +} + +fn setup_reactive_transparency(engine: &mut EngineState) { + let h = 0.9; + let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [ + ( + [0.0, 0.0, -1.0], + [[-h, -h, -h], [h, -h, -h], [h, h, -h], [-h, h, -h]], + ), + ( + [0.0, 0.0, 1.0], + [[h, -h, h], [-h, -h, h], [-h, h, h], [h, h, h]], + ), + ( + [-1.0, 0.0, 0.0], + [[-h, -h, h], [-h, -h, -h], [-h, h, -h], [-h, h, h]], + ), + ( + [1.0, 0.0, 0.0], + [[h, -h, -h], [h, -h, h], [h, h, h], [h, h, -h]], + ), + ( + [0.0, 1.0, 0.0], + [[-h, h, -h], [h, h, -h], [h, h, h], [-h, h, h]], + ), + ( + [0.0, -1.0, 0.0], + [[-h, -h, h], [h, -h, h], [h, -h, -h], [-h, -h, -h]], + ), + ]; + let mut vertices = Vec::with_capacity(24); + let mut indices = Vec::with_capacity(36); + for (normal, positions) in faces { + let base = vertices.len() as u32; + for position in positions { + vertices.push(Vertex3D { + position, + normal, + color: [0.1, 0.8, 1.0, 0.65], + uv: [0.0, 0.0], + joints: [0.0; 4], + weights: [0.0; 4], + tangent: [0.0; 4], + }); + } + indices.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]); + } + let node = engine.scene.create_node(); + engine.scene.update_geometry(node, vertices, indices); + engine.scene.set_transform( + node, + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 1.2, 0.0, 1.0], + ], + ); + engine + .scene + .set_material_gltf_alpha(node, MaterialAlphaMode::Blend, 0.0, false); + engine.scene.set_material_color(node, 0.1, 0.8, 1.0, 0.65); +} + +fn render_frame(engine: &mut EngineState) -> FrameTiming { + let frame_start = Instant::now(); + engine.begin_frame(); + draw_static_ultra_scene(engine); + let prepare_ms = frame_start.elapsed().as_secs_f64() * 1000.0; + let end_start = Instant::now(); + engine.end_frame(); + FrameTiming { + render_submit_ms: frame_start.elapsed().as_secs_f64() * 1000.0, + prepare_ms, + end_frame_ms: end_start.elapsed().as_secs_f64() * 1000.0, + } +} + +fn create_engine(config: &Config) -> Result<(EngineState, String), String> { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::PRIMARY, + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + let adapter = + pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .map_err(|error| format!("request_adapter failed: {error}"))?; + let info = adapter.get_info(); + if info.device_type == wgpu::DeviceType::Cpu { + return Err(format!( + "refusing performance qualification on CPU adapter {}", + info.name + )); + } + if let Some(directory) = &config.trace_dir { + if directory.exists() { + std::fs::remove_dir_all(directory) + .map_err(|error| format!("clear trace directory: {error}"))?; + } + std::fs::create_dir_all(directory) + .map_err(|error| format!("create trace directory: {error}"))?; + } + let trace = config.trace_dir.as_ref().map_or(wgpu::Trace::Off, |path| { + wgpu::Trace::Directory(path.clone()) + }); + let negotiated = pollster::block_on(request_device_with_fallback_and_trace( + &adapter, + DeviceRequestOptions { + // This workload measures the raster steady-state path. Requesting + // ray query can itself change backend scheduling even while PT is + // off, so keep the unused feature out of this comparison. + allow_ray_query: false, + profile: DeviceRequestProfile::NativeFull, + }, + trace, + )) + .map_err(|error| format!("request_device failed: {error}"))?; + let negotiation_report = negotiated.report.report_json(); + let mut renderer = Renderer::new_headless( + negotiated.device, + negotiated.queue, + config.width, + config.height, + ); + renderer.set_device_negotiation_report(negotiation_report); + renderer.apply_quality_preset(config.quality_preset); + if let Some(render_scale) = config.render_scale { + renderer.set_render_scale(render_scale); + } + let adapter_snapshot = renderer.quality_adapter_json(); + let mut engine = EngineState::new(renderer); + if config.reactive_transparency { + setup_reactive_transparency(&mut engine); + } + engine.target_fps = 0.0; + Ok((engine, adapter_snapshot)) +} + +fn data_file(line: &str) -> Option<&str> { + let marker = "File(\""; + let start = line.find(marker)? + marker.len(); + let end = line[start..].find("\")")? + start; + Some(&line[start..end]) +} + +fn trace_uploads(directory: &Path, measured_frames: u32) -> Result { + let trace_path = directory.join("trace.ron"); + let trace = std::fs::read_to_string(&trace_path) + .map_err(|error| format!("read {}: {error}", trace_path.display()))?; + // wgpu-core keeps its global trace recorder alive until process teardown, + // so a live-process snapshot need not have the final closing bracket yet. + // DiskTrace writes each action directly to File; complete Submit actions + // and their preceding upload payloads are safe to analyze here. + #[derive(Clone, Copy)] + enum WriteKind { + Buffer, + Texture, + } + let mut current_write = None; + let mut frame_buffer_bytes = 0u64; + let mut frame_texture_bytes = 0u64; + let mut frames = Vec::new(); + for line in trace.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("WriteBuffer(") { + current_write = Some(WriteKind::Buffer); + } else if trimmed.starts_with("WriteTexture(") { + current_write = Some(WriteKind::Texture); + } + if let (Some(kind), Some(file)) = (current_write, data_file(trimmed)) { + let bytes = std::fs::metadata(directory.join(file)) + .map_err(|error| format!("stat traced upload {file}: {error}"))? + .len(); + match kind { + WriteKind::Buffer => frame_buffer_bytes += bytes, + WriteKind::Texture => frame_texture_bytes += bytes, + } + current_write = None; + } + if trimmed.starts_with("Submit(") { + frames.push((frame_buffer_bytes, frame_texture_bytes)); + frame_buffer_bytes = 0; + frame_texture_bytes = 0; + current_write = None; + } + } + let measured = measured_frames as usize; + if frames.len() < measured { + return Err(format!( + "trace has {} submissions, fewer than {measured} measured frames", + frames.len() + )); + } + let selected = &frames[frames.len() - measured..]; + let buffer_total_bytes = selected.iter().map(|(buffer, _)| *buffer).sum(); + let texture_total_bytes = selected.iter().map(|(_, texture)| *texture).sum(); + Ok(UploadStats { + submit_count: frames.len(), + measured_submit_count: selected.len(), + buffer_total_bytes, + texture_total_bytes, + per_frame: percentiles( + selected + .iter() + .map(|(buffer, texture)| (buffer + texture) as f64), + ), + }) +} + +fn json_escape(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") +} + +fn write_report( + config: &Config, + adapter_snapshot: &str, + renderer_paths: &str, + actual_render_scale: f32, + render_submit: Percentiles, + prepare: Percentiles, + end_frame: Percentiles, + uploads: Option, + pass_profile: Option<&str>, +) -> Result<(), String> { + let revision = std::env::var("BLOOM_RENDER_PERF_ENGINE_REVISION").unwrap_or_else(|_| { + std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|value| value.trim().to_owned()) + .unwrap_or_else(|| "unknown".to_owned()) + }); + let upload_json = uploads.map_or_else( + || "null".to_owned(), + |value| { + format!( + concat!( + "{{\"trace_submit_count\":{},\"measured_submit_count\":{},", + "\"buffer_total_bytes\":{},\"texture_total_bytes\":{},", + "\"total_bytes\":{},\"per_frame_mean_bytes\":{:.3},", + "\"per_frame_p50_bytes\":{:.3},\"per_frame_p95_bytes\":{:.3},", + "\"per_frame_p99_bytes\":{:.3},\"per_frame_max_bytes\":{:.3}}}" + ), + value.submit_count, + value.measured_submit_count, + value.buffer_total_bytes, + value.texture_total_bytes, + value.buffer_total_bytes + value.texture_total_bytes, + value.per_frame.mean, + value.per_frame.p50, + value.per_frame.p95, + value.per_frame.p99, + value.per_frame.max, + ) + }, + ); + let pass_profile_json = pass_profile.unwrap_or("null"); + let report = format!( + concat!( + "{{\n \"schema\":\"bloom-render-perf-v1\",\n", + " \"revision\":\"{}\",\n", + " \"adapter\":{},\n", + " \"renderer_paths\":{},\n", + " \"resolution\":[{},{}],\n", + " \"quality_preset\":{},\n", + " \"render_scale\":{:.6},\n", + " \"reactive_transparency_workload\":{},\n", + " \"pass_profile\":{},\n", + " \"headless_uncapped\":true,\n", + " \"warmup_frames\":{},\n", + " \"measured_frames\":{},\n", + " \"timing_includes_trace_io\":{},\n", + " \"cpu_render_submit_ms\":{{\"mean\":{:.6},\"p50\":{:.6},", + "\"p95\":{:.6},\"p99\":{:.6},\"max\":{:.6}}},\n", + " \"cpu_prepare_ms\":{{\"mean\":{:.6},\"p50\":{:.6},", + "\"p95\":{:.6},\"p99\":{:.6},\"max\":{:.6}}},\n", + " \"cpu_end_frame_ms\":{{\"mean\":{:.6},\"p50\":{:.6},", + "\"p95\":{:.6},\"p99\":{:.6},\"max\":{:.6}}},\n", + " \"uploads\":{}\n}}\n" + ), + json_escape(&revision), + adapter_snapshot, + renderer_paths, + config.width, + config.height, + config.quality_preset, + actual_render_scale, + config.reactive_transparency, + pass_profile_json, + config.warmup_frames, + config.measured_frames, + config.trace_dir.is_some(), + render_submit.mean, + render_submit.p50, + render_submit.p95, + render_submit.p99, + render_submit.max, + prepare.mean, + prepare.p50, + prepare.p95, + prepare.p99, + prepare.max, + end_frame.mean, + end_frame.p50, + end_frame.p95, + end_frame.p99, + end_frame.max, + upload_json, + ); + if let Some(parent) = config.output.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("create report directory: {error}"))?; + } + std::fs::write(&config.output, report) + .map_err(|error| format!("write {}: {error}", config.output.display())) +} + +fn run() -> Result<(), String> { + let config = config()?; + let (mut engine, adapter_snapshot) = create_engine(&config)?; + let actual_render_scale = engine.renderer.render_scale(); + for _ in 0..config.warmup_frames { + let _ = render_frame(&mut engine); + } + if config.profile_passes { + engine.profiler.set_enabled(true); + } + let measurement_start = Instant::now(); + let mut timing_samples = Vec::with_capacity(config.measured_frames as usize); + for _ in 0..config.measured_frames { + timing_samples.push(render_frame(&mut engine)); + } + let measurement_wall_ms = measurement_start.elapsed().as_secs_f64() * 1000.0; + let _ = engine.renderer.device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); + let render_submit = percentiles(timing_samples.iter().map(|sample| sample.render_submit_ms)); + let prepare = percentiles(timing_samples.iter().map(|sample| sample.prepare_ms)); + let end_frame = percentiles(timing_samples.iter().map(|sample| sample.end_frame_ms)); + let renderer_paths = engine.renderer.quality_runtime_paths_json(); + let pass_profile = config.profile_passes.then(|| { + engine.profiler.quality_report_json( + 3, + config.warmup_frames, + config.measured_frames, + 1.0 / 60.0, + config.quality_preset, + actual_render_scale as f64, + measurement_wall_ms, + &adapter_snapshot, + &renderer_paths, + ) + }); + drop(engine); + let uploads = config + .trace_dir + .as_deref() + .map(|directory| trace_uploads(directory, config.measured_frames)) + .transpose()?; + write_report( + &config, + &adapter_snapshot, + &renderer_paths, + actual_render_scale, + render_submit, + prepare, + end_frame, + uploads, + pass_profile.as_deref(), + )?; + println!( + "bloom-render-perf {}x{} CPU p50={:.3} p95={:.3} p99={:.3} ms{}", + config.width, + config.height, + render_submit.p50, + render_submit.p95, + render_submit.p99, + uploads.map_or(String::new(), |value| format!( + " upload/frame={:.0} bytes", + value.per_frame.mean + )) + ); + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("bloom-render-perf: {error}"); + std::process::exit(2); + } +} diff --git a/tools/validate-ffi.js b/tools/validate-ffi.js index 53a1f6c2..91ba1466 100644 --- a/tools/validate-ffi.js +++ b/tools/validate-ffi.js @@ -44,6 +44,92 @@ const NOT_IN_MANIFEST_ALLOWLIST = new Set([ // name presence is checked but a thinner hand-written set is expected. const STUB_PLATFORMS = new Set(['watchos']); +// Renderer controls are status-bearing by contract (#138): 1.0 means the +// active backend applied the request, 0.0 means unsupported/rejected. Keeping +// the policy here prevents a future platform mirror from quietly reverting a +// setter to a void no-op. +const RENDERER_STATUS_FUNCTIONS = new Set([ + 'bloom_set_env_clear_from_hdr', + 'bloom_set_material_params_scratch', + 'bloom_set_material_reflection_probe', + 'bloom_set_material_texture_array', + 'bloom_set_material_shading_model', + 'bloom_set_material_probe_visible', + 'bloom_set_material_foliage', + 'bloom_clear_post_pass', + 'bloom_clear_all_post_passes', + 'bloom_set_joint_test', + 'bloom_set_ambient_light', + 'bloom_set_directional_light', + 'bloom_set_procedural_sky', + 'bloom_set_sun_direction', + 'bloom_set_fog', + 'bloom_set_chromatic_aberration', + 'bloom_set_vignette', + 'bloom_set_film_grain', + 'bloom_set_sharpen_strength', + 'bloom_set_present_mode', + 'bloom_set_transparency_composition_mode', + 'bloom_set_sun_shafts', + 'bloom_set_auto_exposure', + 'bloom_set_taa_enabled', + 'bloom_set_occlusion_culling', + 'bloom_set_render_scale', + 'bloom_set_upscale_mode', + 'bloom_set_cas_strength', + 'bloom_set_auto_resolution', + 'bloom_set_manual_exposure', + 'bloom_set_env_intensity', + 'bloom_set_ssgi_enabled', + 'bloom_set_path_tracing', + 'bloom_reset_temporal_history', + 'bloom_set_ssgi_intensity', + 'bloom_set_ssgi_radius', + 'bloom_set_dof', + 'bloom_set_quality_preset', + 'bloom_set_shadows_enabled', + 'bloom_set_shadows_always_fresh', + 'bloom_set_bloom_enabled', + 'bloom_set_bloom_intensity', + 'bloom_set_tonemap', + 'bloom_set_auto_exposure_key', + 'bloom_set_auto_exposure_rate', + 'bloom_set_ssao_enabled', + 'bloom_set_ssao_intensity', + 'bloom_set_ssao_radius', + 'bloom_set_wind', + 'bloom_set_output_scale', + 'bloom_set_model_foliage_wind', + 'bloom_set_foliage_shadow_motion', + 'bloom_set_cloud_shadows', + 'bloom_set_ssr_enabled', + 'bloom_set_motion_blur_enabled', + 'bloom_set_sss_enabled', + 'bloom_scene_set_visible', + 'bloom_scene_set_cast_shadow', + 'bloom_scene_set_gi_only', + 'bloom_scene_set_receive_shadow', + 'bloom_scene_set_parent', + 'bloom_scene_set_transform', + 'bloom_scene_set_trs', + 'bloom_scene_set_transform16', + 'bloom_scene_set_lod', + 'bloom_scene_attach_model_lod', + 'bloom_scene_set_material_color', + 'bloom_scene_set_material_pbr', + 'bloom_scene_set_material_emissive', + 'bloom_scene_set_material_layered_pbr', + 'bloom_scene_set_material_texture', + 'bloom_scene_set_material_water', + 'bloom_scene_set_user_data', + 'bloom_enable_shadows', + 'bloom_disable_shadows', + 'bloom_postfx_set_selected', + 'bloom_postfx_set_hovered', + 'bloom_postfx_set_outline_color', + 'bloom_postfx_set_outline_thickness', +]); + // --------------------------------------------------------------------------- // parsing helpers @@ -82,6 +168,26 @@ function extractRustFns(src) { return fns; } +/** Return every declared Rust return type for a named exported function. */ +function extractRustReturnTypes(src, name) { + const returns = []; + const re = new RegExp(`pub (?:async )?(?:extern "C" )?fn ${name}\\s*\\(`, 'g'); + let match; + while ((match = re.exec(src)) !== null) { + let depth = 1, i = re.lastIndex; + while (i < src.length && depth > 0) { + if (src[i] === '(') depth++; + else if (src[i] === ')') depth--; + i++; + } + const bodyStart = src.indexOf('{', i); + const headerTail = bodyStart < 0 ? src.slice(i) : src.slice(i, bodyStart); + const returnMatch = headerTail.match(/->\s*([A-Za-z0-9_*:<>]+)/); + returns.push(returnMatch ? returnMatch[1] : '()'); + } + return returns; +} + function readDirRust(dir) { let all = ''; for (const f of fs.readdirSync(dir)) { @@ -90,11 +196,25 @@ function readDirRust(dir) { return all; } +function readTreeTypeScript(dir) { + let all = ''; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const item = path.join(dir, entry.name); + if (entry.isDirectory()) all += readTreeTypeScript(item); + else if (entry.name.endsWith('.ts')) all += fs.readFileSync(item, 'utf8') + '\n'; + } + return all; +} + // --------------------------------------------------------------------------- // 1. manifest const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); const manifest = new Map(); // name -> param count -for (const f of pkg.perry.nativeLibrary.functions) manifest.set(f.name, f.params.length); +const manifestReturns = new Map(); +for (const f of pkg.perry.nativeLibrary.functions) { + manifest.set(f.name, f.params.length); + manifestReturns.set(f.name, f.returns); +} // 2. shared macros const coreSrc = readDirRust(path.join(ROOT, 'native/shared/src/ffi_core')); @@ -102,11 +222,47 @@ const coreFns = extractRustFns(coreSrc); const physSrc = fs.readFileSync(path.join(ROOT, 'native/shared/src/physics_jolt.rs'), 'utf8'); const physFns = extractRustFns(physSrc); -// 3. platforms let failures = 0, warnings = 0; const fail = (msg) => { console.error(`FAIL ${msg}`); failures++; }; const warn = (msg) => { console.warn(`warn ${msg}`); warnings++; }; +const watchSrc = readDirRust(path.join(ROOT, 'native/watchos/src')); +const webSrcForStatus = readDirRust(path.join(ROOT, 'native/web/src')); +const typeScriptApiSrc = readTreeTypeScript(path.join(ROOT, 'src')); +for (const name of RENDERER_STATUS_FUNCTIONS) { + if (manifestReturns.get(name) !== 'f64') { + fail(`${name}: renderer-control manifest return must be f64 status`); + } + for (const [surface, src] of [ + ['shared', coreSrc], + ['watchos', watchSrc], + ['web', webSrcForStatus], + ]) { + const returns = extractRustReturnTypes(src, name); + if (returns.length === 0) { + fail(`${surface}: status-bearing renderer control ${name} is not exported`); + } else if (returns.some((value) => value !== 'f64')) { + fail(`${surface}: ${name} must return f64 status (found ${returns.join(', ')})`); + } + } + let declarationCount = 0; + const marker = `declare function ${name}(`; + for (let start = typeScriptApiSrc.indexOf(marker); + start >= 0; + start = typeScriptApiSrc.indexOf(marker, start + marker.length)) { + declarationCount++; + const end = typeScriptApiSrc.indexOf(';', start); + const declaration = typeScriptApiSrc.slice(start, end + 1); + if (!declaration.endsWith(': number;')) { + fail(`TypeScript declaration for ${name} must expose numeric status`); + } + } + if (declarationCount === 0) { + fail(`TypeScript API does not declare status-bearing renderer control ${name}`); + } +} + +// 3. platforms for (const platform of PLATFORMS) { const dir = path.join(ROOT, 'native', platform, 'src'); if (!fs.existsSync(dir)) continue; @@ -203,20 +359,11 @@ for (const platform of PLATFORMS) { 'bloom_attach_hwnd', // Pointer-taking mesh scratch buffers (#69) — same cross-module WASM // linear-memory bridge TODO as bloom_scene_set_lod. - // Art-direction post-FX controls (#69) not yet wired in the web crate. - 'bloom_set_bloom_intensity', - 'bloom_set_tonemap', - 'bloom_set_auto_exposure_key', - 'bloom_set_auto_exposure_rate', - 'bloom_set_sharpen_strength', // same post-FX group; web port TODO // Profiler ABI (round-2 EN-011 / EN-020) — GPU-timestamp profiling // needs TIMESTAMP_QUERY, which WebGPU does not expose, so neither the // numeric row/history accessors nor the text overlay have a web port. // Present mode (round-2 #80) — the browser owns swap/vsync; the // Fifo/Mailbox/Immediate selector is a no-op on web. - // Scene-node setters (round-2) — same Perry-WASM linear-memory bridge - // TODO as bloom_scene_set_lod above. - 'bloom_scene_set_trs', // Pointer-taking scratch buffers (round-2) — same cross-module WASM // linear-memory bridge TODO as the mesh scratch group above. // Water-ripple impulse (round-2 splat compute) — not yet wired on web. @@ -227,11 +374,6 @@ for (const platform of PLATFORMS) { 'bloom_scene_update_geometry_scratch', // Scene-node transform setter — same group as bloom_scene_set_trs above; // web's scene-node setters are only partially ported. - // Path tracing (PT-1..8) — the PT backend requires wgpu's - // Features::EXPERIMENTAL_RAY_QUERY, which WebGPU does not expose. Same - // class of hard platform gap as the profiler's TIMESTAMP_QUERY entries. - 'bloom_set_path_tracing', - 'bloom_path_tracing_supported', ]); const missing = []; for (const name of manifest.keys()) { diff --git a/tools/validate.sh b/tools/validate.sh index 0ffcf6af..a1385387 100755 --- a/tools/validate.sh +++ b/tools/validate.sh @@ -11,6 +11,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +echo "note: tools/validate.sh is legacy exploration; use tools/quality/run.py for qualification" >&2 RENDERER_TEST="$REPO_ROOT/examples/renderer-test" REF_BIN="$REPO_ROOT/tools/bloom-reference/target/release/bloom-reference" DIFF_BIN="$REPO_ROOT/tools/bloom-diff/target/release/bloom-diff"