1use 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#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct RenderedImage {
48 pub width: u32,
49 pub height: u32,
50 pub rgba: Vec<u8>,
52}
53
54impl RenderedImage {
55 #[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#[derive(Debug, thiserror::Error)]
78pub enum RenderError {
79 #[error("no graphics adapter is available for offscreen rendering: {0}")]
81 NoAdapter(String),
82 #[error("the graphics adapter could not create a device: {0}")]
84 Device(String),
85 #[error("cannot render an image of {width}×{height} pixels (maximum dimension {max})")]
87 InvalidSize { width: u32, height: u32, max: u32 },
88 #[error("the rendered image could not be read back: {0}")]
90 Readback(String),
91}
92
93pub 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
108pub 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
121pub 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 *shared = None;
145 }
146 result
147}
148
149const WAIT_TIMEOUT: Duration = Duration::from_secs(60);
151
152const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
154
155const MSAA_SAMPLES: u32 = 4;
157
158pub struct OffscreenRenderer {
163 device: wgpu::Device,
164 queue: wgpu::Queue,
165 renderer: egui_wgpu::Renderer,
166 sample_count: u32,
167}
168
169impl OffscreenRenderer {
170 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 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 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 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
454fn 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
467fn 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}