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