Skip to main content

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}