From 918f60c498a0f9cd9614ff9ed54701715bea23b5 Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 3 Jul 2026 08:31:38 +0200 Subject: [PATCH] =?UTF-8?q?render2d:=20Headless-Rendering=20=E2=80=94=20PN?= =?UTF-8?q?G=20ohne=20Fenster=20+=20Golden-Image-Test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/design/engine-headless.md | 137 +++++++++++++ src-tauri/render2d/Cargo.toml | 27 +++ src-tauri/render2d/src/bin/render_png.rs | 50 +++++ src-tauri/render2d/src/demo.rs | 71 ++++++- src-tauri/render2d/src/headless.rs | 235 +++++++++++++++++++++++ src-tauri/render2d/src/lib.rs | 5 + src-tauri/render2d/tests/golden.rs | 109 +++++++++++ src-tauri/render2d/tests/golden/demo.png | Bin 0 -> 67252 bytes 8 files changed, 625 insertions(+), 9 deletions(-) create mode 100644 docs/design/engine-headless.md create mode 100644 src-tauri/render2d/src/bin/render_png.rs create mode 100644 src-tauri/render2d/src/headless.rs create mode 100644 src-tauri/render2d/tests/golden.rs create mode 100644 src-tauri/render2d/tests/golden/demo.png diff --git a/docs/design/engine-headless.md b/docs/design/engine-headless.md new file mode 100644 index 0000000..5e8381c --- /dev/null +++ b/docs/design/engine-headless.md @@ -0,0 +1,137 @@ +# Engine-Nordstern — Headless-Rendering (PNG-Export & Golden-Image-Tests) + +> Betrifft `src-tauri/render2d` (Feature `headless`). Bezug: HANDOVER.md, +> Abschnitt ENGINE-NORDSTERN, Punkt 3 ("deterministisches Headless-Rendering, +> PNG-Export und Golden-Image-Tests ohne Fenster"). + +## Warum + +`render2d` trennt die serde-only-Tessellierung (Feature-los, headless testbar) +von der GPU-Schicht (Feature `render`, wgpu). Der Fenster-Pfad (`gpu::Renderer`, +Feature `window`) braucht dafür bislang eine `wgpu::Surface` — also ein echtes +Fenster mit Wayland-/X11-Session. Für deterministische PNG-Exporte und +Golden-Image-Tests (CI, Regressions-Screenshots) ist das unnötig: wgpu kann +genauso gut in eine `wgpu::Texture` rendern, ganz ohne Surface/Fenster. + +`gpu::Renderer::render` nahm bereits vorher nur `Device`/`Queue`/`TextureView` +entgegen — Surface- oder Offscreen-Textur macht für den Draw-Code keinen +Unterschied. Der Offscreen-Pfad (`src/headless.rs`) dupliziert daher NICHTS, +sondern baut nur Device/Queue ohne Surface sowie eine eigene Ziel-Textur + +Buffer-Readback drumherum. + +## Feature-Gating + +Neues Cargo-Feature `headless = ["render", "dep:image"]`: +- zieht `render` (wgpu/glyphon/bytemuck/pollster) plus die `image`-Crate + (nur der PNG-Codec, `default-features = false, features = ["png"]`). +- **berührt den wasm/web-Build nicht**: `cargo check --target wasm32-unknown-unknown + --no-default-features --features web` zieht `image` nicht mit. +- Binary `render_png` und Test `golden` sind zusätzlich per + `required-features = ["headless"]` in `Cargo.toml` abgesichert. + +## API + +```rust +pub struct HeadlessRenderer { /* Device, Queue, gpu::Renderer */ } + +impl HeadlessRenderer { + /// Instance/Adapter/Device OHNE Surface (Backend fest auf Vulkan gepinnt). + /// `Err`, wenn kein Adapter verfügbar ist (z.B. CI-Runner ohne GPU) — die + /// Aufrufer (CLI, Golden-Test) behandeln das, statt zu paniken. + pub fn new() -> Result; + + /// Rendert `scene` in ein `width`x`height`-Bild (Papier-Maßstab + /// `paper_scale_n`, z.B. `100.0` für 1:100). Lädt die Szene bei jedem Aufruf + /// neu hoch (kein Zwischenzustand nötig für CLI-/Test-Anwendungsfall). + pub fn render_to_image( + &mut self, + scene: &Scene, + width: u32, + height: u32, + view_box: ViewBox, + paper_scale_n: f32, + ) -> RgbaImage; // { width, height, pixels: Vec (straff gepackt, RGBA8) } +} + +impl RgbaImage { + pub fn encode_png(&self) -> Vec; + /// Gegenstück für den Golden-Test: Referenz-PNG -> straff gepacktes RGBA8. + pub fn decode_png(bytes: &[u8]) -> Result; +} +``` + +Backend bewusst auf **Vulkan** gepinnt (nicht das Standard-Backend-Set): Vulkan +rendert offscreen ohne jede Fenster-/Display-Server-Abhängigkeit. GL bräuchte auf +Linux i.d.R. einen EGL/GLX-Kontext, der ohne aktive Display-Session (X11/Wayland) +Sonderfälle hat — auf dieser Maschine ist ohnehin ein echter Vulkan-ICD (RADV) +vorhanden, daher kein Rückgriff auf `lavapipe`/`llvmpipe` nötig gewesen. + +## Row-Alignment-Falle (wichtig für zukünftige Agenten) + +`wgpu::Queue::copy_texture_to_buffer` / `CommandEncoder::copy_texture_to_buffer` +verlangt, dass `bytes_per_row` im `ImageDataLayout` ein Vielfaches von +`wgpu::COPY_BYTES_PER_ROW_ALIGNMENT` (256) ist. Bei RGBA8 (4 Byte/Pixel) trifft +das **nur zufällig** zu — z.B. Breite 300 px → 1200 Byte/Zeile, kein Vielfaches +von 256, der Copy schlägt sonst mit einem Validierungsfehler fehl (oder liefert +verzerrte Zeilen, je nach Backend). + +Lösung in `headless::HeadlessRenderer::read_pixels`: +1. `padded_bytes_per_row = align_up(width * 4, 256)` — der Zielpuffer wird auf + diese (größere oder gleiche) Zeilenbreite alloziert. +2. Nach dem Mapping wird **jede Zeile einzeln** von `padded_bytes_per_row` auf + die echte Breite (`width * 4`) zurückgeschnitten und in einen straff + gepackten `Vec` kopiert — sonst hätte das Ausgabebild pro Zeile + Garbage-Padding am rechten Rand. + +## Referenzbild neu erzeugen + +Bei einer **gewollten** Rendering-Änderung (z.B. neue Shader, neue Demo-Szene, +geänderte Antialiasing-Parameter) weicht das Golden-Bild ab. Referenzbild +danach bewusst neu erzeugen: + +```sh +cd src-tauri/render2d +cargo run --features headless --bin render_png -- --width 990 --height 630 --out target/headless-demo.png +cp target/headless-demo.png tests/golden/demo.png +``` + +Vorher unbedingt das erzeugte PNG (`target/headless-demo.png`) visuell prüfen +(z.B. mit einem Bildbetrachter oder Read-Tool eines Agenten) — nicht blind +kopieren. Bei einem fehlgeschlagenen Testlauf schreibt der Golden-Test +automatisch ein Diff-Bild nach `target/golden-diff.png` (abweichende Pixel +signalrot markiert, Rest unverändert) — das hilft beim Einordnen, ob die +Abweichung erwartet (Layout-/Farbänderung) oder ein Bug ist (z.B. verschobene +Geometrie, fehlender Text). + +## Golden-Test + +`tests/golden.rs`, Test `demo_szene_entspricht_referenzbild`: +- rendert die Demo-Szene (`demo::demo_scene`, dieselbe wie der Fenster-Spike) + in 990×630 und vergleicht sie gegen `tests/golden/demo.png`. +- Toleranz: ein Pixel gilt als "abweichend", wenn irgendein Kanal-Delta > 2 ist + (deckt AA-Rundungsrauschen ab); der Test schlägt fehl, wenn mehr als 0,5% + aller Pixel abweichen. +- `#[ignore]`, weil eine Vulkan-fähige GPU auf CI-Runnern nicht garantiert ist + (und ein Software-Rasterizer wie llvmpipe anderes AA liefern würde als das + Referenzbild). Lokal explizit ausführen: + `cargo test --features headless -- --include-ignored`. +- Bei Abweichung über der Toleranz schreibt der Test zusätzlich zum Diff-Bild + auch das Ist-Bild nach `target/golden-actual.png` — nach visueller Prüfung + ist das die Vorlage für eine gewollte Referenz-Aktualisierung. +- `HeadlessRenderer::new()` liefert bei fehlendem Adapter `Err` statt Panik — + der Test überspringt sich dann sauber (`eprintln!` + `return`) statt rot zu + schlagen, falls doch mal ohne `--ignored`/ohne GPU ausgeführt. + +## Offener Punkt: Text-Determinismus zwischen GPU-Treibern + +Der Textpass läuft über `glyphon`/`cosmic-text` (Systemfonts, `Inter`). Die +exakte Glyphen-Rasterisierung (Subpixel-Antialiasing, Hinting) kann je nach +installierter Font-Version, Fontconfig-Konfiguration und GPU-Treiber leicht +variieren — auch bei identischer Geometrie. Auf **derselben** Maschine (gleicher +Treiber, gleiche Font-Version) ist der Test wie erwartet bit-nah deterministisch +(hier: 0 abweichende Pixel bei zwei aufeinanderfolgenden Läufen). Bei einem +Wechsel der Maschine/des Treibers/der Fontconfig-Version ist nicht +auszuschließen, dass die 0,5%-Toleranz für Textkanten knapp wird — sollte das +in der Praxis auftreten: entweder Toleranz für den Text-Bereich lockern, oder +den Text testweise aus der Golden-Szene ausklammern und separat (z.B. nur +Zeilenbreite/Position, nicht Pixel) prüfen. diff --git a/src-tauri/render2d/Cargo.toml b/src-tauri/render2d/Cargo.toml index fe0a7a8..42d5e1b 100644 --- a/src-tauri/render2d/Cargo.toml +++ b/src-tauri/render2d/Cargo.toml @@ -52,6 +52,13 @@ web = [ "dep:serde_json", ] +# Offscreen-Renderpfad (kein Fenster/Surface): Adapter/Device ohne Surface, +# Render-Target ist eine `wgpu::Texture` statt einer Surface-Textur, Ergebnis als +# PNG kodiert (`image`-Crate). Fuer Golden-Image-Tests und headlessen PNG-Export +# (Engine-Nordstern, siehe docs/design/engine-headless.md). Bewusst NICHT Teil von +# `render`/`web`: die `image`-Abhaengigkeit soll den wasm-Build nicht belasten. +headless = ["render", "dep:image"] + [dependencies] serde = { version = "1", features = ["derive"] } # JSON-Szene (dieselbe Ableitung wie der native Push): im Web-Pfad zur Laufzeit @@ -69,6 +76,12 @@ pollster = { version = "0.3", optional = true } # an wgpu gekoppelt: die 0.6er-Reihe ist die zu wgpu 22 passende. glyphon = { version = "0.6", optional = true } +# --- Headless-PNG-Export (nur mit Feature "headless") ------------------------ +# Nur der PNG-Codec noetig (kein volles Format-Universum) -> default-features aus. +image = { version = "0.25", optional = true, default-features = false, features = [ + "png", +] } + # --- Fenster (nur mit Feature "window") -------------------------------------- winit = { version = "0.30", optional = true } env_logger = { version = "0.11", optional = true } @@ -99,3 +112,17 @@ naga = { version = "22", features = ["wgsl-in"] } name = "spike" path = "src/bin/spike.rs" required-features = ["window"] + +# Headless-CLI-Beweis: rendert die Demo-Szene ohne Fenster/Surface nach PNG. +# Nur mit Feature "headless" verfuegbar (siehe Cargo-Feature-Kommentar oben). +[[bin]] +name = "render_png" +path = "src/bin/render_png.rs" +required-features = ["headless"] + +# Golden-Image-Test des Headless-Renderpfads. Nur mit Feature "headless" gebaut +# (der Vergleich laedt das Referenzbild ueber `headless::RgbaImage::decode_png`). +[[test]] +name = "golden" +path = "tests/golden.rs" +required-features = ["headless"] diff --git a/src-tauri/render2d/src/bin/render_png.rs b/src-tauri/render2d/src/bin/render_png.rs new file mode 100644 index 0000000..f607d7f --- /dev/null +++ b/src-tauri/render2d/src/bin/render_png.rs @@ -0,0 +1,50 @@ +// CLI-Beweis fuer den Headless-Renderpfad (Feature "headless"): rendert die +// Demo-Szene (dieselbe wie der Fenster-Spike, `demo::demo_scene`) OHNE Fenster in +// eine PNG-Datei. Belegt, dass `HeadlessRenderer` unabhaengig von einer Display- +// Session funktioniert. +// +// Start: cargo run --features headless --bin render_png -- [--out PFAD] [--width N] [--height N] +#![cfg(feature = "headless")] + +use render2d::headless::HeadlessRenderer; +use render2d::{demo_scene, initial_view_box}; + +fn main() { + // Defaults passend zum Seitenverhaeltnis von `initial_view_box` (990x630 — + // Bildschirm-Raum-Einheiten der Demo-Szene, siehe demo.rs). + let mut width = 990u32; + let mut height = 630u32; + let mut out = String::from("target/headless-demo.png"); + + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--width" => { + width = args + .next() + .and_then(|v| v.parse().ok()) + .unwrap_or(width); + } + "--height" => { + height = args + .next() + .and_then(|v| v.parse().ok()) + .unwrap_or(height); + } + "--out" => out = args.next().unwrap_or(out), + other => eprintln!("render_png: unbekanntes Argument ignoriert: {other}"), + } + } + + let mut renderer = HeadlessRenderer::new().expect("Headless-Renderer initialisieren"); + let image = renderer.render_to_image(&demo_scene(), width, height, initial_view_box(), 100.0); + let png = image.encode_png(); + + if let Some(parent) = std::path::Path::new(&out).parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).expect("Ausgabeverzeichnis anlegen"); + } + } + std::fs::write(&out, &png).expect("PNG schreiben"); + println!("render_png: geschrieben nach {out} ({width}x{height})"); +} diff --git a/src-tauri/render2d/src/demo.rs b/src-tauri/render2d/src/demo.rs index 8d2eb28..80881ca 100644 --- a/src-tauri/render2d/src/demo.rs +++ b/src-tauri/render2d/src/demo.rs @@ -6,13 +6,16 @@ // noetig), damit die Szene auch headless testbar bleibt. use crate::tessellate::PX_PER_M; -use crate::types::{FillPolygon, Line, Outline, Scene, Text, TextAlign, ViewBox}; +use crate::types::{Arc, FillPolygon, Line, Outline, Polyline, Scene, Text, TextAlign, ViewBox}; /// Demo-Szene in Modell-Metern: ein L-foermiger Wand-Poche (konkav!), ein Raum -/// und ein paar Striche — genug, um Fuellung, Umriss und Papier-mm-Linien zu sehen. +/// mit 45-Grad-Schraffur, ein Tuerschwenk-Bogen und ein paar Striche — genug, um +/// ALLE Render-Pfade zu sehen (Fuellung, Umriss, Schraffur/widthScreen, +/// analytischer Bogen, Papier-mm-Linien, Text). pub fn demo_scene() -> Scene { let wall_grey: [f32; 4] = [0.55, 0.55, 0.55, 1.0]; let room_blue: [f32; 4] = [0.20, 0.45, 0.85, 0.18]; + let hatch_blue: [f32; 4] = [0.20, 0.45, 0.85, 0.55]; let ink: [f32; 4] = [0.10, 0.10, 0.10, 1.0]; // Konkaves L (Wandflaeche). @@ -27,6 +30,34 @@ pub fn demo_scene() -> Scene { // Ein transluzenter Raum daneben. let room = vec![[5.0, 0.0], [9.0, 0.0], [9.0, 4.0], [5.0, 4.0]]; + // 45-Grad-Schraffur im Raum (wie ein SVG-): Strichbreite in + // BILDSCHIRM-Einheiten (`width_screen`), skaliert also mit dem Zoom wie die + // Musterkachel — genau der Pfad, den echte Material-Schraffuren nehmen. + // Linien y = x + c, auf das Raum-Rechteck geklippt. + let (hx0, hx1, hy0, hy1) = (5.0f32, 9.0f32, 0.0f32, 4.0f32); + let hatch_step = 0.5f32; + let mut hatch: Vec = Vec::new(); + let mut c = hy0 - hx1 + hatch_step; + while c < hy1 - hx0 { + let xa = hx0.max(hy0 - c); + let xb = hx1.min(hy1 - c); + if xb > xa { + hatch.push(Polyline { + pts: vec![[xa, xa + c], [xb, xb + c]], + color: hatch_blue, + // Breite in Bildschirm-Einheiten (viewBox-px), NICHT Papier-mm. + width_mm: 1.2, + width_screen: true, + dash: None, + // Ueber der Raum-Fuellung (gleiches z=2, Kategorie Polyline kommt + // in der stabilen Sortierung NACH den Fills), unter dem + // Raum-Umriss (z=3). + z: 2, + }); + } + c += hatch_step; + } + Scene { fills: vec![ FillPolygon { @@ -56,16 +87,38 @@ pub fn demo_scene() -> Scene { z: 3, }, ], - polylines: vec![], - arcs: vec![], - lines: vec![Line { - a: [0.0, -1.0], - b: [9.0, -1.0], + polylines: hatch, + // Tuerschwenk in der linken Raumwand: analytischer Bogen (SDF-Pipeline), + // Angel bei (5,1), Blatt offen nach (6,1), Schwenk bis (5,2). + arcs: vec![Arc { + center: [5.0, 1.0], + from: [6.0, 1.0], + to: [5.0, 2.0], + r: 1.0, color: ink, - width_mm: 0.25, + width_mm: 0.18, dash: None, - z: 4, }], + lines: vec![ + Line { + a: [0.0, -1.0], + b: [9.0, -1.0], + color: ink, + width_mm: 0.25, + dash: None, + z: 4, + }, + // Tuerblatt zum Schwenkbogen (die Boegen zeichnen NACH den Linien, + // der Bogen liegt also wie im echten Plan ueber dem Blatt). + Line { + a: [5.0, 1.0], + b: [6.0, 1.0], + color: ink, + width_mm: 0.25, + dash: None, + z: 5, + }, + ], // Ein Raumstempel-artiger Text (echte Glyphen via Atlas), mittig im Raum. texts: vec![Text { pos: [7.0, 2.0], diff --git a/src-tauri/render2d/src/headless.rs b/src-tauri/render2d/src/headless.rs new file mode 100644 index 0000000..1b59672 --- /dev/null +++ b/src-tauri/render2d/src/headless.rs @@ -0,0 +1,235 @@ +// Headless-Offscreen-Renderpfad (Feature "headless"): derselbe Draw-Code wie der +// Fenster-Spike (`gpu::Renderer::render` nimmt ohnehin nur Device/Queue/View — +// Surface- oder Offscreen-Textur macht dafuer keinen Unterschied), aber OHNE +// Fenster/Surface. Ziel ist eine `wgpu::Texture` (RENDER_ATTACHMENT | COPY_SRC), +// aus der die Pixel per Buffer-Copy ausgelesen und als PNG kodiert werden. +// +// Zweck: Engine-Nordstern Punkt 3 (siehe HANDOVER.md) — deterministisches +// Headless-Rendering fuer PNG-Export und Golden-Image-Tests, ohne Fenster/Display- +// Server-Zwang. Details/Row-Alignment-Falle: docs/design/engine-headless.md. +// +// WICHTIG (Row-Alignment-Falle): wgpu verlangt beim Texture->Buffer-Copy, dass +// `bytes_per_row` ein Vielfaches von `wgpu::COPY_BYTES_PER_ROW_ALIGNMENT` (256) +// ist. Bei RGBA8 (4 Byte/Pixel) trifft das NUR zufaellig zu (z.B. Breite 300px = +// 1200 Byte/Zeile -> kein Vielfaches von 256). Der Zielpuffer wird daher auf die +// naechste 256er-Grenze gepolstert (`padded_bytes_per_row`); beim Auslesen wird +// jede Zeile wieder auf die echte Breite (`unpadded_bytes_per_row`) zurueckgeschnitten. + +use image::ImageEncoder; + +use crate::gpu::Renderer; +use crate::types::{Scene, ViewBox}; + +/// Farbformat der Offscreen-Zieltextur. Non-sRGB (Rgba8Unorm): der Fragment- +/// Shader schreibt seine Farbwerte unkonvertiert — identisch zur Annahme, unter +/// der der Textpass mit `glyphon::ColorMode::Web` faehrt (siehe +/// `gpu::Renderer::ensure_text`); damit bleibt der Offscreen-Pfad farblich +/// konsistent zum Fenster-Pfad auf Plattformen ohne sRGB-Surface-Format. +pub const COLOR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +/// Straff gepacktes RGBA8-Bild (kein Zeilen-Padding mehr — das wurde beim +/// Auslesen bereits entfernt, siehe Modul-Kopf). +pub struct RgbaImage { + pub width: u32, + pub height: u32, + /// `width * height * 4` Bytes, Reihenfolge R,G,B,A, zeilenweise von oben. + pub pixels: Vec, +} + +impl RgbaImage { + /// Kodiert das Bild als PNG-Bytes. + pub fn encode_png(&self) -> Vec { + let mut bytes = Vec::new(); + let encoder = image::codecs::png::PngEncoder::new(&mut bytes); + encoder + .write_image( + &self.pixels, + self.width, + self.height, + image::ExtendedColorType::Rgba8, + ) + .expect("PNG-Encoding fehlgeschlagen"); + bytes + } + + /// Dekodiert PNG-Bytes zurueck in ein straff gepacktes RGBA8-Bild — + /// Gegenstueck zu `encode_png`, damit der Golden-Image-Test das Referenzbild + /// laden kann, ohne die `image`-Crate selbst zu ziehen. + pub fn decode_png(bytes: &[u8]) -> Result { + let img = image::load_from_memory_with_format(bytes, image::ImageFormat::Png) + .map_err(|e| format!("PNG-Dekodierung fehlgeschlagen: {e}"))? + .into_rgba8(); + Ok(Self { + width: img.width(), + height: img.height(), + pixels: img.into_raw(), + }) + } +} + +/// Haelt Device/Queue + den geteilten Draw-Code (`gpu::Renderer`) fuer den +/// Offscreen-Pfad. Ein Renderer pro Instanz reicht — die Pipelines sind an +/// `COLOR_FORMAT` gebunden, das bleibt fuer alle Aufrufe gleich. +pub struct HeadlessRenderer { + device: wgpu::Device, + queue: wgpu::Queue, + renderer: Renderer, +} + +impl HeadlessRenderer { + /// Baut Instance/Adapter/Device OHNE Surface (kein Fenster, keine Display- + /// Session noetig). Backend explizit auf Vulkan gepinnt: Vulkan rendert + /// offscreen ohne jede Fenster-/Surface-Abhaengigkeit; GL braucht auf Linux + /// i.d.R. einen EGL/GLX-Kontext, der ohne Display-Server Sonderfaelle hat. + /// + /// `Err` statt Panik, wenn kein Adapter/Device verfuegbar ist — der Golden- + /// Image-Test ueberspringt sich dann sauber (CI-Runner ohne GPU). + pub fn new() -> Result { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN, + ..Default::default() + }); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + // Kein Fenster -> keine kompatible Surface noetig. + compatible_surface: None, + })) + .ok_or_else(|| "kein Vulkan-Adapter fuer Headless-Rendering gefunden".to_string())?; + let (device, queue) = pollster::block_on(adapter.request_device( + &wgpu::DeviceDescriptor { + label: Some("headless.device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::Performance, + }, + None, + )) + .map_err(|e| format!("Headless-Device anfordern fehlgeschlagen: {e}"))?; + + let renderer = Renderer::new(&device, COLOR_FORMAT); + Ok(Self { + device, + queue, + renderer, + }) + } + + /// Rendert `scene` in ein `width`x`height`-Offscreen-Bild (Papier-Massstab + /// `paper_scale_n`, z.B. `100.0` fuer 1:100) und liest es als straff gepacktes + /// RGBA8-Bild zurueck. Jeder Aufruf laedt die Szene neu hoch (kein Zwischen- + /// Zustand noetig fuer den CLI-/Test-Anwendungsfall). + pub fn render_to_image( + &mut self, + scene: &Scene, + width: u32, + height: u32, + view_box: ViewBox, + paper_scale_n: f32, + ) -> RgbaImage { + let (width, height) = (width.max(1), height.max(1)); + self.renderer.paper_scale_n = paper_scale_n; + self.renderer.upload_scene(&self.device, scene); + + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("headless.target"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: COLOR_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + self.renderer + .render(&self.device, &self.queue, &view, view_box, (width, height)); + + RgbaImage { + width, + height, + pixels: self.read_pixels(&texture, width, height), + } + } + + /// Texture -> gepolsterter Buffer -> straff gepacktes Pixel-Array (Row- + /// Alignment-Falle, siehe Modul-Kopf). + fn read_pixels(&self, texture: &wgpu::Texture, width: u32, height: u32) -> Vec { + const BYTES_PER_PIXEL: u32 = 4; + let unpadded_bytes_per_row = width * BYTES_PER_PIXEL; + let padded_bytes_per_row = align_up(unpadded_bytes_per_row, wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); + let buffer_size = u64::from(padded_bytes_per_row) * u64::from(height); + + let output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("headless.readback"), + size: buffer_size, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("headless.copy"), + }); + encoder.copy_texture_to_buffer( + wgpu::ImageCopyTexture { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::ImageCopyBuffer { + buffer: &output_buffer, + layout: wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(padded_bytes_per_row), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + self.queue.submit(std::iter::once(encoder.finish())); + + let slice = output_buffer.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |res| { + let _ = tx.send(res); + }); + // Vulkan hat keine Event-Loop wie ein Fenster -> Poll blockierend, bis + // die Map-Callback feuert (kein busy-loop noetig, `Maintain::Wait` wartet). + self.device.poll(wgpu::Maintain::Wait); + rx.recv() + .expect("Map-Callback nie aufgerufen") + .expect("Buffer-Mapping fehlgeschlagen"); + + let data = slice.get_mapped_range(); + let mut pixels = + Vec::with_capacity(unpadded_bytes_per_row as usize * height as usize); + for row in 0..height as usize { + let start = row * padded_bytes_per_row as usize; + let end = start + unpadded_bytes_per_row as usize; + pixels.extend_from_slice(&data[start..end]); + } + drop(data); + output_buffer.unmap(); + pixels + } +} + +/// Rundet `value` auf das naechste Vielfache von `align` (>=1) auf. +fn align_up(value: u32, align: u32) -> u32 { + if align <= 1 { + return value; + } + ((value + align - 1) / align) * align +} diff --git a/src-tauri/render2d/src/lib.rs b/src-tauri/render2d/src/lib.rs index 11ea7ca..0e6c580 100644 --- a/src-tauri/render2d/src/lib.rs +++ b/src-tauri/render2d/src/lib.rs @@ -27,6 +27,11 @@ pub mod gpu; #[cfg(feature = "web")] pub mod web; +// Offscreen-Renderpfad (kein Fenster/Surface), nur mit Feature "headless". +// Siehe docs/design/engine-headless.md. +#[cfg(feature = "headless")] +pub mod headless; + pub use demo::{demo_scene, initial_view_box}; pub use ortho::{compute_ortho_matrix, meet_scale, mm_to_device_px, Mat4}; pub use tessellate::{ diff --git a/src-tauri/render2d/tests/golden.rs b/src-tauri/render2d/tests/golden.rs new file mode 100644 index 0000000..3b147c5 --- /dev/null +++ b/src-tauri/render2d/tests/golden.rs @@ -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 + ); + } +} diff --git a/src-tauri/render2d/tests/golden/demo.png b/src-tauri/render2d/tests/golden/demo.png new file mode 100644 index 0000000000000000000000000000000000000000..f78529ad220eaef1712e172e6a4f27b09432b46a GIT binary patch literal 67252 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 oYr)dhbL1{E^aC=qum-#}jnO$_