From 98cce628066321769749284f15e766f9756bf980 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 10 Sep 2026 22:21:43 +0200 Subject: [PATCH 1/4] ci: retain canonical Metal images for portability diagnosis --- .github/workflows/image-portability.yml | 127 ++++++++++++++++++++++++ docs/windows-engine-plan.md | 7 +- tools/quality/README.md | 9 ++ 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/image-portability.yml diff --git a/.github/workflows/image-portability.yml b/.github/workflows/image-portability.yml new file mode 100644 index 00000000..b17d892f --- /dev/null +++ b/.github/workflows/image-portability.yml @@ -0,0 +1,127 @@ +name: Portable image diagnostics + +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/image-portability.yml + - native/shared/** + - native/macos/** + - src/** + - examples/quality-motion/** + - examples/sponza/** + - tools/quality/** + +permissions: + contents: read + +concurrency: + group: image-portability-${{ github.ref }} + cancel-in-progress: true + +jobs: + metal: + name: Canonical image captures / Metal + runs-on: macos-14 + timeout-minutes: 30 + env: + MACOSX_DEPLOYMENT_TARGET: "13.0" + BLOOM_WGPU_BACKEND: metal + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + submodules: recursive + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: actions/setup-node@v4 + with: + node-version: "22" + - uses: dtolnay/rust-toolchain@stable + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/macos/target + key: ${{ runner.os }}-macos-crate-${{ hashFiles('native/macos/Cargo.lock') }} + restore-keys: ${{ runner.os }}-macos-crate- + - name: Install the same Perry release used by the Windows captures + shell: bash + run: | + set -euo pipefail + archive="$RUNNER_TEMP/perry-macos-aarch64.tar.gz" + toolchain="$RUNNER_TEMP/bloom-perry-0.5.1182" + curl --fail --location --retry 3 \ + https://github.com/PerryTS/perry/releases/download/v0.5.1182/perry-macos-aarch64.tar.gz \ + --output "$archive" + printf '%s %s\n' \ + 5a1682060342e73f94d74c1f4f8a6d6a14caac3c43c81e8f657ced1190fa04af \ + "$archive" | shasum -a 256 --check + mkdir -p "$toolchain" + tar -xzf "$archive" -C "$toolchain" \ + --exclude '*_ios*' --exclude '*_tvos*' \ + --exclude '*_visionos*' --exclude '*_watchos*' + "$toolchain/perry" --version + echo "$toolchain" >> "$GITHUB_PATH" + mkdir -p target/ci + { + git rev-parse HEAD + shasum -a 256 "$archive" + "$toolchain/perry" --version + } > target/ci/image-portability-toolchain.txt + - name: Build the native library + run: ./scripts/ci-check.sh --full --component host-build + - name: Capture the canonical scenes and all required intermediates + run: | + python3 tools/quality/run.py run full \ + --case sponza-interior --case skinned-alpha-motion \ + --report-only --out tools/quality/out/ci-image-portability-metal + - name: Reject incomplete diagnostic captures + shell: bash + run: | + python3 - <<'PY' + import json + import tomllib + from pathlib import Path + + out = Path("tools/quality/out/ci-image-portability-metal") + result = json.loads((out / "result.json").read_text()) + manifest = tomllib.loads(Path("tools/quality/scenes.toml").read_text()) + cases = {case["id"]: case for case in result["cases"]} + assert set(cases) == {"sponza-interior", "skinned-alpha-motion"} + for spec in manifest["case"]: + if spec["id"] not in cases: + continue + case = cases[spec["id"]] + assert case["status"] in {"pass", "fail"}, case + capture = next(c for c in case["commands"] if c["kind"] == "capture") + assert capture["returncode"] == 0, capture + case_dir = out / "cases" / spec["id"] + telemetry = json.loads((case_dir / "telemetry.json").read_text()) + assert telemetry["adapter"]["backend"].lower() == "metal", telemetry + assert (case_dir / "final.png").is_file(), case + for name in spec.get("required_intermediates", []): + assert (case_dir / "intermediates" / f"{name}.png").is_file(), name + print("Both Metal captures are complete. Visual differences remain in result.json.") + PY + - name: Publish diagnostic summary + if: always() + shell: bash + run: | + echo 'Image diagnostics only; shared-runner timing does not qualify hardware budgets.' >> "$GITHUB_STEP_SUMMARY" + if [[ -f tools/quality/out/ci-image-portability-metal/summary.md ]]; then + cat tools/quality/out/ci-image-portability-metal/summary.md >> "$GITHUB_STEP_SUMMARY" + fi + - name: Retain success and failure evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: image-portability-metal-${{ github.run_attempt }} + path: | + tools/quality/out/ci-image-portability-metal + target/ci + if-no-files-found: warn + retention-days: 30 diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md index ad801035..23adf061 100644 --- a/docs/windows-engine-plan.md +++ b/docs/windows-engine-plan.md @@ -66,10 +66,15 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json frames. Hosted Metal also passes the profiler regression. Its colored-shadow failure exposed an [inverse-matrix upload defect](evidence/windows-transmitted-shadow-inverse-vp-v1.md); the correction passes the isolated local check and rejects the wrong-color - control. Hosted CI must qualify this follow-up's exact source before integration. + control. At follow-up source `fa93690`, all 23 hosted checks and the complete + local shared suite pass. Both regressions pass on Metal. The shadow archive + and CI receipt are published alongside the immutable profiler archive. 3. Diagnose Sponza and skinned/alpha against the portable baselines, then repair HD temporal stability and complete the representative temporal/geometry corpus. Recapture affected timing evidence with explicit coverage fields. + Disabling foliage shadow casting retains the Windows skinned/alpha mismatch; + the next diagnostic captures the same canonical scenes and intermediates on + hosted Metal. Shared-runner timing cannot qualify hardware budgets. 4. Continue starter/all-example and release-install checks, asset/world streaming, schema-generated APIs, components, and runtime UI against each issue's full acceptance criteria. Hardware-specific acceptance remains open while local diff --git a/tools/quality/README.md b/tools/quality/README.md index a75e99cd..dd264cc6 100644 --- a/tools/quality/README.md +++ b/tools/quality/README.md @@ -48,6 +48,15 @@ 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. +The `Portable image diagnostics` GitHub workflow captures Sponza and skinned/alpha +motion on hosted Metal with the canonical commands and a SHA-256-pinned Perry +0.5.1182 toolchain. It uploads final images, required intermediates, telemetry, +diff metrics, and source/toolchain identity on success and failure. It uses +`--report-only` to retain visual failures for backend investigation, then rejects +incomplete captures separately. A green diagnostic job proves capture +completeness; inspect `result.json` for visual results. Shared-runner timing does +not qualify the Apple M1 Max or RTX 4080 budgets. + The Radeon 760M profile selects Vulkan, opts into hardware GI, verifies the reported adapter, and records host preflight/postflight CPU load. Visual, intermediate-image, and telemetry contracts remain strict. Performance is From 1965a0b8fa038c9a157a191fc6f211143bbb8092 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 10 Sep 2026 22:33:47 +0200 Subject: [PATCH 2/4] ci: retain GPU capability skips and clarify Metal profiler coverage --- .github/workflows/test.yml | 4 ++++ docs/evidence/windows-profiler-integrity-v1.md | 7 +++++++ docs/windows-engine-plan.md | 14 ++++++++++---- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ad66e708..cb8ece54 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,6 +67,10 @@ jobs: restore-keys: ${{ runner.os }}-shared- - name: quick / shared-tests + # Retain adapter identities and capability skips even when Rust reports + # an early-returning GPU test as "ok". + env: + RUST_TEST_NOCAPTURE: "1" run: ./scripts/ci-check.sh --quick --component shared-tests - name: Upload failure evidence diff --git a/docs/evidence/windows-profiler-integrity-v1.md b/docs/evidence/windows-profiler-integrity-v1.md index ae253f06..b496b66f 100644 --- a/docs/evidence/windows-profiler-integrity-v1.md +++ b/docs/evidence/windows-profiler-integrity-v1.md @@ -69,6 +69,13 @@ passes on the Radeon through DX12. The regression compares actual query events instead of imposing a GPU-duration threshold. The new SSGI measurement assertion requires all 120 measured GPU frames to be complete. +The hosted macOS shared lane is green, but its timestamp regression's `ok` +status alone does not prove GPU execution: the fixture returns early when +timestamps are unavailable. A subsequent native capture on the hosted Apple +Paravirtual device reports no timestamp capability. Metal GPU timestamp +qualification remains unproven. The initial release notes and shadow archive +README overstated that coverage; this clarification supersedes those claims. + ## Corrected SSGI comparison Both frozen executables use the corrected profiler and identical fixture code. diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md index 23adf061..060dc7c2 100644 --- a/docs/windows-engine-plan.md +++ b/docs/windows-engine-plan.md @@ -63,18 +63,24 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json lane, and the complete shared suite. Its current-frame regression rejects all 12 old Vulkan samples and passes with the correction on Vulkan and DX12. Corrected SSGI timing covers 20 isolated runs, each with 120 complete GPU - frames. Hosted Metal also passes the profiler regression. Its colored-shadow + frames. Hosted Metal's shared lane passes; GPU timestamp validation there + remains unproven because a capability skip can also report a test as "ok". + Its colored-shadow failure exposed an [inverse-matrix upload defect](evidence/windows-transmitted-shadow-inverse-vp-v1.md); the correction passes the isolated local check and rejects the wrong-color control. At follow-up source `fa93690`, all 23 hosted checks and the complete - local shared suite pass. Both regressions pass on Metal. The shadow archive + local shared suite pass. The shadow regression passes on Metal. The shadow archive and CI receipt are published alongside the immutable profiler archive. 3. Diagnose Sponza and skinned/alpha against the portable baselines, then repair HD temporal stability and complete the representative temporal/geometry corpus. Recapture affected timing evidence with explicit coverage fields. Disabling foliage shadow casting retains the Windows skinned/alpha mismatch; - the next diagnostic captures the same canonical scenes and intermediates on - hosted Metal. Shared-runner timing cannot qualify hardware budgets. + canonical captures at `98cce62` pass on hosted Metal with SSIM 0.997442544 for + Sponza and 0.999417603 for skinned/alpha. That Apple Paravirtual adapter uses + the modern tier and software GI and exposes no timestamps. Raw intermediates + are retained in PR #157's diagnostic artifact. Compare matching fallback + paths before assigning a backend cause. Shared-runner timing cannot qualify + hardware budgets. 4. Continue starter/all-example and release-install checks, asset/world streaming, schema-generated APIs, components, and runtime UI against each issue's full acceptance criteria. Hardware-specific acceptance remains open while local From 0dd8f67480f154f462f9db74b155431d948a6609 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 10 Sep 2026 22:40:50 +0200 Subject: [PATCH 3/4] feat: export exact attachment bytes for quality diagnostics --- .github/workflows/image-portability.yml | 12 +++ native/shared/src/ffi_core/assets.rs | 4 +- native/shared/src/renderer/quality_capture.rs | 3 + .../src/renderer/quality_capture_raw.rs | 94 +++++++++++++++++++ tools/quality/README.md | 9 ++ 5 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 native/shared/src/renderer/quality_capture_raw.rs diff --git a/.github/workflows/image-portability.yml b/.github/workflows/image-portability.yml index b17d892f..9b7a6231 100644 --- a/.github/workflows/image-portability.yml +++ b/.github/workflows/image-portability.yml @@ -27,6 +27,7 @@ jobs: env: MACOSX_DEPLOYMENT_TARGET: "13.0" BLOOM_WGPU_BACKEND: metal + BLOOM_QUALITY_RAW: "1" CARGO_TERM_COLOR: always steps: - uses: actions/checkout@v4 @@ -105,6 +106,17 @@ jobs: assert (case_dir / "final.png").is_file(), case for name in spec.get("required_intermediates", []): assert (case_dir / "intermediates" / f"{name}.png").is_file(), name + raw = case_dir / "intermediates" / "raw" + depth = json.loads((raw / "scene-depth.json").read_text()) + assert depth["format"] == "depth32float", depth + assert (raw / "scene-depth.raw").stat().st_size == depth["byte_count"] + mrt = case_dir / "intermediates" / "mrt" + attachments = json.loads((mrt / "scene-mrt.json").read_text()) + assert {a["name"] for a in attachments["attachments"]} == { + "hdr-scene", "material-properties", "motion-vectors", "albedo" + }, attachments + for attachment in attachments["attachments"]: + assert (mrt / (attachment["name"] + ".raw")).stat().st_size == attachment["byte_count"] print("Both Metal captures are complete. Visual differences remain in result.json.") PY - name: Publish diagnostic summary diff --git a/native/shared/src/ffi_core/assets.rs b/native/shared/src/ffi_core/assets.rs index 823591d8..4a81330a 100644 --- a/native/shared/src/ffi_core/assets.rs +++ b/native/shared/src/ffi_core/assets.rs @@ -52,7 +52,7 @@ macro_rules! __bloom_ffi_assets { 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); + eng.renderer.request_quality_capture(directory); } } } @@ -81,7 +81,7 @@ macro_rules! __bloom_ffi_assets { ); return 0.0; } - engine().renderer.pending_quality_capture_dir = Some(path); + engine().renderer.request_quality_capture(path); 1.0 }) } diff --git a/native/shared/src/renderer/quality_capture.rs b/native/shared/src/renderer/quality_capture.rs index 69498b2a..ab85bb23 100644 --- a/native/shared/src/renderer/quality_capture.rs +++ b/native/shared/src/renderer/quality_capture.rs @@ -9,6 +9,8 @@ use std::sync::mpsc; #[path = "capture_pixels.rs"] mod capture_pixels; use capture_pixels::{frame_rgb, rgba8_rgb}; +#[path = "quality_capture_raw.rs"] +mod raw; use super::util::encode_png_simple; use super::weighted_transparency::WEIGHTED_TRANSPARENCY_AUTO_DRAW_THRESHOLD; @@ -847,6 +849,7 @@ impl Renderer { continue; } let data = readback.buffer.slice(..).get_mapped_range(); + raw::write_intermediate(directory, readback, &data); if matches!(readback.kind, ReadbackKind::Hdr) { let metrics = hdr_metrics_json( &data, diff --git a/native/shared/src/renderer/quality_capture_raw.rs b/native/shared/src/renderer/quality_capture_raw.rs new file mode 100644 index 00000000..7f48bb61 --- /dev/null +++ b/native/shared/src/renderer/quality_capture_raw.rs @@ -0,0 +1,94 @@ +//! Exact attachment bytes for cross-backend diagnostics, without PNG transforms. + +use super::{QualityReadback, ReadbackKind, Renderer}; +use std::path::Path; + +fn enabled() -> bool { + std::env::var("BLOOM_QUALITY_RAW").is_ok_and(|value| value == "1") +} + +impl Renderer { + /// Queue named diagnostics and, when explicitly requested by the tool, + /// the existing MRT readback. Both execute after the measured window. + pub fn request_quality_capture(&mut self, directory: String) { + if enabled() { + self.pending_mrt_capture_dir = Some( + Path::new(&directory) + .join("mrt") + .to_string_lossy() + .into_owned(), + ); + } + self.pending_quality_capture_dir = Some(directory); + } +} + +fn packed_rows(data: &[u8], row_bytes: usize, pitch: usize, height: usize) -> Option> { + if row_bytes > pitch || data.len() < pitch.checked_mul(height)? { + return None; + } + Some( + data.chunks_exact(pitch) + .take(height) + .flat_map(|row| row[..row_bytes].iter().copied()) + .collect(), + ) +} + +pub(super) fn write_intermediate(directory: &Path, readback: &QualityReadback, data: &[u8]) { + if !enabled() { + return; + } + let (format, bytes_per_pixel) = match readback.kind { + ReadbackKind::Hdr => ("rgba16float", 8), + ReadbackKind::Depth => ("depth32float", 4), + ReadbackKind::Rgba8 => ("rgba8unorm", 4), + }; + let Some(bytes) = packed_rows( + data, + readback.width as usize * bytes_per_pixel, + readback.padded_bytes_per_row as usize, + readback.height as usize, + ) else { + eprintln!( + "bloom: invalid raw intermediate layout for '{}'", + readback.name + ); + return; + }; + let hash = bytes.iter().fold(0xcbf2_9ce4_8422_2325u64, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) + }); + let metadata = format!( + "{{\"schema\":\"bloom-raw-intermediate-v1\",\"name\":\"{}\",\"format\":\"{format}\",\"width\":{},\"height\":{},\"bytes_per_pixel\":{bytes_per_pixel},\"byte_count\":{},\"row_order\":\"top-to-bottom\",\"endianness\":\"little\",\"fnv1a64\":\"{hash:016x}\"}}\n", + readback.name, readback.width, readback.height, bytes.len(), + ); + let directory = directory.join("raw"); + let result = std::fs::create_dir_all(&directory) + .and_then(|()| std::fs::write(directory.join(format!("{}.raw", readback.name)), bytes)) + .and_then(|()| std::fs::write(directory.join(format!("{}.json", readback.name)), metadata)); + if let Err(error) = result { + eprintln!( + "bloom: raw intermediate '{}' write failed: {error}", + readback.name + ); + } +} + +#[cfg(test)] +mod tests { + use super::packed_rows; + + #[test] + fn raw_rows_exclude_gpu_padding_and_reject_truncated_layouts() { + let mut bytes = vec![0x99; 512]; + bytes[..4].copy_from_slice(&1.0_f32.to_le_bytes()); + bytes[256..260].copy_from_slice(&0.5_f32.to_le_bytes()); + assert_eq!( + packed_rows(&bytes, 4, 256, 2).unwrap(), + [1.0_f32.to_le_bytes(), 0.5_f32.to_le_bytes()].concat(), + ); + assert!(packed_rows(&bytes[..511], 4, 256, 2).is_none()); + assert!(packed_rows(&bytes, 257, 256, 2).is_none()); + } +} diff --git a/tools/quality/README.md b/tools/quality/README.md index dd264cc6..d8de8494 100644 --- a/tools/quality/README.md +++ b/tools/quality/README.md @@ -57,6 +57,15 @@ incomplete captures separately. A green diagnostic job proves capture completeness; inspect `result.json` for visual results. Shared-runner timing does not qualify the Apple M1 Max or RTX 4080 budgets. +For comparisons that need original attachment values, set `BLOOM_QUALITY_RAW=1` +before running the diagnostic command. Alongside the PNGs, `intermediates/raw/` +contains packed rows of each captured texture and JSON describing dimensions, +format, byte count, and checksum. `intermediates/mrt/` contains the existing raw +HDR, material, motion-vector, and albedo capture with its manifest. These files +preserve float depth and linear HDR values; depth PNGs independently normalize +their display range and cannot establish numerical depth equality. Raw capture +is opt-in and runs after the measured window. + The Radeon 760M profile selects Vulkan, opts into hardware GI, verifies the reported adapter, and records host preflight/postflight CPU load. Visual, intermediate-image, and telemetry contracts remain strict. Performance is From 30e76258942dd8e43fe7967e601b146c613ee153 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 10 Sep 2026 23:17:17 +0200 Subject: [PATCH 4/4] ci: compare exact cutout inputs on Windows and Metal --- .github/workflows/image-portability.yml | 4 + docs/windows-engine-plan.md | 22 ++- tools/quality/README.md | 14 ++ tools/quality/alpha_probe.py | 197 ++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 6 deletions(-) create mode 100644 tools/quality/alpha_probe.py diff --git a/.github/workflows/image-portability.yml b/.github/workflows/image-portability.yml index 9b7a6231..7725c27f 100644 --- a/.github/workflows/image-portability.yml +++ b/.github/workflows/image-portability.yml @@ -119,6 +119,8 @@ jobs: assert (mrt / (attachment["name"] + ".raw")).stat().st_size == attachment["byte_count"] print("Both Metal captures are complete. Visual differences remain in result.json.") PY + - name: Capture the cutout decision on opaque diagnostic cards + run: python3 tools/quality/alpha_probe.py --out tools/quality/out/ci-alpha-inputs-metal - name: Publish diagnostic summary if: always() shell: bash @@ -134,6 +136,8 @@ jobs: name: image-portability-metal-${{ github.run_attempt }} path: | tools/quality/out/ci-image-portability-metal + tools/quality/out/ci-alpha-inputs-metal + !tools/quality/out/ci-alpha-inputs-metal/build/** target/ci if-no-files-found: warn retention-days: 30 diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md index 060dc7c2..c07897a4 100644 --- a/docs/windows-engine-plan.md +++ b/docs/windows-engine-plan.md @@ -63,8 +63,10 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json lane, and the complete shared suite. Its current-frame regression rejects all 12 old Vulkan samples and passes with the correction on Vulkan and DX12. Corrected SSGI timing covers 20 isolated runs, each with 120 complete GPU - frames. Hosted Metal's shared lane passes; GPU timestamp validation there - remains unproven because a capability skip can also report a test as "ok". + frames. Hosted Metal's shared lane passes, but its Apple Paravirtual adapter + lacks timestamp queries: the profiler GPU regression explicitly skips. + The retained `--nocapture` log at `1965a0b` confirms this; a test reported as + "ok" after that early return does not qualify Metal GPU timing. Its colored-shadow failure exposed an [inverse-matrix upload defect](evidence/windows-transmitted-shadow-inverse-vp-v1.md); the correction passes the isolated local check and rejects the wrong-color @@ -77,10 +79,18 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json Disabling foliage shadow casting retains the Windows skinned/alpha mismatch; canonical captures at `98cce62` pass on hosted Metal with SSIM 0.997442544 for Sponza and 0.999417603 for skinned/alpha. That Apple Paravirtual adapter uses - the modern tier and software GI and exposes no timestamps. Raw intermediates - are retained in PR #157's diagnostic artifact. Compare matching fallback - paths before assigning a backend cause. Shared-runner timing cannot qualify - hardware budgets. + the modern tier and software GI and exposes no timestamps. At `0dd8f67`, + [PR #157](https://github.com/Bloom-Engine/engine/pull/157) has all 24 hosted + checks passing; both Metal images pass with raw export enabled. The + [published diagnostic archive](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-image-portability-20260910) + retains exact depth and MRT bytes, commands, checksums, and source identity. + On matching modern/software-GI paths, Windows still fails both images. + Skinned/alpha has 9,745 depth coverage disagreements before TAA, while + albedo RGB closely agrees on matching surfaces. Disabling foliage shadows + and an isolated isotropic alpha-sampling control retain the failure. + The cutout decision is the next diagnostic target; its precise cause remains + unresolved. Raw export leaves both Windows final PNGs byte-identical. + Shared-runner timing cannot qualify hardware budgets. 4. Continue starter/all-example and release-install checks, asset/world streaming, schema-generated APIs, components, and runtime UI against each issue's full acceptance criteria. Hardware-specific acceptance remains open while local diff --git a/tools/quality/README.md b/tools/quality/README.md index d8de8494..43bc2356 100644 --- a/tools/quality/README.md +++ b/tools/quality/README.md @@ -66,6 +66,20 @@ preserve float depth and linear HDR values; depth PNGs independently normalize their display range and cannot establish numerical depth equality. Raw capture is opt-in and runs after the measured window. +`python3 tools/quality/alpha_probe.py --out ` builds two temporary +skinned/alpha diagnostics on Windows or macOS. It makes the depth prepass opaque +and records the original cutout decision on each nearest card. The albedo MRT +contains the exact little-endian f32 bits of U or V; HDR RGB contains mip LOD, +coverage probability, and authored alpha. Material R contains the Bayer threshold +(UNORM8); material G stores bit flags for survival (1), coverage mips (2), and +positive alpha cutoff (4). Motion RG contains half-precision UVs for orientation. +These are input diagnostics, not rendered-image or timing qualification: opaque +cards change occlusion and do not describe all layers of the original leaf. +The tool retains shader patches, commands, source and executable hashes, logs, +and raw captures, then restores both the original source and native library. +Run it without concurrent native builds. The Metal diagnostic workflow retains +these inputs alongside the unmodified canonical captures. + The Radeon 760M profile selects Vulkan, opts into hardware GI, verifies the reported adapter, and records host preflight/postflight CPU load. Visual, intermediate-image, and telemetry contracts remain strict. Performance is diff --git a/tools/quality/alpha_probe.py b/tools/quality/alpha_probe.py new file mode 100644 index 00000000..168dac45 --- /dev/null +++ b/tools/quality/alpha_probe.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Capture cutout inputs on opaque leaf cards using a temporary shader build. + +This is a diagnostic, not a quality or timing gate. Source and the native +library are restored before returning. Run without concurrent native builds. +""" + +from __future__ import annotations + +import argparse +import difflib +import hashlib +import json +import math +import os +import platform +import subprocess +import struct +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SHADER = ROOT / "native/shared/src/renderer/shaders/core.rs" + + +def function_body(source: str, signature: str) -> tuple[int, int]: + assert source.count(signature) == 1, signature + start = source.index("{", source.index(signature)) + 1 + depth = 1 + for index in range(start, len(source)): + depth += (source[index] == "{") - (source[index] == "}") + if depth == 0: + return start, index + raise ValueError(f"unterminated shader function: {signature}") + + +def probe_shader(source: str, channel: str) -> str: + begin, end = function_body(source, "fn fs_depth_prepass(") + decision = source[begin:end] + assert decision.count("var survives = true;") == 1 + assert decision.count("if (!survives) { discard; }") == 1 + decision = "\n var survives = true;\n" + decision.replace( + "var survives = true;", "" + ).replace("if (!survives) { discard; }", "") + # Opaque cards expose both accepted and rejected fragments. This changes + # occlusion: the values describe the nearest card, not the composited leaf. + source = source[:begin] + "\n" + source[end:] + begin, end = function_body(source, "fn shade_main_scene(") + output = r""" + let probe_lod = mask_texture_lod( + base_uv, textureDimensions(base_color_tex), lighting.shadow_cascade_splits.w, + ); + let probe_coverage = textureSampleLevel( + base_color_tex, base_color_samp, base_uv, max(probe_lod, 1.0), + ).a; + let probe_authored_alpha = textureSampleLevel( + base_color_tex, base_color_samp, base_uv, 0.0, + ).a * in.color.a; + let probe_threshold = mask_coverage_threshold( + base_uv, textureDimensions(base_color_tex), probe_lod, + ); + let bits = bitcast(PROBE_SCALAR); + var result: SceneOut; + // Keep alpha at one: the ordinary scene pipeline uses alpha blending. + result.color = vec4(probe_lod, probe_coverage, probe_authored_alpha, 1.0); + let flags = select(0u, 1u, survives) + select(0u, 2u, material.emissive.w > 0.5) + + select(0u, 4u, alpha_cutoff > 0.0); + result.material = vec2(probe_threshold, f32(flags) / 255.0); + result.velocity = base_uv; + // Rgba8Unorm stores all four bytes of the selected f32 exactly. HDR and + // velocity are half precision and cannot establish tiny UV differences. + result.albedo = vec4(vec4( + bits & 255u, (bits >> 8u) & 255u, (bits >> 16u) & 255u, bits >> 24u, + )) / 255.0; + return result; +""".replace("PROBE_SCALAR", {"u": "base_uv.x", "v": "base_uv.y"}[channel]) + signature_start = source.index("fn shade_main_scene(") + declaration = source[signature_start:begin] + # Keep the original helper available: later renderer specialization + # rewrites its marked lighting blocks even though this probe never calls it. + original_helper = source[signature_start:].replace( + "fn shade_main_scene(", "fn shade_main_scene_unprobed(", 1 + ) + return source[:signature_start] + declaration + decision + output + "}\n\n" + original_helper + + +def verify_capture(directory: Path) -> dict[str, int]: + intermediates = directory / "intermediates" + mrt = intermediates / "mrt" + manifest = json.loads((mrt / "scene-mrt.json").read_text()) + expected = {"hdr-scene": 8, "material-properties": 2, "motion-vectors": 4, "albedo": 4} + assert {item["name"] for item in manifest["attachments"]} == set(expected), manifest + pixels = manifest["width"] * manifest["height"] + for item in manifest["attachments"]: + assert (mrt / (item["name"] + ".raw")).stat().st_size == pixels * expected[item["name"]] + depth = (intermediates / "raw/scene-depth.raw").read_bytes() + material = (mrt / "material-properties.raw").read_bytes() + coordinates = (mrt / "albedo.raw").read_bytes() + assert len(depth) == pixels * 4 + masked, accepted = 0, 0 + for (z,), flags, (uv,) in zip(struct.iter_unpack(" int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=True) + host = {"Windows": "windows", "Darwin": "macos"}.get(platform.system()) + if host is None: + parser.error("this native diagnostic supports Windows and macOS") + original = SHADER.read_bytes() + source = original.decode("utf-8").replace("\r\n", "\n") + receipt = { + "schema": "bloom-alpha-input-probe-v1", + "git_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(), + "original_shader_sha256": hashlib.sha256(original).hexdigest(), + "timing_qualification": False, + "commands": [], + "channels": {}, + } + (out / "original-core.rs.txt").write_bytes(original) + native = ["cargo", "build", "--release", "--manifest-path", f"native/{host}/Cargo.toml"] + env = dict(os.environ) + + def run(command: list[str], cwd: Path, log: Path, run_env: dict[str, str]) -> None: + print("+ " + " ".join(command), flush=True) + started = time.monotonic() + with log.open("w", encoding="utf-8") as output: + result = subprocess.run(command, cwd=cwd, env=run_env, stdout=output, stderr=subprocess.STDOUT) + receipt["commands"].append({ + "argv": command, "cwd": str(cwd), "exit_code": result.returncode, + "elapsed_seconds": time.monotonic() - started, "log": str(log.relative_to(out)), + }) + if result.returncode: + raise RuntimeError(f"command failed ({result.returncode}); see {log}") + + try: + for channel in ("u", "v"): + directory = out / channel + directory.mkdir(exist_ok=True) + candidate = probe_shader(source, channel) + SHADER.write_bytes(candidate.encode("utf-8")) + (directory / "shader.patch").write_text("".join(difflib.unified_diff( + source.splitlines(keepends=True), candidate.splitlines(keepends=True), + fromfile="core.rs", tofile=f"core-probe-{channel}.rs", + )), encoding="utf-8") + run(native, ROOT, directory / "native-build.log", env) + build = out / "build" + build.mkdir(exist_ok=True) + executable = build / (f"quality-motion-{channel}" + (".exe" if host == "windows" else "")) + run([sys.executable, "tools/quality/build_example.py", "examples/quality-motion", + "--output", str(executable)], ROOT, directory / "example-build.log", env) + capture_env = dict(env, **{ + "BLOOM_HW_GI": "0", "BLOOM_FORCE_RENDER_TIER": "modern", + "BLOOM_HEADLESS": "1", "BLOOM_HEADLESS_PIXEL_EXACT": "1", + "BLOOM_NO_FULLSCREEN": "1", "BLOOM_QUALITY": "1", "BLOOM_QUALITY_RAW": "1", + "BLOOM_QUALITY_CASE": "skinned-alpha-motion", "BLOOM_QUALITY_SEED": "0", + "BLOOM_QUALITY_FIXED_TIMESTEP": "0.016666666667", + "BLOOM_QUALITY_WARMUP_FRAMES": "120", "BLOOM_QUALITY_MEASURED_FRAMES": "240", + "BLOOM_QUALITY_TELEMETRY": str(directory / "telemetry.json"), + "BLOOM_QUALITY_INTERMEDIATES": str(directory / "intermediates"), + }) + receipt["channels"][channel] = { + "shader_sha256": hashlib.sha256(candidate.encode("utf-8")).hexdigest(), + "executable_sha256": hashlib.sha256(executable.read_bytes()).hexdigest(), + "capture_env": {key: value for key, value in capture_env.items() if key.startswith("BLOOM_")}, + } + run([str(executable), "--quality-preset", "3", "--render-scale", "1", + "--quality-run", "120", "240", "0.016666666667", str(directory / "final.png"), + str(directory / "telemetry.json"), str(directory / "intermediates")], + ROOT / "examples/quality-motion", directory / "capture.log", capture_env) + telemetry = json.loads((directory / "telemetry.json").read_text()) + receipt["channels"][channel]["adapter"] = telemetry["adapter"] + receipt["channels"][channel]["verification"] = verify_capture(directory) + finally: + SHADER.write_bytes(original) + receipt["source_restored"] = SHADER.read_bytes() == original + try: + run(native, ROOT, out / "restored-native-build.log", env) + finally: + (out / "receipt.json").write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8") + print(f"Alpha probes retained in {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())