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::{DrawCommand, DrawList, GradientStop, RenderBackend};
33use oxiui_core::{Color, UiError};
34use wgpu::util::DeviceExt;
35
36use crate::clip::{ClipRect, ClipStack};
37use crate::gpu::buffer::{
38    push_circle_quad, push_ellipse_quad, push_gradient_quad, push_line_quad, push_rect_quad,
39    push_rounded_rect_per_corner_quad, push_rounded_rect_quad, Globals, GradientUniforms,
40    GradientVertex, LineQuadParams, Vertex, MAX_GRADIENT_STOPS,
41};
42use crate::gpu::device::GpuContext;
43use crate::gpu::pipeline::{GradientPipeline, SolidPipeline};
44use crate::gpu::tessellator::{tessellate_fill, tessellate_stroke};
45
46// ── DrawSegment ───────────────────────────────────────────────────────────────
47
48#[derive(Clone, Copy, Debug)]
49struct DrawSegment {
50    start: u32,
51    end: u32,
52    scissor: Option<[u32; 4]>,
53}
54
55// ── GradientDraw ──────────────────────────────────────────────────────────────
56
57struct GradientDraw {
58    verts: Vec<GradientVertex>,
59    uniforms: GradientUniforms,
60    scissor: Option<[u32; 4]>,
61}
62
63// ── WgpuBackend ───────────────────────────────────────────────────────────────
64
65/// Headless GPU backend implementing [`RenderBackend`].
66pub struct WgpuBackend {
67    ctx: GpuContext,
68    pipeline: SolidPipeline,
69    gradient_pipeline: GradientPipeline,
70    globals_buffer: wgpu::Buffer,
71    globals_bind_group: wgpu::BindGroup,
72    clear_color: Color,
73}
74
75impl WgpuBackend {
76    /// Initialise a headless backend with an offscreen target of
77    /// `width × height` physical pixels.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`UiError::Unsupported`] when no GPU adapter is available (so
82    /// the caller can skip on a machine without a usable GPU), or
83    /// [`UiError::Backend`] when device creation fails.
84    pub fn headless(width: u32, height: u32) -> Result<Self, UiError> {
85        let ctx = GpuContext::headless(width, height)?;
86        let pipeline = SolidPipeline::new(&ctx.device);
87        let gradient_pipeline = GradientPipeline::new(&ctx.device);
88
89        let globals = Globals::new(width, height);
90        let globals_buffer = ctx
91            .device
92            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
93                label: Some("oxiui-render-wgpu globals"),
94                contents: bytemuck::bytes_of(&globals),
95                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
96            });
97
98        let globals_bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
99            label: Some("oxiui-render-wgpu globals bind group"),
100            layout: &pipeline.globals_layout,
101            entries: &[wgpu::BindGroupEntry {
102                binding: 0,
103                resource: globals_buffer.as_entire_binding(),
104            }],
105        });
106
107        Ok(Self {
108            ctx,
109            pipeline,
110            gradient_pipeline,
111            globals_buffer,
112            globals_bind_group,
113            clear_color: Color(0, 0, 0, 0),
114        })
115    }
116
117    /// Set the colour the offscreen target is cleared to before each frame.
118    pub fn set_clear_color(&mut self, color: Color) {
119        self.clear_color = color;
120    }
121
122    /// Return the current clear colour.
123    pub fn clear_color(&self) -> Color {
124        self.clear_color
125    }
126
127    /// Target width in physical pixels.
128    pub fn width(&self) -> u32 {
129        self.ctx.width
130    }
131
132    /// Target height in physical pixels.
133    pub fn height(&self) -> u32 {
134        self.ctx.height
135    }
136
137    fn scissor_from_stack(&self, stack: &ClipStack) -> Option<[u32; 4]> {
138        let raw = stack.as_scissor()?;
139        Some(self.clamp_scissor(raw))
140    }
141
142    fn clamp_scissor(&self, [x, y, w, h]: [u32; 4]) -> [u32; 4] {
143        let x = x.min(self.ctx.width);
144        let y = y.min(self.ctx.height);
145        let w = w.min(self.ctx.width - x);
146        let h = h.min(self.ctx.height - y);
147        [x, y, w, h]
148    }
149
150    fn build_geometry(
151        &self,
152        list: &DrawList,
153    ) -> (Vec<Vertex>, Vec<DrawSegment>, Vec<GradientDraw>) {
154        let mut verts: Vec<Vertex> = Vec::new();
155        let mut segments: Vec<DrawSegment> = Vec::new();
156        let mut gradient_draws: Vec<GradientDraw> = Vec::new();
157        let mut stack = ClipStack::new();
158
159        let mut current_scissor = self.scissor_from_stack(&stack);
160        let mut segment_start: u32 = 0;
161
162        let flush = |segs: &mut Vec<DrawSegment>, start: u32, end: u32, sc: Option<[u32; 4]>| {
163            if end > start {
164                segs.push(DrawSegment {
165                    start,
166                    end,
167                    scissor: sc,
168                });
169            }
170        };
171
172        for cmd in list.iter() {
173            match cmd {
174                DrawCommand::PushClip { rect } => {
175                    flush(
176                        &mut segments,
177                        segment_start,
178                        verts.len() as u32,
179                        current_scissor,
180                    );
181                    stack.push(ClipRect::new(
182                        rect.left(),
183                        rect.top(),
184                        rect.width(),
185                        rect.height(),
186                    ));
187                    current_scissor = self.scissor_from_stack(&stack);
188                    segment_start = verts.len() as u32;
189                }
190                DrawCommand::PopClip => {
191                    flush(
192                        &mut segments,
193                        segment_start,
194                        verts.len() as u32,
195                        current_scissor,
196                    );
197                    stack.pop();
198                    current_scissor = self.scissor_from_stack(&stack);
199                    segment_start = verts.len() as u32;
200                }
201                DrawCommand::FillRect { rect, color } => {
202                    push_rect_quad(
203                        &mut verts,
204                        rect.left(),
205                        rect.top(),
206                        rect.width(),
207                        rect.height(),
208                        *color,
209                    );
210                }
211                DrawCommand::StrokeRect {
212                    rect,
213                    thickness,
214                    color,
215                } => {
216                    emit_stroke_rect(
217                        &mut verts,
218                        rect.left(),
219                        rect.top(),
220                        rect.width(),
221                        rect.height(),
222                        *thickness,
223                        *color,
224                    );
225                }
226                DrawCommand::FillRoundedRect {
227                    rect,
228                    radius,
229                    color,
230                } => {
231                    push_rounded_rect_quad(
232                        &mut verts,
233                        rect.left(),
234                        rect.top(),
235                        rect.width(),
236                        rect.height(),
237                        *radius,
238                        *color,
239                    );
240                }
241                DrawCommand::FillRoundedRectPerCorner { rect, radii, color } => {
242                    push_rounded_rect_per_corner_quad(
243                        &mut verts,
244                        rect.left(),
245                        rect.top(),
246                        rect.width(),
247                        rect.height(),
248                        *radii,
249                        *color,
250                    );
251                }
252                DrawCommand::FillCircle {
253                    center,
254                    radius,
255                    color,
256                } => {
257                    push_circle_quad(&mut verts, center.x, center.y, *radius, *color);
258                }
259                DrawCommand::FillEllipse {
260                    center,
261                    rx,
262                    ry,
263                    color,
264                } => {
265                    push_ellipse_quad(&mut verts, center.x, center.y, *rx, *ry, *color);
266                }
267                DrawCommand::Line { from, to, color } => {
268                    push_line_quad(
269                        &mut verts,
270                        LineQuadParams {
271                            from_x: from.x,
272                            from_y: from.y,
273                            to_x: to.x,
274                            to_y: to.y,
275                            half_width: 0.5,
276                            color: *color,
277                            aa_smooth: false,
278                        },
279                    );
280                }
281                DrawCommand::LineAa { from, to, color } => {
282                    push_line_quad(
283                        &mut verts,
284                        LineQuadParams {
285                            from_x: from.x,
286                            from_y: from.y,
287                            to_x: to.x,
288                            to_y: to.y,
289                            half_width: 0.5,
290                            color: *color,
291                            aa_smooth: true,
292                        },
293                    );
294                }
295                DrawCommand::LineThick {
296                    from,
297                    to,
298                    width,
299                    color,
300                } => {
301                    push_line_quad(
302                        &mut verts,
303                        LineQuadParams {
304                            from_x: from.x,
305                            from_y: from.y,
306                            to_x: to.x,
307                            to_y: to.y,
308                            half_width: width * 0.5,
309                            color: *color,
310                            aa_smooth: true,
311                        },
312                    );
313                }
314                DrawCommand::LineDashed {
315                    from,
316                    to,
317                    dash_len,
318                    gap_len,
319                    color,
320                } => {
321                    emit_dashed_line(
322                        &mut verts,
323                        DashedLineParams {
324                            x0: from.x,
325                            y0: from.y,
326                            x1: to.x,
327                            y1: to.y,
328                            dash_len: *dash_len,
329                            gap_len: *gap_len,
330                            color: *color,
331                        },
332                    );
333                }
334                DrawCommand::FillPath { path, color } => {
335                    tessellate_fill(&mut verts, path, *color);
336                }
337                DrawCommand::StrokePath { path, style, color } => {
338                    tessellate_stroke(&mut verts, path, style, *color);
339                }
340                DrawCommand::LinearGradient {
341                    rect,
342                    start,
343                    end,
344                    stops,
345                } => {
346                    if let Some(gd) = build_gradient_draw_linear(LinearGradientParams {
347                        x: rect.left(),
348                        y: rect.top(),
349                        w: rect.width(),
350                        h: rect.height(),
351                        sx: start.x,
352                        sy: start.y,
353                        ex: end.x,
354                        ey: end.y,
355                        stops,
356                        scissor: current_scissor,
357                    }) {
358                        gradient_draws.push(gd);
359                    }
360                }
361                DrawCommand::RadialGradient {
362                    rect,
363                    center,
364                    radius,
365                    stops,
366                } => {
367                    if let Some(gd) = build_gradient_draw_radial(RadialGradientParams {
368                        x: rect.left(),
369                        y: rect.top(),
370                        w: rect.width(),
371                        h: rect.height(),
372                        cx: center.x,
373                        cy: center.y,
374                        radius: *radius,
375                        stops,
376                        scissor: current_scissor,
377                    }) {
378                        gradient_draws.push(gd);
379                    }
380                }
381                // Image, NineSlice, BoxShadow, DrawText: deferred (require
382                // texture-atlas / blur pipeline — out of scope for this slice).
383                _ => {}
384            }
385        }
386
387        flush(
388            &mut segments,
389            segment_start,
390            verts.len() as u32,
391            current_scissor,
392        );
393        (verts, segments, gradient_draws)
394    }
395
396    /// Read the offscreen colour target back into a tightly packed
397    /// `width * height * 4` RGBA byte vector (row padding stripped).
398    ///
399    /// # Errors
400    ///
401    /// Returns [`UiError::Render`] if the GPU poll or buffer mapping fails.
402    pub fn readback_rgba(&self) -> Result<Vec<u8>, UiError> {
403        let width = self.ctx.width;
404        let height = self.ctx.height;
405        let unpadded_bytes_per_row = width * 4;
406        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
407        let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
408        let buffer_size = (padded_bytes_per_row * height) as wgpu::BufferAddress;
409
410        let readback = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
411            label: Some("oxiui-render-wgpu readback"),
412            size: buffer_size,
413            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
414            mapped_at_creation: false,
415        });
416
417        let mut encoder = self
418            .ctx
419            .device
420            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
421                label: Some("oxiui-render-wgpu readback encoder"),
422            });
423
424        encoder.copy_texture_to_buffer(
425            wgpu::TexelCopyTextureInfo {
426                texture: &self.ctx.color_texture,
427                mip_level: 0,
428                origin: wgpu::Origin3d::ZERO,
429                aspect: wgpu::TextureAspect::All,
430            },
431            wgpu::TexelCopyBufferInfo {
432                buffer: &readback,
433                layout: wgpu::TexelCopyBufferLayout {
434                    offset: 0,
435                    bytes_per_row: Some(padded_bytes_per_row),
436                    rows_per_image: Some(height),
437                },
438            },
439            wgpu::Extent3d {
440                width,
441                height,
442                depth_or_array_layers: 1,
443            },
444        );
445
446        self.ctx.queue.submit(Some(encoder.finish()));
447
448        let slice = readback.slice(..);
449        slice.map_async(wgpu::MapMode::Read, |_| {});
450        self.ctx
451            .device
452            .poll(wgpu::PollType::wait_indefinitely())
453            .map_err(|e| UiError::Render(format!("GPU poll failed during readback: {e:?}")))?;
454
455        let data = slice.get_mapped_range();
456
457        let mut out = Vec::with_capacity((unpadded_bytes_per_row * height) as usize);
458        for row in 0..height {
459            let start = (row * padded_bytes_per_row) as usize;
460            let end = start + unpadded_bytes_per_row as usize;
461            out.extend_from_slice(&data[start..end]);
462        }
463
464        drop(data);
465        readback.unmap();
466        Ok(out)
467    }
468
469    /// Read back a single pixel as `(r, g, b, a)`, or `None` if out of bounds.
470    pub fn read_pixel(&self, x: u32, y: u32) -> Result<Option<(u8, u8, u8, u8)>, UiError> {
471        if x >= self.ctx.width || y >= self.ctx.height {
472            return Ok(None);
473        }
474        let buf = self.readback_rgba()?;
475        let idx = ((y * self.ctx.width + x) * 4) as usize;
476        Ok(Some((buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3])))
477    }
478}
479
480// ── RenderBackend impl ────────────────────────────────────────────────────────
481
482impl RenderBackend for WgpuBackend {
483    fn surface_size(&self) -> Size {
484        Size::new(self.ctx.width as f32, self.ctx.height as f32)
485    }
486
487    fn supports_gradients(&self) -> bool {
488        true
489    }
490
491    fn supports_paths(&self) -> bool {
492        true
493    }
494
495    fn execute(&mut self, list: &DrawList) -> Result<(), UiError> {
496        let globals = Globals::new(self.ctx.width, self.ctx.height);
497        self.ctx
498            .queue
499            .write_buffer(&self.globals_buffer, 0, bytemuck::bytes_of(&globals));
500
501        let (verts, segments, gradient_draws) = self.build_geometry(list);
502
503        let clear = self.clear_color;
504        let clear_value = wgpu::Color {
505            r: clear.0 as f64 / 255.0,
506            g: clear.1 as f64 / 255.0,
507            b: clear.2 as f64 / 255.0,
508            a: clear.3 as f64 / 255.0,
509        };
510
511        let vertex_buffer = if verts.is_empty() {
512            None
513        } else {
514            Some(
515                self.ctx
516                    .device
517                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
518                        label: Some("oxiui-render-wgpu solid verts"),
519                        contents: bytemuck::cast_slice(&verts),
520                        usage: wgpu::BufferUsages::VERTEX,
521                    }),
522            )
523        };
524
525        let mut encoder = self
526            .ctx
527            .device
528            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
529                label: Some("oxiui-render-wgpu frame encoder"),
530            });
531
532        // ── Solid pass ────────────────────────────────────────────────────────
533        {
534            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
535                label: Some("oxiui-render-wgpu solid pass"),
536                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
537                    view: &self.ctx.color_view,
538                    depth_slice: None,
539                    resolve_target: None,
540                    ops: wgpu::Operations {
541                        load: wgpu::LoadOp::Clear(clear_value),
542                        store: wgpu::StoreOp::Store,
543                    },
544                })],
545                depth_stencil_attachment: None,
546                timestamp_writes: None,
547                occlusion_query_set: None,
548                multiview_mask: None,
549            });
550
551            if let Some(ref vb) = vertex_buffer {
552                pass.set_pipeline(&self.pipeline.pipeline);
553                pass.set_bind_group(0, &self.globals_bind_group, &[]);
554                pass.set_vertex_buffer(0, vb.slice(..));
555
556                for seg in &segments {
557                    match seg.scissor {
558                        Some([_, _, 0, _]) | Some([_, _, _, 0]) => continue,
559                        Some([x, y, w, h]) => pass.set_scissor_rect(x, y, w, h),
560                        None => pass.set_scissor_rect(0, 0, self.ctx.width, self.ctx.height),
561                    }
562                    pass.draw(seg.start..seg.end, 0..1);
563                }
564            }
565        }
566
567        // ── Gradient pass (one render pass per gradient draw) ─────────────────
568        for gd in &gradient_draws {
569            if gd.verts.is_empty() {
570                continue;
571            }
572
573            let grad_vb = self
574                .ctx
575                .device
576                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
577                    label: Some("oxiui-render-wgpu gradient verts"),
578                    contents: bytemuck::cast_slice(&gd.verts),
579                    usage: wgpu::BufferUsages::VERTEX,
580                });
581
582            let grad_ub = self
583                .ctx
584                .device
585                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
586                    label: Some("oxiui-render-wgpu gradient uniforms"),
587                    contents: bytemuck::bytes_of(&gd.uniforms),
588                    usage: wgpu::BufferUsages::UNIFORM,
589                });
590
591            let grad_bg = self
592                .ctx
593                .device
594                .create_bind_group(&wgpu::BindGroupDescriptor {
595                    label: Some("oxiui-render-wgpu gradient bg"),
596                    layout: &self.gradient_pipeline.bind_group_layout,
597                    entries: &[
598                        wgpu::BindGroupEntry {
599                            binding: 0,
600                            resource: self.globals_buffer.as_entire_binding(),
601                        },
602                        wgpu::BindGroupEntry {
603                            binding: 1,
604                            resource: grad_ub.as_entire_binding(),
605                        },
606                    ],
607                });
608
609            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
610                label: Some("oxiui-render-wgpu gradient pass"),
611                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
612                    view: &self.ctx.color_view,
613                    depth_slice: None,
614                    resolve_target: None,
615                    ops: wgpu::Operations {
616                        load: wgpu::LoadOp::Load,
617                        store: wgpu::StoreOp::Store,
618                    },
619                })],
620                depth_stencil_attachment: None,
621                timestamp_writes: None,
622                occlusion_query_set: None,
623                multiview_mask: None,
624            });
625
626            pass.set_pipeline(&self.gradient_pipeline.pipeline);
627            pass.set_bind_group(0, &grad_bg, &[]);
628            pass.set_vertex_buffer(0, grad_vb.slice(..));
629
630            match gd.scissor {
631                Some([_, _, 0, _]) | Some([_, _, _, 0]) => continue,
632                Some([x, y, w, h]) => pass.set_scissor_rect(x, y, w, h),
633                None => pass.set_scissor_rect(0, 0, self.ctx.width, self.ctx.height),
634            }
635
636            pass.draw(0..gd.verts.len() as u32, 0..1);
637        }
638
639        self.ctx.queue.submit(Some(encoder.finish()));
640        Ok(())
641    }
642}
643
644// ── Geometry helpers ──────────────────────────────────────────────────────────
645
646fn emit_stroke_rect(out: &mut Vec<Vertex>, x: f32, y: f32, w: f32, h: f32, t: f32, color: Color) {
647    push_rect_quad(out, x, y, w, t, color);
648    push_rect_quad(out, x, y + h - t, w, t, color);
649    push_rect_quad(out, x, y + t, t, h - 2.0 * t, color);
650    push_rect_quad(out, x + w - t, y + t, t, h - 2.0 * t, color);
651}
652
653struct DashedLineParams {
654    x0: f32,
655    y0: f32,
656    x1: f32,
657    y1: f32,
658    dash_len: f32,
659    gap_len: f32,
660    color: Color,
661}
662
663fn emit_dashed_line(out: &mut Vec<Vertex>, p: DashedLineParams) {
664    let DashedLineParams {
665        x0,
666        y0,
667        x1,
668        y1,
669        dash_len,
670        gap_len,
671        color,
672    } = p;
673    let dx = x1 - x0;
674    let dy = y1 - y0;
675    let total = (dx * dx + dy * dy).sqrt();
676    if total < 1e-6 || dash_len <= 0.0 {
677        return;
678    }
679    let ux = dx / total;
680    let uy = dy / total;
681    let period = dash_len + gap_len.max(0.0);
682    if period < 1e-6 {
683        return;
684    }
685    let mut t = 0.0_f32;
686    while t < total {
687        let end = (t + dash_len).min(total);
688        push_line_quad(
689            out,
690            LineQuadParams {
691                from_x: x0 + ux * t,
692                from_y: y0 + uy * t,
693                to_x: x0 + ux * end,
694                to_y: y0 + uy * end,
695                half_width: 0.5,
696                color,
697                aa_smooth: false,
698            },
699        );
700        t += period;
701    }
702}
703
704struct LinearGradientParams<'a> {
705    x: f32,
706    y: f32,
707    w: f32,
708    h: f32,
709    sx: f32,
710    sy: f32,
711    ex: f32,
712    ey: f32,
713    stops: &'a [GradientStop],
714    scissor: Option<[u32; 4]>,
715}
716
717fn build_gradient_draw_linear(p: LinearGradientParams<'_>) -> Option<GradientDraw> {
718    let LinearGradientParams {
719        x,
720        y,
721        w,
722        h,
723        sx,
724        sy,
725        ex,
726        ey,
727        stops,
728        scissor,
729    } = p;
730    let uniforms = build_gradient_uniforms(0, [sx, sy], [ex, ey], 0.0, stops)?;
731    let mut verts = Vec::new();
732    push_gradient_quad(&mut verts, x, y, w, h);
733    Some(GradientDraw {
734        verts,
735        uniforms,
736        scissor,
737    })
738}
739
740struct RadialGradientParams<'a> {
741    x: f32,
742    y: f32,
743    w: f32,
744    h: f32,
745    cx: f32,
746    cy: f32,
747    radius: f32,
748    stops: &'a [GradientStop],
749    scissor: Option<[u32; 4]>,
750}
751
752fn build_gradient_draw_radial(p: RadialGradientParams<'_>) -> Option<GradientDraw> {
753    let RadialGradientParams {
754        x,
755        y,
756        w,
757        h,
758        cx,
759        cy,
760        radius,
761        stops,
762        scissor,
763    } = p;
764    let uniforms = build_gradient_uniforms(1, [cx, cy], [0.0, 0.0], radius, stops)?;
765    let mut verts = Vec::new();
766    push_gradient_quad(&mut verts, x, y, w, h);
767    Some(GradientDraw {
768        verts,
769        uniforms,
770        scissor,
771    })
772}
773
774fn build_gradient_uniforms(
775    gradient_type: u32,
776    p0: [f32; 2],
777    p1: [f32; 2],
778    radius: f32,
779    stops: &[GradientStop],
780) -> Option<GradientUniforms> {
781    if stops.is_empty() {
782        return None;
783    }
784    let count = stops.len().min(MAX_GRADIENT_STOPS);
785    let mut stop_offsets = [[0.0f32; 4]; MAX_GRADIENT_STOPS];
786    let mut stop_colors = [[0.0f32; 4]; MAX_GRADIENT_STOPS];
787    for (i, s) in stops.iter().take(count).enumerate() {
788        stop_offsets[i] = [s.offset, 0.0, 0.0, 0.0];
789        stop_colors[i] = [
790            s.color.0 as f32 / 255.0,
791            s.color.1 as f32 / 255.0,
792            s.color.2 as f32 / 255.0,
793            s.color.3 as f32 / 255.0,
794        ];
795    }
796    Some(GradientUniforms {
797        p0,
798        p1,
799        radius,
800        gradient_type,
801        stop_count: count as u32,
802        _pad: 0,
803        stop_offsets,
804        stop_colors,
805    })
806}
807
808// ── Tests ─────────────────────────────────────────────────────────────────────
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813    use oxiui_core::geometry::{Point, Rect};
814    use oxiui_core::paint::{DrawList, GradientStop, PathData, StrokeStyle};
815    use oxiui_core::Color;
816
817    fn try_backend(w: u32, h: u32) -> Option<WgpuBackend> {
818        WgpuBackend::headless(w, h).ok()
819    }
820
821    fn assert_visible(b: &WgpuBackend, x: u32, y: u32, label: &str) {
822        let px = b
823            .read_pixel(x, y)
824            .expect("read_pixel ok")
825            .expect("in bounds");
826        assert!(px.3 > 0, "{label}: pixel ({x},{y}) alpha=0, got {px:?}");
827    }
828
829    fn assert_transparent(b: &WgpuBackend, x: u32, y: u32, label: &str) {
830        let px = b
831            .read_pixel(x, y)
832            .expect("read_pixel ok")
833            .expect("in bounds");
834        assert!(
835            px.3 == 0,
836            "{label}: pixel ({x},{y}) expected transparent, got {px:?}"
837        );
838    }
839
840    #[test]
841    fn test_stroke_rect_renders() {
842        let Some(mut b) = try_backend(100, 100) else {
843            return;
844        };
845        let mut dl = DrawList::new();
846        dl.push(DrawCommand::StrokeRect {
847            rect: Rect::new(10.0, 10.0, 80.0, 80.0),
848            thickness: 4.0,
849            color: Color(255, 0, 0, 255),
850        });
851        b.execute(&dl).expect("execute ok");
852        assert_visible(&b, 12, 10, "stroke_rect top border");
853        assert_transparent(&b, 50, 50, "stroke_rect interior");
854    }
855
856    #[test]
857    fn test_fill_rounded_rect_renders() {
858        let Some(mut b) = try_backend(100, 100) else {
859            return;
860        };
861        let mut dl = DrawList::new();
862        dl.push(DrawCommand::FillRoundedRect {
863            rect: Rect::new(10.0, 10.0, 80.0, 80.0),
864            radius: 10.0,
865            color: Color(0, 200, 0, 255),
866        });
867        b.execute(&dl).expect("execute ok");
868        assert_visible(&b, 50, 50, "rrect centre");
869        assert_transparent(&b, 10, 10, "rrect corner tl");
870    }
871
872    #[test]
873    fn test_fill_rounded_rect_per_corner_renders() {
874        let Some(mut b) = try_backend(100, 100) else {
875            return;
876        };
877        let mut dl = DrawList::new();
878        dl.push(DrawCommand::FillRoundedRectPerCorner {
879            rect: Rect::new(10.0, 10.0, 80.0, 80.0),
880            radii: [15.0, 5.0, 15.0, 5.0],
881            color: Color(0, 100, 200, 255),
882        });
883        b.execute(&dl).expect("execute ok");
884        assert_visible(&b, 50, 50, "rrect-pc centre");
885    }
886
887    #[test]
888    fn test_fill_ellipse_renders() {
889        let Some(mut b) = try_backend(100, 100) else {
890            return;
891        };
892        let mut dl = DrawList::new();
893        dl.push(DrawCommand::FillEllipse {
894            center: Point::new(50.0, 50.0),
895            rx: 30.0,
896            ry: 20.0,
897            color: Color(200, 0, 200, 255),
898        });
899        b.execute(&dl).expect("execute ok");
900        assert_visible(&b, 50, 50, "ellipse centre");
901        assert_transparent(&b, 2, 2, "ellipse exterior");
902    }
903
904    #[test]
905    fn test_line_renders() {
906        let Some(mut b) = try_backend(100, 100) else {
907            return;
908        };
909        let mut dl = DrawList::new();
910        dl.push(DrawCommand::Line {
911            from: Point::new(10.0, 50.0),
912            to: Point::new(90.0, 50.0),
913            color: Color(255, 255, 0, 255),
914        });
915        b.execute(&dl).expect("execute ok");
916        assert_visible(&b, 50, 50, "line mid");
917    }
918
919    #[test]
920    fn test_fill_path_renders() {
921        let Some(mut b) = try_backend(100, 100) else {
922            return;
923        };
924        let mut path = PathData::new();
925        path.move_to(Point::new(20.0, 20.0));
926        path.line_to(Point::new(80.0, 20.0));
927        path.line_to(Point::new(50.0, 80.0));
928        path.close();
929        let mut dl = DrawList::new();
930        dl.push(DrawCommand::FillPath {
931            path,
932            color: Color(255, 0, 128, 255),
933        });
934        b.execute(&dl).expect("execute ok");
935        assert_visible(&b, 50, 40, "fill_path interior");
936        assert_transparent(&b, 2, 2, "fill_path exterior");
937    }
938
939    #[test]
940    fn test_stroke_path_renders() {
941        let Some(mut b) = try_backend(100, 100) else {
942            return;
943        };
944        let mut path = PathData::new();
945        path.move_to(Point::new(20.0, 50.0));
946        path.line_to(Point::new(80.0, 50.0));
947        let style = StrokeStyle {
948            width: 4.0,
949            ..Default::default()
950        };
951        let mut dl = DrawList::new();
952        dl.push(DrawCommand::StrokePath {
953            path,
954            style,
955            color: Color(200, 200, 0, 255),
956        });
957        b.execute(&dl).expect("execute ok");
958        assert_visible(&b, 50, 50, "stroke_path mid");
959    }
960
961    #[test]
962    fn test_linear_gradient_renders() {
963        let Some(mut b) = try_backend(100, 100) else {
964            return;
965        };
966        let stops = vec![
967            GradientStop::new(0.0, Color(255, 0, 0, 255)),
968            GradientStop::new(1.0, Color(0, 0, 255, 255)),
969        ];
970        let mut dl = DrawList::new();
971        dl.push(DrawCommand::LinearGradient {
972            rect: Rect::new(0.0, 0.0, 100.0, 100.0),
973            start: Point::new(0.0, 50.0),
974            end: Point::new(100.0, 50.0),
975            stops,
976        });
977        b.execute(&dl).expect("execute ok");
978        let left = b.read_pixel(5, 50).expect("ok").expect("bounds");
979        assert!(left.0 > 128, "left reddish: {left:?}");
980        let right = b.read_pixel(95, 50).expect("ok").expect("bounds");
981        assert!(right.2 > 128, "right bluish: {right:?}");
982        let mid = b.read_pixel(50, 50).expect("ok").expect("bounds");
983        assert!(mid.3 > 0, "mid visible: {mid:?}");
984    }
985
986    #[test]
987    fn test_radial_gradient_renders() {
988        let Some(mut b) = try_backend(100, 100) else {
989            return;
990        };
991        let stops = vec![
992            GradientStop::new(0.0, Color(255, 255, 255, 255)),
993            GradientStop::new(1.0, Color(0, 0, 0, 255)),
994        ];
995        let mut dl = DrawList::new();
996        dl.push(DrawCommand::RadialGradient {
997            rect: Rect::new(0.0, 0.0, 100.0, 100.0),
998            center: Point::new(50.0, 50.0),
999            radius: 40.0,
1000            stops,
1001        });
1002        b.execute(&dl).expect("execute ok");
1003        let centre = b.read_pixel(50, 50).expect("ok").expect("bounds");
1004        assert!(centre.0 > 200, "centre bright: {centre:?}");
1005        let edge = b.read_pixel(90, 50).expect("ok").expect("bounds");
1006        assert!(
1007            edge.0 < centre.0,
1008            "edge darker: edge={edge:?} centre={centre:?}"
1009        );
1010    }
1011
1012    #[test]
1013    fn test_supports_probes() {
1014        let Some(b) = try_backend(64, 64) else {
1015            return;
1016        };
1017        assert!(b.supports_gradients());
1018        assert!(b.supports_paths());
1019    }
1020}