// 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, } impl RgbaImage { /// Kodiert das Bild als PNG-Bytes. pub fn encode_png(&self) -> Vec { 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 { 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 { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends: wgpu::Backends::VULKAN, ..wgpu::InstanceDescriptor::new_without_display_handle() }); 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, })) .map_err(|e| format!("kein Vulkan-Adapter fuer Headless-Rendering gefunden: {e}"))?; 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(), experimental_features: wgpu::ExperimentalFeatures::disabled(), memory_hints: wgpu::MemoryHints::Performance, trace: wgpu::Trace::Off, })) .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 { 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::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { buffer: &output_buffer, layout: wgpu::TexelCopyBufferLayout { 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, `PollType::Wait` wartet // ohne Timeout auf die juengste Submission). self.device .poll(wgpu::PollType::wait_indefinitely()) .expect("Device-Poll fehlgeschlagen"); 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 }