Skip to main content

ironlab_viewer/
offscreen.rs

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