918f60c498
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.
138 lines
6.7 KiB
Markdown
138 lines
6.7 KiB
Markdown
# 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.
|