impeller_hal/batch.rs
1//! Draws accumulated for one submission.
2//!
3//! A batch is a declarative description of a scene: shared geometry plus a
4//! list of draws over it. Backends are handed the whole thing rather than a
5//! stream of recording calls, which lets each decide how to realize it — a
6//! Vulkan backend binds pipelines only where they change, and a record-and-
7//! replay backend can inspect the whole batch before touching any state.
8
9use crate::material::ColorFilter;
10use crate::{BlendMode, Error, Material, Result, Scissor};
11
12/// What a draw does with the stencil buffer.
13///
14/// # Why the stencil holds a depth rather than a mask
15///
16/// The obvious encoding gives each clip a bit, which caps nesting at eight and
17/// makes intersecting two clips a per-bit affair. Storing the *nesting depth*
18/// instead lets a clip stack of any size fit in the same eight bits, and makes
19/// the test a single comparison: content belongs to depth `d` and draws where
20/// the stencil holds `d`, which is true only where every clip down to that
21/// depth admitted the pixel.
22///
23/// It also makes undoing a clip a local operation. Because a stack unwinds in
24/// the order it was built, no pixel can hold more than the depth being left, so
25/// stepping back is a decrement rather than a recomputation from the remaining
26/// clips.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
28pub enum ClipRole {
29 /// Draw color where the stencil already matches. Leaves the stencil alone.
30 #[default]
31 Content,
32 /// Narrow the clip: step the stencil forward where it matches and this draw
33 /// covers. Writes no color.
34 ///
35 /// The geometry must be a triangulation of the clip region rather than an
36 /// overlapping set, since a pixel covered twice would step forward twice
37 /// and stop matching anything. The fill tessellator produces exactly that,
38 /// which is what lets this be a plain increment instead of the parity trick
39 /// an overlapping fan would need.
40 Narrow,
41 /// Widen the clip back: step the stencil back where it matches. Writes no
42 /// color.
43 Widen,
44}
45
46impl ClipRole {
47 /// Whether this role writes to the color attachment.
48 pub const fn writes_color(self) -> bool {
49 matches!(self, Self::Content)
50 }
51
52 /// Whether this role modifies the stencil.
53 pub const fn writes_stencil(self) -> bool {
54 !matches!(self, Self::Content)
55 }
56}
57
58/// The stencil state one draw needs.
59///
60/// `reference` is what the stencil is compared against, stated directly rather
61/// than derived from a nesting depth, so the HAL needs no notion of a clip
62/// stack: a narrowing draw compares against the depth it is leaving and a
63/// widening draw against the one it is leaving behind, and which is which is
64/// the recorder's business.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
66pub struct ClipState {
67 pub reference: u32,
68 pub role: ClipRole,
69}
70
71impl ClipState {
72 /// Content outside any clip, which needs no stencil at all.
73 pub const UNCLIPPED: Self = Self {
74 reference: 0,
75 role: ClipRole::Content,
76 };
77
78 pub const fn content(reference: u32) -> Self {
79 Self {
80 reference,
81 role: ClipRole::Content,
82 }
83 }
84
85 pub const fn narrow(from: u32) -> Self {
86 Self {
87 reference: from,
88 role: ClipRole::Narrow,
89 }
90 }
91
92 pub const fn widen(from: u32) -> Self {
93 Self {
94 reference: from,
95 role: ClipRole::Widen,
96 }
97 }
98
99 /// Whether this needs a stencil attachment to mean anything.
100 pub const fn needs_stencil(self) -> bool {
101 self.reference != 0 || self.role.writes_stencil()
102 }
103}
104
105/// One vertex: where it is, and where it reads from.
106///
107/// # Why every vertex carries texture coordinates
108///
109/// Most geometry here does not need them — a solid fill and a gradient both
110/// locate themselves from the interpolated clip position. A glyph run does: a
111/// run is many quads reading different parts of one atlas, and a material is
112/// per draw, so coordinates carried in the paint would mean a draw per glyph.
113/// Text is the highest draw-count content there is, so that is the wrong place
114/// to spend.
115///
116/// The cost is eight bytes on every vertex, including the ones that ignore
117/// them. The alternative — a second vertex format and a second pipeline for
118/// text — spends more in pipeline state and in the code that has to decide
119/// which of two shapes a batch is in, to save memory on the geometry that is
120/// already the cheapest to store.
121#[derive(Debug, Clone, Copy, PartialEq, Default)]
122#[repr(C)]
123pub struct Vertex {
124 /// Homogeneous clip position: the point as the recorder produced it,
125 /// *before* the rasterizer divides.
126 ///
127 /// `w` is one for everything an affine transform placed, which is nearly
128 /// everything, and the third float is what lets a transform with
129 /// perspective say anything at all — there is no two-component form of a
130 /// point that has been divided by a quantity varying across the triangle.
131 ///
132 /// Carrying it undivided rather than dividing on the way here buys two
133 /// things beyond the mapping itself. The rasterizer clips against the plane
134 /// where `w` reaches zero, so geometry crossing the vanishing line is cut
135 /// there by the hardware instead of arriving as coordinates on both sides
136 /// of infinity. And every varying beside this one — texture coordinates
137 /// most of all — is then interpolated perspective-correctly, which is the
138 /// difference between a textured quad seen at an angle and the diagonal
139 /// seam that affine interpolation puts across it.
140 pub position: [f32; 3],
141 /// Where in a sampled texture this vertex reads, if the material samples
142 /// one. Zero where it does not, which costs nothing to interpolate.
143 pub uv: [f32; 2],
144 /// A color multiplied into whatever the material produced, **premultiplied**.
145 ///
146 /// Opaque white for everything but a mesh a caller colored, and white is
147 /// the identity, so a fill pays for this in bandwidth rather than in a
148 /// second path. Sixteen bytes per vertex: at fifty thousand vertices a
149 /// frame, which is a great deal of two-dimensional geometry, that is under
150 /// fifty megabytes a second against a tiler already spending ten times
151 /// that on the framebuffer alone. A second vertex layout and a second
152 /// pipeline would save it and cost a permanent split in the batch model,
153 /// which is the wrong trade at this magnitude.
154 ///
155 /// Premultiplied rather than straight because it is interpolated across a
156 /// triangle, and interpolating straight color between vertices whose alpha
157 /// differs gives a color no point on the edge actually has.
158 pub color: [f32; 4],
159}
160
161impl Vertex {
162 pub const fn new(position: [f32; 2], uv: [f32; 2]) -> Self {
163 Self::projected([position[0], position[1], 1.0], uv)
164 }
165
166 /// A vertex that samples nothing.
167 pub const fn at(position: [f32; 2]) -> Self {
168 Self::new(position, [0.0, 0.0])
169 }
170
171 /// A vertex whose position is already homogeneous.
172 ///
173 /// The form a transform carrying perspective produces. [`Self::new`] is
174 /// this with a `w` of one, which is what an affine always gives, and is why
175 /// the ordinary constructors did not have to change when the third float
176 /// arrived.
177 pub const fn projected(position: [f32; 3], uv: [f32; 2]) -> Self {
178 Self {
179 position,
180 uv,
181 color: WHITE,
182 }
183 }
184
185 /// A homogeneous vertex that samples nothing.
186 pub const fn at_projected(position: [f32; 3]) -> Self {
187 Self::projected(position, [0.0, 0.0])
188 }
189
190 /// The same vertex, tinted.
191 ///
192 /// `color` is premultiplied; see [`Self::color`].
193 pub const fn with_color(mut self, color: [f32; 4]) -> Self {
194 self.color = color;
195 self
196 }
197}
198
199/// The color that changes nothing when multiplied in.
200const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
201
202/// One draw within a batch.
203#[derive(Debug, Clone)]
204pub struct BatchDraw {
205 pub first_index: u32,
206 pub index_count: u32,
207 pub material: Material,
208 /// A function applied to the material's color before the blend.
209 ///
210 /// Beside the material rather than inside it, for the same reason the
211 /// blend mode is: it applies to every kind of material equally and belongs
212 /// to none of them. It is packed into the same uniform the material is,
213 /// because the shader reads one block per draw.
214 pub filter: ColorFilter,
215 pub blend: BlendMode,
216 /// The region of the target this draw may write to.
217 ///
218 /// `None` is the whole target. It is distinct from a rectangle that happens
219 /// to cover the target so a backend can tell "this draw was never clipped"
220 /// from "this draw's clip works out to everything", and skip the state
221 /// change in the first case without having to know the target's size.
222 ///
223 /// Independent of [`Self::stencil`], and both apply. An axis-aligned clip
224 /// stays here even where a stencil is already in play, because a scissor is
225 /// exact and costs nothing while a stencil pass costs a draw.
226 pub clip: Option<Scissor>,
227 /// What this draw does with the stencil buffer.
228 pub stencil: ClipState,
229 /// How a color the caller attached to a vertex or a sprite combines with
230 /// what the material produced.
231 ///
232 /// [`BlendMode::Modulate`] multiplies them, which is what every draw did
233 /// before this existed and is what a paint with no per-vertex color wants:
234 /// white is the identity under it. Distinct from [`Self::blend`], which is
235 /// how the result then reaches the target -- these two colors are both in
236 /// the shader, so this one needs no extension and every mode is available.
237 pub tint_blend: BlendMode,
238 /// Read the paint at this draw's texture coordinates rather than at the
239 /// position of the fragment.
240 ///
241 /// A property of the geometry rather than of the material, which is why it
242 /// is here: a mesh that states a coordinate per vertex has said where each
243 /// one sits in the paint's space, and there is nothing left to derive. An
244 /// image already worked this way and had its own material for it; this is
245 /// what lets a gradient or a caller's program do the same.
246 ///
247 /// False everywhere else, and it costs those draws nothing: the flag lands
248 /// in a slot no material that could set it uses, and the shader's select
249 /// is one instruction on a value it has already computed.
250 pub paint_at_texture_coords: bool,
251}
252
253impl BatchDraw {
254 /// The uniform block this draw's shader reads.
255 ///
256 /// The material and the filter are packed together because the shader
257 /// takes one block per draw, and separately here because they are separate
258 /// things: a filter applies to any material, and a material knows nothing
259 /// about being filtered.
260 /// `target` is the format this draw is about to be written into, which
261 /// only the backend knows: a recording is built without one, and the same
262 /// recording is drawn into an eight-bit surface and a float one. It decides
263 /// the dither, and nothing else here.
264 pub fn to_uniform(&self, target: crate::PixelFormat) -> [f32; crate::MATERIAL_FLOATS] {
265 let mut out = self.material.to_uniform();
266 self.filter.pack_into(&mut out);
267 out[crate::material::layout::FILTER_PARAMS + 1] = self.tint_blend.code();
268 if self.paint_at_texture_coords {
269 // `geometry.x`, which no gradient writes. See the field's own note
270 // and the `paint_space` comment in the shader.
271 out[crate::material::layout::GEOMETRY] = 1.0;
272 }
273 let dither = crate::material::layout::DITHER;
274 // Upstream's rate exactly: `kDitherRate` is 1/64 and is added to the
275 // premultiplied color whatever the target is. That is a single constant
276 // there because its values are encoded, so a quantization step is a
277 // flat 1/255 wherever it stands -- and now for the same reason it is a
278 // single constant here.
279 //
280 // Still zero for a target with no quantum to bridge. Half's precision
281 // is relative, so there is no step to straddle and upstream's constant
282 // would be noise added to a surface that had none.
283 out[dither] = if target.quantization_step() > 0.0 {
284 1.0 / 64.0
285 } else {
286 0.0
287 };
288 out
289 }
290}
291
292/// Geometry and paint for a sequence of draws sharing one target.
293///
294/// Draws are kept in submission order rather than sorted by pipeline. Sorting
295/// would cut pipeline binds, but 2D drawing is painter's-algorithm ordered:
296/// reordering two overlapping draws changes which one ends up on top. Deciding
297/// when a reorder is safe needs either overlap analysis or a depth buffer, and
298/// that belongs to the layer that knows what the draws represent.
299#[derive(Debug, Default, Clone)]
300pub struct Batch {
301 vertices: Vec<Vertex>,
302 indices: Vec<u32>,
303 draws: Vec<BatchDraw>,
304}
305
306impl Batch {
307 pub fn new() -> Self {
308 Self::default()
309 }
310
311 /// Append a draw covering the whole target.
312 ///
313 /// Indices are relative to `vertices` and are rebased onto the batch's
314 /// shared buffer, so a caller need not know what came before it.
315 pub fn push(
316 &mut self,
317 vertices: &[[f32; 2]],
318 indices: &[u32],
319 material: Material,
320 blend: BlendMode,
321 ) -> Result<()> {
322 self.push_clipped(vertices, indices, material, blend, None)
323 }
324
325 /// Append a draw confined to a region of the target.
326 ///
327 /// A separate entry point rather than an extra parameter on [`Self::push`]:
328 /// most draws are unclipped, and threading `None` through every call site
329 /// makes the ones that do carry a clip harder to pick out, not easier.
330 ///
331 /// An empty scissor drops the draw. Recording something that provably
332 /// writes no pixel would cost a pipeline bind and a draw call to produce
333 /// the same target, and a clip stack that has narrowed to nothing is a
334 /// normal state for a scrolled-away subtree rather than an error.
335 pub fn push_clipped(
336 &mut self,
337 vertices: &[[f32; 2]],
338 indices: &[u32],
339 material: Material,
340 blend: BlendMode,
341 clip: Option<Scissor>,
342 ) -> Result<()> {
343 self.push_with(
344 vertices,
345 indices,
346 material,
347 ColorFilter::None,
348 blend,
349 clip,
350 ClipState::UNCLIPPED,
351 )
352 }
353
354 /// Append a draw with an explicit stencil role.
355 ///
356 /// The general form the other two delegate to. A caller reaches for this
357 /// only when building or unwinding a clip, or when drawing content inside
358 /// one; everything else is confined by a scissor or not confined at all.
359 #[allow(clippy::too_many_arguments)]
360 pub fn push_with(
361 &mut self,
362 positions: &[[f32; 2]],
363 indices: &[u32],
364 material: Material,
365 filter: ColorFilter,
366 blend: BlendMode,
367 clip: Option<Scissor>,
368 stencil: ClipState,
369 ) -> Result<()> {
370 // Tessellated geometry has no texture coordinates of its own, and the
371 // materials it carries do not read them.
372 let vertices: Vec<Vertex> = positions.iter().copied().map(Vertex::at).collect();
373 self.push_mesh(&vertices, indices, material, filter, blend, clip, stencil)
374 }
375
376 /// Append a draw whose vertices carry texture coordinates.
377 ///
378 /// The form a glyph run takes: one draw over many quads, each reading a
379 /// different part of the same atlas.
380 #[allow(clippy::too_many_arguments)]
381 pub fn push_mesh(
382 &mut self,
383 vertices: &[Vertex],
384 indices: &[u32],
385 material: Material,
386 filter: ColorFilter,
387 blend: BlendMode,
388 clip: Option<Scissor>,
389 stencil: ClipState,
390 ) -> Result<()> {
391 self.push_mesh_tinted(
392 vertices,
393 indices,
394 material,
395 filter,
396 blend,
397 clip,
398 stencil,
399 BlendMode::Modulate,
400 false,
401 )
402 }
403
404 /// Append a mesh, saying how its vertex colors combine with the material.
405 ///
406 /// Separate from [`Self::push_mesh`] rather than an extra parameter on it,
407 /// for the reason [`Self::push_clipped`] is separate: the mode is
408 /// `Modulate` for everything that does not ask, white being the identity
409 /// under it, and threading a parameter through every call site to say so
410 /// would be noise at all of them and a decision at none.
411 #[allow(clippy::too_many_arguments)]
412 pub fn push_mesh_tinted(
413 &mut self,
414 vertices: &[Vertex],
415 indices: &[u32],
416 material: Material,
417 filter: ColorFilter,
418 blend: BlendMode,
419 clip: Option<Scissor>,
420 stencil: ClipState,
421 tint_blend: BlendMode,
422 paint_at_texture_coords: bool,
423 ) -> Result<()> {
424 if clip.is_some_and(Scissor::is_empty) {
425 return Ok(());
426 }
427 if indices.len() % 3 != 0 {
428 return Err(Error::Unsupported("index count is not a whole triangle"));
429 }
430 if let Some(&max) = indices.iter().max() {
431 if max as usize >= vertices.len() {
432 return Err(Error::Backend {
433 backend: "vulkan",
434 detail: format!(
435 "index {max} addresses past the {} vertices supplied",
436 vertices.len()
437 ),
438 });
439 }
440 }
441 if indices.is_empty() {
442 return Ok(());
443 }
444
445 let base = u32::try_from(self.vertices.len()).map_err(|_| Error::LimitExceeded {
446 what: "batch vertex count",
447 requested: self.vertices.len() as u64,
448 limit: u32::MAX as u64,
449 })?;
450 let first_index = self.indices.len() as u32;
451
452 self.vertices.extend_from_slice(vertices);
453 self.indices.extend(indices.iter().map(|i| i + base));
454
455 // A draw that differs from the one before it in nothing a backend can
456 // set is not a second draw. Its indices were just appended to the same
457 // buffer, so extending the previous range covers both, and the
458 // triangles are rasterized in the same order either way -- which is
459 // what makes this safe under painter's-algorithm ordering, where two
460 // overlapping shapes must not trade places.
461 //
462 // Adjacent only, never sorted. Reordering to create more of these is a
463 // different decision with a different safety argument, and this one
464 // needs none: the sequence is untouched.
465 if let Some(last) = self.draws.last_mut() {
466 if last.first_index + last.index_count == first_index
467 && last.material == material
468 && last.filter == filter
469 && last.blend == blend
470 && last.clip == clip
471 && last.stencil == stencil
472 && last.tint_blend == tint_blend
473 && last.paint_at_texture_coords == paint_at_texture_coords
474 {
475 last.index_count += indices.len() as u32;
476 return Ok(());
477 }
478 }
479
480 self.draws.push(BatchDraw {
481 filter,
482 first_index,
483 index_count: indices.len() as u32,
484 material,
485 blend,
486 clip,
487 stencil,
488 tint_blend,
489 paint_at_texture_coords,
490 });
491 Ok(())
492 }
493
494 /// Drop the contents but keep the allocations, for reuse next frame.
495 pub fn clear(&mut self) {
496 self.vertices.clear();
497 self.indices.clear();
498 self.draws.clear();
499 }
500
501 pub fn draw_count(&self) -> usize {
502 self.draws.len()
503 }
504
505 pub fn is_empty(&self) -> bool {
506 self.draws.is_empty()
507 }
508
509 /// Whether recording this needs a stencil attachment.
510 ///
511 /// Derived from the draws rather than declared alongside them, so a batch
512 /// cannot ask for a clip and forget to say it needs somewhere to put it.
513 /// Most batches clip nothing, and those pay for no attachment.
514 pub fn uses_stencil(&self) -> bool {
515 self.draws.iter().any(|draw| draw.stencil.needs_stencil())
516 }
517
518 /// The deepest clip stack an eight-bit stencil can distinguish.
519 ///
520 /// Eight bits is the only stencil depth every device is required to offer,
521 /// on either graphics API, so this is the portable limit rather than any
522 /// one device's.
523 pub const MAX_CLIP_DEPTH: u32 = 255;
524
525 /// Refuse a batch whose clip stack is deeper than a stencil can hold.
526 ///
527 /// Here rather than in each backend because the limit is a property of the
528 /// stencil format both are required to offer, and the failure it prevents
529 /// is one neither can detect afterwards: past the limit the value wraps or
530 /// saturates, and either way a later test for a depth that no longer fits
531 /// admits every pixel the clip was meant to exclude. Nothing about that
532 /// looks like an error -- it draws content the caller clipped away.
533 ///
534 /// It was in one backend and not the other, so the same recording was
535 /// refused on Vulkan and silently rendered wrong on GLES.
536 pub fn check_clip_depth(&self) -> Result<()> {
537 let depth = self.max_clip_depth();
538 if depth > Self::MAX_CLIP_DEPTH {
539 return Err(Error::LimitExceeded {
540 what: "clip nesting depth",
541 requested: depth as u64,
542 limit: Self::MAX_CLIP_DEPTH as u64,
543 });
544 }
545 Ok(())
546 }
547
548 /// The largest stencil value this batch can produce.
549 pub fn max_clip_depth(&self) -> u32 {
550 self.draws
551 .iter()
552 .map(|draw| match draw.stencil.role {
553 ClipRole::Narrow => draw.stencil.reference + 1,
554 _ => draw.stencil.reference,
555 })
556 .max()
557 .unwrap_or(0)
558 }
559
560 /// The texture slots this batch samples, in ascending order without
561 /// repeats.
562 ///
563 /// A backend uses this to size its bindings before recording, and to check
564 /// the table it was given covers what the draws ask for.
565 pub fn texture_slots(&self) -> Vec<u32> {
566 let mut slots: Vec<u32> = self
567 .draws
568 .iter()
569 .flat_map(|draw| draw.material.texture_slots())
570 .flatten()
571 .collect();
572 slots.sort_unstable();
573 slots.dedup();
574 slots
575 }
576
577 /// How many times a pipeline will be bound when this batch is recorded.
578 ///
579 /// Consecutive draws sharing a blend mode reuse the bound pipeline, so this
580 /// counts transitions rather than draws.
581 pub fn pipeline_binds(&self) -> usize {
582 let mut binds = 0;
583 let mut current: Option<BlendMode> = None;
584 for draw in &self.draws {
585 if current != Some(draw.blend) {
586 binds += 1;
587 current = Some(draw.blend);
588 }
589 }
590 binds
591 }
592}
593
594impl Batch {
595 /// Shared vertex buffer, positions in clip space.
596 /// Move every scissor into a target whose origin moved by `(dx, dy)`.
597 ///
598 /// For a layer whose target was narrowed after its draws were recorded.
599 /// The geometry is left alone -- it is in clip space and the pass's
600 /// viewport is what places it -- but a scissor is in target pixels, so it
601 /// is the one recorded thing the move does reach.
602 pub fn rebase_scissors(&mut self, dx: u32, dy: u32, extent: crate::Extent2D) {
603 for draw in &mut self.draws {
604 if let Some(clip) = draw.clip {
605 draw.clip = Some(clip.shifted(dx, dy, extent));
606 }
607 }
608 }
609
610 pub fn vertices(&self) -> &[Vertex] {
611 &self.vertices
612 }
613
614 /// Shared index buffer, already rebased onto [`Batch::vertices`].
615 pub fn indices(&self) -> &[u32] {
616 &self.indices
617 }
618
619 /// The draws, in submission order.
620 pub fn draws(&self) -> &[BatchDraw] {
621 &self.draws
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 const TRI: [[f32; 2]; 3] = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
630
631 #[test]
632 fn indices_are_rebased_onto_the_shared_buffer() {
633 let mut batch = Batch::new();
634 // Two colors, so the draws do not merge and the second one's own
635 // range is visible. What is being checked is the rebasing, which a
636 // merged pair would hide behind a single range covering both.
637 batch
638 .push(&TRI, &[0, 1, 2], Material::solid([1.0; 4]), BlendMode::Src)
639 .unwrap();
640 batch
641 .push(&TRI, &[0, 1, 2], Material::solid([0.5; 4]), BlendMode::Src)
642 .unwrap();
643
644 // The second draw's indices must point at its own vertices, not the
645 // first draw's, or both draws render the same triangle.
646 assert_eq!(batch.indices, vec![0, 1, 2, 3, 4, 5]);
647 assert_eq!(batch.vertices.len(), 6);
648 assert_eq!(batch.draws[1].first_index, 3);
649 assert_eq!(batch.draw_count(), 2);
650 }
651
652 #[test]
653 fn a_draw_that_differs_from_the_one_before_it_in_nothing_is_not_a_second_draw() {
654 let mut batch = Batch::new();
655 for _ in 0..4 {
656 batch
657 .push(&TRI, &[0, 1, 2], Material::solid([1.0; 4]), BlendMode::Src)
658 .unwrap();
659 }
660 assert_eq!(batch.draw_count(), 1, "four alike draws should be one");
661 // All four triangles are still there, and still in order: merging
662 // changes how many times a backend is asked to draw, not what it
663 // draws.
664 assert_eq!(batch.indices, vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
665 assert_eq!(batch.draws[0].index_count, 12);
666
667 // A change in any one thing a backend sets ends the run.
668 batch
669 .push(
670 &TRI,
671 &[0, 1, 2],
672 Material::solid([1.0; 4]),
673 BlendMode::SrcOver,
674 )
675 .unwrap();
676 assert_eq!(batch.draw_count(), 2);
677 }
678
679 #[test]
680 fn pipeline_binds_count_transitions_not_draws() {
681 let mut batch = Batch::new();
682 // A different color each time, so no two draws merge and the count
683 // this is about -- pipeline binds against draws -- stays a real
684 // distinction rather than one merging has already collapsed.
685 for (i, blend) in [
686 BlendMode::Src,
687 BlendMode::Src,
688 BlendMode::SrcOver,
689 BlendMode::SrcOver,
690 BlendMode::Src,
691 ]
692 .into_iter()
693 .enumerate()
694 {
695 let shade = i as f32 / 8.0;
696 batch
697 .push(&TRI, &[0, 1, 2], Material::solid([shade; 4]), blend)
698 .unwrap();
699 }
700 // Five draws, three runs of like pipelines.
701 assert_eq!(batch.draw_count(), 5);
702 assert_eq!(batch.pipeline_binds(), 3);
703 }
704
705 #[test]
706 fn an_empty_draw_adds_nothing() {
707 let mut batch = Batch::new();
708 batch
709 .push(&[], &[], Material::solid([1.0; 4]), BlendMode::Src)
710 .unwrap();
711 assert!(batch.is_empty());
712 assert_eq!(batch.draw_count(), 0);
713 }
714
715 #[test]
716 fn malformed_geometry_is_refused_where_it_is_pushed() {
717 let mut batch = Batch::new();
718 // Catching this at push means the caller learns which draw was wrong,
719 // rather than a whole batch failing later at submission.
720 assert!(batch
721 .push(
722 &[[0.0, 0.0]],
723 &[0, 1, 2],
724 Material::solid([1.0; 4]),
725 BlendMode::Src
726 )
727 .is_err());
728 assert!(batch
729 .push(
730 &[[0.0, 0.0]],
731 &[0, 0],
732 Material::solid([1.0; 4]),
733 BlendMode::Src
734 )
735 .is_err());
736 assert!(batch.is_empty(), "a refused draw must leave no residue");
737 }
738
739 #[test]
740 fn clearing_keeps_the_batch_reusable() {
741 let mut batch = Batch::new();
742 batch
743 .push(&TRI, &[0, 1, 2], Material::solid([1.0; 4]), BlendMode::Src)
744 .unwrap();
745 batch.clear();
746 assert!(batch.is_empty());
747
748 batch
749 .push(&TRI, &[0, 1, 2], Material::solid([1.0; 4]), BlendMode::Src)
750 .unwrap();
751 // Rebasing must start from zero again rather than continuing from the
752 // cleared contents.
753 assert_eq!(batch.indices, vec![0, 1, 2]);
754 }
755
756 /// Both backends describe this struct to their own API by asking it where
757 /// its fields are, so what they agree on is whatever this says. Pinning it
758 /// means a reordering shows up here, once, rather than as geometry that
759 /// reads its color out of its position on both backends identically.
760 #[test]
761 fn the_vertex_layout_is_what_both_backends_describe() {
762 use std::mem::{offset_of, size_of};
763 assert_eq!(size_of::<Vertex>(), 36);
764 assert_eq!(offset_of!(Vertex, position), 0);
765 assert_eq!(offset_of!(Vertex, uv), 12);
766 assert_eq!(offset_of!(Vertex, color), 20);
767 }
768
769 /// The property that let the third float arrive without touching a caller.
770 #[test]
771 fn an_ordinary_vertex_carries_a_w_of_one() {
772 assert_eq!(Vertex::at([3.0, 4.0]).position, [3.0, 4.0, 1.0]);
773 assert_eq!(
774 Vertex::new([3.0, 4.0], [0.5, 0.5]).position,
775 [3.0, 4.0, 1.0]
776 );
777 assert_eq!(
778 Vertex::at_projected([3.0, 4.0, 2.0]).position,
779 [3.0, 4.0, 2.0]
780 );
781 }
782}