1use oxiui_core::geometry::Rect;
12use oxiui_core::paint::{DrawCommand, DrawList};
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
18pub enum PipelineKind {
19 SolidColor,
21 Textured,
23 Gradient,
25 Path,
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
31pub enum BlendMode {
32 Normal,
34 Multiply,
36 Screen,
38 Overlay,
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
49pub struct BatchKey {
50 pub texture_id: Option<u64>,
52 pub pipeline: PipelineKind,
54 pub blend: BlendMode,
56}
57
58pub struct DrawBatch {
62 pub key: BatchKey,
64 pub command_range: std::ops::Range<usize>,
67 pub instance_count: usize,
69}
70
71pub struct PreparedFrame {
75 pub batches: Vec<DrawBatch>,
77 pub culled_count: usize,
79}
80
81pub fn batch(list: &DrawList, active_clip: Option<[f32; 4]>) -> PreparedFrame {
98 let mut drawable: Vec<(usize, &DrawCommand)> = list
100 .iter()
101 .enumerate()
102 .filter(|(_, cmd)| !is_clip_ctrl(cmd))
103 .collect();
104
105 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, 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 drawable.sort_by_key(|(_, cmd)| classify(cmd));
126
127 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
154fn is_clip_ctrl(cmd: &DrawCommand) -> bool {
158 matches!(cmd, DrawCommand::PushClip { .. } | DrawCommand::PopClip)
159}
160
161fn 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 #[cfg(feature = "text")]
181 DrawCommand::DrawText { .. } => (PipelineKind::Textured, None),
182
183 #[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 _ => (PipelineKind::SolidColor, None),
198 };
199 BatchKey {
200 texture_id,
201 pipeline,
202 blend: BlendMode::Normal,
203 }
204}
205
206fn 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 _ => None,
296 }
297}
298
299fn clip_array_to_rect(clip: [f32; 4]) -> Rect {
301 Rect::new(clip[0], clip[1], clip[2], clip[3])
302}
303
304fn 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#[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 let list = list_with_n_rects(1000);
337 let frame = batch(&list, None);
338 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 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 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 list.push_rect(Rect::new(0.0, 0.0, 10.0, 10.0), red());
365 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 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 assert_eq!(frame.batches.len(), 3);
394 }
395}