Skip to main content

oxiui_render_wgpu/gpu/
renderer.rs

1//! [`WgpuBackend`]: headless GPU [`RenderBackend`] implementing Tier 1
2//! primitives and gradient fills.
3//!
4//! # Supported `DrawCommand` variants
5//!
6//! | Command                       | Pipeline | Notes                              |
7//! |-------------------------------|----------|------------------------------------|
8//! | `PushClip` / `PopClip`        | solid    | Hardware scissor via `ClipStack`    |
9//! | `FillRect`                    | solid    | kind=0 solid quad                  |
10//! | `FillCircle`                  | solid    | kind=1 SDF disc                    |
11//! | `StrokeRect`                  | solid    | 4 thin edge quads                  |
12//! | `FillRoundedRect`             | solid    | kind=2 SDF rounded rect            |
13//! | `FillRoundedRectPerCorner`    | solid    | kind=3 SDF per-corner rounded rect |
14//! | `FillEllipse`                 | solid    | kind=4 SDF ellipse                 |
15//! | `Line`                        | solid    | kind=5 hard-clip line              |
16//! | `LineAa`                      | solid    | kind=5 AA line                     |
17//! | `LineThick`                   | solid    | kind=5 AA line with custom width   |
18//! | `LineDashed`                  | solid    | CPU-split into solid segments      |
19//! | `FillPath`                    | solid    | CPU fan-tessellation               |
20//! | `StrokePath`                  | solid    | CPU stroke-expansion               |
21//! | `LinearGradient`              | gradient | per-draw uniform + gradient quad   |
22//! | `RadialGradient`              | gradient | per-draw uniform + gradient quad   |
23//!
24//! # Out-of-scope (deferred)
25//!
26//! `Image`, `NineSlice`, `BoxShadow`, `DrawText` — require texture atlas /
27//! blur pipeline and are left in the wildcard arm with an honest comment.
28//!
29//! [`RenderBackend`]: oxiui_core::paint::RenderBackend
30
31use oxiui_core::geometry::Size;
32use oxiui_core::paint::{DrawList, RenderBackend};
33use oxiui_core::{Color, UiError};
34use wgpu::util::DeviceExt;
35
36use crate::gpu::buffer::Globals;
37use crate::gpu::device::GpuContext;
38use crate::gpu::exec::{
39    run_gradient_pass_batched, run_solid_pass, run_textured_pass, FrameStats, GradientPassParams,
40    SolidPassParams, TexturedPassParams,
41};
42use crate::gpu::geometry::build_geometry;
43use crate::gpu::pipeline::{
44    BlurPipeline, CompositePipeline, GradientPipeline, SolidPipeline, TexturedPipeline,
45};
46
47// ── WgpuBackend ───────────────────────────────────────────────────────────────
48
49/// Headless GPU backend implementing [`RenderBackend`].
50pub struct WgpuBackend {
51    ctx: GpuContext,
52    /// Screen solid pipeline — uses `ctx.sample_count` (may be MSAA).
53    pipeline: SolidPipeline,
54    gradient_pipeline: GradientPipeline,
55    textured_pipeline: TexturedPipeline,
56    /// Shadow offscreen blur pipeline — always count=1 (ping/pong are count=1).
57    blur_pipeline: BlurPipeline,
58    /// Shadow composite pipeline — uses `ctx.sample_count` (must match screen target).
59    composite_pipeline: CompositePipeline,
60    /// Shadow mask solid pipeline — always count=1 (ping target is count=1).
61    solid_mask_pipeline: SolidPipeline,
62    globals_buffer: wgpu::Buffer,
63    globals_bind_group: wgpu::BindGroup,
64    clear_color: Color,
65    /// Per-frame statistics populated by the most recent `execute()` call.
66    last_frame_stats: FrameStats,
67    /// Persistent solid vertex buffer (reused across frames, grown on demand).
68    solid_vertex_buf: Option<wgpu::Buffer>,
69    /// Byte capacity of `solid_vertex_buf`.
70    solid_vertex_buf_capacity: usize,
71}
72
73impl WgpuBackend {
74    /// Initialise a headless backend with an offscreen target of
75    /// `width × height` physical pixels, using the provided
76    /// [`crate::quality::RenderQuality`] to determine the MSAA sample count.
77    ///
78    /// Screen pipelines (solid, gradient, textured, composite) are created with
79    /// the effective sample count from `quality`.  Shadow offscreen pipelines
80    /// (blur and solid_mask) are always count=1 because ping-pong textures are
81    /// always single-sample.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`UiError::Unsupported`] when no GPU adapter is available (so
86    /// the caller can skip on a machine without a usable GPU), or
87    /// [`UiError::Backend`] when device creation fails.
88    pub fn headless_with_quality(
89        width: u32,
90        height: u32,
91        quality: &crate::RenderQuality,
92    ) -> Result<Self, UiError> {
93        let sc = quality.sample_count();
94        let ctx = GpuContext::headless_with_sample_count(width, height, sc)?;
95        // Screen pipelines use the effective sample count.
96        let pipeline = SolidPipeline::new(&ctx.device, ctx.sample_count);
97        let gradient_pipeline = GradientPipeline::new(&ctx.device, ctx.sample_count);
98        let textured_pipeline = TexturedPipeline::new(&ctx.device, ctx.sample_count);
99        let composite_pipeline = CompositePipeline::new(&ctx.device, ctx.sample_count);
100        // Shadow offscreen pipelines MUST be count=1 (ping/pong are count=1 textures).
101        let blur_pipeline = BlurPipeline::new(&ctx.device, 1);
102        let solid_mask_pipeline = SolidPipeline::new(&ctx.device, 1);
103
104        let globals = Globals::new(width, height);
105        let globals_buffer = ctx
106            .device
107            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
108                label: Some("oxiui-render-wgpu globals"),
109                contents: bytemuck::bytes_of(&globals),
110                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
111            });
112
113        let globals_bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
114            label: Some("oxiui-render-wgpu globals bind group"),
115            layout: &pipeline.globals_layout,
116            entries: &[wgpu::BindGroupEntry {
117                binding: 0,
118                resource: globals_buffer.as_entire_binding(),
119            }],
120        });
121
122        Ok(Self {
123            ctx,
124            pipeline,
125            gradient_pipeline,
126            textured_pipeline,
127            blur_pipeline,
128            composite_pipeline,
129            solid_mask_pipeline,
130            globals_buffer,
131            globals_bind_group,
132            clear_color: Color(0, 0, 0, 0),
133            last_frame_stats: FrameStats::default(),
134            solid_vertex_buf: None,
135            solid_vertex_buf_capacity: 0,
136        })
137    }
138
139    /// Initialise a headless backend with an offscreen target of
140    /// `width × height` physical pixels, using [`crate::quality::RenderQuality::low`] (no
141    /// MSAA, sample_count=1).
142    ///
143    /// This is the backward-compatible entry point.  It delegates to
144    /// [`headless_with_quality`] with `RenderQuality::low()`, so existing
145    /// callers receive the exact same code path as before MSAA support was
146    /// added.
147    ///
148    /// # Errors
149    ///
150    /// Returns [`UiError::Unsupported`] when no GPU adapter is available (so
151    /// the caller can skip on a machine without a usable GPU), or
152    /// [`UiError::Backend`] when device creation fails.
153    ///
154    /// [`headless_with_quality`]: WgpuBackend::headless_with_quality
155    pub fn headless(width: u32, height: u32) -> Result<Self, UiError> {
156        Self::headless_with_quality(width, height, &crate::RenderQuality::low())
157    }
158
159    /// Returns a reference to the underlying [`GpuContext`].
160    pub fn ctx(&self) -> &GpuContext {
161        &self.ctx
162    }
163
164    /// Set the colour the offscreen target is cleared to before each frame.
165    pub fn set_clear_color(&mut self, color: Color) {
166        self.clear_color = color;
167    }
168
169    /// Return the current clear colour.
170    pub fn clear_color(&self) -> Color {
171        self.clear_color
172    }
173
174    /// Target width in physical pixels.
175    pub fn width(&self) -> u32 {
176        self.ctx.width
177    }
178
179    /// Target height in physical pixels.
180    pub fn height(&self) -> u32 {
181        self.ctx.height
182    }
183
184    /// Return the per-frame statistics populated by the most recent
185    /// [`execute`] call.
186    ///
187    /// Statistics are reset to zero at the start of each `execute()` and
188    /// incrementally updated as GPU passes are issued.
189    ///
190    /// [`execute`]: WgpuBackend::execute
191    pub fn frame_stats(&self) -> FrameStats {
192        self.last_frame_stats
193    }
194
195    /// Read the offscreen colour target back into a tightly packed
196    /// `width * height * 4` RGBA byte vector (row padding stripped).
197    ///
198    /// # Errors
199    ///
200    /// Returns [`UiError::Render`] if the GPU poll or buffer mapping fails.
201    pub fn readback_rgba(&self) -> Result<Vec<u8>, UiError> {
202        let width = self.ctx.width;
203        let height = self.ctx.height;
204        let unpadded_bytes_per_row = width * 4;
205        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
206        let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
207        let buffer_size = (padded_bytes_per_row * height) as wgpu::BufferAddress;
208
209        let readback = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
210            label: Some("oxiui-render-wgpu readback"),
211            size: buffer_size,
212            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
213            mapped_at_creation: false,
214        });
215
216        let mut encoder = self
217            .ctx
218            .device
219            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
220                label: Some("oxiui-render-wgpu readback encoder"),
221            });
222
223        encoder.copy_texture_to_buffer(
224            wgpu::TexelCopyTextureInfo {
225                texture: &self.ctx.color_texture,
226                mip_level: 0,
227                origin: wgpu::Origin3d::ZERO,
228                aspect: wgpu::TextureAspect::All,
229            },
230            wgpu::TexelCopyBufferInfo {
231                buffer: &readback,
232                layout: wgpu::TexelCopyBufferLayout {
233                    offset: 0,
234                    bytes_per_row: Some(padded_bytes_per_row),
235                    rows_per_image: Some(height),
236                },
237            },
238            wgpu::Extent3d {
239                width,
240                height,
241                depth_or_array_layers: 1,
242            },
243        );
244
245        self.ctx.queue.submit(Some(encoder.finish()));
246
247        let slice = readback.slice(..);
248        slice.map_async(wgpu::MapMode::Read, |_| {});
249        self.ctx
250            .device
251            .poll(wgpu::PollType::wait_indefinitely())
252            .map_err(|e| UiError::Render(format!("GPU poll failed during readback: {e:?}")))?;
253
254        let data = slice.get_mapped_range();
255
256        let mut out = Vec::with_capacity((unpadded_bytes_per_row * height) as usize);
257        for row in 0..height {
258            let start = (row * padded_bytes_per_row) as usize;
259            let end = start + unpadded_bytes_per_row as usize;
260            out.extend_from_slice(&data[start..end]);
261        }
262
263        drop(data);
264        readback.unmap();
265        Ok(out)
266    }
267
268    /// Read back a single pixel as `(r, g, b, a)`, or `None` if out of bounds.
269    pub fn read_pixel(&self, x: u32, y: u32) -> Result<Option<(u8, u8, u8, u8)>, UiError> {
270        if x >= self.ctx.width || y >= self.ctx.height {
271            return Ok(None);
272        }
273        let buf = self.readback_rgba()?;
274        let idx = ((y * self.ctx.width + x) * 4) as usize;
275        Ok(Some((buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3])))
276    }
277
278    /// Resize the headless offscreen target to `new_width × new_height` pixels.
279    ///
280    /// Recreates only the offscreen colour texture (and the MSAA texture if
281    /// active).  The `wgpu::Device`, `Queue`, and compiled pipelines are
282    /// preserved — only size-dependent GPU resources are rebuilt.
283    ///
284    /// All texture views obtained from this backend before the resize become
285    /// invalid and must not be used afterwards.
286    ///
287    /// # Errors
288    ///
289    /// Returns [`UiError::Unsupported`] if either dimension is zero.
290    pub fn resize(&mut self, new_width: u32, new_height: u32) -> Result<(), UiError> {
291        // Resize the colour textures in-place (device/queue/pipelines unchanged).
292        self.ctx.resize(new_width, new_height)?;
293
294        // Update the globals uniform buffer with the new viewport size.
295        let globals = Globals::new(new_width, new_height);
296        self.ctx
297            .queue
298            .write_buffer(&self.globals_buffer, 0, bytemuck::bytes_of(&globals));
299
300        // Invalidate the persistent solid vertex buffer so it is reallocated on
301        // the next frame (the old buffer remains valid; we just stop using it).
302        self.solid_vertex_buf = None;
303        self.solid_vertex_buf_capacity = 0;
304
305        Ok(())
306    }
307}
308
309// ── RenderBackend impl ────────────────────────────────────────────────────────
310
311impl RenderBackend for WgpuBackend {
312    fn surface_size(&self) -> Size {
313        Size::new(self.ctx.width as f32, self.ctx.height as f32)
314    }
315
316    fn supports_gradients(&self) -> bool {
317        true
318    }
319
320    fn supports_paths(&self) -> bool {
321        true
322    }
323
324    fn supports_images(&self) -> bool {
325        true
326    }
327
328    fn supports_blur(&self) -> bool {
329        true
330    }
331
332    fn supports_blend_modes(&self) -> bool {
333        true
334    }
335
336    fn supports_backdrop_blur(&self) -> bool {
337        true
338    }
339
340    fn execute(&mut self, list: &DrawList) -> Result<(), UiError> {
341        // Reset per-frame stats at the start of each execute().
342        self.last_frame_stats = FrameStats::default();
343
344        // Update the viewport globals uniform.
345        let globals = Globals::new(self.ctx.width, self.ctx.height);
346        self.ctx
347            .queue
348            .write_buffer(&self.globals_buffer, 0, bytemuck::bytes_of(&globals));
349
350        let (verts, segments, gradient_draws, textured_draws, _backdrop_blur_draws) =
351            build_geometry(list, self.ctx.width, self.ctx.height);
352
353        let clear = self.clear_color;
354        let clear_value = wgpu::Color {
355            r: clear.0 as f64 / 255.0,
356            g: clear.1 as f64 / 255.0,
357            b: clear.2 as f64 / 255.0,
358            a: clear.3 as f64 / 255.0,
359        };
360
361        // Obtain the screen colour attachment.  Under MSAA this is
362        // (msaa_view, Some(color_view)); under no MSAA it is (color_view, None).
363        let (screen_view, screen_resolve) = self.ctx.color_attachment();
364
365        // ── Pass 0: Dedicated clear ───────────────────────────────────────────
366        // We separate the clear from the solid draw pass so that shadow passes
367        // (which use LoadOp::Load) can composite onto the cleared target *before*
368        // the solid/gradient/textured content is drawn on top.
369        //
370        // Under MSAA we clear the MSAA surface directly so the resolve target
371        // also ends up cleared after any subsequent resolve.
372        {
373            let mut encoder =
374                self.ctx
375                    .device
376                    .create_command_encoder(&wgpu::CommandEncoderDescriptor {
377                        label: Some("oxiui-render-wgpu clear encoder"),
378                    });
379            let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
380                label: Some("oxiui-render-wgpu clear pass"),
381                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
382                    view: screen_view,
383                    depth_slice: None,
384                    resolve_target: screen_resolve,
385                    ops: wgpu::Operations {
386                        load: wgpu::LoadOp::Clear(clear_value),
387                        store: wgpu::StoreOp::Store,
388                    },
389                })],
390                depth_stencil_attachment: None,
391                timestamp_writes: None,
392                occlusion_query_set: None,
393                multiview_mask: None,
394            });
395            // No draws — clear only.
396            drop(_pass);
397            self.ctx.queue.submit(Some(encoder.finish()));
398        }
399        // Count the clear pass.
400        self.last_frame_stats.render_passes += 1;
401
402        // ── Passes 1-N: Shadow composites ─────────────────────────────────────
403        // Each shadow submits its own command encoders internally.  These are
404        // submitted before the main-frame encoder so shadows appear under content.
405        //
406        // The shadow composite pass writes to the screen target (which may be
407        // the MSAA surface when MSAA is active), so we pass both `screen_view`
408        // and `screen_resolve` through `ShadowGpuState`.
409        let shadows = crate::gpu::shadow::collect_shadows(list);
410        let shadow_gpu = crate::gpu::shadow::ShadowGpuState {
411            device: &self.ctx.device,
412            queue: &self.ctx.queue,
413            target_view: screen_view,
414            resolve_target: screen_resolve,
415            globals_buffer: &self.globals_buffer,
416            globals_bind_group: &self.globals_bind_group,
417            viewport_w: self.ctx.width,
418            viewport_h: self.ctx.height,
419        };
420        let shadow_pipelines = crate::gpu::shadow::ShadowPipelines {
421            // Mask pass uses solid_mask_pipeline (count=1, ping is count=1).
422            solid: &self.solid_mask_pipeline,
423            blur: &self.blur_pipeline,
424            // Composite pass uses composite_pipeline (count=ctx.sample_count, writes to screen).
425            composite: &self.composite_pipeline,
426        };
427        let shadow_stats =
428            crate::gpu::shadow::render_shadows(&shadow_gpu, &shadow_pipelines, &shadows)?;
429        self.last_frame_stats.render_passes += shadow_stats.render_passes;
430        self.last_frame_stats.draw_calls += shadow_stats.draw_calls;
431
432        let mut encoder = self
433            .ctx
434            .device
435            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
436                label: Some("oxiui-render-wgpu frame encoder"),
437            });
438
439        // Re-borrow after submitting the clear encoder above.
440        let (screen_view2, screen_resolve2) = self.ctx.color_attachment();
441
442        // ── Solid pass (LoadOp::Load — clear already done above) ──────────────
443        let solid_draws = run_solid_pass(SolidPassParams {
444            device: &self.ctx.device,
445            queue: &self.ctx.queue,
446            encoder: &mut encoder,
447            screen_view: screen_view2,
448            screen_resolve: screen_resolve2,
449            pipeline: &self.pipeline,
450            globals_bind_group: &self.globals_bind_group,
451            verts: &verts,
452            segments: &segments,
453            viewport_w: self.ctx.width,
454            viewport_h: self.ctx.height,
455            solid_vertex_buf: &mut self.solid_vertex_buf,
456            solid_vertex_buf_capacity: &mut self.solid_vertex_buf_capacity,
457        });
458        // Count the solid pass itself (it is always opened, even when empty).
459        self.last_frame_stats.render_passes += 1;
460        self.last_frame_stats.draw_calls += solid_draws;
461
462        // ── Gradient pass (all gradient draws coalesced into one pass) ────────
463        {
464            let (sv, sr) = self.ctx.color_attachment();
465            let (rp, dc) = run_gradient_pass_batched(GradientPassParams {
466                device: &self.ctx.device,
467                queue: &self.ctx.queue,
468                encoder: &mut encoder,
469                screen_view: sv,
470                screen_resolve: sr,
471                pipeline: &self.gradient_pipeline,
472                globals_buffer: &self.globals_buffer,
473                gradient_draws: &gradient_draws,
474                viewport_w: self.ctx.width,
475                viewport_h: self.ctx.height,
476            });
477            self.last_frame_stats.render_passes += rp;
478            self.last_frame_stats.draw_calls += dc;
479        }
480
481        // ── Textured pass (one render pass per textured draw) ─────────────────
482        for td in &textured_draws {
483            let (sv, sr) = self.ctx.color_attachment();
484            let (rp, dc) = run_textured_pass(TexturedPassParams {
485                device: &self.ctx.device,
486                queue: &self.ctx.queue,
487                encoder: &mut encoder,
488                screen_view: sv,
489                screen_resolve: sr,
490                pipeline: &self.textured_pipeline,
491                globals_bind_group: &self.globals_bind_group,
492                td,
493                viewport_w: self.ctx.width,
494                viewport_h: self.ctx.height,
495            })?;
496            self.last_frame_stats.render_passes += rp;
497            self.last_frame_stats.draw_calls += dc;
498        }
499
500        self.ctx.queue.submit(Some(encoder.finish()));
501        Ok(())
502    }
503}
504
505// ── Tests ─────────────────────────────────────────────────────────────────────
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use oxiui_core::geometry::{Point, Rect};
511    use oxiui_core::paint::{DrawCommand, DrawList, FillRule, GradientStop, PathData, StrokeStyle};
512    use oxiui_core::Color;
513
514    // ── MSAA tests ────────────────────────────────────────────────────────────
515
516    #[test]
517    fn msaa_smooths_diagonal_edge() {
518        // Build a 4x MSAA backend. If MSAA is not supported by the adapter, we
519        // may get sample_count=1 (fallback), in which case we skip the
520        // intermediate-alpha assertion but still pass.
521        let Some(mut b) =
522            WgpuBackend::headless_with_quality(64, 64, &crate::RenderQuality::balanced()).ok()
523        else {
524            return;
525        };
526        let mut list = DrawList::new();
527        // Draw a filled right-triangle with a 45° diagonal edge.
528        let red = Color(255, 0, 0, 255);
529        let mut path = PathData::new();
530        path.move_to(Point::new(0.0, 0.0));
531        path.line_to(Point::new(63.0, 0.0));
532        path.line_to(Point::new(0.0, 63.0));
533        path.close();
534        list.push_path(path, red);
535        b.execute(&list).expect("execute");
536        let buf = b.readback_rgba().expect("readback");
537        let w = b.width();
538        let pixel = |x: u32, y: u32| -> (u8, u8, u8, u8) {
539            let i = ((y * w + x) * 4) as usize;
540            (buf[i], buf[i + 1], buf[i + 2], buf[i + 3])
541        };
542        if b.ctx().sample_count() > 1 {
543            // With MSAA: at least one diagonal-edge pixel should have
544            // intermediate alpha (0 < a < 255).
545            let mut found_intermediate = false;
546            for d in 5u32..58u32 {
547                let p = pixel(d, d);
548                if p.3 > 0 && p.3 < 255 {
549                    found_intermediate = true;
550                    break;
551                }
552            }
553            assert!(
554                found_intermediate,
555                "MSAA should produce intermediate-alpha pixels on diagonal edge"
556            );
557        }
558        // Fully-inside pixel should be full red.
559        let inside = pixel(5, 5);
560        assert_eq!(inside.3, 255, "inside pixel must be fully opaque");
561    }
562
563    #[test]
564    fn non_msaa_edge_is_hard() {
565        let Some(mut b) = try_backend(64, 64) else {
566            return;
567        };
568        let mut list = DrawList::new();
569        let red = Color(255, 0, 0, 255);
570        let mut path = PathData::new();
571        path.move_to(Point::new(0.0, 0.0));
572        path.line_to(Point::new(63.0, 0.0));
573        path.line_to(Point::new(0.0, 63.0));
574        path.close();
575        list.push_path(path, red);
576        b.execute(&list).expect("execute");
577        let buf = b.readback_rgba().expect("readback");
578        let w = b.width();
579        // No MSAA: all pixels on the diagonal must be either fully opaque or
580        // fully transparent.
581        for d in 0u32..64u32 {
582            let i = ((d * w + d) * 4) as usize;
583            let a = buf[i + 3];
584            assert!(
585                a == 0 || a == 255,
586                "non-MSAA edge pixel at ({d},{d}) must be 0 or 255, got {a}"
587            );
588        }
589    }
590
591    #[test]
592    fn msaa_default_path_unchanged() {
593        // headless() = RenderQuality::low() = msaa=1 → sample_count=1,
594        // byte-identical path.
595        let Some(mut b) = try_backend(64, 64) else {
596            return;
597        };
598        assert_eq!(
599            b.ctx().sample_count(),
600            1,
601            "headless() must use sample_count=1"
602        );
603        let mut list = DrawList::new();
604        list.push_rect(Rect::new(10.0, 10.0, 20.0, 20.0), Color(255, 0, 0, 255));
605        b.execute(&list).expect("execute");
606        let px = b.read_pixel(20, 20).expect("read").expect("pixel");
607        assert_eq!(
608            (px.0, px.1, px.2, px.3),
609            (255, 0, 0, 255),
610            "basic rect fill must still work"
611        );
612    }
613
614    fn try_backend(w: u32, h: u32) -> Option<WgpuBackend> {
615        WgpuBackend::headless(w, h).ok()
616    }
617
618    fn assert_visible(b: &WgpuBackend, x: u32, y: u32, label: &str) {
619        let px = b
620            .read_pixel(x, y)
621            .expect("read_pixel ok")
622            .expect("in bounds");
623        assert!(px.3 > 0, "{label}: pixel ({x},{y}) alpha=0, got {px:?}");
624    }
625
626    fn assert_transparent(b: &WgpuBackend, x: u32, y: u32, label: &str) {
627        let px = b
628            .read_pixel(x, y)
629            .expect("read_pixel ok")
630            .expect("in bounds");
631        assert!(
632            px.3 == 0,
633            "{label}: pixel ({x},{y}) expected transparent, got {px:?}"
634        );
635    }
636
637    #[test]
638    fn test_stroke_rect_renders() {
639        let Some(mut b) = try_backend(100, 100) else {
640            return;
641        };
642        let mut dl = DrawList::new();
643        dl.push(DrawCommand::StrokeRect {
644            rect: Rect::new(10.0, 10.0, 80.0, 80.0),
645            thickness: 4.0,
646            color: Color(255, 0, 0, 255),
647        });
648        b.execute(&dl).expect("execute ok");
649        assert_visible(&b, 12, 10, "stroke_rect top border");
650        assert_transparent(&b, 50, 50, "stroke_rect interior");
651    }
652
653    #[test]
654    fn test_fill_rounded_rect_renders() {
655        let Some(mut b) = try_backend(100, 100) else {
656            return;
657        };
658        let mut dl = DrawList::new();
659        dl.push(DrawCommand::FillRoundedRect {
660            rect: Rect::new(10.0, 10.0, 80.0, 80.0),
661            radius: 10.0,
662            color: Color(0, 200, 0, 255),
663        });
664        b.execute(&dl).expect("execute ok");
665        assert_visible(&b, 50, 50, "rrect centre");
666        assert_transparent(&b, 10, 10, "rrect corner tl");
667    }
668
669    #[test]
670    fn test_fill_rounded_rect_per_corner_renders() {
671        let Some(mut b) = try_backend(100, 100) else {
672            return;
673        };
674        let mut dl = DrawList::new();
675        dl.push(DrawCommand::FillRoundedRectPerCorner {
676            rect: Rect::new(10.0, 10.0, 80.0, 80.0),
677            radii: [15.0, 5.0, 15.0, 5.0],
678            color: Color(0, 100, 200, 255),
679        });
680        b.execute(&dl).expect("execute ok");
681        assert_visible(&b, 50, 50, "rrect-pc centre");
682    }
683
684    #[test]
685    fn test_fill_ellipse_renders() {
686        let Some(mut b) = try_backend(100, 100) else {
687            return;
688        };
689        let mut dl = DrawList::new();
690        dl.push(DrawCommand::FillEllipse {
691            center: Point::new(50.0, 50.0),
692            rx: 30.0,
693            ry: 20.0,
694            color: Color(200, 0, 200, 255),
695        });
696        b.execute(&dl).expect("execute ok");
697        assert_visible(&b, 50, 50, "ellipse centre");
698        assert_transparent(&b, 2, 2, "ellipse exterior");
699    }
700
701    #[test]
702    fn test_line_renders() {
703        let Some(mut b) = try_backend(100, 100) else {
704            return;
705        };
706        let mut dl = DrawList::new();
707        dl.push(DrawCommand::Line {
708            from: Point::new(10.0, 50.0),
709            to: Point::new(90.0, 50.0),
710            color: Color(255, 255, 0, 255),
711        });
712        b.execute(&dl).expect("execute ok");
713        assert_visible(&b, 50, 50, "line mid");
714    }
715
716    #[test]
717    fn test_fill_path_renders() {
718        let Some(mut b) = try_backend(100, 100) else {
719            return;
720        };
721        let mut path = PathData::new();
722        path.move_to(Point::new(20.0, 20.0));
723        path.line_to(Point::new(80.0, 20.0));
724        path.line_to(Point::new(50.0, 80.0));
725        path.close();
726        let mut dl = DrawList::new();
727        dl.push(DrawCommand::FillPath {
728            path,
729            color: Color(255, 0, 128, 255),
730        });
731        b.execute(&dl).expect("execute ok");
732        assert_visible(&b, 50, 40, "fill_path interior");
733        assert_transparent(&b, 2, 2, "fill_path exterior");
734    }
735
736    #[test]
737    fn test_stroke_path_renders() {
738        let Some(mut b) = try_backend(100, 100) else {
739            return;
740        };
741        let mut path = PathData::new();
742        path.move_to(Point::new(20.0, 50.0));
743        path.line_to(Point::new(80.0, 50.0));
744        let style = StrokeStyle {
745            width: 4.0,
746            ..Default::default()
747        };
748        let mut dl = DrawList::new();
749        dl.push(DrawCommand::StrokePath {
750            path,
751            style,
752            color: Color(200, 200, 0, 255),
753        });
754        b.execute(&dl).expect("execute ok");
755        assert_visible(&b, 50, 50, "stroke_path mid");
756    }
757
758    #[test]
759    fn test_linear_gradient_renders() {
760        let Some(mut b) = try_backend(100, 100) else {
761            return;
762        };
763        let stops = vec![
764            GradientStop::new(0.0, Color(255, 0, 0, 255)),
765            GradientStop::new(1.0, Color(0, 0, 255, 255)),
766        ];
767        let mut dl = DrawList::new();
768        dl.push(DrawCommand::LinearGradient {
769            rect: Rect::new(0.0, 0.0, 100.0, 100.0),
770            start: Point::new(0.0, 50.0),
771            end: Point::new(100.0, 50.0),
772            stops,
773        });
774        b.execute(&dl).expect("execute ok");
775        let left = b.read_pixel(5, 50).expect("ok").expect("bounds");
776        assert!(left.0 > 128, "left reddish: {left:?}");
777        let right = b.read_pixel(95, 50).expect("ok").expect("bounds");
778        assert!(right.2 > 128, "right bluish: {right:?}");
779        let mid = b.read_pixel(50, 50).expect("ok").expect("bounds");
780        assert!(mid.3 > 0, "mid visible: {mid:?}");
781    }
782
783    #[test]
784    fn test_radial_gradient_renders() {
785        let Some(mut b) = try_backend(100, 100) else {
786            return;
787        };
788        let stops = vec![
789            GradientStop::new(0.0, Color(255, 255, 255, 255)),
790            GradientStop::new(1.0, Color(0, 0, 0, 255)),
791        ];
792        let mut dl = DrawList::new();
793        dl.push(DrawCommand::RadialGradient {
794            rect: Rect::new(0.0, 0.0, 100.0, 100.0),
795            center: Point::new(50.0, 50.0),
796            radius: 40.0,
797            stops,
798        });
799        b.execute(&dl).expect("execute ok");
800        let centre = b.read_pixel(50, 50).expect("ok").expect("bounds");
801        assert!(centre.0 > 200, "centre bright: {centre:?}");
802        let edge = b.read_pixel(90, 50).expect("ok").expect("bounds");
803        assert!(
804            edge.0 < centre.0,
805            "edge darker: edge={edge:?} centre={centre:?}"
806        );
807    }
808
809    #[test]
810    fn test_supports_probes() {
811        let Some(b) = try_backend(64, 64) else {
812            return;
813        };
814        assert!(b.supports_gradients());
815        assert!(b.supports_paths());
816    }
817
818    #[test]
819    fn image_solid_fill_readback() {
820        use oxiui_core::paint::{DrawList, ImageData, ImageFilter};
821        let Some(mut b) = try_backend(64, 64) else {
822            return;
823        };
824        // 2x2 solid red image
825        let image = ImageData::new(
826            vec![
827                255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255,
828            ],
829            2,
830            2,
831        );
832        let mut dl = DrawList::new();
833        dl.push_image(
834            image,
835            Rect::new(12.0, 12.0, 40.0, 40.0),
836            ImageFilter::Nearest,
837        );
838        b.execute(&dl).expect("execute ok");
839        let px = b.read_pixel(32, 32).expect("ok").expect("bounds");
840        assert!(px.0 > 200 && px.3 > 200, "centre should be red: {px:?}");
841        assert_transparent(&b, 2, 2, "outside image");
842    }
843
844    #[test]
845    fn nine_slice_renders() {
846        use oxiui_core::paint::{DrawList, ImageData};
847        let Some(mut b) = try_backend(128, 128) else {
848            return;
849        };
850        // 12x12 image: red corners (4px), blue centre
851        let mut rgba = vec![0u8; 12 * 12 * 4];
852        for y in 0..12u32 {
853            for x in 0..12u32 {
854                let i = ((y * 12 + x) * 4) as usize;
855                let corner = !(4..8).contains(&x) || !(4..8).contains(&y);
856                if corner {
857                    rgba[i] = 255;
858                    rgba[i + 1] = 0;
859                    rgba[i + 2] = 0;
860                    rgba[i + 3] = 255; // red
861                } else {
862                    rgba[i] = 0;
863                    rgba[i + 1] = 0;
864                    rgba[i + 2] = 255;
865                    rgba[i + 3] = 255; // blue
866                }
867            }
868        }
869        let image = ImageData::new(rgba, 12, 12);
870        let mut dl = DrawList::new();
871        dl.push_nine_slice(image, Rect::new(0.0, 0.0, 128.0, 128.0), [4, 4, 4, 4]);
872        b.execute(&dl).expect("execute ok");
873        // Corner region should be reddish
874        let corner = b.read_pixel(2, 2).expect("ok").expect("bounds");
875        assert!(corner.0 > 100, "corner should be reddish: {corner:?}");
876        // Centre region should be bluish
877        let centre = b.read_pixel(64, 64).expect("ok").expect("bounds");
878        assert!(centre.2 > 100, "centre should be bluish: {centre:?}");
879    }
880
881    #[test]
882    fn tex_vertex_size_is_32() {
883        use crate::gpu::buffer::TexVertex;
884        assert_eq!(core::mem::size_of::<TexVertex>(), 32);
885    }
886
887    // ── BoxShadow tests ───────────────────────────────────────────────────────
888
889    #[test]
890    fn box_shadow_zero_blur_is_sharp() {
891        let Some(mut b) = try_backend(128, 128) else {
892            return;
893        };
894        let mut dl = DrawList::new();
895        dl.push_shadow(
896            Rect::new(20.0, 20.0, 80.0, 80.0),
897            Point::new(0.0, 0.0),
898            0.0,
899            Color(0, 0, 0, 200),
900        );
901        b.execute(&dl).expect("execute ok");
902        // Interior: shadow visible
903        let interior = b.read_pixel(60, 60).expect("ok").expect("bounds");
904        assert!(interior.3 > 100, "interior should be visible: {interior:?}");
905        // Far outside: transparent
906        let outside = b.read_pixel(5, 5).expect("ok").expect("bounds");
907        assert!(outside.3 == 0, "outside should be transparent: {outside:?}");
908    }
909
910    #[test]
911    fn box_shadow_blur_halo_falloff() {
912        let Some(mut b) = try_backend(200, 200) else {
913            return;
914        };
915        let mut dl = DrawList::new();
916        dl.push_shadow(
917            Rect::new(50.0, 50.0, 100.0, 100.0),
918            Point::new(0.0, 0.0),
919            12.0,
920            Color(0, 0, 0, 255),
921        );
922        b.execute(&dl).expect("execute ok");
923        // Near-interior: high alpha
924        let interior = b.read_pixel(100, 100).expect("ok").expect("bounds");
925        assert!(interior.3 > 100, "interior should be visible: {interior:?}");
926        // Just outside: some alpha (blur halo)
927        let edge = b.read_pixel(45, 100).expect("ok").expect("bounds");
928        // Far outside: low/zero alpha
929        let far = b.read_pixel(5, 5).expect("ok").expect("bounds");
930        assert!(far.3 < edge.3, "falloff: far={far:?} edge={edge:?}");
931    }
932
933    #[test]
934    fn box_shadow_offset_translates() {
935        let Some(mut b) = try_backend(200, 200) else {
936            return;
937        };
938        let mut dl = DrawList::new();
939        dl.push_shadow(
940            Rect::new(50.0, 50.0, 80.0, 80.0),
941            Point::new(20.0, 20.0),
942            0.0,
943            Color(0, 0, 0, 255),
944        );
945        b.execute(&dl).expect("execute ok");
946        // Original rect position (before offset) should be transparent
947        let orig_pos = b.read_pixel(55, 55).expect("ok").expect("bounds");
948        assert!(
949            orig_pos.3 == 0,
950            "original rect pos should be transparent: {orig_pos:?}"
951        );
952        // Offset position should be visible
953        let offset_pos = b.read_pixel(80, 80).expect("ok").expect("bounds");
954        assert!(
955            offset_pos.3 > 100,
956            "offset pos should be visible: {offset_pos:?}"
957        );
958    }
959
960    #[test]
961    fn shadows_render_under_solids() {
962        let Some(mut b) = try_backend(200, 200) else {
963            return;
964        };
965        let mut dl = DrawList::new();
966        // Shadow covering most of the viewport
967        dl.push_shadow(
968            Rect::new(10.0, 10.0, 180.0, 180.0),
969            Point::new(0.0, 0.0),
970            0.0,
971            Color(255, 0, 0, 255), // red shadow
972        );
973        // Blue rect covering the shadow area
974        dl.push(DrawCommand::FillRect {
975            rect: Rect::new(10.0, 10.0, 180.0, 180.0),
976            color: Color(0, 0, 255, 255), // solid blue
977        });
978        b.execute(&dl).expect("execute ok");
979        // The blue rect should be on top — pixel should show blue, not red
980        let px = b.read_pixel(100, 100).expect("ok").expect("bounds");
981        assert!(
982            px.2 > 200 && px.0 < 100,
983            "blue rect should be on top: {px:?}"
984        );
985    }
986
987    #[test]
988    fn fill_path_concave_notch_empty() {
989        // Arrow/chevron shape: concave polygon with a notch at the bottom.
990        // The notch interior pixel should be transparent.
991        let Some(mut b) = try_backend(64, 64) else {
992            return;
993        };
994        let mut list = DrawList::new();
995        let red = Color(255, 0, 0, 255);
996        // Concave polygon (CCW): (5,5) (59,5) (59,59) (32,40) (5,59) — concave at (32,40).
997        let mut path = PathData::new();
998        path.move_to(Point::new(5.0, 5.0));
999        path.line_to(Point::new(59.0, 5.0));
1000        path.line_to(Point::new(59.0, 59.0));
1001        path.line_to(Point::new(32.0, 40.0)); // concave vertex (notch tip)
1002        path.line_to(Point::new(5.0, 59.0));
1003        path.close();
1004        list.push_path(path, red);
1005        b.execute(&list).expect("execute");
1006        // Top body pixel should be red.
1007        let body = b.read_pixel(32, 10).expect("read").expect("pixel");
1008        assert_eq!(body.3, 255, "body should be opaque");
1009        // Pixel deep in the notch should be transparent.
1010        let notch = b.read_pixel(32, 55).expect("read").expect("pixel");
1011        assert_eq!(
1012            notch.3, 0,
1013            "notch must be transparent (concave fill correct)"
1014        );
1015    }
1016
1017    #[test]
1018    fn fill_path_donut_hole_empty() {
1019        // Outer CCW ring (big square) + inner CW ring (small square) = donut.
1020        // Centre pixel must be transparent under NonZero (CW inner = opposite winding → hole).
1021        let Some(mut b) = try_backend(64, 64) else {
1022            return;
1023        };
1024        let mut list = DrawList::new();
1025        let blue = Color(0, 0, 255, 255);
1026        let mut path = PathData::new();
1027        // Outer CCW
1028        path.move_to(Point::new(4.0, 4.0));
1029        path.line_to(Point::new(60.0, 4.0));
1030        path.line_to(Point::new(60.0, 60.0));
1031        path.line_to(Point::new(4.0, 60.0));
1032        path.close();
1033        // Inner CW (hole): reversed winding
1034        path.move_to(Point::new(20.0, 20.0));
1035        path.line_to(Point::new(20.0, 44.0));
1036        path.line_to(Point::new(44.0, 44.0));
1037        path.line_to(Point::new(44.0, 20.0));
1038        path.close();
1039        list.push_path(path, blue);
1040        b.execute(&list).expect("execute");
1041        // Outer ring should be blue.
1042        let ring = b.read_pixel(10, 10).expect("read").expect("pixel");
1043        assert_eq!(
1044            (ring.0, ring.1, ring.2, ring.3),
1045            (0, 0, 255, 255),
1046            "ring must be blue"
1047        );
1048        // Hole centre must be transparent.
1049        let hole = b.read_pixel(32, 32).expect("read").expect("pixel");
1050        assert_eq!(hole.3, 0, "donut hole must be transparent");
1051    }
1052
1053    #[test]
1054    fn fill_rule_evenodd_vs_nonzero() {
1055        // Same-winding nested rings: outer CCW + inner CCW.
1056        // EvenOdd: inner is at depth 1 → hole → inner pixel transparent.
1057        // NonZero: inner winding sum = +2 → filled → inner pixel opaque.
1058        let Some(mut b_eo) = try_backend(64, 64) else {
1059            return;
1060        };
1061        let Some(mut b_nz) = try_backend(64, 64) else {
1062            return;
1063        };
1064        let make_path = |fill_rule: FillRule| {
1065            let mut path = PathData::new().with_fill_rule(fill_rule);
1066            // Outer CCW
1067            path.move_to(Point::new(4.0, 4.0));
1068            path.line_to(Point::new(60.0, 4.0));
1069            path.line_to(Point::new(60.0, 60.0));
1070            path.line_to(Point::new(4.0, 60.0));
1071            path.close();
1072            // Inner CCW (same winding as outer)
1073            path.move_to(Point::new(20.0, 20.0));
1074            path.line_to(Point::new(44.0, 20.0));
1075            path.line_to(Point::new(44.0, 44.0));
1076            path.line_to(Point::new(20.0, 44.0));
1077            path.close();
1078            path
1079        };
1080        let green = Color(0, 255, 0, 255);
1081        let mut list_eo = DrawList::new();
1082        list_eo.push_path(make_path(FillRule::EvenOdd), green);
1083        let mut list_nz = DrawList::new();
1084        list_nz.push_path(make_path(FillRule::NonZero), green);
1085        b_eo.execute(&list_eo).expect("execute");
1086        b_nz.execute(&list_nz).expect("execute");
1087        // Inner centre pixel
1088        let inner_eo = b_eo.read_pixel(32, 32).expect("read").expect("pixel");
1089        let inner_nz = b_nz.read_pixel(32, 32).expect("read").expect("pixel");
1090        // EvenOdd: inner CCW ring is at depth 1 → hole → transparent.
1091        assert_eq!(
1092            inner_eo.3, 0,
1093            "EvenOdd: same-winding inner ring must be transparent (depth=1 = hole)"
1094        );
1095        // NonZero: inner CCW ring winding sum = +1 (outer) + +1 (inner) = +2 ≠ 0 → filled.
1096        assert_eq!(
1097            inner_nz.3, 255,
1098            "NonZero: same-winding inner ring must be opaque (winding=2 ≠ 0)"
1099        );
1100    }
1101
1102    // ── Visibility culling tests ───────────────────────────────────────────────
1103
1104    #[test]
1105    fn culled_offscreen_rect_is_transparent() {
1106        // A FillRect placed entirely outside the active clip region should
1107        // produce no visible pixels — the visibility culling optimisation
1108        // must discard it before vertices are emitted.
1109        let Some(mut b) = try_backend(64, 64) else {
1110            return;
1111        };
1112        let mut list = DrawList::new();
1113        // Active clip: top-left 32×32 quadrant.
1114        list.push_clip(Rect::new(0.0, 0.0, 32.0, 32.0));
1115        // Draw a rect entirely in the bottom-right quadrant → outside the clip.
1116        list.push_rect(Rect::new(40.0, 40.0, 20.0, 20.0), Color(255, 0, 0, 255));
1117        list.pop_clip();
1118        b.execute(&list).expect("execute");
1119        // Pixel inside the culled rect should remain transparent.
1120        let px = b.read_pixel(45, 45).expect("read").expect("pixel");
1121        assert_eq!(px.3, 0, "rect outside clip must be culled (transparent)");
1122        // Pixel in the clipped-but-undrawn top-left region also stays transparent.
1123        let px2 = b.read_pixel(10, 10).expect("read").expect("pixel");
1124        assert_eq!(px2.3, 0, "undrawn area must remain transparent");
1125    }
1126
1127    #[test]
1128    fn culling_does_not_affect_visible_rect() {
1129        // Verify that visibility culling does not accidentally discard a rect
1130        // that lies within the viewport (no active scissor → no culling).
1131        let Some(mut b) = try_backend(64, 64) else {
1132            return;
1133        };
1134        let mut list = DrawList::new();
1135        list.push_rect(Rect::new(10.0, 10.0, 40.0, 40.0), Color(0, 255, 0, 255));
1136        b.execute(&list).expect("execute");
1137        let px = b.read_pixel(30, 30).expect("read").expect("pixel");
1138        assert_eq!(
1139            (px.0, px.1, px.2, px.3),
1140            (0, 255, 0, 255),
1141            "visible rect must not be culled"
1142        );
1143    }
1144
1145    // ── FrameStats tests ──────────────────────────────────────────────────────
1146
1147    #[test]
1148    fn frame_stats_counts_solid_draws() {
1149        let Some(mut backend) = try_backend(64, 64) else {
1150            return;
1151        };
1152        // One solid rect with no clip changes = one DrawSegment = one draw
1153        let mut list = DrawList::new();
1154        list.push(DrawCommand::FillRect {
1155            rect: Rect::new(10.0, 10.0, 44.0, 44.0),
1156            color: Color(255, 0, 0, 255),
1157        });
1158        backend.execute(&list).expect("execute failed");
1159        let stats = backend.frame_stats();
1160        assert!(stats.draw_calls >= 1, "should have at least 1 draw call");
1161        assert!(
1162            stats.render_passes >= 1,
1163            "should have at least 1 render pass"
1164        );
1165    }
1166
1167    // ── R2: Draw-call batching tests ─────────────────────────────────────────
1168
1169    #[test]
1170    fn two_gradients_one_pass() {
1171        let Some(mut backend) = try_backend(128, 64) else {
1172            return;
1173        };
1174        let mut list = DrawList::new();
1175        // Left half: linear gradient red→blue
1176        list.push(DrawCommand::LinearGradient {
1177            rect: Rect::new(0.0, 0.0, 64.0, 64.0),
1178            start: Point::new(0.0, 0.0),
1179            end: Point::new(64.0, 0.0),
1180            stops: vec![
1181                GradientStop::new(0.0, Color(255, 0, 0, 255)),
1182                GradientStop::new(1.0, Color(0, 0, 255, 255)),
1183            ],
1184        });
1185        // Right half: linear gradient green→yellow
1186        list.push(DrawCommand::LinearGradient {
1187            rect: Rect::new(64.0, 0.0, 64.0, 64.0),
1188            start: Point::new(64.0, 0.0),
1189            end: Point::new(128.0, 0.0),
1190            stops: vec![
1191                GradientStop::new(0.0, Color(0, 255, 0, 255)),
1192                GradientStop::new(1.0, Color(255, 255, 0, 255)),
1193            ],
1194        });
1195        backend.execute(&list).expect("execute");
1196        let stats = backend.frame_stats();
1197        // Both gradients should produce at least 2 draw calls
1198        assert!(
1199            stats.draw_calls >= 2,
1200            "should have at least 2 draw calls for 2 gradients, got {}",
1201            stats.draw_calls
1202        );
1203
1204        // Left side near x=2: gradient starts as red
1205        let left_px = backend
1206            .read_pixel(2, 32)
1207            .expect("read left")
1208            .expect("bounds");
1209        assert!(left_px.0 > 200, "left should be reddish, got {:?}", left_px);
1210        assert!(
1211            left_px.2 < 100,
1212            "left should not be blue, got {:?}",
1213            left_px
1214        );
1215
1216        // Right side near x=66: gradient starts as green
1217        let right_px = backend
1218            .read_pixel(66, 32)
1219            .expect("read right")
1220            .expect("bounds");
1221        assert!(
1222            right_px.1 > 200,
1223            "right should be greenish, got {:?}",
1224            right_px
1225        );
1226        assert!(
1227            right_px.0 < 100,
1228            "right should not be red, got {:?}",
1229            right_px
1230        );
1231    }
1232
1233    #[test]
1234    fn gradient_byte_exact_single() {
1235        // A single gradient must produce correct pixel values through the
1236        // batched path (offset=0 dynamic offset invariant).
1237        let Some(mut backend) = try_backend(64, 64) else {
1238            return;
1239        };
1240        let mut list = DrawList::new();
1241        list.push(DrawCommand::LinearGradient {
1242            rect: Rect::new(0.0, 0.0, 64.0, 64.0),
1243            start: Point::new(0.0, 0.0),
1244            end: Point::new(64.0, 0.0),
1245            stops: vec![
1246                GradientStop::new(0.0, Color(255, 0, 0, 255)),
1247                GradientStop::new(1.0, Color(0, 0, 255, 255)),
1248            ],
1249        });
1250        backend.execute(&list).expect("execute");
1251        // Left edge ~red
1252        let left = backend
1253            .read_pixel(1, 32)
1254            .expect("read left")
1255            .expect("bounds");
1256        assert!(
1257            left.0 > 200 && left.2 < 100,
1258            "left should be reddish: {:?}",
1259            left
1260        );
1261        // Right edge ~blue
1262        let right = backend
1263            .read_pixel(62, 32)
1264            .expect("read right")
1265            .expect("bounds");
1266        assert!(
1267            right.2 > 200 && right.0 < 100,
1268            "right should be bluish: {:?}",
1269            right
1270        );
1271        // Mid should have intermediate values (not pure red or pure blue)
1272        let mid = backend
1273            .read_pixel(32, 32)
1274            .expect("read mid")
1275            .expect("bounds");
1276        assert!(mid.3 > 0, "mid should be visible: {:?}", mid);
1277    }
1278
1279    #[test]
1280    fn persistent_buffer_reuse_stable() {
1281        // Render two consecutive frames with different primitive counts;
1282        // both must be correct (stale tail from frame 1 must not bleed into frame 2).
1283        let Some(mut backend) = try_backend(64, 64) else {
1284            return;
1285        };
1286
1287        // Frame 1: 10 rects filling left strips
1288        let mut list1 = DrawList::new();
1289        for i in 0..10u32 {
1290            list1.push(DrawCommand::FillRect {
1291                rect: Rect::new(i as f32 * 4.0, 0.0, 4.0, 64.0),
1292                color: Color(255, 0, 0, 255),
1293            });
1294        }
1295        backend.execute(&list1).expect("frame 1");
1296        let px1 = backend
1297            .read_pixel(2, 32)
1298            .expect("frame 1 pixel")
1299            .expect("bounds");
1300        assert_eq!(px1.0, 255, "frame 1 should be red: {:?}", px1);
1301        assert_eq!(px1.3, 255, "frame 1 should be opaque: {:?}", px1);
1302
1303        // Frame 2: single blue rect covering the whole canvas
1304        let mut list2 = DrawList::new();
1305        list2.push(DrawCommand::FillRect {
1306            rect: Rect::new(0.0, 0.0, 64.0, 64.0),
1307            color: Color(0, 0, 255, 255),
1308        });
1309        backend.execute(&list2).expect("frame 2");
1310        let px2 = backend
1311            .read_pixel(32, 32)
1312            .expect("frame 2 pixel")
1313            .expect("bounds");
1314        assert_eq!(px2.2, 255, "frame 2 should be blue: {:?}", px2);
1315        // Stale-tail check: no red from the previous frame's extra vertices
1316        assert!(
1317            px2.0 < 10,
1318            "frame 2 stale-tail check: should not see red, got {:?}",
1319            px2
1320        );
1321    }
1322}