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 fenestra_core::{Fonts, Frame, FrameState};
7use image::RgbaImage;
8use vello::peniko::Color;
9use vello::util::RenderContext;
10use vello::wgpu::{
11    self, BufferDescriptor, BufferUsages, CommandEncoderDescriptor, Extent3d, TexelCopyBufferInfo,
12    TexelCopyBufferLayout, TextureDescriptor, TextureFormat, TextureUsages,
13};
14use vello::{AaConfig, AaSupport, RenderParams, Renderer, RendererOptions, Scene};
15
16use crate::ShellError;
17
18/// A reusable offscreen renderer. Creating one compiles vello's shaders, so
19/// tests should create it once and render many scenes through it.
20pub struct Headless {
21    context: RenderContext,
22    dev_id: usize,
23    renderer: Renderer,
24    max_dim: u32,
25}
26
27impl Headless {
28    /// Acquires a compute-capable adapter (no surface required) and builds a
29    /// vello renderer on it.
30    pub fn new() -> Result<Self, ShellError> {
31        let mut context = RenderContext::new();
32        let dev_id = pollster::block_on(context.device(None)).ok_or(ShellError::NoDevice)?;
33        let device = &context.devices[dev_id].device;
34        let max_dim = device.limits().max_texture_dimension_2d;
35        let renderer = Renderer::new(
36            device,
37            RendererOptions {
38                use_cpu: false,
39                antialiasing_support: AaSupport::area_only(),
40                num_init_threads: NonZeroUsize::new(1),
41                pipeline_cache: None,
42            },
43        )
44        .map_err(ShellError::Vello)?;
45        Ok(Self {
46            context,
47            dev_id,
48            renderer,
49            max_dim,
50        })
51    }
52
53    /// The largest texture dimension the device supports; render sizes are
54    /// clamped to it.
55    pub fn max_dimension(&self) -> u32 {
56        self.max_dim
57    }
58
59    /// Clamps a requested render size on both axes to the supported
60    /// `1..=max_dimension()` range.
61    pub fn clamp_size(&self, width: u32, height: u32) -> (u32, u32) {
62        (width.clamp(1, self.max_dim), height.clamp(1, self.max_dim))
63    }
64
65    /// Renders `scene` at the given pixel size over `base_color` and reads the
66    /// result back into an RGBA image. The size is clamped to
67    /// `1..=max_dimension()` on both axes, so hostile dimensions cannot
68    /// trigger wgpu's fatal validation handler.
69    pub fn render(
70        &mut self,
71        scene: &Scene,
72        width: u32,
73        height: u32,
74        base_color: Color,
75    ) -> Result<RgbaImage, ShellError> {
76        let (width, height) = self.clamp_size(width, height);
77        let handle = &self.context.devices[self.dev_id];
78        let (device, queue) = (&handle.device, &handle.queue);
79
80        let size = Extent3d {
81            width,
82            height,
83            depth_or_array_layers: 1,
84        };
85        let target = device.create_texture(&TextureDescriptor {
86            label: Some("fenestra headless target"),
87            size,
88            mip_level_count: 1,
89            sample_count: 1,
90            dimension: wgpu::TextureDimension::D2,
91            format: TextureFormat::Rgba8Unorm,
92            usage: TextureUsages::STORAGE_BINDING | TextureUsages::COPY_SRC,
93            view_formats: &[],
94        });
95        let view = target.create_view(&wgpu::TextureViewDescriptor::default());
96        self.renderer
97            .render_to_texture(
98                device,
99                queue,
100                scene,
101                &view,
102                &RenderParams {
103                    base_color,
104                    width,
105                    height,
106                    antialiasing_method: AaConfig::Area,
107                },
108            )
109            .map_err(ShellError::Vello)?;
110
111        // wgpu requires copy rows padded to 256 bytes.
112        let padded_byte_width = (width * 4).next_multiple_of(256);
113        let buffer = device.create_buffer(&BufferDescriptor {
114            label: Some("fenestra headless readback"),
115            size: u64::from(padded_byte_width) * u64::from(height),
116            usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,
117            mapped_at_creation: false,
118        });
119        let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
120            label: Some("fenestra headless copy"),
121        });
122        encoder.copy_texture_to_buffer(
123            target.as_image_copy(),
124            TexelCopyBufferInfo {
125                buffer: &buffer,
126                layout: TexelCopyBufferLayout {
127                    offset: 0,
128                    bytes_per_row: Some(padded_byte_width),
129                    rows_per_image: None,
130                },
131            },
132            size,
133        );
134        queue.submit([encoder.finish()]);
135
136        let slice = buffer.slice(..);
137        let (tx, rx) = std::sync::mpsc::channel();
138        slice.map_async(wgpu::MapMode::Read, move |result| {
139            let _ = tx.send(result);
140        });
141        device
142            .poll(wgpu::PollType::wait_indefinitely())
143            .map_err(|_| ShellError::Readback)?;
144        rx.recv()
145            .map_err(|_| ShellError::Readback)?
146            .map_err(|_| ShellError::Readback)?;
147
148        let data = slice.get_mapped_range();
149        let mut pixels = Vec::with_capacity((width * height * 4) as usize);
150        for row in 0..height {
151            let start = (row * padded_byte_width) as usize;
152            pixels.extend_from_slice(&data[start..start + (width * 4) as usize]);
153        }
154        drop(data);
155        buffer.unmap();
156
157        Ok(RgbaImage::from_raw(width, height, pixels)
158            .expect("readback buffer matches image dimensions"))
159    }
160
161    /// Renders `frame` with real frosted-glass backdrop blur and foreground
162    /// [`ElementFilter`](fenestra_core::ElementFilter)s via the two-pass
163    /// pipeline, falling back to a single pass when the frame has neither.
164    ///
165    /// The fast path is byte-for-byte identical to [`render`](Self::render): a
166    /// frame with no filtered node produces an empty plan, so the backdrop scene
167    /// *is* the final image and nothing is read back or reprocessed. Otherwise
168    /// the backdrop scene (every glass subtree skipped) is rendered and read
169    /// back, each region is blurred/filtered on the CPU
170    /// ([`process_specs`](crate::multi_pass::process_specs)), and the final scene
171    /// composites those images. `width`/`height` are physical pixels.
172    pub fn render_plan(
173        &mut self,
174        frame: &Frame,
175        fonts: &mut Fonts,
176        state: &mut FrameState,
177        width: u32,
178        height: u32,
179        base_color: Color,
180    ) -> Result<RgbaImage, ShellError> {
181        let (backdrop_scene, specs) = frame.paint_backdrop(fonts, state);
182        if specs.is_empty() {
183            // Fast path: one pass, identical to `render`.
184            return self.render(&backdrop_scene, width, height, base_color);
185        }
186        let backdrop = self.render(&backdrop_scene, width, height, base_color)?;
187        let injected = crate::multi_pass::process_specs(&backdrop, &specs, frame.scale());
188        let final_scene = frame.paint_final(fonts, state, &injected);
189        self.render(&final_scene, width, height, base_color)
190    }
191}