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 { .. } => (PipelineKind::SolidColor, None),
175
176        // When the `text` feature is enabled, DrawText is pre-expanded into
177        // per-glyph Image blits (textured quads) by TextBridge before the
178        // batcher is called; classify any residual DrawText as Textured so
179        // it doesn't merge with solid-colour geometry.
180        #[cfg(feature = "text")]
181        DrawCommand::DrawText { .. } => (PipelineKind::Textured, None),
182
183        // Without the `text` feature, DrawText falls back to solid-colour
184        // (the geometry builder will skip it with a no-op comment).
185        #[cfg(not(feature = "text"))]
186        DrawCommand::DrawText { .. } => (PipelineKind::SolidColor, None),
187
188        DrawCommand::Image { .. } | DrawCommand::NineSlice { .. } => (PipelineKind::Textured, None),
189
190        DrawCommand::LinearGradient { .. } | DrawCommand::RadialGradient { .. } => {
191            (PipelineKind::Gradient, None)
192        }
193
194        DrawCommand::FillPath { .. } | DrawCommand::StrokePath { .. } => (PipelineKind::Path, None),
195
196        // Clip commands are filtered out before calling classify; handle anyway.
197        _ => (PipelineKind::SolidColor, None),
198    };
199    BatchKey {
200        texture_id,
201        pipeline,
202        blend: BlendMode::Normal,
203    }
204}
205
206/// Conservative bounding rect for a single command.
207///
208/// Returns `None` for clip-stack commands (which carry no draw-space geometry).
209/// This is a local re-implementation because `DrawList::cmd_bounds` is private.
210fn command_bounds(cmd: &DrawCommand) -> Option<Rect> {
211    match cmd {
212        DrawCommand::FillRect { rect, .. }
213        | DrawCommand::StrokeRect { rect, .. }
214        | DrawCommand::FillRoundedRect { rect, .. }
215        | DrawCommand::FillRoundedRectPerCorner { rect, .. }
216        | DrawCommand::LinearGradient { rect, .. }
217        | DrawCommand::RadialGradient { rect, .. }
218        | DrawCommand::Image { dest: rect, .. }
219        | DrawCommand::NineSlice { dest: rect, .. }
220        | DrawCommand::DrawText { rect, .. } => Some(*rect),
221
222        DrawCommand::BoxShadow {
223            rect,
224            offset,
225            blur_radius,
226            ..
227        } => {
228            let pad = *blur_radius;
229            Some(Rect::new(
230                rect.left() + offset.x - pad,
231                rect.top() + offset.y - pad,
232                rect.width() + 2.0 * pad,
233                rect.height() + 2.0 * pad,
234            ))
235        }
236
237        DrawCommand::FillCircle { center, radius, .. } => Some(Rect::new(
238            center.x - radius,
239            center.y - radius,
240            radius * 2.0,
241            radius * 2.0,
242        )),
243
244        DrawCommand::FillEllipse { center, rx, ry, .. } => {
245            Some(Rect::new(center.x - rx, center.y - ry, rx * 2.0, ry * 2.0))
246        }
247
248        DrawCommand::Line { from, to, .. } | DrawCommand::LineAa { from, to, .. } => {
249            let x = from.x.min(to.x);
250            let y = from.y.min(to.y);
251            Some(Rect::new(
252                x,
253                y,
254                (from.x - to.x).abs(),
255                (from.y - to.y).abs(),
256            ))
257        }
258
259        DrawCommand::LineThick {
260            from, to, width, ..
261        } => {
262            let pad = width / 2.0;
263            Some(Rect::new(
264                from.x.min(to.x) - pad,
265                from.y.min(to.y) - pad,
266                (from.x - to.x).abs() + *width,
267                (from.y - to.y).abs() + *width,
268            ))
269        }
270
271        DrawCommand::LineDashed { from, to, .. } => {
272            let x = from.x.min(to.x);
273            let y = from.y.min(to.y);
274            Some(Rect::new(
275                x,
276                y,
277                (from.x - to.x).abs(),
278                (from.y - to.y).abs(),
279            ))
280        }
281
282        DrawCommand::FillPath { path, .. } => path.bounds(),
283
284        DrawCommand::StrokePath { path, style, .. } => path.bounds().map(|b| {
285            let pad = style.width / 2.0;
286            Rect::new(
287                b.left() - pad,
288                b.top() - pad,
289                b.width() + style.width,
290                b.height() + style.width,
291            )
292        }),
293
294        // Clip commands and unknown future variants have no bounds.
295        _ => None,
296    }
297}
298
299/// Convert the `[x, y, w, h]` clip array to a [`Rect`].
300fn clip_array_to_rect(clip: [f32; 4]) -> Rect {
301    Rect::new(clip[0], clip[1], clip[2], clip[3])
302}
303
304/// Half-open rectangle intersection test.
305fn rects_intersect(a: Rect, b: Rect) -> bool {
306    a.left() < b.right() && b.left() < a.right() && a.top() < b.bottom() && b.top() < a.bottom()
307}
308
309// ── Tests ─────────────────────────────────────────────────────────────────────
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use oxiui_core::paint::{DrawList, ImageData, ImageFilter};
315    use oxiui_core::{
316        geometry::{Point, Rect},
317        Color,
318    };
319
320    fn red() -> Color {
321        Color(255, 0, 0, 255)
322    }
323
324    fn list_with_n_rects(n: usize) -> DrawList {
325        let mut list = DrawList::new();
326        for i in 0..n {
327            list.push_rect(Rect::new(i as f32, 0.0, 1.0, 1.0), red());
328        }
329        list
330    }
331
332    #[test]
333    fn batcher_1000_rects_5_textures_le_5_batches() {
334        // 1000 solid-colour rects → all SolidColor pipeline → should merge
335        // into a single batch.
336        let list = list_with_n_rects(1000);
337        let frame = batch(&list, None);
338        // All solid-colour → 1 batch; image → adds 1 more only if we add one.
339        assert!(
340            frame.batches.len() <= 5,
341            "expected ≤5 batches, got {}",
342            frame.batches.len()
343        );
344    }
345
346    #[test]
347    fn batcher_preserves_relative_order_within_batch() {
348        // Two rects at x=0 and x=10: both SolidColor. After stable sort,
349        // their relative order within the batch must be preserved.
350        let mut list = DrawList::new();
351        list.push_rect(Rect::new(0.0, 0.0, 1.0, 1.0), Color(255, 0, 0, 255));
352        list.push_rect(Rect::new(10.0, 0.0, 1.0, 1.0), Color(0, 255, 0, 255));
353        let frame = batch(&list, None);
354        assert_eq!(frame.batches.len(), 1);
355        assert_eq!(frame.batches[0].instance_count, 2);
356        // command_range.start must be 0 (first command's original index).
357        assert_eq!(frame.batches[0].command_range.start, 0);
358    }
359
360    #[test]
361    fn batcher_visibility_culling_drops_offscreen() {
362        let mut list = DrawList::new();
363        // On-screen rect.
364        list.push_rect(Rect::new(0.0, 0.0, 10.0, 10.0), red());
365        // Off-screen rect (far right).
366        list.push_rect(Rect::new(500.0, 500.0, 10.0, 10.0), red());
367
368        let clip = [0.0_f32, 0.0, 100.0, 100.0];
369        let frame = batch(&list, Some(clip));
370        assert_eq!(frame.culled_count, 1, "off-screen rect must be culled");
371        // One batch with 1 instance (the on-screen rect).
372        let total_instances: usize = frame.batches.iter().map(|b| b.instance_count).sum();
373        assert_eq!(total_instances, 1);
374    }
375
376    #[test]
377    fn batcher_multiple_pipeline_kinds_produce_multiple_batches() {
378        let mut list = DrawList::new();
379        list.push_rect(Rect::new(0.0, 0.0, 10.0, 10.0), red());
380        list.push_gradient_linear(
381            Rect::new(10.0, 0.0, 10.0, 10.0),
382            Point::new(10.0, 0.0),
383            Point::new(20.0, 0.0),
384            vec![],
385        );
386        list.push_image(
387            ImageData::new(vec![0, 0, 0, 255], 1, 1),
388            Rect::new(20.0, 0.0, 10.0, 10.0),
389            ImageFilter::Nearest,
390        );
391        let frame = batch(&list, None);
392        // SolidColor, Gradient, Textured → 3 different pipelines.
393        assert_eq!(frame.batches.len(), 3);
394    }
395}