Skip to main content

oxiui_render_wgpu/
batch.rs

1//! Draw-call batcher that groups [`DrawList`] commands by pipeline state.
2//!
3//! The batcher sorts draw commands by [`BatchKey`] (texture × pipeline ×
4//! blend-mode), merges adjacent runs with the same key into a single
5//! [`DrawBatch`], and optionally culls commands whose bounds fall entirely
6//! outside an active clip rectangle.
7//!
8//! The original relative order of commands *within* a batch is preserved
9//! (stable sort).
10
11use oxiui_core::geometry::Rect;
12use oxiui_core::paint::{DrawCommand, DrawList};
13
14// ── Pipeline / blend enumerations ─────────────────────────────────────────────
15
16/// The shader pipeline required to render a draw command.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
18pub enum PipelineKind {
19    /// Solid-colour fill or stroke.
20    SolidColor,
21    /// Textured blit.
22    Textured,
23    /// Gradient fill.
24    Gradient,
25    /// Arbitrary vector path.
26    Path,
27}
28
29/// The compositing mode applied when blending a draw command onto the target.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
31pub enum BlendMode {
32    /// Standard source-over alpha compositing.
33    Normal,
34    /// Multiply blend.
35    Multiply,
36    /// Screen blend.
37    Screen,
38    /// Overlay blend.
39    Overlay,
40}
41
42// ── BatchKey ──────────────────────────────────────────────────────────────────
43
44/// The minimal state that forces a draw-call boundary.
45///
46/// Commands that share the same `BatchKey` and are adjacent in the sorted
47/// order can be merged into a single [`DrawBatch`].
48#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
49pub struct BatchKey {
50    /// Optional texture ID (`None` for untextured commands).
51    pub texture_id: Option<u64>,
52    /// Required shader pipeline.
53    pub pipeline: PipelineKind,
54    /// Required blend mode.
55    pub blend: BlendMode,
56}
57
58// ── DrawBatch ─────────────────────────────────────────────────────────────────
59
60/// A contiguous run of draw commands that share the same [`BatchKey`].
61pub struct DrawBatch {
62    /// The shared pipeline / texture / blend state.
63    pub key: BatchKey,
64    /// Range of *original* command indices (before sorting) covered by this
65    /// batch.  The GPU consumer uses these to look up the actual commands.
66    pub command_range: std::ops::Range<usize>,
67    /// Number of draw instances in this batch.
68    pub instance_count: usize,
69}
70
71// ── PreparedFrame ─────────────────────────────────────────────────────────────
72
73/// The output of a single [`batch`] call.
74pub struct PreparedFrame {
75    /// Merged batches in sorted draw order.
76    pub batches: Vec<DrawBatch>,
77    /// Number of commands that were dropped by visibility culling.
78    pub culled_count: usize,
79}
80
81// ── Public batch() function ──────────────────────────────────────────────────
82
83/// Classify, cull, sort, and batch the commands in `list`.
84///
85/// ## Visibility culling
86///
87/// If `active_clip` is `Some([x, y, w, h])`, commands whose conservative
88/// bounding box does not intersect the clip rectangle are skipped and counted
89/// in [`PreparedFrame::culled_count`].  Commands that carry no bounds (e.g.
90/// `PopClip`) always pass culling.  Clip-stack management commands
91/// (`PushClip` / `PopClip`) are excluded from batching entirely.
92///
93/// ## Ordering guarantee
94///
95/// The sort is stable, so the relative submission order of commands that share
96/// the same [`BatchKey`] is preserved.
97pub fn batch(list: &DrawList, active_clip: Option<[f32; 4]>) -> PreparedFrame {
98    // Collect (original_index, command_ref) pairs for drawable commands only.
99    let mut drawable: Vec<(usize, &DrawCommand)> = list
100        .iter()
101        .enumerate()
102        .filter(|(_, cmd)| !is_clip_ctrl(cmd))
103        .collect();
104
105    // --- Visibility culling ---
106    let mut culled_count = 0usize;
107    if let Some(clip) = active_clip {
108        let clip_rect = clip_array_to_rect(clip);
109        drawable.retain(|(_, cmd)| {
110            match command_bounds(cmd) {
111                None => true, // no bounds → always keep
112                Some(bounds) => {
113                    if rects_intersect(bounds, clip_rect) {
114                        true
115                    } else {
116                        culled_count += 1;
117                        false
118                    }
119                }
120            }
121        });
122    }
123
124    // --- Stable sort by BatchKey ---
125    drawable.sort_by_key(|(_, cmd)| classify(cmd));
126
127    // --- Merge adjacent same-key runs ---
128    let mut batches: Vec<DrawBatch> = Vec::new();
129    let mut i = 0;
130    while i < drawable.len() {
131        let key = classify(drawable[i].1);
132        let orig_start = drawable[i].0;
133        let mut orig_end = orig_start + 1;
134        let run_start = i;
135        i += 1;
136        while i < drawable.len() && classify(drawable[i].1) == key {
137            orig_end = drawable[i].0 + 1;
138            i += 1;
139        }
140        let run_len = i - run_start;
141        batches.push(DrawBatch {
142            key,
143            command_range: orig_start..orig_end,
144            instance_count: run_len,
145        });
146    }
147
148    PreparedFrame {
149        batches,
150        culled_count,
151    }
152}
153
154// ── Private helpers ───────────────────────────────────────────────────────────
155
156/// Returns `true` for clip-stack management commands that are not batched.
157fn is_clip_ctrl(cmd: &DrawCommand) -> bool {
158    matches!(cmd, DrawCommand::PushClip { .. } | DrawCommand::PopClip)
159}
160
161/// Derive a [`BatchKey`] for a single drawable command.
162fn classify(cmd: &DrawCommand) -> BatchKey {
163    let (pipeline, texture_id) = match cmd {
164        DrawCommand::FillRect { .. }
165        | DrawCommand::StrokeRect { .. }
166        | DrawCommand::FillRoundedRect { .. }
167        | DrawCommand::FillRoundedRectPerCorner { .. }
168        | DrawCommand::FillCircle { .. }
169        | DrawCommand::FillEllipse { .. }
170        | DrawCommand::Line { .. }
171        | DrawCommand::LineAa { .. }
172        | DrawCommand::LineThick { .. }
173        | DrawCommand::LineDashed { .. }
174        | DrawCommand::BoxShadow { .. }
175        | DrawCommand::DrawText { .. } => (PipelineKind::SolidColor, None),
176
177        DrawCommand::Image { .. } | DrawCommand::NineSlice { .. } => (PipelineKind::Textured, None),
178
179        DrawCommand::LinearGradient { .. } | DrawCommand::RadialGradient { .. } => {
180            (PipelineKind::Gradient, None)
181        }
182
183        DrawCommand::FillPath { .. } | DrawCommand::StrokePath { .. } => (PipelineKind::Path, None),
184
185        // Clip commands are filtered out before calling classify; handle anyway.
186        _ => (PipelineKind::SolidColor, None),
187    };
188    BatchKey {
189        texture_id,
190        pipeline,
191        blend: BlendMode::Normal,
192    }
193}
194
195/// Conservative bounding rect for a single command.
196///
197/// Returns `None` for clip-stack commands (which carry no draw-space geometry).
198/// This is a local re-implementation because `DrawList::cmd_bounds` is private.
199fn command_bounds(cmd: &DrawCommand) -> Option<Rect> {
200    match cmd {
201        DrawCommand::FillRect { rect, .. }
202        | DrawCommand::StrokeRect { rect, .. }
203        | DrawCommand::FillRoundedRect { rect, .. }
204        | DrawCommand::FillRoundedRectPerCorner { rect, .. }
205        | DrawCommand::LinearGradient { rect, .. }
206        | DrawCommand::RadialGradient { rect, .. }
207        | DrawCommand::Image { dest: rect, .. }
208        | DrawCommand::NineSlice { dest: rect, .. }
209        | DrawCommand::DrawText { rect, .. } => Some(*rect),
210
211        DrawCommand::BoxShadow {
212            rect,
213            offset,
214            blur_radius,
215            ..
216        } => {
217            let pad = *blur_radius;
218            Some(Rect::new(
219                rect.left() + offset.x - pad,
220                rect.top() + offset.y - pad,
221                rect.width() + 2.0 * pad,
222                rect.height() + 2.0 * pad,
223            ))
224        }
225
226        DrawCommand::FillCircle { center, radius, .. } => Some(Rect::new(
227            center.x - radius,
228            center.y - radius,
229            radius * 2.0,
230            radius * 2.0,
231        )),
232
233        DrawCommand::FillEllipse { center, rx, ry, .. } => {
234            Some(Rect::new(center.x - rx, center.y - ry, rx * 2.0, ry * 2.0))
235        }
236
237        DrawCommand::Line { from, to, .. } | DrawCommand::LineAa { from, to, .. } => {
238            let x = from.x.min(to.x);
239            let y = from.y.min(to.y);
240            Some(Rect::new(
241                x,
242                y,
243                (from.x - to.x).abs(),
244                (from.y - to.y).abs(),
245            ))
246        }
247
248        DrawCommand::LineThick {
249            from, to, width, ..
250        } => {
251            let pad = width / 2.0;
252            Some(Rect::new(
253                from.x.min(to.x) - pad,
254                from.y.min(to.y) - pad,
255                (from.x - to.x).abs() + *width,
256                (from.y - to.y).abs() + *width,
257            ))
258        }
259
260        DrawCommand::LineDashed { from, to, .. } => {
261            let x = from.x.min(to.x);
262            let y = from.y.min(to.y);
263            Some(Rect::new(
264                x,
265                y,
266                (from.x - to.x).abs(),
267                (from.y - to.y).abs(),
268            ))
269        }
270
271        DrawCommand::FillPath { path, .. } => path.bounds(),
272
273        DrawCommand::StrokePath { path, style, .. } => path.bounds().map(|b| {
274            let pad = style.width / 2.0;
275            Rect::new(
276                b.left() - pad,
277                b.top() - pad,
278                b.width() + style.width,
279                b.height() + style.width,
280            )
281        }),
282
283        // Clip commands and unknown future variants have no bounds.
284        _ => None,
285    }
286}
287
288/// Convert the `[x, y, w, h]` clip array to a [`Rect`].
289fn clip_array_to_rect(clip: [f32; 4]) -> Rect {
290    Rect::new(clip[0], clip[1], clip[2], clip[3])
291}
292
293/// Half-open rectangle intersection test.
294fn rects_intersect(a: Rect, b: Rect) -> bool {
295    a.left() < b.right() && b.left() < a.right() && a.top() < b.bottom() && b.top() < a.bottom()
296}
297
298// ── Tests ─────────────────────────────────────────────────────────────────────
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use oxiui_core::paint::{DrawList, ImageData, ImageFilter};
304    use oxiui_core::{
305        geometry::{Point, Rect},
306        Color,
307    };
308
309    fn red() -> Color {
310        Color(255, 0, 0, 255)
311    }
312
313    fn list_with_n_rects(n: usize) -> DrawList {
314        let mut list = DrawList::new();
315        for i in 0..n {
316            list.push_rect(Rect::new(i as f32, 0.0, 1.0, 1.0), red());
317        }
318        list
319    }
320
321    #[test]
322    fn batcher_1000_rects_5_textures_le_5_batches() {
323        // 1000 solid-colour rects → all SolidColor pipeline → should merge
324        // into a single batch.
325        let list = list_with_n_rects(1000);
326        let frame = batch(&list, None);
327        // All solid-colour → 1 batch; image → adds 1 more only if we add one.
328        assert!(
329            frame.batches.len() <= 5,
330            "expected ≤5 batches, got {}",
331            frame.batches.len()
332        );
333    }
334
335    #[test]
336    fn batcher_preserves_relative_order_within_batch() {
337        // Two rects at x=0 and x=10: both SolidColor. After stable sort,
338        // their relative order within the batch must be preserved.
339        let mut list = DrawList::new();
340        list.push_rect(Rect::new(0.0, 0.0, 1.0, 1.0), Color(255, 0, 0, 255));
341        list.push_rect(Rect::new(10.0, 0.0, 1.0, 1.0), Color(0, 255, 0, 255));
342        let frame = batch(&list, None);
343        assert_eq!(frame.batches.len(), 1);
344        assert_eq!(frame.batches[0].instance_count, 2);
345        // command_range.start must be 0 (first command's original index).
346        assert_eq!(frame.batches[0].command_range.start, 0);
347    }
348
349    #[test]
350    fn batcher_visibility_culling_drops_offscreen() {
351        let mut list = DrawList::new();
352        // On-screen rect.
353        list.push_rect(Rect::new(0.0, 0.0, 10.0, 10.0), red());
354        // Off-screen rect (far right).
355        list.push_rect(Rect::new(500.0, 500.0, 10.0, 10.0), red());
356
357        let clip = [0.0_f32, 0.0, 100.0, 100.0];
358        let frame = batch(&list, Some(clip));
359        assert_eq!(frame.culled_count, 1, "off-screen rect must be culled");
360        // One batch with 1 instance (the on-screen rect).
361        let total_instances: usize = frame.batches.iter().map(|b| b.instance_count).sum();
362        assert_eq!(total_instances, 1);
363    }
364
365    #[test]
366    fn batcher_multiple_pipeline_kinds_produce_multiple_batches() {
367        let mut list = DrawList::new();
368        list.push_rect(Rect::new(0.0, 0.0, 10.0, 10.0), red());
369        list.push_gradient_linear(
370            Rect::new(10.0, 0.0, 10.0, 10.0),
371            Point::new(10.0, 0.0),
372            Point::new(20.0, 0.0),
373            vec![],
374        );
375        list.push_image(
376            ImageData::new(vec![0, 0, 0, 255], 1, 1),
377            Rect::new(20.0, 0.0, 10.0, 10.0),
378            ImageFilter::Nearest,
379        );
380        let frame = batch(&list, None);
381        // SolidColor, Gradient, Textured → 3 different pipelines.
382        assert_eq!(frame.batches.len(), 3);
383    }
384}