Skip to main content

fenestra_shell/
headless.rs

1//! Offscreen rendering: a wgpu device plus vello renderer with no window or
2//! display server. This is the backbone of fenestra's snapshot testing.
3
4use std::num::NonZeroUsize;
5
6use image::RgbaImage;
7use vello::peniko::Color;
8use vello::util::RenderContext;
9use vello::wgpu::{
10    self, BufferDescriptor, BufferUsages, CommandEncoderDescriptor, Extent3d, TexelCopyBufferInfo,
11    TexelCopyBufferLayout, TextureDescriptor, TextureFormat, TextureUsages,
12};
13use vello::{AaConfig, AaSupport, RenderParams, Renderer, RendererOptions, Scene};
14
15use crate::ShellError;
16
17/// A reusable offscreen renderer. Creating one compiles vello's shaders, so
18/// tests should create it once and render many scenes through it.
19pub struct Headless {
20    context: RenderContext,
21    dev_id: usize,
22    renderer: Renderer,
23    max_dim: u32,
24}
25
26impl Headless {
27    /// Acquires a compute-capable adapter (no surface required) and builds a
28    /// vello renderer on it.
29    pub fn new() -> Result<Self, ShellError> {
30        let mut context = RenderContext::new();
31        let dev_id = pollster::block_on(context.device(None)).ok_or(ShellError::NoDevice)?;
32        let device = &context.devices[dev_id].device;
33        let max_dim = device.limits().max_texture_dimension_2d;
34        let renderer = Renderer::new(
35            device,
36            RendererOptions {
37                use_cpu: false,
38                antialiasing_support: AaSupport::area_only(),
39                num_init_threads: NonZeroUsize::new(1),
40                pipeline_cache: None,
41            },
42        )
43        .map_err(ShellError::Vello)?;
44        Ok(Self {
45            context,
46            dev_id,
47            renderer,
48            max_dim,
49        })
50    }
51
52    /// The largest texture dimension the device supports; render sizes are
53    /// clamped to it.
54    pub fn max_dimension(&self) -> u32 {
55        self.max_dim
56    }
57
58    /// Clamps a requested render size on both axes to the supported
59    /// `1..=max_dimension()` range.
60    pub fn clamp_size(&self, width: u32, height: u32) -> (u32, u32) {
61        (width.clamp(1, self.max_dim), height.clamp(1, self.max_dim))
62    }
63
64    /// Renders `scene` at the given pixel size over `base_color` and reads the
65    /// result back into an RGBA image. The size is clamped to
66    /// `1..=max_dimension()` on both axes, so hostile dimensions cannot
67    /// trigger wgpu's fatal validation handler.
68    pub fn render(
69        &mut self,
70        scene: &Scene,
71        width: u32,
72        height: u32,
73        base_color: Color,
74    ) -> Result<RgbaImage, ShellError> {
75        let (width, height) = self.clamp_size(width, height);
76        let handle = &self.context.devices[self.dev_id];
77        let (device, queue) = (&handle.device, &handle.queue);
78
79        let size = Extent3d {
80            width,
81            height,
82            depth_or_array_layers: 1,
83        };
84        let target = device.create_texture(&TextureDescriptor {
85            label: Some("fenestra headless target"),
86            size,
87            mip_level_count: 1,
88            sample_count: 1,
89            dimension: wgpu::TextureDimension::D2,
90            format: TextureFormat::Rgba8Unorm,
91            usage: TextureUsages::STORAGE_BINDING | TextureUsages::COPY_SRC,
92            view_formats: &[],
93        });
94        let view = target.create_view(&wgpu::TextureViewDescriptor::default());
95        self.renderer
96            .render_to_texture(
97                device,
98                queue,
99                scene,
100                &view,
101                &RenderParams {
102                    base_color,
103                    width,
104                    height,
105                    antialiasing_method: AaConfig::Area,
106                },
107            )
108            .map_err(ShellError::Vello)?;
109
110        // wgpu requires copy rows padded to 256 bytes.
111        let padded_byte_width = (width * 4).next_multiple_of(256);
112        let buffer = device.create_buffer(&BufferDescriptor {
113            label: Some("fenestra headless readback"),
114            size: u64::from(padded_byte_width) * u64::from(height),
115            usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,
116            mapped_at_creation: false,
117        });
118        let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
119            label: Some("fenestra headless copy"),
120        });
121        encoder.copy_texture_to_buffer(
122            target.as_image_copy(),
123            TexelCopyBufferInfo {
124                buffer: &buffer,
125                layout: TexelCopyBufferLayout {
126                    offset: 0,
127                    bytes_per_row: Some(padded_byte_width),
128                    rows_per_image: None,
129                },
130            },
131            size,
132        );
133        queue.submit([encoder.finish()]);
134
135        let slice = buffer.slice(..);
136        let (tx, rx) = std::sync::mpsc::channel();
137        slice.map_async(wgpu::MapMode::Read, move |result| {
138            let _ = tx.send(result);
139        });
140        device
141            .poll(wgpu::PollType::wait_indefinitely())
142            .map_err(|_| ShellError::Readback)?;
143        rx.recv()
144            .map_err(|_| ShellError::Readback)?
145            .map_err(|_| ShellError::Readback)?;
146
147        let data = slice.get_mapped_range();
148        let mut pixels = Vec::with_capacity((width * height * 4) as usize);
149        for row in 0..height {
150            let start = (row * padded_byte_width) as usize;
151            pixels.extend_from_slice(&data[start..start + (width * 4) as usize]);
152        }
153        drop(data);
154        buffer.unmap();
155
156        Ok(RgbaImage::from_raw(width, height, pixels)
157            .expect("readback buffer matches image dimensions"))
158    }
159}