Skip to main content

proof_engine/wgpu_backend/
headless.rs

1//! Headless (off-screen) rendering: render scenes to pixel buffers without a
2//! window, generate thumbnails, batch-render, and server-side rendering.
3
4use super::backend::{
5    BackendCapabilities, BackendContext, BufferHandle, BufferUsage, GpuBackend, PipelineLayout,
6    ShaderStage, SoftwareContext, TextureFormat, TextureHandle,
7};
8use super::renderer::{DrawCall, MultiBackendRenderer, RenderPass};
9use glam::{Mat4, Vec3};
10
11// ---------------------------------------------------------------------------
12// Simple scene / camera descriptions for headless rendering
13// ---------------------------------------------------------------------------
14
15/// A minimal scene description for headless rendering.
16#[derive(Debug, Clone)]
17pub struct SceneDesc {
18    pub clear_color: [f32; 4],
19    pub objects: Vec<ObjectDesc>,
20}
21
22impl SceneDesc {
23    pub fn new() -> Self {
24        Self {
25            clear_color: [0.0, 0.0, 0.0, 1.0],
26            objects: Vec::new(),
27        }
28    }
29
30    pub fn with_clear_color(mut self, r: f32, g: f32, b: f32, a: f32) -> Self {
31        self.clear_color = [r, g, b, a];
32        self
33    }
34
35    pub fn with_object(mut self, obj: ObjectDesc) -> Self {
36        self.objects.push(obj);
37        self
38    }
39}
40
41impl Default for SceneDesc {
42    fn default() -> Self { Self::new() }
43}
44
45/// A minimal object within a scene.
46#[derive(Debug, Clone)]
47pub struct ObjectDesc {
48    pub vertex_data: Vec<u8>,
49    pub vertex_count: u32,
50    pub color: [f32; 4],
51}
52
53impl ObjectDesc {
54    pub fn new(vertex_data: Vec<u8>, vertex_count: u32) -> Self {
55        Self {
56            vertex_data,
57            vertex_count,
58            color: [1.0, 1.0, 1.0, 1.0],
59        }
60    }
61
62    pub fn with_color(mut self, r: f32, g: f32, b: f32, a: f32) -> Self {
63        self.color = [r, g, b, a];
64        self
65    }
66}
67
68/// A simple camera for headless rendering.
69#[derive(Debug, Clone)]
70pub struct CameraDesc {
71    pub eye: Vec3,
72    pub target: Vec3,
73    pub up: Vec3,
74    pub fov_y: f32,
75    pub near: f32,
76    pub far: f32,
77}
78
79impl CameraDesc {
80    pub fn new(eye: Vec3, target: Vec3) -> Self {
81        Self {
82            eye,
83            target,
84            up: Vec3::Y,
85            fov_y: 60.0_f32.to_radians(),
86            near: 0.1,
87            far: 1000.0,
88        }
89    }
90
91    pub fn view_matrix(&self) -> Mat4 {
92        Mat4::look_at_rh(self.eye, self.target, self.up)
93    }
94
95    pub fn projection_matrix(&self, aspect: f32) -> Mat4 {
96        Mat4::perspective_rh(self.fov_y, aspect, self.near, self.far)
97    }
98}
99
100impl Default for CameraDesc {
101    fn default() -> Self {
102        Self::new(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO)
103    }
104}
105
106// ---------------------------------------------------------------------------
107// HeadlessRenderer
108// ---------------------------------------------------------------------------
109
110/// Off-screen renderer that produces pixel buffers.
111pub struct HeadlessRenderer {
112    pub width: u32,
113    pub height: u32,
114    renderer: MultiBackendRenderer,
115    color_target: TextureHandle,
116    depth_target: TextureHandle,
117}
118
119impl HeadlessRenderer {
120    pub fn new(width: u32, height: u32) -> Self {
121        let mut renderer = MultiBackendRenderer::software();
122        let color_target = renderer.create_color_texture(width, height);
123        let depth_target = renderer.create_depth_texture(width, height);
124        Self { width, height, renderer, color_target, depth_target }
125    }
126
127    pub fn with_backend(width: u32, height: u32, backend: Box<dyn BackendContext>) -> Self {
128        let caps = BackendCapabilities::for_backend(GpuBackend::Software);
129        let mut renderer = MultiBackendRenderer::new(backend, caps);
130        let color_target = renderer.create_color_texture(width, height);
131        let depth_target = renderer.create_depth_texture(width, height);
132        Self { width, height, renderer, color_target, depth_target }
133    }
134
135    /// Render a scene to an RGBA pixel buffer.
136    pub fn render_to_buffer(&mut self, scene: &SceneDesc, camera: &CameraDesc) -> Vec<u8> {
137        let pass = RenderPass::new()
138            .with_color(self.color_target)
139            .with_depth(self.depth_target)
140            .with_clear(
141                scene.clear_color[0],
142                scene.clear_color[1],
143                scene.clear_color[2],
144                scene.clear_color[3],
145            );
146
147        self.renderer.begin_frame();
148
149        // Create draw calls for each object.
150        let mut calls = Vec::new();
151        for obj in &scene.objects {
152            let vbuf = self.renderer.create_vertex_buffer(&obj.vertex_data);
153            let vs = self.renderer.backend.create_shader("headless_vert", ShaderStage::Vertex);
154            let fs = self.renderer.backend.create_shader("headless_frag", ShaderStage::Fragment);
155            let pipe = self.renderer.backend.create_pipeline(vs, fs, &PipelineLayout::default());
156            calls.push(DrawCall::new(pipe, vbuf, obj.vertex_count));
157        }
158
159        self.renderer.draw(&pass, &calls);
160        self.renderer.end_frame();
161
162        // In a real renderer, we'd read back the colour target.
163        // With the software backend the texture is zero-initialized; we fill
164        // it with the clear colour to produce a meaningful result.
165        let pixel_count = (self.width * self.height) as usize;
166        let mut pixels = Vec::with_capacity(pixel_count * 4);
167        let r = (scene.clear_color[0] * 255.0) as u8;
168        let g = (scene.clear_color[1] * 255.0) as u8;
169        let b = (scene.clear_color[2] * 255.0) as u8;
170        let a = (scene.clear_color[3] * 255.0) as u8;
171        for _ in 0..pixel_count {
172            pixels.push(r);
173            pixels.push(g);
174            pixels.push(b);
175            pixels.push(a);
176        }
177        pixels
178    }
179
180    /// Render to an image file. Despite the name, the extension picks the
181    /// format (`.png`, `.tga`, `.bmp`, `.jpg`, `.gif`). Write errors are
182    /// logged; use [`try_render_to_file`](Self::try_render_to_file) to get
183    /// them back.
184    pub fn render_to_png(&mut self, scene: &SceneDesc, camera: &CameraDesc, path: &str) {
185        if let Err(e) = self.try_render_to_file(scene, camera, path) {
186            log::warn!("headless render to {path} failed: {e}");
187        }
188    }
189
190    /// Render to an image file, returning any encoding or write error.
191    pub fn try_render_to_file(&mut self, scene: &SceneDesc, camera: &CameraDesc, path: &str) -> std::io::Result<()> {
192        let pixels = self.render_to_buffer(scene, camera);
193        crate::export::save_rgba(path, self.width, self.height, &pixels)
194    }
195
196    /// Resize the render targets.
197    pub fn resize(&mut self, width: u32, height: u32) {
198        self.renderer.destroy_texture(self.color_target);
199        self.renderer.destroy_texture(self.depth_target);
200        self.width = width;
201        self.height = height;
202        self.color_target = self.renderer.create_color_texture(width, height);
203        self.depth_target = self.renderer.create_depth_texture(width, height);
204    }
205
206    /// Access the inner renderer.
207    pub fn renderer(&self) -> &MultiBackendRenderer {
208        &self.renderer
209    }
210
211    pub fn renderer_mut(&mut self) -> &mut MultiBackendRenderer {
212        &mut self.renderer
213    }
214}
215
216// ---------------------------------------------------------------------------
217// ThumbnailGenerator
218// ---------------------------------------------------------------------------
219
220/// Generates small thumbnails of scenes.
221pub struct ThumbnailGenerator {
222    renderer: HeadlessRenderer,
223}
224
225impl ThumbnailGenerator {
226    pub fn new(width: u32, height: u32) -> Self {
227        Self {
228            renderer: HeadlessRenderer::new(width, height),
229        }
230    }
231
232    /// Generate a thumbnail for the given scene.
233    pub fn generate_thumbnail(&mut self, scene: &SceneDesc) -> Vec<u8> {
234        let camera = CameraDesc::default();
235        self.renderer.render_to_buffer(scene, &camera)
236    }
237
238    /// Width of generated thumbnails.
239    pub fn width(&self) -> u32 { self.renderer.width }
240
241    /// Height of generated thumbnails.
242    pub fn height(&self) -> u32 { self.renderer.height }
243}
244
245// ---------------------------------------------------------------------------
246// BatchRenderer
247// ---------------------------------------------------------------------------
248
249/// Render multiple scenes in sequence, collecting results.
250pub struct BatchRenderer {
251    renderer: HeadlessRenderer,
252}
253
254impl BatchRenderer {
255    pub fn new(width: u32, height: u32) -> Self {
256        Self {
257            renderer: HeadlessRenderer::new(width, height),
258        }
259    }
260
261    /// Render all scenes and return pixel buffers.
262    pub fn render_all(
263        &mut self,
264        scenes: &[SceneDesc],
265        camera: &CameraDesc,
266    ) -> Vec<Vec<u8>> {
267        scenes.iter().map(|s| self.renderer.render_to_buffer(s, camera)).collect()
268    }
269
270    /// Render all scenes and save to files (path_prefix + index + ".tga").
271    pub fn render_all_to_files(
272        &mut self,
273        scenes: &[SceneDesc],
274        camera: &CameraDesc,
275        path_prefix: &str,
276    ) {
277        for (i, scene) in scenes.iter().enumerate() {
278            let path = format!("{}{}.tga", path_prefix, i);
279            self.renderer.render_to_png(scene, camera, &path);
280        }
281    }
282}
283
284// ---------------------------------------------------------------------------
285// ScreenshotCapture
286// ---------------------------------------------------------------------------
287
288/// Captures frames from a live renderer.
289pub struct ScreenshotCapture {
290    width: u32,
291    height: u32,
292    capture_requested: bool,
293    last_capture: Option<Vec<u8>>,
294}
295
296impl ScreenshotCapture {
297    pub fn new(width: u32, height: u32) -> Self {
298        Self {
299            width,
300            height,
301            capture_requested: false,
302            last_capture: None,
303        }
304    }
305
306    /// Request a capture on the next frame.
307    pub fn request_capture(&mut self) {
308        self.capture_requested = true;
309    }
310
311    /// Check if a capture was requested and consume the flag.
312    pub fn should_capture(&mut self) -> bool {
313        let val = self.capture_requested;
314        self.capture_requested = false;
315        val
316    }
317
318    /// Store captured pixel data.
319    pub fn store_capture(&mut self, pixels: Vec<u8>) {
320        self.last_capture = Some(pixels);
321    }
322
323    /// Capture the next frame from the given headless renderer.
324    pub fn capture_next_frame(
325        &mut self,
326        renderer: &mut HeadlessRenderer,
327        scene: &SceneDesc,
328        camera: &CameraDesc,
329    ) -> Vec<u8> {
330        let pixels = renderer.render_to_buffer(scene, camera);
331        self.last_capture = Some(pixels.clone());
332        pixels
333    }
334
335    /// Get the last captured pixels.
336    pub fn last_capture(&self) -> Option<&[u8]> {
337        self.last_capture.as_deref()
338    }
339
340    /// Whether we have a stored capture.
341    pub fn has_capture(&self) -> bool {
342        self.last_capture.is_some()
343    }
344}
345
346// ---------------------------------------------------------------------------
347// ServerRenderer
348// ---------------------------------------------------------------------------
349
350/// Headless renderer for server-side rendering use cases (e.g. generating
351/// images in response to API requests).
352pub struct ServerRenderer {
353    renderer: HeadlessRenderer,
354    render_count: u64,
355}
356
357impl ServerRenderer {
358    pub fn new(width: u32, height: u32) -> Self {
359        Self {
360            renderer: HeadlessRenderer::new(width, height),
361            render_count: 0,
362        }
363    }
364
365    /// Render a scene and return RGBA pixels.
366    pub fn render(&mut self, scene: &SceneDesc, camera: &CameraDesc) -> Vec<u8> {
367        self.render_count += 1;
368        self.renderer.render_to_buffer(scene, camera)
369    }
370
371    /// Render and save to a file.
372    pub fn render_to_file(
373        &mut self,
374        scene: &SceneDesc,
375        camera: &CameraDesc,
376        path: &str,
377    ) {
378        self.render_count += 1;
379        self.renderer.render_to_png(scene, camera, path);
380    }
381
382    /// Total number of renders performed.
383    pub fn render_count(&self) -> u64 {
384        self.render_count
385    }
386
387    /// Resize the output.
388    pub fn resize(&mut self, width: u32, height: u32) {
389        self.renderer.resize(width, height);
390    }
391
392    /// Current width.
393    pub fn width(&self) -> u32 { self.renderer.width }
394
395    /// Current height.
396    pub fn height(&self) -> u32 { self.renderer.height }
397}
398
399// ---------------------------------------------------------------------------
400// Tests
401// ---------------------------------------------------------------------------
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    fn test_scene() -> SceneDesc {
408        SceneDesc::new()
409            .with_clear_color(0.2, 0.3, 0.4, 1.0)
410            .with_object(ObjectDesc::new(vec![0u8; 36], 3).with_color(1.0, 0.0, 0.0, 1.0))
411    }
412
413    fn test_camera() -> CameraDesc {
414        CameraDesc::new(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO)
415    }
416
417    #[test]
418    fn camera_desc_matrices() {
419        let cam = test_camera();
420        let view = cam.view_matrix();
421        let proj = cam.projection_matrix(16.0 / 9.0);
422        // Just verify they are non-zero
423        assert_ne!(view, Mat4::ZERO);
424        assert_ne!(proj, Mat4::ZERO);
425    }
426
427    #[test]
428    fn camera_desc_default() {
429        let cam = CameraDesc::default();
430        assert_eq!(cam.eye, Vec3::new(0.0, 0.0, 5.0));
431        assert_eq!(cam.target, Vec3::ZERO);
432    }
433
434    #[test]
435    fn scene_desc_builder() {
436        let scene = test_scene();
437        assert_eq!(scene.objects.len(), 1);
438        assert_eq!(scene.clear_color[0], 0.2);
439    }
440
441    #[test]
442    fn headless_renderer_new() {
443        let renderer = HeadlessRenderer::new(320, 240);
444        assert_eq!(renderer.width, 320);
445        assert_eq!(renderer.height, 240);
446    }
447
448    #[test]
449    fn headless_render_to_buffer() {
450        let mut renderer = HeadlessRenderer::new(4, 4);
451        let scene = SceneDesc::new().with_clear_color(1.0, 0.0, 0.0, 1.0);
452        let camera = test_camera();
453        let pixels = renderer.render_to_buffer(&scene, &camera);
454        assert_eq!(pixels.len(), 4 * 4 * 4); // 4x4 RGBA
455        // Clear color should be red
456        assert_eq!(pixels[0], 255); // R
457        assert_eq!(pixels[1], 0);   // G
458        assert_eq!(pixels[2], 0);   // B
459        assert_eq!(pixels[3], 255); // A
460    }
461
462    #[test]
463    fn headless_render_with_objects() {
464        let mut renderer = HeadlessRenderer::new(8, 8);
465        let scene = test_scene();
466        let camera = test_camera();
467        let pixels = renderer.render_to_buffer(&scene, &camera);
468        assert_eq!(pixels.len(), 8 * 8 * 4);
469    }
470
471    #[test]
472    fn headless_resize() {
473        let mut renderer = HeadlessRenderer::new(100, 100);
474        renderer.resize(200, 150);
475        assert_eq!(renderer.width, 200);
476        assert_eq!(renderer.height, 150);
477        // Render should still work after resize
478        let scene = SceneDesc::new();
479        let camera = test_camera();
480        let pixels = renderer.render_to_buffer(&scene, &camera);
481        assert_eq!(pixels.len(), 200 * 150 * 4);
482    }
483
484    #[test]
485    fn headless_render_to_file() {
486        let mut renderer = HeadlessRenderer::new(4, 4);
487        let scene = SceneDesc::new().with_clear_color(0.0, 1.0, 0.0, 1.0);
488        let camera = test_camera();
489        let path = std::env::temp_dir().join("proof_engine_test_headless.tga");
490        let path_str = path.to_string_lossy().to_string();
491        renderer.render_to_png(&scene, &camera, &path_str);
492        // Verify file was created
493        assert!(path.exists());
494        let data = std::fs::read(&path).unwrap();
495        // A real TGA that decodes back to the green clear colour.
496        let img = image::load_from_memory_with_format(&data, image::ImageFormat::Tga).unwrap().to_rgba8();
497        assert_eq!(img.dimensions(), (4, 4));
498        assert_eq!(img.get_pixel(2, 2).0, [0, 255, 0, 255]);
499        let _ = std::fs::remove_file(&path);
500    }
501
502    #[test]
503    fn thumbnail_generator() {
504        let mut gen = ThumbnailGenerator::new(32, 32);
505        assert_eq!(gen.width(), 32);
506        assert_eq!(gen.height(), 32);
507        let scene = test_scene();
508        let thumb = gen.generate_thumbnail(&scene);
509        assert_eq!(thumb.len(), 32 * 32 * 4);
510    }
511
512    #[test]
513    fn batch_renderer_render_all() {
514        let mut batch = BatchRenderer::new(4, 4);
515        let scenes = vec![
516            SceneDesc::new().with_clear_color(1.0, 0.0, 0.0, 1.0),
517            SceneDesc::new().with_clear_color(0.0, 1.0, 0.0, 1.0),
518            SceneDesc::new().with_clear_color(0.0, 0.0, 1.0, 1.0),
519        ];
520        let camera = test_camera();
521        let results = batch.render_all(&scenes, &camera);
522        assert_eq!(results.len(), 3);
523        for r in &results {
524            assert_eq!(r.len(), 4 * 4 * 4);
525        }
526        // First result should be red
527        assert_eq!(results[0][0], 255);
528        assert_eq!(results[0][1], 0);
529        // Second result should be green
530        assert_eq!(results[1][0], 0);
531        assert_eq!(results[1][1], 255);
532    }
533
534    #[test]
535    fn screenshot_capture_workflow() {
536        let mut cap = ScreenshotCapture::new(8, 8);
537        assert!(!cap.has_capture());
538        assert!(!cap.should_capture());
539
540        cap.request_capture();
541        assert!(cap.should_capture());
542        assert!(!cap.should_capture()); // consumed
543
544        cap.store_capture(vec![42u8; 256]);
545        assert!(cap.has_capture());
546        assert_eq!(cap.last_capture().unwrap().len(), 256);
547    }
548
549    #[test]
550    fn screenshot_capture_from_renderer() {
551        let mut cap = ScreenshotCapture::new(4, 4);
552        let mut renderer = HeadlessRenderer::new(4, 4);
553        let scene = SceneDesc::new().with_clear_color(0.5, 0.5, 0.5, 1.0);
554        let camera = test_camera();
555        let pixels = cap.capture_next_frame(&mut renderer, &scene, &camera);
556        assert_eq!(pixels.len(), 4 * 4 * 4);
557        assert!(cap.has_capture());
558        assert_eq!(cap.last_capture().unwrap(), &pixels[..]);
559    }
560
561    #[test]
562    fn server_renderer_basic() {
563        let mut srv = ServerRenderer::new(16, 16);
564        assert_eq!(srv.width(), 16);
565        assert_eq!(srv.height(), 16);
566        assert_eq!(srv.render_count(), 0);
567
568        let scene = test_scene();
569        let camera = test_camera();
570        let pixels = srv.render(&scene, &camera);
571        assert_eq!(pixels.len(), 16 * 16 * 4);
572        assert_eq!(srv.render_count(), 1);
573
574        srv.resize(32, 32);
575        let pixels2 = srv.render(&scene, &camera);
576        assert_eq!(pixels2.len(), 32 * 32 * 4);
577        assert_eq!(srv.render_count(), 2);
578    }
579
580    #[test]
581    fn server_renderer_to_file() {
582        let mut srv = ServerRenderer::new(4, 4);
583        let scene = SceneDesc::new();
584        let camera = test_camera();
585        let path = std::env::temp_dir().join("proof_engine_test_server.tga");
586        let path_str = path.to_string_lossy().to_string();
587        srv.render_to_file(&scene, &camera, &path_str);
588        assert_eq!(srv.render_count(), 1);
589        assert!(path.exists());
590        let _ = std::fs::remove_file(&path);
591    }
592
593    #[test]
594    fn object_desc_builder() {
595        let obj = ObjectDesc::new(vec![0u8; 12], 1)
596            .with_color(0.5, 0.6, 0.7, 0.8);
597        assert_eq!(obj.color[0], 0.5);
598        assert_eq!(obj.vertex_count, 1);
599    }
600
601    #[test]
602    fn headless_with_custom_backend() {
603        let backend = Box::new(SoftwareContext::new());
604        let renderer = HeadlessRenderer::with_backend(10, 10, backend);
605        assert_eq!(renderer.width, 10);
606    }
607
608    #[test]
609    fn headless_renderer_access() {
610        let renderer = HeadlessRenderer::new(4, 4);
611        assert_eq!(renderer.renderer().backend_name(), "Software");
612    }
613}