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.
This commit is contained in:
@@ -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<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.
|
||||||
@@ -52,6 +52,13 @@ web = [
|
|||||||
"dep:serde_json",
|
"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]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
# JSON-Szene (dieselbe Ableitung wie der native Push): im Web-Pfad zur Laufzeit
|
# 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.
|
# an wgpu gekoppelt: die 0.6er-Reihe ist die zu wgpu 22 passende.
|
||||||
glyphon = { version = "0.6", optional = true }
|
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") --------------------------------------
|
# --- Fenster (nur mit Feature "window") --------------------------------------
|
||||||
winit = { version = "0.30", optional = true }
|
winit = { version = "0.30", optional = true }
|
||||||
env_logger = { version = "0.11", optional = true }
|
env_logger = { version = "0.11", optional = true }
|
||||||
@@ -99,3 +112,17 @@ naga = { version = "22", features = ["wgsl-in"] }
|
|||||||
name = "spike"
|
name = "spike"
|
||||||
path = "src/bin/spike.rs"
|
path = "src/bin/spike.rs"
|
||||||
required-features = ["window"]
|
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"]
|
||||||
|
|||||||
@@ -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})");
|
||||||
|
}
|
||||||
@@ -6,13 +6,16 @@
|
|||||||
// noetig), damit die Szene auch headless testbar bleibt.
|
// noetig), damit die Szene auch headless testbar bleibt.
|
||||||
|
|
||||||
use crate::tessellate::PX_PER_M;
|
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
|
/// 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 {
|
pub fn demo_scene() -> Scene {
|
||||||
let wall_grey: [f32; 4] = [0.55, 0.55, 0.55, 1.0];
|
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 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];
|
let ink: [f32; 4] = [0.10, 0.10, 0.10, 1.0];
|
||||||
|
|
||||||
// Konkaves L (Wandflaeche).
|
// Konkaves L (Wandflaeche).
|
||||||
@@ -27,6 +30,34 @@ pub fn demo_scene() -> Scene {
|
|||||||
// Ein transluzenter Raum daneben.
|
// Ein transluzenter Raum daneben.
|
||||||
let room = vec![[5.0, 0.0], [9.0, 0.0], [9.0, 4.0], [5.0, 4.0]];
|
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-<pattern>): 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<Polyline> = 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 {
|
Scene {
|
||||||
fills: vec![
|
fills: vec![
|
||||||
FillPolygon {
|
FillPolygon {
|
||||||
@@ -56,16 +87,38 @@ pub fn demo_scene() -> Scene {
|
|||||||
z: 3,
|
z: 3,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
polylines: vec![],
|
polylines: hatch,
|
||||||
arcs: vec![],
|
// Tuerschwenk in der linken Raumwand: analytischer Bogen (SDF-Pipeline),
|
||||||
lines: vec![Line {
|
// 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.18,
|
||||||
|
dash: None,
|
||||||
|
}],
|
||||||
|
lines: vec![
|
||||||
|
Line {
|
||||||
a: [0.0, -1.0],
|
a: [0.0, -1.0],
|
||||||
b: [9.0, -1.0],
|
b: [9.0, -1.0],
|
||||||
color: ink,
|
color: ink,
|
||||||
width_mm: 0.25,
|
width_mm: 0.25,
|
||||||
dash: None,
|
dash: None,
|
||||||
z: 4,
|
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.
|
// Ein Raumstempel-artiger Text (echte Glyphen via Atlas), mittig im Raum.
|
||||||
texts: vec![Text {
|
texts: vec![Text {
|
||||||
pos: [7.0, 2.0],
|
pos: [7.0, 2.0],
|
||||||
|
|||||||
@@ -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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RgbaImage {
|
||||||
|
/// Kodiert das Bild als PNG-Bytes.
|
||||||
|
pub fn encode_png(&self) -> Vec<u8> {
|
||||||
|
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<Self, String> {
|
||||||
|
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<Self, String> {
|
||||||
|
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<u8> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -27,6 +27,11 @@ pub mod gpu;
|
|||||||
#[cfg(feature = "web")]
|
#[cfg(feature = "web")]
|
||||||
pub mod 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 demo::{demo_scene, initial_view_box};
|
||||||
pub use ortho::{compute_ortho_matrix, meet_scale, mm_to_device_px, Mat4};
|
pub use ortho::{compute_ortho_matrix, meet_scale, mm_to_device_px, Mat4};
|
||||||
pub use tessellate::{
|
pub use tessellate::{
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Reference in New Issue
Block a user