1use 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#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct RenderedImage {
53 pub width: u32,
54 pub height: u32,
55 pub rgba: Vec<u8>,
57}
58
59impl RenderedImage {
60 #[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#[derive(Debug, thiserror::Error)]
83pub enum RenderError {
84 #[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 #[error("the graphics adapter could not create a device: {0}")]
92 Device(String),
93 #[error("cannot render an image of {width}×{height} pixels (maximum dimension {max})")]
95 InvalidSize { width: u32, height: u32, max: u32 },
96 #[error("the rendered image could not be read back: {0}")]
98 Readback(String),
99}
100
101pub 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
116pub 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
129pub 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 *shared = None;
153 }
154 result
155}
156
157const WAIT_TIMEOUT: Duration = Duration::from_secs(60);
159
160const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
162
163const MSAA_SAMPLES: u32 = 4;
165
166pub struct OffscreenRenderer {
171 device: wgpu::Device,
172 queue: wgpu::Queue,
173 painter: GpuPainter,
174 sample_count: u32,
175}
176
177impl OffscreenRenderer {
178 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 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 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 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 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 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
450pub 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
491fn 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}