Skip to main content

frus_gpu/
offscreen.rs

1//! **Offscreen** rendering: the scene is drawn into a texture then read back by
2//! the CPU, with no window required. This is what the render tests are built on —
3//! the `frus-test` goldens — and the only way to observe the pipeline under WSL or
4//! CI.
5//!
6//! The pipeline is **the same** as the windowed one ([`crate::Renderer`]): quads,
7//! text decoration quads included, then glyphs, onto an sRGB target — the bytes
8//! read back match what a screenshot would give.
9
10use frus_core::{Color, Scene};
11
12use crate::compositor::Painters;
13
14/// A frame rendered offscreen: **sRGB** RGBA bytes, row by row.
15pub struct OffscreenFrame {
16    /// The width of the frame, in pixels.
17    pub width: u32,
18    /// The height of the frame, in pixels.
19    pub height: u32,
20    /// `width * height * 4` bytes in RGBA order, origin at the top left.
21    pub rgba: Vec<u8>,
22    /// The MSAA sample count actually used; 1 means no smoothing, on a GPU without
23    /// MSAA support. Informative, and useful to the anti-aliasing tests.
24    pub samples: u32,
25}
26
27/// Renders `scene` into a `width`×`height` texture cleared to `clear`, then reads
28/// the pixels back. `None` when no GPU adapter is available — a machine with neither
29/// GPU nor software rasteriser — in which case test callers skip themselves.
30pub 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    // The same pipeline as `Renderer::render`, through the compositor, layers
56    // included. The clear colour is authored in sRGB, and an sRGB target wants linear.
57    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    // Readback: wgpu requires rows aligned to 256 bytes, so we pad and then strip
76    // the padding on the CPU side.
77    let unpadded_bytes_per_row = width * 4;
78    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; // 256
79    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
135/// Creates a surfaceless wgpu device, along with the MSAA sample count supported
136/// for the sRGB target. `None` when there is no adapter.
137fn 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    /// The offscreen path renders what the windowed pipeline does: a solid red
171    /// rectangle gives a red pixel inside, and the clear colour outside.
172    #[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        // A width that is not a multiple of 64 exercises the readback padding.
177        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    /// A **vector triangle** filled with green: a point well inside is green, and a
191    /// corner outside the triangle keeps the clear colour. Proof that tessellation and
192    /// the path pipeline do produce pixels.
193    #[test]
194    fn fills_a_vector_triangle() {
195        // A triangle covering the bottom of the surface: apex at the top centre,
196        // base spanning the full width along the bottom.
197        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        // Near the base, centred: well inside → green.
214        assert_eq!(px(32, 54), [0, 255, 0], "inside the triangle → green");
215        // Top-left corner: above and beside the apex → outside → clear.
216        assert_eq!(px(4, 4), [0, 0, 0], "outside the triangle → clear");
217    }
218
219    /// A path's **stroke** paints pixels on the outline but not in the middle, since
220    /// the path is not filled.
221    #[test]
222    fn strokes_a_path_outline_only() {
223        // A centred 40×40 square, stroked and not filled.
224        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        // On the square's left edge → blue; in the middle → clear, since no fill.
237        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    /// **A line's ends are drawn as asked** (milestone 630): a flat end stops at the end
242    /// point, a square one runs half the width past it, and a round one is a half circle,
243    /// so its corners stay clear where a square end's are painted.
244    #[test]
245    fn strokes_end_as_asked() {
246        use frus_core::{Stroke, StrokeCap};
247        // An 8 px line from x = 20 to x = 44, on y = 32.
248        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        // Just past the end, on the line: 2.5 px out. The pixel at (16, 28): 3.5 px out
263        // and 3.5 px up, inside a square end's corner and outside a round end's.
264        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    /// Texture sampling: a 2×2 image — red, green, blue, white — stretched (`Fill`)
284    /// over the whole surface, so each quadrant reads its own colour. Proof of the
285    /// upload, the sampling and the UV mapping.
286    #[test]
287    fn samples_a_texture_by_quadrant() {
288        use frus_core::{BoxFit, ImageData};
289        // 2×2: (0,0) red, (1,0) green, (0,1) blue, (1,1) white.
290        let pixels = vec![
291            255, 0, 0, 255, /* R */ 0, 255, 0, 255, /* G */
292            0, 0, 255, 255, /* B */ 255, 255, 255, 255, /* W */
293        ];
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        // A point well inside each quadrant, away from the interpolated edges.
307        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    /// **Anti-aliasing (MSAA)**: a triangle's **diagonal** edge produces
314    /// **partially** covered pixels, blending background and shape — the signature of
315    /// multisampling, impossible with hard-edged rendering. The test skips itself when
316    /// the GPU does not support MSAA (`samples == 1`).
317    #[test]
318    fn msaa_smooths_a_diagonal_edge() {
319        // A right triangle whose hypotenuse lies on the anti-diagonal x + y = 64.
320        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        // Along the diagonal edge at least one pixel must be an **in-between**
341        // green, neither full 255 nor background 0 — the proof of smoothing. Hard-edged
342        // rendering would only produce 0 or 255. We sweep the whole surface: only the
343        // partially covered edge pixels come out in between.
344        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        // Deep inside the triangle → full green; far outside → black background.
355        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    /// **Layer compositing**: two **opaque** red rectangles that overlap, grouped in
360    /// a layer at opacity 0.5. The group alpha is **uniform** — the overlap has the
361    /// same colour as a single coverage, with no double-blending — and it does come
362    /// out at about half red over the black background.
363    #[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); // covered by the first rectangle only
380        let overlap = px(32, 32); // covered by both
381        assert_eq!(
382            single, overlap,
383            "the group alpha is uniform across the overlap"
384        );
385        // About 50% red over black: neither full red (255) nor background (0).
386        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        // Outside both rectangles: black background.
394        assert_eq!(px(60, 8), [0, 0, 0], "outside the layer → background");
395    }
396
397    /// Builds a layer around one filled rectangle, carrying `filter`.
398    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    /// A **colour matrix**, sampled on the rendered pixel rather than believed from
415    /// the scene: pure red through a greyscale filter must come out at the luminance
416    /// of red, which is 0.2126 — and 0.2126 of the **encoded** value, not of the
417    /// light. Evaluated in linear light the same matrix would give 0.48, more than
418    /// twice as bright, which is exactly the slip this asserts against.
419    #[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    /// A **blur** spreads a shape past its own edge and takes the middle with it: a
444    /// pixel outside the rectangle lights up, and the very centre stays bright.
445    #[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        // Eight pixels outside the square, level with its middle.
464        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        // And the spread came from somewhere: the centre is no longer full white.
471        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    /// A **mask** fades a white block from opaque at its top to gone at its bottom.
480    /// The middle is the test that matters: it must sit near the halfway point
481    /// between the two ends, not collapse toward the background — which is what a
482    /// coverage applied twice would do.
483    #[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        // A fifty-fifth of the light, and still 42 out of 255 once encoded: the sRGB
506        // curve spends most of its range on the dark end, which is why a fade that
507        // looks finished is not, and why a byte threshold here has to be generous.
508        assert!(bottom < 60, "all but gone at the bottom: {bottom}");
509        assert!(
510            top > middle && middle > bottom,
511            "monotonic: {top} {middle} {bottom}"
512        );
513        // Half of white over black is 0.5 of the light, which is 188 encoded — not
514        // the 137 a doubled coverage would give.
515        assert!(
516            (middle - 188).abs() <= 12,
517            "half way is half the light: {middle}"
518        );
519    }
520    /// A **backdrop** filters what is already painted, not the layer that carries it.
521    ///
522    /// The scene is a hard black/white split down the middle with a backdrop blur over
523    /// the bottom half. Along the seam, the top half must stay a clean edge and the
524    /// bottom half must be a gradient — which is the whole claim: the blur reached
525    /// pixels that were painted by something else entirely.
526    #[test]
527    fn a_backdrop_blurs_what_is_underneath_it() {
528        let mut scene = Scene::new();
529        // The picture underneath: white on the left, nothing (the clear colour) right.
530        scene.fill_rect(Rect::new(0.0, 0.0, 32.0, 64.0), Color::WHITE);
531        // A backdrop over the bottom half only, with no content of its own.
532        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        // Above the backdrop: the seam is untouched, black one side and white the other.
550        assert_eq!(at(36, 16), 0, "above: still the clear colour");
551        assert_eq!(at(28, 16), 255, "above: still white");
552        // Below it: the same two places have bled into each other.
553        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    /// The backdrop is bounded by the layer's clip and by nothing else: outside it,
566    /// the frame is exactly what it was.
567    #[test]
568    fn a_backdrop_stops_at_its_clip() {
569        let mut scene = Scene::new();
570        // The top half white, the bottom the clear colour: an erode has something to
571        // eat only where the two meet.
572        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                // An erode eats the shape inwards, so its effect is unmistakable
581                // against a flat fill and cannot be confused with a rounding error.
582                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        // Four pixels above the seam, inside the clip: eaten by the black below it.
599        assert!(
600            at(2, 28) < 255,
601            "inside the clip, the white was eaten ({})",
602            at(2, 28)
603        );
604        // The same place outside the clip: untouched white.
605        assert_eq!(at(40, 28), 255, "outside the clip, nothing happened");
606    }
607
608    /// Two backdrops sharing a key are filtered **once**: they read the same copy, so
609    /// the second one cannot see what the first one drew. That is the price of the
610    /// sharing and the reason overlapping backdrops must not share a key — and it is
611    /// also the only observable proof that the sharing happened at all.
612    #[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        // The two backdrops cover the same region. Unshared, the second blurs the
630        // first's output and the seam softens twice; shared, both blur the same copy
631        // and it softens once.
632        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        // Twelve pixels past the seam: two blurs reach further than one.
646        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    /// A layer **inside** a layer is composited, not dropped.
654    ///
655    /// A group is rendered into a texture of its own and that texture is composited;
656    /// a layer found inside it is not a primitive the group can paint, so for a long
657    /// time it was simply skipped — a rounded card around a fading group, a clip
658    /// around a transform, gone. The group now renders its nested layers first and
659    /// composites them into its own pass, and this is the test that says so.
660    #[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            // A group opacity, so the test also proves the nested layer keeps its own
667            // compositing rather than being flattened into the parent.
668            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        // Half of white over black is half the light, which is 188 once encoded.
691        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    /// Three deep, with the middle one clipped to an ellipse: the recursion is not a
700    /// special case for one level.
701    #[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}