render2d: Headless-Rendering — PNG ohne Fenster + Golden-Image-Test

HeadlessRenderer (Feature headless) baut Instance/Adapter/Device ohne
Surface (Vulkan, kein Display-Server); render_to_image rendert in eine
Rgba8Unorm-Texture (non-sRGB wie ColorMode::Web) und liest mit 256-Byte-
Row-Padding zurueck. Draw-Code unveraendert geteilt mit dem Fenster-Pfad.
CLI-Bin render_png (Demo-Szene, 990x630); Demo additiv um 45-Grad-
Schraffur, Tuerblatt und Schwenkbogen erweitert, damit das Golden alle
Pfade abdeckt. Golden-Test mit eingechecktem Referenzbild: 0/623700 Pixel
Abweichung, bit-exakt reproduzierbar; bei Abweichung Diff-Dump nach
target/. Doku in docs/design/engine-headless.md.
This commit is contained in:
2026-07-03 08:31:38 +02:00
parent ce6bd26ff7
commit 4b02e40238
8 changed files with 625 additions and 9 deletions
+109
View File
@@ -0,0 +1,109 @@
// Golden-Image-Test des Headless-Renderpfads (Feature "headless"): rendert die
// Demo-Szene (dieselbe wie der Fenster-Spike) und vergleicht sie pixelweise gegen
// ein eingechecktes Referenzbild (`tests/golden/demo.png`), mit Toleranz fuer
// GPU-/Treiber-AA-Rauschen.
//
// `#[ignore]`: die Vulkan-/GPU-Verfuegbarkeit ist auf CI-Runnern nicht garantiert,
// und ein Software-Rasterizer (llvmpipe) wuerde anderes AA liefern als das
// Referenzbild (siehe docs/design/engine-headless.md). Lokal ausfuehren mit:
// cargo test --features headless -- --include-ignored
//
// Bei einer bewussten Rendering-Aenderung das Referenzbild neu erzeugen (siehe
// docs/design/engine-headless.md, Abschnitt "Referenzbild aktualisieren").
#![cfg(feature = "headless")]
use render2d::headless::{HeadlessRenderer, RgbaImage};
use render2d::{demo_scene, initial_view_box};
/// Ort des eingecheckten Referenzbilds (absolut ueber das Crate-Manifest, damit
/// der Test unabhaengig vom Arbeitsverzeichnis laeuft).
const GOLDEN_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/golden/demo.png");
/// Debug-Diff-Bild bei Abweichung (rote Pixel == abweichend, sonst Ist-Bild).
const DIFF_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/target/golden-diff.png");
/// Ist-Bild bei Abweichung (Basis fuer eine gewollte Referenz-Aktualisierung).
const ACTUAL_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/target/golden-actual.png");
/// Ein Pixel gilt als "abweichend", wenn IRGENDEIN Kanal-Delta diesen Wert
/// ueberschreitet (deckt AA-Rundungsrauschen zwischen GPU-Treibern ab).
const CHANNEL_DELTA_THRESHOLD: i32 = 2;
/// Maximal erlaubter Anteil abweichender Pixel, bevor der Test fehlschlaegt.
const MAX_DIFFERING_FRACTION: f64 = 0.005;
#[test]
#[ignore]
fn demo_szene_entspricht_referenzbild() {
// Dieselben Defaults wie `render_png` (siehe src/bin/render_png.rs) — das
// Referenzbild wird exakt so erzeugt.
let width = 990u32;
let height = 630u32;
// Kein Adapter (z.B. CI-Runner ohne GPU) -> Test sauber ueberspringen statt
// rot zu schlagen (siehe `HeadlessRenderer::new`-Doku).
let mut renderer = match HeadlessRenderer::new() {
Ok(r) => r,
Err(e) => {
eprintln!("golden: uebersprungen, kein Headless-Adapter verfuegbar: {e}");
return;
}
};
let actual = renderer.render_to_image(&demo_scene(), width, height, initial_view_box(), 100.0);
let golden_bytes = std::fs::read(GOLDEN_PATH).unwrap_or_else(|e| {
panic!(
"Referenzbild {GOLDEN_PATH} fehlt oder unlesbar ({e}); zuerst erzeugen, \
siehe docs/design/engine-headless.md"
)
});
let golden = RgbaImage::decode_png(&golden_bytes).expect("Referenzbild dekodieren");
assert_eq!(golden.width, actual.width, "Referenzbild-Breite weicht ab");
assert_eq!(golden.height, actual.height, "Referenzbild-Hoehe weicht ab");
let total_pixels = (actual.width as usize) * (actual.height as usize);
let mut differing = 0usize;
let mut diff_pixels = vec![0u8; actual.pixels.len()];
for i in 0..total_pixels {
let base = i * 4;
let is_diff = (0..4).any(|c| {
(actual.pixels[base + c] as i32 - golden.pixels[base + c] as i32).abs()
> CHANNEL_DELTA_THRESHOLD
});
if is_diff {
differing += 1;
// Abweichende Pixel signalrot markieren, Rest unveraendert lassen
// (Kontext fuers Debuggen).
diff_pixels[base] = 255;
diff_pixels[base + 1] = 0;
diff_pixels[base + 2] = 0;
diff_pixels[base + 3] = 255;
} else {
diff_pixels[base..base + 4].copy_from_slice(&actual.pixels[base..base + 4]);
}
}
let fraction = differing as f64 / total_pixels as f64;
println!(
"golden: {differing}/{total_pixels} Pixel abweichend ({:.4}%), Toleranz {:.1}%",
fraction * 100.0,
MAX_DIFFERING_FRACTION * 100.0
);
if fraction > MAX_DIFFERING_FRACTION {
if let Some(parent) = std::path::Path::new(DIFF_PATH).parent() {
let _ = std::fs::create_dir_all(parent);
}
let diff = RgbaImage {
width: actual.width,
height: actual.height,
pixels: diff_pixels,
};
std::fs::write(DIFF_PATH, diff.encode_png()).expect("Diff-Bild schreiben");
std::fs::write(ACTUAL_PATH, actual.encode_png()).expect("Ist-Bild schreiben");
panic!(
"Golden-Image-Test fehlgeschlagen: {differing}/{total_pixels} Pixel weichen ab \
({:.3}% > {:.3}% Toleranz), Diff-Bild: {DIFF_PATH}, Ist-Bild: {ACTUAL_PATH}",
fraction * 100.0,
MAX_DIFFERING_FRACTION * 100.0
);
}
}