Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions .github/workflows/image-portability.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
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
BLOOM_QUALITY_RAW: "1"
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
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: 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
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
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
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/evidence/windows-profiler-integrity-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 23 additions & 2 deletions docs/windows-engine-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,34 @@ 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, 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
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. 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;
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. 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
Expand Down
4 changes: 2 additions & 2 deletions native/shared/src/ffi_core/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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
})
}
Expand Down
3 changes: 3 additions & 0 deletions native/shared/src/renderer/quality_capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
94 changes: 94 additions & 0 deletions native/shared/src/renderer/quality_capture_raw.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<u8>> {
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());
}
}
32 changes: 32 additions & 0 deletions tools/quality/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,38 @@ 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.

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.

`python3 tools/quality/alpha_probe.py --out <directory>` 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
Expand Down
Loading
Loading