Skip to main content

ironlab_viewer/
offscreen.rs

1//! Headless rendering of figures through the viewer's own mesh pipeline.
2//!
3//! [`render_offscreen`] compiles a figure, tessellates its display list with [`crate::canvas::tessellate`] at
4//! `dpi / 72` pixels per point, and draws the meshes with [`egui_wgpu::Renderer`] into an offscreen texture. No
5//! window or surface is created, so it runs in CI on a software adapter (for example lavapipe).
6//!
7//! # Pipeline
8//!
9//! 1. A wgpu instance is created without a display handle
10//!    (`egui_wgpu::WgpuSetupCreateNew::without_display_handle`), an adapter is requested with no compatible
11//!    surface, and a device and queue are requested from it. The backends honour the `WGPU_BACKEND` environment
12//!    variable, as `without_display_handle` does, and there is no fallback to other backends when it is set. Failure
13//!    to find an adapter is reported as [`RenderError::NoAdapter`], never as a panic. An [`OffscreenRenderer`] owns
14//!    the device and can render any number of images; [`render_offscreen`] and [`render_display_list_offscreen`]
15//!    share one process-wide renderer, created on first successful use, so that rendering a whole gallery creates
16//!    the device only once.
17//! 2. An [`egui_wgpu::Renderer`] is created for `Rgba8Unorm` (egui blends in gamma space and outputs gamma-encoded
18//!    colour into a non-sRGB target) with 4× multisampling (when the adapter supports it) and dithering disabled,
19//!    so that repeated renders of the same figure are identical.
20//! 3. The default egui texture (`TextureId::Managed(0)`) is uploaded as a 1×1 white image, because the meshes sample
21//!    it at [`egui::epaint::WHITE_UV`].
22//! 4. The image size is checked against the device's maximum texture dimension before any texture is created, and
23//!    an invalid size is reported as [`RenderError::InvalidSize`].
24//! 5. The figure background is painted as a rectangle mesh beneath the meshes of the display list. The meshes are
25//!    wrapped in `egui::ClippedPrimitive`s whose clip rectangle is the whole image, and `Renderer::update_buffers`
26//!    and `Renderer::render` draw them with `ScreenDescriptor { size_in_pixels: [width, height], pixels_per_point:
27//!    1.0 }` into a multisampled colour texture that is cleared to transparent black and resolved into a
28//!    single-sample `COPY_SRC` texture. A transparent background therefore stays transparent.
29//! 6. The resolved texture is copied into a `MAP_READ` buffer with rows padded to
30//!    `wgpu::COPY_BYTES_PER_ROW_ALIGNMENT`, the device is polled until the copy completes, and the padding is
31//!    stripped. The GPU output has premultiplied alpha, which is converted to straight alpha.
32//!
33//! The pixel size of the image is `round(width_pt · dpi / 72)` by `round(height_pt · dpi / 72)`.
34
35use std::sync::{Mutex, PoisonError};
36use std::time::Duration;
37
38use egui_wgpu::wgpu;
39use ironlab_ir::Figure;
40use ironlab_scene::display::DisplayList;
41use ironlab_text::TextEngine;
42
43use crate::canvas::{ScreenTransform, color32, tessellate};
44
45/// An 8-bit RGBA image with straight alpha, stored row by row from the top-left pixel.
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct RenderedImage {
48    pub width: u32,
49    pub height: u32,
50    /// `width · height · 4` bytes.
51    pub rgba: Vec<u8>,
52}
53
54impl RenderedImage {
55    /// Returns the RGBA value of the pixel at column `x` and row `y`.
56    ///
57    /// # Panics
58    ///
59    /// Panics when the pixel is outside the image.
60    #[must_use]
61    pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
62        assert!(
63            x < self.width && y < self.height,
64            "pixel ({x}, {y}) is outside the image"
65        );
66        let i = (y as usize * self.width as usize + x as usize) * 4;
67        [
68            self.rgba[i],
69            self.rgba[i + 1],
70            self.rgba[i + 2],
71            self.rgba[i + 3],
72        ]
73    }
74}
75
76/// A failure to render offscreen.
77#[derive(Debug, thiserror::Error)]
78pub enum RenderError {
79    /// No wgpu adapter is available, for example on a machine without a GPU or a software rasteriser.
80    #[error("no graphics adapter is available for offscreen rendering: {0}")]
81    NoAdapter(String),
82    /// The adapter refused to create a device.
83    #[error("the graphics adapter could not create a device: {0}")]
84    Device(String),
85    /// The requested image is empty or exceeds the adapter's maximum texture dimension.
86    #[error("cannot render an image of {width}×{height} pixels (maximum dimension {max})")]
87    InvalidSize { width: u32, height: u32, max: u32 },
88    /// The rendered image could not be read back from the GPU.
89    #[error("the rendered image could not be read back: {0}")]
90    Readback(String),
91}
92
93/// Compiles `figure` and renders it at `dpi` dots per inch.
94///
95/// # Errors
96///
97/// Returns a [`RenderError`] when no adapter or device is available, when the image size is invalid for the adapter,
98/// or when readback fails.
99pub fn render_offscreen(
100    figure: &Figure,
101    text: &TextEngine,
102    dpi: f64,
103) -> Result<RenderedImage, RenderError> {
104    let scene = ironlab_scene::compile(figure, text);
105    render_display_list_offscreen(&scene.display_list, text, dpi)
106}
107
108/// Renders an already compiled display list at `dpi` dots per inch.
109///
110/// # Errors
111///
112/// As for [`render_offscreen`].
113pub fn render_display_list_offscreen(
114    list: &DisplayList,
115    text: &TextEngine,
116    dpi: f64,
117) -> Result<RenderedImage, RenderError> {
118    with_shared_renderer(|renderer| renderer.render_display_list(list, text, dpi))
119}
120
121/// Runs `use_renderer` against the process-wide renderer, creating its device on first use.
122///
123/// The device is by far the most expensive part of an offscreen render, so every caller in the process shares one,
124/// and the renderer is discarded when a render fails at readback, which is how a lost device shows itself.
125///
126/// # Errors
127///
128/// Returns [`RenderError::NoAdapter`] or [`RenderError::Device`] when the renderer cannot be created, and otherwise
129/// whatever `use_renderer` returns.
130pub fn with_shared_renderer<T>(
131    use_renderer: impl FnOnce(&mut OffscreenRenderer) -> Result<T, RenderError>,
132) -> Result<T, RenderError> {
133    static SHARED: Mutex<Option<OffscreenRenderer>> = Mutex::new(None);
134    let mut shared = SHARED.lock().unwrap_or_else(PoisonError::into_inner);
135    if shared.is_none() {
136        *shared = Some(OffscreenRenderer::new()?);
137    }
138    let renderer = shared
139        .as_mut()
140        .expect("the shared renderer was just created");
141    let result = use_renderer(renderer);
142    if matches!(result, Err(RenderError::Readback(_))) {
143        // The device may have been lost; create a new one on the next call.
144        *shared = None;
145    }
146    result
147}
148
149/// The longest time to wait for the GPU to finish a render and its readback.
150const WAIT_TIMEOUT: Duration = Duration::from_secs(60);
151
152/// The texture format rendered into. egui outputs gamma-encoded colour into a non-sRGB target.
153const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
154
155/// The number of samples per pixel used for anti-aliasing, when the adapter supports it.
156const MSAA_SAMPLES: u32 = 4;
157
158/// A headless renderer that owns a wgpu device and draws display lists into images.
159///
160/// Creating the device is by far the most expensive step of an offscreen render, so a caller rendering many images
161/// should create one renderer and reuse it. [`render_display_list_offscreen`] does this with a process-wide instance.
162pub struct OffscreenRenderer {
163    device: wgpu::Device,
164    queue: wgpu::Queue,
165    renderer: egui_wgpu::Renderer,
166    sample_count: u32,
167}
168
169impl OffscreenRenderer {
170    /// Creates a device on the first available adapter and prepares an egui renderer on it.
171    ///
172    /// # Errors
173    ///
174    /// Returns [`RenderError::NoAdapter`] when no adapter is available and [`RenderError::Device`] when the adapter
175    /// cannot create a device.
176    pub fn new() -> Result<Self, RenderError> {
177        let setup = egui_wgpu::WgpuSetupCreateNew::without_display_handle();
178        let instance =
179            pollster::block_on(egui_wgpu::WgpuSetup::CreateNew(setup.clone()).new_instance());
180        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
181            power_preference: setup.power_preference,
182            ..Default::default()
183        }))
184        .map_err(|error| {
185            RenderError::NoAdapter(format!(
186                "{error} (backends {:?})",
187                setup.instance_descriptor.backends
188            ))
189        })?;
190        let adapter_limits = adapter.limits();
191        let (device, queue) =
192            pollster::block_on(
193                adapter.request_device(&wgpu::DeviceDescriptor {
194                    label: Some("ironlab offscreen device"),
195                    required_limits: wgpu::Limits::downlevel_webgl2_defaults()
196                        .using_resolution(adapter_limits.clone()),
197                    ..Default::default()
198                }),
199            )
200            .map_err(|error| RenderError::Device(error.to_string()))?;
201
202        let sample_count = if adapter
203            .get_texture_format_features(FORMAT)
204            .flags
205            .sample_count_supported(MSAA_SAMPLES)
206        {
207            MSAA_SAMPLES
208        } else {
209            1
210        };
211        let mut renderer = egui_wgpu::Renderer::new(
212            &device,
213            FORMAT,
214            egui_wgpu::RendererOptions {
215                msaa_samples: sample_count,
216                depth_stencil_format: None,
217                dithering: false,
218                predictable_texture_filtering: true,
219            },
220        );
221        renderer.update_texture(
222            &device,
223            &queue,
224            egui::TextureId::Managed(0),
225            &egui::epaint::ImageDelta::full(
226                egui::ColorImage::new([1, 1], vec![egui::Color32::WHITE]),
227                egui::TextureOptions::NEAREST,
228            ),
229        );
230        Ok(Self {
231            device,
232            queue,
233            renderer,
234            sample_count,
235        })
236    }
237
238    /// Compiles `figure` and renders it at `dpi` dots per inch.
239    ///
240    /// # Errors
241    ///
242    /// Returns [`RenderError::InvalidSize`] when the image size is invalid for the device and
243    /// [`RenderError::Readback`] when rendering or readback fails.
244    pub fn render(
245        &mut self,
246        figure: &Figure,
247        text: &TextEngine,
248        dpi: f64,
249    ) -> Result<RenderedImage, RenderError> {
250        let scene = ironlab_scene::compile(figure, text);
251        self.render_display_list(&scene.display_list, text, dpi)
252    }
253
254    /// Renders an already compiled display list at `dpi` dots per inch.
255    ///
256    /// # Errors
257    ///
258    /// As for [`OffscreenRenderer::render`].
259    pub fn render_display_list(
260        &mut self,
261        list: &DisplayList,
262        text: &TextEngine,
263        dpi: f64,
264    ) -> Result<RenderedImage, RenderError> {
265        let max = self.device.limits().max_texture_dimension_2d;
266        let pixels = |points: f64| {
267            let value = (points * dpi / 72.0).round();
268            if value.is_finite() && value > 0.0 {
269                value.min(f64::from(u32::MAX)) as u32
270            } else {
271                0
272            }
273        };
274        let (width, height) = (pixels(list.width_pt), pixels(list.height_pt));
275        if width == 0 || height == 0 || width > max || height > max {
276            return Err(RenderError::InvalidSize { width, height, max });
277        }
278
279        let scale = (dpi / 72.0) as f32;
280        let mut meshes = Vec::new();
281        if let Some(background) = background_mesh(list, scale) {
282            meshes.push(background);
283        }
284        meshes.extend(tessellate(
285            list,
286            text,
287            ScreenTransform {
288                scale,
289                origin: egui::Pos2::ZERO,
290            },
291        ));
292        let screen_rect =
293            egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width as f32, height as f32));
294        let primitives: Vec<egui::ClippedPrimitive> = meshes
295            .into_iter()
296            .map(|mesh| egui::ClippedPrimitive {
297                clip_rect: screen_rect,
298                primitive: egui::epaint::Primitive::Mesh(mesh),
299            })
300            .collect();
301        let screen = egui_wgpu::ScreenDescriptor {
302            size_in_pixels: [width, height],
303            pixels_per_point: 1.0,
304        };
305
306        let validation = self.device.push_error_scope(wgpu::ErrorFilter::Validation);
307        let out_of_memory = self.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
308        let rendered = self.draw(&primitives, &screen);
309        let oom = pollster::block_on(out_of_memory.pop());
310        let invalid = pollster::block_on(validation.pop());
311        if let Some(error) = oom.or(invalid) {
312            return Err(RenderError::Readback(error.to_string()));
313        }
314        let mut rgba = rendered?;
315        unpremultiply(&mut rgba);
316        Ok(RenderedImage {
317            width,
318            height,
319            rgba,
320        })
321    }
322
323    /// Draws the primitives into a new texture and reads the result back as premultiplied RGBA bytes.
324    fn draw(
325        &mut self,
326        primitives: &[egui::ClippedPrimitive],
327        screen: &egui_wgpu::ScreenDescriptor,
328    ) -> Result<Vec<u8>, RenderError> {
329        let [width, height] = screen.size_in_pixels;
330        let size = wgpu::Extent3d {
331            width,
332            height,
333            depth_or_array_layers: 1,
334        };
335        let texture = |label, sample_count, usage| {
336            self.device.create_texture(&wgpu::TextureDescriptor {
337                label: Some(label),
338                size,
339                mip_level_count: 1,
340                sample_count,
341                dimension: wgpu::TextureDimension::D2,
342                format: FORMAT,
343                usage,
344                view_formats: &[],
345            })
346        };
347        let resolved = texture(
348            "ironlab offscreen resolved",
349            1,
350            wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
351        );
352        let resolved_view = resolved.create_view(&wgpu::TextureViewDescriptor::default());
353        let multisampled = (self.sample_count > 1).then(|| {
354            texture(
355                "ironlab offscreen multisampled",
356                self.sample_count,
357                wgpu::TextureUsages::RENDER_ATTACHMENT,
358            )
359        });
360        let multisampled_view = multisampled
361            .as_ref()
362            .map(|t| t.create_view(&wgpu::TextureViewDescriptor::default()));
363        let (view, resolve_target) = match &multisampled_view {
364            Some(ms) => (ms, Some(&resolved_view)),
365            None => (&resolved_view, None),
366        };
367
368        let mut encoder = self
369            .device
370            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
371                label: Some("ironlab offscreen encoder"),
372            });
373        let user_buffers = self.renderer.update_buffers(
374            &self.device,
375            &self.queue,
376            &mut encoder,
377            primitives,
378            screen,
379        );
380        {
381            let mut pass = encoder
382                .begin_render_pass(&wgpu::RenderPassDescriptor {
383                    label: Some("ironlab offscreen pass"),
384                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
385                        view,
386                        resolve_target,
387                        ops: wgpu::Operations {
388                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
389                            store: wgpu::StoreOp::Store,
390                        },
391                        depth_slice: None,
392                    })],
393                    ..Default::default()
394                })
395                .forget_lifetime();
396            self.renderer.render(&mut pass, primitives, screen);
397        }
398
399        let unpadded_bytes_per_row = width as usize * 4;
400        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
401        let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
402        let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
403            label: Some("ironlab offscreen readback"),
404            size: (padded_bytes_per_row * height as usize) as u64,
405            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
406            mapped_at_creation: false,
407        });
408        encoder.copy_texture_to_buffer(
409            resolved.as_image_copy(),
410            wgpu::TexelCopyBufferInfo {
411                buffer: &buffer,
412                layout: wgpu::TexelCopyBufferLayout {
413                    offset: 0,
414                    bytes_per_row: Some(padded_bytes_per_row as u32),
415                    rows_per_image: None,
416                },
417            },
418            size,
419        );
420        let submission = self.queue.submit(
421            user_buffers
422                .into_iter()
423                .chain(std::iter::once(encoder.finish())),
424        );
425
426        let slice = buffer.slice(..);
427        let (sender, receiver) = std::sync::mpsc::channel();
428        slice.map_async(wgpu::MapMode::Read, move |result| {
429            let _ = sender.send(result);
430        });
431        self.device
432            .poll(wgpu::PollType::Wait {
433                submission_index: Some(submission),
434                timeout: Some(WAIT_TIMEOUT),
435            })
436            .map_err(|error| RenderError::Readback(error.to_string()))?;
437        receiver
438            .recv_timeout(WAIT_TIMEOUT)
439            .map_err(|error| RenderError::Readback(error.to_string()))?
440            .map_err(|error| RenderError::Readback(error.to_string()))?;
441        let data = slice
442            .get_mapped_range()
443            .map_err(|error| RenderError::Readback(error.to_string()))?;
444        let mut rgba = Vec::with_capacity(unpadded_bytes_per_row * height as usize);
445        for row in data.chunks_exact(padded_bytes_per_row) {
446            rgba.extend_from_slice(&row[..unpadded_bytes_per_row]);
447        }
448        drop(data);
449        buffer.unmap();
450        Ok(rgba)
451    }
452}
453
454/// A rectangle covering the page in the display list's background colour, in pixels, or `None` when the background
455/// is fully transparent or invalid.
456fn background_mesh(list: &DisplayList, scale: f32) -> Option<egui::Mesh> {
457    let color = color32(list.background)?;
458    if color.a() == 0 {
459        return None;
460    }
461    let size = egui::vec2(list.width_pt as f32 * scale, list.height_pt as f32 * scale);
462    let mut mesh = egui::Mesh::default();
463    mesh.add_colored_rect(egui::Rect::from_min_size(egui::Pos2::ZERO, size), color);
464    Some(mesh)
465}
466
467/// Converts premultiplied RGBA bytes to straight alpha in place. Fully transparent pixels become transparent black.
468fn unpremultiply(rgba: &mut [u8]) {
469    for pixel in rgba.as_chunks_mut::<4>().0 {
470        let alpha = pixel[3];
471        match alpha {
472            0 => pixel[..3].fill(0),
473            255 => {}
474            _ => {
475                for channel in &mut pixel[..3] {
476                    let straight =
477                        (u32::from(*channel) * 255 + u32::from(alpha) / 2) / u32::from(alpha);
478                    *channel = straight.min(255) as u8;
479                }
480            }
481        }
482    }
483}