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:
2026-07-03 08:31:38 +02:00
parent 23410f9b9e
commit 918f60c498
8 changed files with 625 additions and 9 deletions
+50
View File
@@ -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})");
}
+62 -9
View File
@@ -6,13 +6,16 @@
// noetig), damit die Szene auch headless testbar bleibt.
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
/// 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 {
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 hatch_blue: [f32; 4] = [0.20, 0.45, 0.85, 0.55];
let ink: [f32; 4] = [0.10, 0.10, 0.10, 1.0];
// Konkaves L (Wandflaeche).
@@ -27,6 +30,34 @@ pub fn demo_scene() -> Scene {
// Ein transluzenter Raum daneben.
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 {
fills: vec![
FillPolygon {
@@ -56,16 +87,38 @@ pub fn demo_scene() -> Scene {
z: 3,
},
],
polylines: vec![],
arcs: vec![],
lines: vec![Line {
a: [0.0, -1.0],
b: [9.0, -1.0],
polylines: hatch,
// Tuerschwenk in der linken Raumwand: analytischer Bogen (SDF-Pipeline),
// 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.25,
width_mm: 0.18,
dash: None,
z: 4,
}],
lines: vec![
Line {
a: [0.0, -1.0],
b: [9.0, -1.0],
color: ink,
width_mm: 0.25,
dash: None,
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.
texts: vec![Text {
pos: [7.0, 2.0],
+235
View File
@@ -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
}
+5
View File
@@ -27,6 +27,11 @@ pub mod gpu;
#[cfg(feature = "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 ortho::{compute_ortho_matrix, meet_scale, mm_to_device_px, Mat4};
pub use tessellate::{