1use frus_core::{Color, Scene};
11
12use crate::compositor::Painters;
13
14pub struct OffscreenFrame {
16 pub width: u32,
18 pub height: u32,
20 pub rgba: Vec<u8>,
22 pub samples: u32,
25}
26
27pub fn render_offscreen(
31 scene: &Scene,
32 width: u32,
33 height: u32,
34 clear: Color,
35) -> Option<OffscreenFrame> {
36 let (device, queue, sample_count) = headless_device()?;
37 let format = wgpu::TextureFormat::Rgba8UnormSrgb;
38
39 let texture = device.create_texture(&wgpu::TextureDescriptor {
40 label: Some("frus.offscreen.target"),
41 size: wgpu::Extent3d {
42 width,
43 height,
44 depth_or_array_layers: 1,
45 },
46 mip_level_count: 1,
47 sample_count: 1,
48 dimension: wgpu::TextureDimension::D2,
49 format,
50 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
51 view_formats: &[],
52 });
53 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
54
55 let clear_linear = clear.to_linear();
58 let mut painters = Painters::new(&device, &queue, format, sample_count);
59 painters.render(
60 &device,
61 &queue,
62 format,
63 &view,
64 width,
65 height,
66 scene,
67 Some(wgpu::Color {
68 r: clear_linear.r as f64,
69 g: clear_linear.g as f64,
70 b: clear_linear.b as f64,
71 a: clear.a as f64,
72 }),
73 );
74
75 let unpadded_bytes_per_row = width * 4;
78 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
80 let readback = device.create_buffer(&wgpu::BufferDescriptor {
81 label: Some("frus.offscreen.readback"),
82 size: (padded_bytes_per_row * height) as wgpu::BufferAddress,
83 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
84 mapped_at_creation: false,
85 });
86 let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
87 label: Some("frus.offscreen.copy"),
88 });
89 encoder.copy_texture_to_buffer(
90 wgpu::TexelCopyTextureInfo {
91 texture: &texture,
92 mip_level: 0,
93 origin: wgpu::Origin3d::ZERO,
94 aspect: wgpu::TextureAspect::All,
95 },
96 wgpu::TexelCopyBufferInfo {
97 buffer: &readback,
98 layout: wgpu::TexelCopyBufferLayout {
99 offset: 0,
100 bytes_per_row: Some(padded_bytes_per_row),
101 rows_per_image: Some(height),
102 },
103 },
104 wgpu::Extent3d {
105 width,
106 height,
107 depth_or_array_layers: 1,
108 },
109 );
110 queue.submit(std::iter::once(encoder.finish()));
111
112 let slice = readback.slice(..);
113 let (tx, rx) = std::sync::mpsc::channel();
114 slice.map_async(wgpu::MapMode::Read, move |result| {
115 let _ = tx.send(result);
116 });
117 let _ = device.poll(wgpu::PollType::wait_indefinitely());
118 rx.recv().ok()?.ok()?;
119
120 let data = slice.get_mapped_range().ok()?;
121 let mut rgba = Vec::with_capacity((unpadded_bytes_per_row * height) as usize);
122 for row in 0..height {
123 let start = (row * padded_bytes_per_row) as usize;
124 rgba.extend_from_slice(&data[start..start + unpadded_bytes_per_row as usize]);
125 }
126
127 Some(OffscreenFrame {
128 width,
129 height,
130 rgba,
131 samples: sample_count,
132 })
133}
134
135fn headless_device() -> Option<(wgpu::Device, wgpu::Queue, u32)> {
138 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
139 backends: wgpu::Backends::all(),
140 ..wgpu::InstanceDescriptor::new_without_display_handle()
141 });
142 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
143 power_preference: wgpu::PowerPreference::default(),
144 compatible_surface: None,
145 force_fallback_adapter: false,
146 ..Default::default()
147 }))
148 .ok()?;
149 let sample_count =
150 crate::compositor::preferred_sample_count(&adapter, wgpu::TextureFormat::Rgba8UnormSrgb);
151 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
152 label: Some("frus.offscreen.device"),
153 required_features: wgpu::Features::empty(),
154 required_limits: wgpu::Limits::downlevel_defaults(),
155 memory_hints: wgpu::MemoryHints::default(),
156 ..Default::default()
157 }))
158 .ok()?;
159 Some((device, queue, sample_count))
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use frus_core::{
166 Backdrop, ColorFilter, ImageFilter, LayerFilter, MaskShader, Path, Point, Primitive, Rect,
167 ShaderMask,
168 };
169
170 #[test]
173 fn renders_rect_and_reads_back_srgb() {
174 let mut scene = Scene::new();
175 scene.fill_rect(Rect::new(0.0, 0.0, 20.0, 20.0), Color::rgb(1.0, 0.0, 0.0));
176 let Some(frame) = render_offscreen(&scene, 70, 40, Color::BLACK) else {
178 eprintln!("no GPU adapter available: test skipped");
179 return;
180 };
181 assert_eq!(frame.rgba.len(), 70 * 40 * 4);
182 let px = |x: u32, y: u32| {
183 let i = ((y * frame.width + x) * 4) as usize;
184 [frame.rgba[i], frame.rgba[i + 1], frame.rgba[i + 2]]
185 };
186 assert_eq!(px(10, 10), [255, 0, 0], "inside the rect → red");
187 assert_eq!(px(60, 30), [0, 0, 0], "outside the rect → clear");
188 }
189
190 #[test]
194 fn fills_a_vector_triangle() {
195 let triangle = Path::new()
198 .move_to(Point::new(32.0, 4.0))
199 .line_to(Point::new(60.0, 60.0))
200 .line_to(Point::new(4.0, 60.0))
201 .close();
202 let mut scene = Scene::new();
203 scene.fill_path(&triangle, Color::rgb(0.0, 1.0, 0.0));
204
205 let Some(frame) = render_offscreen(&scene, 64, 64, Color::BLACK) else {
206 eprintln!("no GPU adapter available: test skipped");
207 return;
208 };
209 let px = |x: u32, y: u32| {
210 let i = ((y * frame.width + x) * 4) as usize;
211 [frame.rgba[i], frame.rgba[i + 1], frame.rgba[i + 2]]
212 };
213 assert_eq!(px(32, 54), [0, 255, 0], "inside the triangle → green");
215 assert_eq!(px(4, 4), [0, 0, 0], "outside the triangle → clear");
217 }
218
219 #[test]
222 fn strokes_a_path_outline_only() {
223 let square = Path::rect(Rect::new(12.0, 12.0, 40.0, 40.0));
225 let mut scene = Scene::new();
226 scene.stroke_path(&square, Color::rgb(0.0, 0.0, 1.0), 4.0);
227
228 let Some(frame) = render_offscreen(&scene, 64, 64, Color::BLACK) else {
229 eprintln!("no GPU adapter available: test skipped");
230 return;
231 };
232 let px = |x: u32, y: u32| {
233 let i = ((y * frame.width + x) * 4) as usize;
234 [frame.rgba[i], frame.rgba[i + 1], frame.rgba[i + 2]]
235 };
236 assert_eq!(px(12, 32), [0, 0, 255], "on the outline → blue");
238 assert_eq!(px(32, 32), [0, 0, 0], "in the middle → clear, not filled");
239 }
240
241 #[test]
245 fn strokes_end_as_asked() {
246 use frus_core::{Stroke, StrokeCap};
247 let line = Path::new()
249 .move_to(Point::new(20.0, 32.0))
250 .line_to(Point::new(44.0, 32.0));
251 let blue_at = |cap: StrokeCap, x: u32, y: u32| -> Option<bool> {
252 let mut scene = Scene::new();
253 scene.paint_path(
254 &line,
255 None,
256 Some(Stroke::new(Color::rgb(0.0, 0.0, 1.0), 8.0).with_cap(cap)),
257 );
258 let frame = render_offscreen(&scene, 64, 64, Color::BLACK)?;
259 let i = ((y * frame.width + x) * 4) as usize;
260 Some(frame.rgba[i + 2] > 200)
261 };
262 let Some(butt) = blue_at(StrokeCap::Butt, 17, 31) else {
265 eprintln!("no GPU adapter available: test skipped");
266 return;
267 };
268 assert!(!butt, "a flat end stops at the end point");
269 assert_eq!(blue_at(StrokeCap::Square, 17, 31), Some(true));
270 assert_eq!(
271 blue_at(StrokeCap::Square, 16, 28),
272 Some(true),
273 "a square corner"
274 );
275 assert_eq!(blue_at(StrokeCap::Round, 17, 31), Some(true));
276 assert_eq!(
277 blue_at(StrokeCap::Round, 16, 28),
278 Some(false),
279 "a round end's corner"
280 );
281 }
282
283 #[test]
287 fn samples_a_texture_by_quadrant() {
288 use frus_core::{BoxFit, ImageData};
289 let pixels = vec![
291 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255, ];
294 let image = ImageData::from_rgba(2, 2, pixels).into_handle();
295 let mut scene = Scene::new();
296 scene.image(&image, Rect::new(0.0, 0.0, 64.0, 64.0), BoxFit::Fill);
297
298 let Some(frame) = render_offscreen(&scene, 64, 64, Color::BLACK) else {
299 eprintln!("no GPU adapter available: test skipped");
300 return;
301 };
302 let px = |x: u32, y: u32| {
303 let i = ((y * frame.width + x) * 4) as usize;
304 [frame.rgba[i], frame.rgba[i + 1], frame.rgba[i + 2]]
305 };
306 assert_eq!(px(10, 10), [255, 0, 0], "top-left → red");
308 assert_eq!(px(54, 10), [0, 255, 0], "top-right → green");
309 assert_eq!(px(10, 54), [0, 0, 255], "bottom-left → blue");
310 assert_eq!(px(54, 54), [255, 255, 255], "bottom-right → white");
311 }
312
313 #[test]
318 fn msaa_smooths_a_diagonal_edge() {
319 let triangle = Path::new()
321 .move_to(Point::new(0.0, 0.0))
322 .line_to(Point::new(64.0, 0.0))
323 .line_to(Point::new(0.0, 64.0))
324 .close();
325 let mut scene = Scene::new();
326 scene.fill_path(&triangle, Color::rgb(0.0, 1.0, 0.0));
327
328 let Some(frame) = render_offscreen(&scene, 64, 64, Color::BLACK) else {
329 eprintln!("no GPU adapter available: test skipped");
330 return;
331 };
332 if frame.samples == 1 {
333 eprintln!("MSAA unsupported by this GPU: test skipped");
334 return;
335 }
336 let px = |x: u32, y: u32| {
337 let i = ((y * frame.width + x) * 4) as usize;
338 [frame.rgba[i], frame.rgba[i + 1], frame.rgba[i + 2]]
339 };
340 let found_partial = (0..frame.height).any(|y| {
345 (0..frame.width).any(|x| {
346 let g = px(x, y)[1];
347 g > 20 && g < 235
348 })
349 });
350 assert!(
351 found_partial,
352 "a smoothed diagonal edge has in-between green pixels"
353 );
354 assert_eq!(px(4, 4), [0, 255, 0], "inside → full green");
356 assert_eq!(px(60, 60), [0, 0, 0], "outside → background");
357 }
358
359 #[test]
364 fn layer_group_opacity_is_uniform_over_overlap() {
365 let mut scene = Scene::new();
366 scene.layer(0.5, |inner| {
367 inner.fill_rect(Rect::new(0.0, 0.0, 40.0, 40.0), Color::rgb(1.0, 0.0, 0.0));
368 inner.fill_rect(Rect::new(24.0, 24.0, 40.0, 40.0), Color::rgb(1.0, 0.0, 0.0));
369 });
370
371 let Some(frame) = render_offscreen(&scene, 64, 64, Color::BLACK) else {
372 eprintln!("no GPU adapter available: test skipped");
373 return;
374 };
375 let px = |x: u32, y: u32| {
376 let i = ((y * frame.width + x) * 4) as usize;
377 [frame.rgba[i], frame.rgba[i + 1], frame.rgba[i + 2]]
378 };
379 let single = px(8, 8); let overlap = px(32, 32); assert_eq!(
382 single, overlap,
383 "the group alpha is uniform across the overlap"
384 );
385 assert!(
387 single[0] > 120 && single[0] < 215,
388 "about half red (R={})",
389 single[0]
390 );
391 assert_eq!(single[1], 0, "no green");
392 assert_eq!(single[2], 0, "no blue");
393 assert_eq!(px(60, 8), [0, 0, 0], "outside the layer → background");
395 }
396
397 fn filtered(rect: Rect, color: Color, filter: LayerFilter) -> Scene {
399 let mut inner = Scene::new();
400 inner.fill_rect(rect, color);
401 let mut scene = Scene::new();
402 scene.push_primitive(Primitive::Layer {
403 primitives: inner.primitives().to_vec(),
404 opacity: 1.0,
405 clip: Rect::UNBOUNDED,
406 clip_shape: frus_core::ClipShape::Rect,
407 transform: None,
408 filter,
409 owner: 0,
410 });
411 scene
412 }
413
414 #[test]
420 fn a_greyscale_filter_gives_the_luminance_of_red() {
421 let scene = filtered(
422 Rect::new(0.0, 0.0, 64.0, 64.0),
423 Color::rgb(1.0, 0.0, 0.0),
424 LayerFilter {
425 color: Some(ColorFilter::grayscale()),
426 ..LayerFilter::NONE
427 },
428 );
429 let Some(frame) = render_offscreen(&scene, 64, 64, Color::BLACK) else {
430 eprintln!("no GPU adapter available: test skipped");
431 return;
432 };
433 let i = ((32 * frame.width + 32) * 4) as usize;
434 let (r, g, b) = (frame.rgba[i], frame.rgba[i + 1], frame.rgba[i + 2]);
435 let want = (0.2126f32 * 255.0).round() as i32;
436 assert!(
437 (r as i32 - want).abs() <= 3,
438 "red at its luminance: {r} (wanted about {want})"
439 );
440 assert_eq!((r, g, b), (r, r, r), "grey: the three channels agree");
441 }
442
443 #[test]
446 fn a_blur_spreads_past_the_edge() {
447 let square = Rect::new(24.0, 24.0, 16.0, 16.0);
448 let sharp = filtered(square, Color::WHITE, LayerFilter::NONE);
449 let blurred = filtered(
450 square,
451 Color::WHITE,
452 LayerFilter {
453 image: Some(ImageFilter::blur(5.0)),
454 ..LayerFilter::NONE
455 },
456 );
457 let Some(a) = render_offscreen(&sharp, 64, 64, Color::BLACK) else {
458 eprintln!("no GPU adapter available: test skipped");
459 return;
460 };
461 let b = render_offscreen(&blurred, 64, 64, Color::BLACK).expect("the adapter is there");
462 let at = |f: &OffscreenFrame, x: u32, y: u32| f.rgba[((y * f.width + x) * 4) as usize];
463 assert_eq!(at(&a, 16, 32), 0, "sharp: nothing outside the square");
465 assert!(
466 at(&b, 16, 32) > 20,
467 "blurred: the edge has reached here ({})",
468 at(&b, 16, 32)
469 );
470 assert_eq!(at(&a, 32, 32), 255, "sharp: white in the middle");
472 assert!(
473 at(&b, 32, 32) < 255,
474 "blurred: the middle gave some away ({})",
475 at(&b, 32, 32)
476 );
477 }
478
479 #[test]
484 fn a_mask_fades_the_layer_from_top_to_bottom() {
485 let scene = filtered(
486 Rect::new(0.0, 0.0, 64.0, 64.0),
487 Color::WHITE,
488 LayerFilter {
489 mask: Some(ShaderMask::new(MaskShader::Linear {
490 from: Point::new(0.0, 0.0),
491 to: Point::new(0.0, 64.0),
492 from_color: Color::WHITE,
493 to_color: Color::WHITE.fade(0.0),
494 })),
495 ..LayerFilter::NONE
496 },
497 );
498 let Some(frame) = render_offscreen(&scene, 64, 64, Color::BLACK) else {
499 eprintln!("no GPU adapter available: test skipped");
500 return;
501 };
502 let at = |y: u32| frame.rgba[((y * frame.width + 32) * 4) as usize] as i32;
503 let (top, middle, bottom) = (at(1), at(32), at(62));
504 assert!(top > 240, "opaque at the top: {top}");
505 assert!(bottom < 60, "all but gone at the bottom: {bottom}");
509 assert!(
510 top > middle && middle > bottom,
511 "monotonic: {top} {middle} {bottom}"
512 );
513 assert!(
516 (middle - 188).abs() <= 12,
517 "half way is half the light: {middle}"
518 );
519 }
520 #[test]
527 fn a_backdrop_blurs_what_is_underneath_it() {
528 let mut scene = Scene::new();
529 scene.fill_rect(Rect::new(0.0, 0.0, 32.0, 64.0), Color::WHITE);
531 scene.push_primitive(Primitive::Layer {
533 primitives: Vec::new(),
534 opacity: 1.0,
535 clip: Rect::new(0.0, 32.0, 64.0, 32.0),
536 clip_shape: frus_core::ClipShape::Rect,
537 transform: None,
538 filter: LayerFilter {
539 backdrop: Some(Backdrop::blur(6.0)),
540 ..LayerFilter::NONE
541 },
542 owner: 0,
543 });
544 let Some(frame) = render_offscreen(&scene, 64, 64, Color::BLACK) else {
545 eprintln!("no GPU adapter available: test skipped");
546 return;
547 };
548 let at = |x: u32, y: u32| frame.rgba[((y * frame.width + x) * 4) as usize] as i32;
549 assert_eq!(at(36, 16), 0, "above: still the clear colour");
551 assert_eq!(at(28, 16), 255, "above: still white");
552 assert!(
554 at(36, 48) > 20,
555 "below: the white has spread right ({})",
556 at(36, 48)
557 );
558 assert!(
559 at(28, 48) < 255,
560 "below: the white has given some away ({})",
561 at(28, 48)
562 );
563 }
564
565 #[test]
568 fn a_backdrop_stops_at_its_clip() {
569 let mut scene = Scene::new();
570 scene.fill_rect(Rect::new(0.0, 0.0, 64.0, 32.0), Color::WHITE);
573 scene.push_primitive(Primitive::Layer {
574 primitives: Vec::new(),
575 opacity: 1.0,
576 clip: Rect::new(0.0, 0.0, 20.0, 64.0),
577 clip_shape: frus_core::ClipShape::Rect,
578 transform: None,
579 filter: LayerFilter {
580 backdrop: Some(Backdrop {
583 filter: ImageFilter::Erode {
584 radius_x: 8.0,
585 radius_y: 8.0,
586 },
587 ..Backdrop::blur(0.0)
588 }),
589 ..LayerFilter::NONE
590 },
591 owner: 0,
592 });
593 let Some(frame) = render_offscreen(&scene, 64, 64, Color::BLACK) else {
594 eprintln!("no GPU adapter available: test skipped");
595 return;
596 };
597 let at = |x: u32, y: u32| frame.rgba[((y * frame.width + x) * 4) as usize] as i32;
598 assert!(
600 at(2, 28) < 255,
601 "inside the clip, the white was eaten ({})",
602 at(2, 28)
603 );
604 assert_eq!(at(40, 28), 255, "outside the clip, nothing happened");
606 }
607
608 #[test]
613 fn a_shared_key_filters_once() {
614 let backdrop = |key: Option<u64>, y: f32| Primitive::Layer {
615 primitives: Vec::new(),
616 opacity: 1.0,
617 clip: Rect::new(0.0, y, 64.0, 32.0),
618 clip_shape: frus_core::ClipShape::Rect,
619 transform: None,
620 filter: LayerFilter {
621 backdrop: Some(Backdrop {
622 key,
623 ..Backdrop::blur(6.0)
624 }),
625 ..LayerFilter::NONE
626 },
627 owner: 0,
628 };
629 let build = |key: Option<u64>| {
633 let mut scene = Scene::new();
634 scene.fill_rect(Rect::new(0.0, 0.0, 32.0, 64.0), Color::WHITE);
635 scene.push_primitive(backdrop(key, 0.0));
636 scene.push_primitive(backdrop(key, 0.0));
637 scene
638 };
639 let Some(twice) = render_offscreen(&build(None), 64, 64, Color::BLACK) else {
640 eprintln!("no GPU adapter available: test skipped");
641 return;
642 };
643 let once = render_offscreen(&build(Some(7)), 64, 64, Color::BLACK).expect("adapter");
644 let at = |f: &OffscreenFrame, x: u32| f.rgba[((16 * f.width + x) * 4) as usize] as i32;
645 assert!(
647 at(&twice, 44) > at(&once, 44),
648 "unshared blurs twice ({}) and shared blurs once ({})",
649 at(&twice, 44),
650 at(&once, 44)
651 );
652 }
653 #[test]
661 fn a_layer_inside_a_layer_is_drawn() {
662 let mut inner = Scene::new();
663 inner.fill_rect(Rect::new(8.0, 8.0, 48.0, 48.0), Color::WHITE);
664 let group = Primitive::Layer {
665 primitives: inner.primitives().to_vec(),
666 opacity: 0.5,
669 clip: Rect::UNBOUNDED,
670 clip_shape: frus_core::ClipShape::Rect,
671 transform: None,
672 filter: LayerFilter::NONE,
673 owner: 0,
674 };
675 let mut scene = Scene::new();
676 scene.push_primitive(Primitive::Layer {
677 primitives: vec![group],
678 opacity: 1.0,
679 clip: Rect::new(0.0, 0.0, 64.0, 64.0),
680 clip_shape: frus_core::ClipShape::Rect,
681 transform: None,
682 filter: LayerFilter::NONE,
683 owner: 0,
684 });
685 let Some(frame) = render_offscreen(&scene, 64, 64, Color::BLACK) else {
686 eprintln!("no GPU adapter available: test skipped");
687 return;
688 };
689 let at = |x: u32, y: u32| frame.rgba[((y * frame.width + x) * 4) as usize] as i32;
690 assert!(
692 (at(32, 32) - 188).abs() <= 12,
693 "the nested group is drawn, at its own opacity: {}",
694 at(32, 32)
695 );
696 assert_eq!(at(2, 2), 0, "and only where it is");
697 }
698
699 #[test]
702 fn nesting_goes_as_deep_as_the_scene_does() {
703 let mut leaf = Scene::new();
704 leaf.fill_rect(Rect::new(0.0, 0.0, 64.0, 64.0), Color::WHITE);
705 let level = |primitives: Vec<Primitive>, shape: frus_core::ClipShape| Primitive::Layer {
706 primitives,
707 opacity: 1.0,
708 clip: Rect::new(0.0, 0.0, 64.0, 64.0),
709 clip_shape: shape,
710 transform: None,
711 filter: LayerFilter::NONE,
712 owner: 0,
713 };
714 let inner = level(leaf.primitives().to_vec(), frus_core::ClipShape::Rect);
715 let middle = level(vec![inner], frus_core::ClipShape::Oval);
716 let mut scene = Scene::new();
717 scene.push_primitive(level(vec![middle], frus_core::ClipShape::Rect));
718 let Some(frame) = render_offscreen(&scene, 64, 64, Color::BLACK) else {
719 eprintln!("no GPU adapter available: test skipped");
720 return;
721 };
722 let at = |x: u32, y: u32| frame.rgba[((y * frame.width + x) * 4) as usize] as i32;
723 assert_eq!(at(32, 32), 255, "the middle of the disc is white");
724 assert_eq!(at(2, 2), 0, "its corner is not: the ellipse clipped it");
725 }
726}