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

```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.
