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.
6.7 KiB
Engine-Nordstern — Headless-Rendering (PNG-Export & Golden-Image-Tests)
Betrifft
src-tauri/render2d(Featureheadless). 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 dieimage-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 webziehtimagenicht mit. - Binary
render_pngund Testgoldensind zusätzlich perrequired-features = ["headless"]inCargo.tomlabgesichert.
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:
padded_bytes_per_row = align_up(width * 4, 256)— der Zielpuffer wird auf diese (größere oder gleiche) Zeilenbreite alloziert.- Nach dem Mapping wird jede Zeile einzeln von
padded_bytes_per_rowauf die echte Breite (width * 4) zurückgeschnitten und in einen straff gepacktenVec<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 gegentests/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 AdapterErrstatt 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.