Files
DOSSIER-STANDALONE/docs/design/engine-headless.md
T
karim 918f60c498 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.
2026-07-03 08:31:38 +02:00

6.7 KiB
Raw Blame History

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

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<Self, String>;

    /// 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<u8> (straff gepackt, RGBA8) }
}

impl RgbaImage {
    pub fn encode_png(&self) -> Vec<u8>;
    /// Gegenstück für den Golden-Test: Referenz-PNG -> straff gepacktes RGBA8.
    pub fn decode_png(bytes: &[u8]) -> Result<Self, String>;
}

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<u8> 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:

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.