Skip to main content

emblema_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, Extent2D, 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    /// Whether this draw may be moved ahead of earlier draws it covers.
255    ///
256    /// A draw that answers yes replaces every sample it touches, so nothing
257    /// underneath it can show through and the painter's-order guarantee this
258    /// batch otherwise relies on does not apply to it. That is what makes an
259    /// opaque reordering possible: `docs/non-parity.md` 21 has the measurement,
260    /// and on both boards here the covered part of a frame's background is
261    /// around forty per cent of the frame.
262    ///
263    /// **Conservative on purpose, and every condition below is load-bearing.** A
264    /// wrong yes is not a slow frame, it is a wrong picture -- a background
265    /// showing through where it should not, or showing when it should not -- so
266    /// each test is for a property that can be read off the draw rather than
267    /// reasoned about, and anything this cannot prove answers no.
268    ///
269    /// - **`Material::Solid` with an opaque alpha, and nothing else.** A solid
270    ///   fill takes its coverage from the rasterizer, so a sample is either
271    ///   inside the geometry or outside it and there is no partial result. The
272    ///   analytic materials are the case this exists to exclude:
273    ///   `RoundedRect`, `Ellipse` and `RoundedRectBlur` compute coverage in the
274    ///   shader and blend it, so an *opaque* color still leaves a soft edge, and
275    ///   writing depth there would hide the background behind a half-covered
276    ///   pixel. Gradients and images could be opaque and are refused anyway:
277    ///   proving it means reading every stop or every texel.
278    /// - **Alpha at or above one.** Premultiplied and straight color agree
279    ///   there, so the form the material carries does not have to be known.
280    /// - **`Src` or `SrcOver`.** Both put an opaque source through unchanged.
281    ///   Every other mode reads the destination, which is the thing being
282    ///   reordered away.
283    /// - **No color filter.** A matrix or a blend filter can take alpha below
284    ///   one after the material produced it.
285    /// - **`Modulate` tinting.** It is the identity against the white a solid
286    ///   fill carries; another mode is a second color this cannot see.
287    /// - **Every vertex carrying white.** A vertex color multiplies the
288    ///   material, so a translucent one makes a translucent draw out of an
289    ///   opaque material -- and `Material::Solid` is exactly the pairing
290    ///   `draw_vertices` produces for a caller's mesh. This is why the vertex
291    ///   buffer is a parameter: the material cannot answer it, and the draw
292    ///   does not hold it. Refused for any non-white color rather than only a
293    ///   translucent one, since a colored-but-opaque vertex still has to be
294    ///   read to know that, and it costs nothing to say no.
295    /// - **Unclipped.** A `Narrow` or `Widen` draw writes the stencil rather
296    ///   than color and is sequencing, not content. A clipped `Content` draw
297    ///   writes color but depends on stencil state that the draws around it
298    ///   establish, so moving it past them would change what it is clipped to.
299    ///
300    /// Antialiasing does not appear here, and that is the point rather than an
301    /// omission. It is multisampling in this renderer -- `Canvas::pass_samples`
302    /// raises the whole pass's sample count and no draw blends its own coverage
303    /// -- so an opaque solid fill is binary at every sample whether the pass is
304    /// multisampled or not, which is exactly the case a depth test is built for.
305    /// A renderer that antialiased by blending coverage could not use this
306    /// predicate at all.
307    /// The whole pixels this draw certainly covers, where that is knowable exactly.
308    ///
309    /// `None` unless the geometry is a quad standing on its own bounding box: four
310    /// vertices at the four corners, six indices forming two triangles that share the
311    /// quad's diagonal. That is what an axis-aligned rectangle fill tessellates to, and
312    /// it is the one shape whose covered area is its bounding box rather than something
313    /// strictly inside it. Everything else -- a rotated rectangle, a path, a stroke, a
314    /// glyph run -- is refused rather than approximated, because the answer is used to
315    /// stop drawing something underneath and a rectangle too large leaves a hole in the
316    /// frame.
317    ///
318    /// Every test here is discrete, with no tolerance anywhere. A quad one part in ten
319    /// thousand short of its bounding box would pass an area comparison and leave a
320    /// sub-pixel notch, and at four samples a notch is a visible seam. Exact corners or
321    /// nothing.
322    ///
323    /// Two triangles sharing a *side* rather than the diagonal are refused too. They
324    /// have six indices over four vertices and cover half the box, so nothing short of
325    /// looking at which pair is shared tells them apart.
326    ///
327    /// The positions are homogeneous clip coordinates, so this converts. `w` must be
328    /// exactly one on all four vertices, which refuses perspective rather than dividing
329    /// by a quantity that varies across the quad, and the normalized range maps onto the
330    /// target with y running downward -- the orientation [`Scissor`] fixes and that both
331    /// backends already agree on. [`Scissor::covered_device_bounds`] then rounds inward.
332    pub fn covered(
333        &self,
334        vertices: &[Vertex],
335        indices: &[u32],
336        extent: Extent2D,
337    ) -> Option<Scissor> {
338        if self.index_count != 6 {
339            return None;
340        }
341        let first = self.first_index as usize;
342        let six = indices.get(first..first.checked_add(6)?)?;
343        let (left, right) = (&six[..3], &six[3..]);
344        // Each triangle names three distinct vertices, or it has no area.
345        for tri in [left, right] {
346            if tri[0] == tri[1] || tri[1] == tri[2] || tri[0] == tri[2] {
347                return None;
348            }
349        }
350        // Two shared vertices, which is what sharing an edge means.
351        let shared: Vec<u32> = left.iter().copied().filter(|i| right.contains(i)).collect();
352        if shared.len() != 2 {
353            return None;
354        }
355
356        let mut distinct: Vec<u32> = Vec::with_capacity(4);
357        for &i in six {
358            if !distinct.contains(&i) {
359                distinct.push(i);
360            }
361        }
362        if distinct.len() != 4 {
363            return None;
364        }
365
366        let at = |index: u32| -> Option<[f32; 2]> {
367            let v = vertices.get(index as usize)?;
368            // Affine only. A perspective quad's covered region is not its bounding box.
369            (v.position[2] == 1.0).then_some([v.position[0], v.position[1]])
370        };
371        let mut corners = [[0.0f32; 2]; 4];
372        for (slot, &index) in corners.iter_mut().zip(&distinct) {
373            *slot = at(index)?;
374        }
375
376        let fold = |f: fn(f32, f32) -> f32, axis: usize, seed: f32| {
377            corners.iter().map(|c| c[axis]).fold(seed, f)
378        };
379        let min_x = fold(f32::min, 0, f32::INFINITY);
380        let max_x = fold(f32::max, 0, f32::NEG_INFINITY);
381        let min_y = fold(f32::min, 1, f32::INFINITY);
382        let max_y = fold(f32::max, 1, f32::NEG_INFINITY);
383
384        // Every corner at one extreme in each axis, and all four combinations present.
385        // A quad with three corners on its box and the fourth inside passes neither.
386        let quadrant = |c: [f32; 2]| -> Option<usize> {
387            let east = if c[0] == min_x {
388                false
389            } else if c[0] == max_x {
390                true
391            } else {
392                return None;
393            };
394            let south = if c[1] == min_y {
395                false
396            } else if c[1] == max_y {
397                true
398            } else {
399                return None;
400            };
401            Some(usize::from(east) + 2 * usize::from(south))
402        };
403        let mut seen = [false; 4];
404        for &c in &corners {
405            seen[quadrant(c)?] = true;
406        }
407        if !seen.iter().all(|&s| s) {
408            return None;
409        }
410
411        // The shared pair must be opposite corners. Sharing a side leaves both triangles
412        // on one half of the quad.
413        let a = quadrant(at(shared[0])?)?;
414        let b = quadrant(at(shared[1])?)?;
415        if a + b != 3 {
416            return None;
417        }
418
419        // `viewport_projection` is `x * 2/w - 1` across and `1 - y * 2/h` down, so clip
420        // space runs left to right with the target but *bottom to top against it*: a
421        // clip y of +1 is the target's first row. Inverting that mapping swaps which end
422        // is the minimum, and getting it backwards is not a subtle failure -- it mirrors
423        // every culled region vertically, which showed as a card's shadow landing above
424        // the card instead of below it.
425        let across = |v: f32| (v + 1.0) * 0.5 * extent.width as f32;
426        let down = |v: f32| (1.0 - v) * 0.5 * extent.height as f32;
427        let min = [across(min_x), down(max_y)];
428        let max = [across(max_x), down(min_y)];
429        let covered = Scissor::covered_device_bounds(min, max, extent);
430        Some(match self.clip {
431            Some(clip) => covered.intersect(clip),
432            None => covered,
433        })
434    }
435
436    /// Whether writing this draw's pixels twice gives what writing them once gives.
437    ///
438    /// The condition for splitting a draw into several, and it is not the same question
439    /// as [`Self::occludes`]. That one asks whether a draw hides what is under it; this
440    /// asks whether it is safe to draw *overlapping* copies of it -- which matters
441    /// because a driver may write a pixel outside the scissor it was given.
442    ///
443    /// **Measured, not hypothetical.** lavapipe on Mesa 25.2.8 and 15.0.6 writes the
444    /// pixel to the left of a scissor at half coverage when the pass is multisampled;
445    /// 26.1.7, RADV and PanVK are clean. `public_api.rs` probes for it. Where it happens,
446    /// two pieces of one draw overlap by a column -- and a translucent draw blends there
447    /// twice, which reads 90 against 121 on a half-transparent wash under an opaque bar.
448    /// An opaque one writes the same color twice and cannot tell.
449    ///
450    /// So a draw splits only where a second write is a no-op: `Src` replaces whatever the
451    /// alpha, and `SrcOver` replaces only where the source is opaque -- which means the
452    /// material, the absence of a color filter, an identity tint, and the vertex colors
453    /// the tint multiplies in, all four.
454    pub fn splits_safely(&self, vertices: &[Vertex], indices: &[u32]) -> bool {
455        if self.filter != ColorFilter::None || self.tint_blend != BlendMode::Modulate {
456            return false;
457        }
458        // Premultiplied, and interpolated across the triangle -- so a single translucent
459        // corner makes part of the draw translucent however opaque its material is.
460        let first = self.first_index as usize;
461        let count = self.index_count as usize;
462        let Some(range) = indices.get(first..first.saturating_add(count)) else {
463            return false;
464        };
465        for &index in range {
466            match vertices.get(index as usize) {
467                Some(v) if v.color[3] >= 1.0 => {}
468                _ => return false,
469            }
470        }
471        match self.blend {
472            BlendMode::Src => true,
473            // `VertexGradient` shades opaque white, and the tint above is
474            // `Modulate`, so the result's alpha *is* the vertex alpha -- which
475            // the loop has just checked. `Material::is_opaque` cannot say so:
476            // it answers about the material alone, and the colors are not
477            // there. Without this a vertex-interpolated gradient is never
478            // split, so the wash under an interface paints every pixel the
479            // panels cover.
480            BlendMode::SrcOver => {
481                self.material.is_opaque() || matches!(self.material, Material::VertexGradient)
482            }
483            _ => false,
484        }
485    }
486
487    pub fn occludes(&self, vertices: &[Vertex], indices: &[u32]) -> bool {
488        self.stencil == ClipState::UNCLIPPED
489            && matches!(self.blend, BlendMode::Src | BlendMode::SrcOver)
490            && self.filter == ColorFilter::None
491            && self.tint_blend == BlendMode::Modulate
492            && !self.paint_at_texture_coords
493            && matches!(self.material, Material::Solid(color) if color[3] >= 1.0)
494            // Last, because it is the only test here that reads a buffer.
495            && self.vertices_are_white(vertices, indices)
496    }
497
498    /// Whether every vertex this draw names carries white, the identity for a
499    /// color that multiplies the material.
500    ///
501    /// A draw naming a vertex or an index that is not there answers no. That
502    /// cannot happen in a batch this crate built, and a predicate whose wrong
503    /// answer is a wrong picture does not get to assume it.
504    fn vertices_are_white(&self, vertices: &[Vertex], indices: &[u32]) -> bool {
505        let first = self.first_index as usize;
506        let Some(end) = first.checked_add(self.index_count as usize) else {
507            return false;
508        };
509        let Some(range) = indices.get(first..end) else {
510            return false;
511        };
512        range
513            .iter()
514            .all(|&i| vertices.get(i as usize).is_some_and(|v| v.color == WHITE))
515    }
516
517    /// The uniform block this draw's shader reads.
518    ///
519    /// The material and the filter are packed together because the shader
520    /// takes one block per draw, and separately here because they are separate
521    /// things: a filter applies to any material, and a material knows nothing
522    /// about being filtered.
523    /// `target` is the format this draw is about to be written into, which
524    /// only the backend knows: a recording is built without one, and the same
525    /// recording is drawn into an eight-bit surface and a float one. It decides
526    /// the dither, and nothing else here.
527    pub fn to_uniform(&self, target: crate::PixelFormat) -> [f32; crate::MATERIAL_FLOATS] {
528        let mut out = self.material.to_uniform();
529        self.filter.pack_into(&mut out);
530        out[crate::material::layout::FILTER_PARAMS + 1] = self.tint_blend.code();
531        if self.paint_at_texture_coords {
532            // `geometry.x`, which no gradient writes. See the field's own note
533            // and the `paint_space` comment in the shader.
534            out[crate::material::layout::GEOMETRY] = 1.0;
535        }
536        let dither = crate::material::layout::DITHER;
537        // Upstream's rate exactly: `kDitherRate` is 1/64 and is added to the
538        // premultiplied color whatever the target is. That is a single constant
539        // there because its values are encoded, so a quantization step is a
540        // flat 1/255 wherever it stands -- and now for the same reason it is a
541        // single constant here.
542        //
543        // Still zero for a target with no quantum to bridge. Half's precision
544        // is relative, so there is no step to straddle and upstream's constant
545        // would be noise added to a surface that had none.
546        //
547        // Zero for a material with no band to break, which is every one but a
548        // gradient. That test was the shader's and is here now: it tested the
549        // material kind, so a second route to the same picture under another
550        // kind stopped dithering without saying so.
551        out[dither] = if self.material.dithers() && target.quantization_step() > 0.0 {
552            1.0 / 64.0
553        } else {
554            0.0
555        };
556        out
557    }
558}
559
560/// Geometry and paint for a sequence of draws sharing one target.
561///
562/// Draws are kept in submission order rather than sorted by pipeline. Sorting
563/// would cut pipeline binds, but 2D drawing is painter's-algorithm ordered:
564/// reordering two overlapping draws changes which one ends up on top. Deciding
565/// when a reorder is safe needs either overlap analysis or a depth buffer, and
566/// that belongs to the layer that knows what the draws represent.
567#[derive(Debug, Default, Clone)]
568pub struct Batch {
569    vertices: Vec<Vertex>,
570    indices: Vec<u32>,
571    draws: Vec<BatchDraw>,
572}
573
574impl Batch {
575    pub fn new() -> Self {
576        Self::default()
577    }
578
579    /// Append a draw covering the whole target.
580    ///
581    /// Indices are relative to `vertices` and are rebased onto the batch's
582    /// shared buffer, so a caller need not know what came before it.
583    pub fn push(
584        &mut self,
585        vertices: &[[f32; 2]],
586        indices: &[u32],
587        material: Material,
588        blend: BlendMode,
589    ) -> Result<()> {
590        self.push_clipped(vertices, indices, material, blend, None)
591    }
592
593    /// Append a draw confined to a region of the target.
594    ///
595    /// A separate entry point rather than an extra parameter on [`Self::push`]:
596    /// most draws are unclipped, and threading `None` through every call site
597    /// makes the ones that do carry a clip harder to pick out, not easier.
598    ///
599    /// An empty scissor drops the draw. Recording something that provably
600    /// writes no pixel would cost a pipeline bind and a draw call to produce
601    /// the same target, and a clip stack that has narrowed to nothing is a
602    /// normal state for a scrolled-away subtree rather than an error.
603    pub fn push_clipped(
604        &mut self,
605        vertices: &[[f32; 2]],
606        indices: &[u32],
607        material: Material,
608        blend: BlendMode,
609        clip: Option<Scissor>,
610    ) -> Result<()> {
611        self.push_with(
612            vertices,
613            indices,
614            material,
615            ColorFilter::None,
616            blend,
617            clip,
618            ClipState::UNCLIPPED,
619        )
620    }
621
622    /// Append a draw with an explicit stencil role.
623    ///
624    /// The general form the other two delegate to. A caller reaches for this
625    /// only when building or unwinding a clip, or when drawing content inside
626    /// one; everything else is confined by a scissor or not confined at all.
627    #[allow(clippy::too_many_arguments)]
628    pub fn push_with(
629        &mut self,
630        positions: &[[f32; 2]],
631        indices: &[u32],
632        material: Material,
633        filter: ColorFilter,
634        blend: BlendMode,
635        clip: Option<Scissor>,
636        stencil: ClipState,
637    ) -> Result<()> {
638        // Tessellated geometry has no texture coordinates of its own, and the
639        // materials it carries do not read them.
640        let vertices: Vec<Vertex> = positions.iter().copied().map(Vertex::at).collect();
641        self.push_mesh(&vertices, indices, material, filter, blend, clip, stencil)
642    }
643
644    /// Append a draw whose vertices carry texture coordinates.
645    ///
646    /// The form a glyph run takes: one draw over many quads, each reading a
647    /// different part of the same atlas.
648    #[allow(clippy::too_many_arguments)]
649    pub fn push_mesh(
650        &mut self,
651        vertices: &[Vertex],
652        indices: &[u32],
653        material: Material,
654        filter: ColorFilter,
655        blend: BlendMode,
656        clip: Option<Scissor>,
657        stencil: ClipState,
658    ) -> Result<()> {
659        self.push_mesh_tinted(
660            vertices,
661            indices,
662            material,
663            filter,
664            blend,
665            clip,
666            stencil,
667            BlendMode::Modulate,
668            false,
669        )
670    }
671
672    /// Append a mesh, saying how its vertex colors combine with the material.
673    ///
674    /// Separate from [`Self::push_mesh`] rather than an extra parameter on it,
675    /// for the reason [`Self::push_clipped`] is separate: the mode is
676    /// `Modulate` for everything that does not ask, white being the identity
677    /// under it, and threading a parameter through every call site to say so
678    /// would be noise at all of them and a decision at none.
679    #[allow(clippy::too_many_arguments)]
680    pub fn push_mesh_tinted(
681        &mut self,
682        vertices: &[Vertex],
683        indices: &[u32],
684        material: Material,
685        filter: ColorFilter,
686        blend: BlendMode,
687        clip: Option<Scissor>,
688        stencil: ClipState,
689        tint_blend: BlendMode,
690        paint_at_texture_coords: bool,
691    ) -> Result<()> {
692        if clip.is_some_and(Scissor::is_empty) {
693            return Ok(());
694        }
695        if indices.len() % 3 != 0 {
696            return Err(Error::Unsupported("index count is not a whole triangle"));
697        }
698        if let Some(&max) = indices.iter().max() {
699            if max as usize >= vertices.len() {
700                return Err(Error::Backend {
701                    backend: "vulkan",
702                    detail: format!(
703                        "index {max} addresses past the {} vertices supplied",
704                        vertices.len()
705                    ),
706                });
707            }
708        }
709        if indices.is_empty() {
710            return Ok(());
711        }
712
713        let base = u32::try_from(self.vertices.len()).map_err(|_| Error::LimitExceeded {
714            what: "batch vertex count",
715            requested: self.vertices.len() as u64,
716            limit: u32::MAX as u64,
717        })?;
718        let first_index = self.indices.len() as u32;
719
720        self.vertices.extend_from_slice(vertices);
721        self.indices.extend(indices.iter().map(|i| i + base));
722
723        // A draw that differs from the one before it in nothing a backend can
724        // set is not a second draw. Its indices were just appended to the same
725        // buffer, so extending the previous range covers both, and the
726        // triangles are rasterized in the same order either way -- which is
727        // what makes this safe under painter's-algorithm ordering, where two
728        // overlapping shapes must not trade places.
729        //
730        // Adjacent only, never sorted. Reordering to create more of these is a
731        // different decision with a different safety argument, and this one
732        // needs none: the sequence is untouched.
733        if let Some(last) = self.draws.last_mut() {
734            if last.first_index + last.index_count == first_index
735                && last.material == material
736                && last.filter == filter
737                && last.blend == blend
738                && last.clip == clip
739                && last.stencil == stencil
740                && last.tint_blend == tint_blend
741                && last.paint_at_texture_coords == paint_at_texture_coords
742            {
743                last.index_count += indices.len() as u32;
744                return Ok(());
745            }
746        }
747
748        self.draws.push(BatchDraw {
749            filter,
750            first_index,
751            index_count: indices.len() as u32,
752            material,
753            blend,
754            clip,
755            stencil,
756            tint_blend,
757            paint_at_texture_coords,
758        });
759        Ok(())
760    }
761
762    /// Drop the contents but keep the allocations, for reuse next frame.
763    pub fn clear(&mut self) {
764        self.vertices.clear();
765        self.indices.clear();
766        self.draws.clear();
767    }
768
769    pub fn draw_count(&self) -> usize {
770        self.draws.len()
771    }
772
773    pub fn is_empty(&self) -> bool {
774        self.draws.is_empty()
775    }
776
777    /// Whether recording this needs a stencil attachment.
778    ///
779    /// Derived from the draws rather than declared alongside them, so a batch
780    /// cannot ask for a clip and forget to say it needs somewhere to put it.
781    /// Most batches clip nothing, and those pay for no attachment.
782    pub fn uses_stencil(&self) -> bool {
783        self.draws.iter().any(|draw| draw.stencil.needs_stencil())
784    }
785
786    /// The deepest clip stack an eight-bit stencil can distinguish.
787    ///
788    /// Eight bits is the only stencil depth every device is required to offer,
789    /// on either graphics API, so this is the portable limit rather than any
790    /// one device's.
791    pub const MAX_CLIP_DEPTH: u32 = 255;
792
793    /// Refuse a batch whose clip stack is deeper than a stencil can hold.
794    ///
795    /// Here rather than in each backend because the limit is a property of the
796    /// stencil format both are required to offer, and the failure it prevents
797    /// is one neither can detect afterwards: past the limit the value wraps or
798    /// saturates, and either way a later test for a depth that no longer fits
799    /// admits every pixel the clip was meant to exclude. Nothing about that
800    /// looks like an error -- it draws content the caller clipped away.
801    ///
802    /// It was in one backend and not the other, so the same recording was
803    /// refused on Vulkan and silently rendered wrong on GLES.
804    pub fn check_clip_depth(&self) -> Result<()> {
805        let depth = self.max_clip_depth();
806        if depth > Self::MAX_CLIP_DEPTH {
807            return Err(Error::LimitExceeded {
808                what: "clip nesting depth",
809                requested: depth as u64,
810                limit: Self::MAX_CLIP_DEPTH as u64,
811            });
812        }
813        Ok(())
814    }
815
816    /// The largest stencil value this batch can produce.
817    pub fn max_clip_depth(&self) -> u32 {
818        self.draws
819            .iter()
820            .map(|draw| match draw.stencil.role {
821                ClipRole::Narrow => draw.stencil.reference + 1,
822                _ => draw.stencil.reference,
823            })
824            .max()
825            .unwrap_or(0)
826    }
827
828    /// The texture slots this batch samples, in ascending order without
829    /// repeats.
830    ///
831    /// A backend uses this to size its bindings before recording, and to check
832    /// the table it was given covers what the draws ask for.
833    pub fn texture_slots(&self) -> Vec<u32> {
834        let mut slots: Vec<u32> = self
835            .draws
836            .iter()
837            .flat_map(|draw| draw.material.texture_slots())
838            .flatten()
839            .collect();
840        slots.sort_unstable();
841        slots.dedup();
842        slots
843    }
844
845    /// How many times a pipeline will be bound when this batch is recorded.
846    ///
847    /// Consecutive draws sharing a blend mode reuse the bound pipeline, so this
848    /// counts transitions rather than draws.
849    pub fn pipeline_binds(&self) -> usize {
850        let mut binds = 0;
851        let mut current: Option<BlendMode> = None;
852        for draw in &self.draws {
853            if current != Some(draw.blend) {
854                binds += 1;
855                current = Some(draw.blend);
856            }
857        }
858        binds
859    }
860}
861
862impl Batch {
863    /// Shared vertex buffer, positions in clip space.
864    /// Move every scissor into a target whose origin moved by `(dx, dy)`.
865    ///
866    /// For a layer whose target was narrowed after its draws were recorded.
867    /// The geometry is left alone -- it is in clip space and the pass's
868    /// viewport is what places it -- but a scissor is in target pixels, so it
869    /// is the one recorded thing the move does reach.
870    pub fn rebase_scissors(&mut self, dx: u32, dy: u32, extent: crate::Extent2D) {
871        for draw in &mut self.draws {
872            if let Some(clip) = draw.clip {
873                draw.clip = Some(clip.shifted(dx, dy, extent));
874            }
875        }
876    }
877
878    pub fn vertices(&self) -> &[Vertex] {
879        &self.vertices
880    }
881
882    /// Shared index buffer, already rebased onto [`Batch::vertices`].
883    pub fn indices(&self) -> &[u32] {
884        &self.indices
885    }
886
887    /// The draws, in submission order.
888    pub fn draws(&self) -> &[BatchDraw] {
889        &self.draws
890    }
891
892    /// Stop each draw writing pixels a later opaque draw will overwrite.
893    ///
894    /// Returns how many draws were narrowed or dropped, which is what a test asserts
895    /// on -- a pass that quietly did nothing would otherwise look like a pass.
896    ///
897    /// # Why this is not reordering
898    ///
899    /// Draw order is untouched. Each draw is confined, by scissor, to the pixels no
900    /// later opaque draw replaces. `docs/non-parity.md` 21 wanted a depth buffer to
901    /// reorder opaque draws and `docs/on-a-board.md` records why that is closed here:
902    /// at four samples the attachment costs four times the pass on V3D, and the frame
903    /// worth reordering is four samples. A scissor costs nothing and needs no
904    /// attachment.
905    ///
906    /// It is also pixel-identical rather than approximately right. For draws `i` before
907    /// `j`, if `j` replaces every sample of a pixel then nothing `i` wrote there can
908    /// reach the frame -- including by way of something between them that blended
909    /// against it, since that result is replaced too. [`BatchDraw::occludes`] is
910    /// exactly the "replaces every sample it touches" predicate, and
911    /// [`BatchDraw::covered`] is where it does so.
912    ///
913    /// # What limits it
914    ///
915    /// Only the occluder needs known coverage. The draw being narrowed needs nothing at
916    /// all, because a scissor restricts any geometry -- which is what makes this worth
917    /// doing, since the thing being saved is usually a gradient or an image and neither
918    /// is a shape this could reason about.
919    ///
920    /// Two caps keep the work bounded on a batch that is nothing like a frame of
921    /// interface. `MAX_BLOCKERS` is how many occluders are carried at once, and
922    /// [`crate::occlusion::MAX_PIECES`] is how many rectangles a remainder may need before the
923    /// draw is left alone. Both failures are safe: drawing more than necessary is slow,
924    /// never wrong.
925    pub fn cull_occluded(&mut self, extent: Extent2D) -> usize {
926        /// Occluders carried while walking back through the draws.
927        ///
928        /// The walk is from the front of the frame backwards, so these are the draws
929        /// nearest the viewer -- the ones most likely to be hiding something. Sixteen
930        /// bounds the remainder arithmetic, which is quadratic in this count.
931        const MAX_BLOCKERS: usize = 16;
932
933        if self.draws.len() < 2 || extent.width == 0 || extent.height == 0 {
934            return 0;
935        }
936        let whole = Scissor::covering(extent);
937
938        let mut blockers: Vec<Scissor> = Vec::with_capacity(MAX_BLOCKERS);
939        let mut rewritten = 0usize;
940        // Built back to front and reversed once, rather than inserted into.
941        let mut out: Vec<BatchDraw> = Vec::with_capacity(self.draws.len());
942
943        for index in (0..self.draws.len()).rev() {
944            let draw = self.draws[index].clone();
945            let covered = draw
946                .occludes(&self.vertices, &self.indices)
947                .then(|| draw.covered(&self.vertices, &self.indices, extent))
948                .flatten();
949
950            // A draw that writes the stencil is sequencing rather than content: its
951            // effect is not confined to the pixels it colors, so narrowing its scissor
952            // would change which pixels a *later* clipped draw is clipped to. Left
953            // alone, and it cannot be an occluder either -- `occludes` already refuses
954            // anything but `UNCLIPPED`.
955            // A draw whose shading reads screen-space derivatives cannot be split by
956            // scissor without changing its edge -- see
957            // `Material::needs_screen_derivatives`, which has the measurement. This is
958            // where the pass stops being free, and it is why the prize survives anyway:
959            // the gradient that costs the frame is derivative-free and the analytic
960            // shapes that are not are cheap.
961            if blockers.is_empty()
962                || draw.stencil.role.writes_stencil()
963                || draw.material.needs_screen_derivatives()
964            {
965                out.push(draw);
966            } else {
967                let own = draw.clip.unwrap_or(whole);
968                match crate::occlusion::remainder(own, &blockers) {
969                    // Nothing of this draw survives, so it does not need drawing.
970                    Some(pieces) if pieces.is_empty() => rewritten += 1,
971                    // One piece covering what it already had: leave the draw exactly as
972                    // it was, clip included. A draw that was never clipped keeps saying
973                    // so, which is a distinction `BatchDraw::clip` documents.
974                    Some(pieces) if pieces.len() == 1 && pieces[0] == own => out.push(draw),
975                    // More than one piece means overlapping writes on a driver that
976                    // does not honor a scissor exactly, so the draw has to survive
977                    // being written twice. One piece cannot overlap anything.
978                    Some(pieces)
979                        if pieces.len() > 1
980                            && !draw.splits_safely(&self.vertices, &self.indices) =>
981                    {
982                        out.push(draw);
983                    }
984                    Some(pieces) => {
985                        rewritten += 1;
986                        for piece in pieces {
987                            out.push(BatchDraw {
988                                clip: Some(piece),
989                                ..draw.clone()
990                            });
991                        }
992                    }
993                    // Past the cap. Left alone, which is always correct.
994                    None => out.push(draw),
995                }
996            }
997
998            if let Some(area) = covered {
999                if !area.is_empty() && blockers.len() < MAX_BLOCKERS {
1000                    blockers.push(area);
1001                }
1002            }
1003        }
1004
1005        out.reverse();
1006        self.draws = out;
1007        rewritten
1008    }
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013    use super::*;
1014
1015    const TRI: [[f32; 2]; 3] = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
1016
1017    /// A draw carrying just the material, since the dither depends on nothing
1018    /// else about it.
1019    fn draw_of(material: Material) -> BatchDraw {
1020        BatchDraw {
1021            first_index: 0,
1022            index_count: 3,
1023            material,
1024            filter: ColorFilter::None,
1025            blend: BlendMode::SrcOver,
1026            clip: None,
1027            stencil: ClipState::UNCLIPPED,
1028            tint_blend: BlendMode::Modulate,
1029            paint_at_texture_coords: false,
1030        }
1031    }
1032
1033    fn wash() -> crate::Material {
1034        crate::Material::LinearGradient {
1035            axis: [1.0, 0.0],
1036            to_local: [0.0; 12],
1037            stops: vec![
1038                crate::Stop::new([0.0, 0.0, 0.0, 1.0], 0.0),
1039                crate::Stop::new([1.0, 1.0, 1.0, 1.0], 1.0),
1040            ],
1041            ramp: None,
1042            tile: crate::TileMode::Clamp,
1043        }
1044    }
1045
1046    /// The dither amplitude follows the material, not the shader's reading of
1047    /// the material's kind.
1048    ///
1049    /// This is the contract that replaced a branch in `solid.wgsl`. The shader
1050    /// could only ask what kind a draw was, so a second route to a gradient
1051    /// under another kind stopped dithering silently -- which is how the
1052    /// reverted fast-gradient attempt lost it. See `Material::dithers` and §19
1053    /// of `docs/non-parity.md`.
1054    #[test]
1055    fn only_a_material_that_asks_for_a_dither_gets_an_amplitude() {
1056        let dither = crate::material::layout::DITHER;
1057        let eight_bit = crate::PixelFormat::Rgba8Unorm;
1058
1059        let gradient = draw_of(wash()).to_uniform(eight_bit);
1060        assert!(
1061            gradient[dither] > 0.0,
1062            "a gradient on an eight-bit target got no dither"
1063        );
1064
1065        let solid = draw_of(Material::Solid([1.0, 0.0, 0.0, 1.0])).to_uniform(eight_bit);
1066        assert_eq!(
1067            solid[dither], 0.0,
1068            "a solid fill was dithered, which adds noise to a flat color"
1069        );
1070    }
1071
1072    /// And a target with no quantum to bridge gets none whatever the material
1073    /// asks for: half's precision is relative, so there is no step to straddle.
1074    #[test]
1075    fn a_float_target_gets_no_dither_even_for_a_gradient() {
1076        let dither = crate::material::layout::DITHER;
1077        let packed = draw_of(wash()).to_uniform(crate::PixelFormat::Rgba16Float);
1078        assert_eq!(packed[dither], 0.0, "a float target was dithered");
1079    }
1080
1081    #[test]
1082    fn indices_are_rebased_onto_the_shared_buffer() {
1083        let mut batch = Batch::new();
1084        // Two colors, so the draws do not merge and the second one's own
1085        // range is visible. What is being checked is the rebasing, which a
1086        // merged pair would hide behind a single range covering both.
1087        batch
1088            .push(&TRI, &[0, 1, 2], Material::solid([1.0; 4]), BlendMode::Src)
1089            .unwrap();
1090        batch
1091            .push(&TRI, &[0, 1, 2], Material::solid([0.5; 4]), BlendMode::Src)
1092            .unwrap();
1093
1094        // The second draw's indices must point at its own vertices, not the
1095        // first draw's, or both draws render the same triangle.
1096        assert_eq!(batch.indices, vec![0, 1, 2, 3, 4, 5]);
1097        assert_eq!(batch.vertices.len(), 6);
1098        assert_eq!(batch.draws[1].first_index, 3);
1099        assert_eq!(batch.draw_count(), 2);
1100    }
1101
1102    #[test]
1103    fn a_draw_that_differs_from_the_one_before_it_in_nothing_is_not_a_second_draw() {
1104        let mut batch = Batch::new();
1105        for _ in 0..4 {
1106            batch
1107                .push(&TRI, &[0, 1, 2], Material::solid([1.0; 4]), BlendMode::Src)
1108                .unwrap();
1109        }
1110        assert_eq!(batch.draw_count(), 1, "four alike draws should be one");
1111        // All four triangles are still there, and still in order: merging
1112        // changes how many times a backend is asked to draw, not what it
1113        // draws.
1114        assert_eq!(batch.indices, vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
1115        assert_eq!(batch.draws[0].index_count, 12);
1116
1117        // A change in any one thing a backend sets ends the run.
1118        batch
1119            .push(
1120                &TRI,
1121                &[0, 1, 2],
1122                Material::solid([1.0; 4]),
1123                BlendMode::SrcOver,
1124            )
1125            .unwrap();
1126        assert_eq!(batch.draw_count(), 2);
1127    }
1128
1129    #[test]
1130    fn pipeline_binds_count_transitions_not_draws() {
1131        let mut batch = Batch::new();
1132        // A different color each time, so no two draws merge and the count
1133        // this is about -- pipeline binds against draws -- stays a real
1134        // distinction rather than one merging has already collapsed.
1135        for (i, blend) in [
1136            BlendMode::Src,
1137            BlendMode::Src,
1138            BlendMode::SrcOver,
1139            BlendMode::SrcOver,
1140            BlendMode::Src,
1141        ]
1142        .into_iter()
1143        .enumerate()
1144        {
1145            let shade = i as f32 / 8.0;
1146            batch
1147                .push(&TRI, &[0, 1, 2], Material::solid([shade; 4]), blend)
1148                .unwrap();
1149        }
1150        // Five draws, three runs of like pipelines.
1151        assert_eq!(batch.draw_count(), 5);
1152        assert_eq!(batch.pipeline_binds(), 3);
1153    }
1154
1155    #[test]
1156    fn an_empty_draw_adds_nothing() {
1157        let mut batch = Batch::new();
1158        batch
1159            .push(&[], &[], Material::solid([1.0; 4]), BlendMode::Src)
1160            .unwrap();
1161        assert!(batch.is_empty());
1162        assert_eq!(batch.draw_count(), 0);
1163    }
1164
1165    #[test]
1166    fn malformed_geometry_is_refused_where_it_is_pushed() {
1167        let mut batch = Batch::new();
1168        // Catching this at push means the caller learns which draw was wrong,
1169        // rather than a whole batch failing later at submission.
1170        assert!(batch
1171            .push(
1172                &[[0.0, 0.0]],
1173                &[0, 1, 2],
1174                Material::solid([1.0; 4]),
1175                BlendMode::Src
1176            )
1177            .is_err());
1178        assert!(batch
1179            .push(
1180                &[[0.0, 0.0]],
1181                &[0, 0],
1182                Material::solid([1.0; 4]),
1183                BlendMode::Src
1184            )
1185            .is_err());
1186        assert!(batch.is_empty(), "a refused draw must leave no residue");
1187    }
1188
1189    #[test]
1190    fn clearing_keeps_the_batch_reusable() {
1191        let mut batch = Batch::new();
1192        batch
1193            .push(&TRI, &[0, 1, 2], Material::solid([1.0; 4]), BlendMode::Src)
1194            .unwrap();
1195        batch.clear();
1196        assert!(batch.is_empty());
1197
1198        batch
1199            .push(&TRI, &[0, 1, 2], Material::solid([1.0; 4]), BlendMode::Src)
1200            .unwrap();
1201        // Rebasing must start from zero again rather than continuing from the
1202        // cleared contents.
1203        assert_eq!(batch.indices, vec![0, 1, 2]);
1204    }
1205
1206    /// Both backends describe this struct to their own API by asking it where
1207    /// its fields are, so what they agree on is whatever this says. Pinning it
1208    /// means a reordering shows up here, once, rather than as geometry that
1209    /// reads its color out of its position on both backends identically.
1210    #[test]
1211    fn the_vertex_layout_is_what_both_backends_describe() {
1212        use std::mem::{offset_of, size_of};
1213        assert_eq!(size_of::<Vertex>(), 36);
1214        assert_eq!(offset_of!(Vertex, position), 0);
1215        assert_eq!(offset_of!(Vertex, uv), 12);
1216        assert_eq!(offset_of!(Vertex, color), 20);
1217    }
1218
1219    /// The property that let the third float arrive without touching a caller.
1220    #[test]
1221    fn an_ordinary_vertex_carries_a_w_of_one() {
1222        assert_eq!(Vertex::at([3.0, 4.0]).position, [3.0, 4.0, 1.0]);
1223        assert_eq!(
1224            Vertex::new([3.0, 4.0], [0.5, 0.5]).position,
1225            [3.0, 4.0, 1.0]
1226        );
1227        assert_eq!(
1228            Vertex::at_projected([3.0, 4.0, 2.0]).position,
1229            [3.0, 4.0, 2.0]
1230        );
1231    }
1232}
1233
1234#[cfg(test)]
1235mod occlusion {
1236    use super::*;
1237    use crate::material::ToLocal;
1238
1239    const TRI: [[f32; 2]; 3] = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
1240
1241    fn one(material: Material, blend: BlendMode) -> BatchDraw {
1242        let mut batch = Batch::new();
1243        batch.push(&TRI, &[0, 1, 2], material, blend).unwrap();
1244        batch.draws().first().expect("one draw").clone()
1245    }
1246
1247    /// The buffers `one` builds: three vertices carrying white, indexed in
1248    /// order. `occludes` reads them, so every call here needs a pair, and these
1249    /// are the ones a plain `push` produces.
1250    const WHITE_TRI_INDICES: [u32; 3] = [0, 1, 2];
1251
1252    fn white_tri() -> Vec<Vertex> {
1253        vec![Vertex::at([0.0, 0.0]); 3]
1254    }
1255
1256    /// A vertex color multiplies the material, so a translucent one makes a
1257    /// translucent draw out of an opaque `Material::Solid` -- which is exactly
1258    /// the pairing `draw_vertices` builds for a caller's mesh.
1259    ///
1260    /// This was reachable from the public API and wrong by 128 of 255: an
1261    /// indexed quad with translucent vertex colors passed both `occludes` and
1262    /// `covered`, so the draw beneath it was culled and showed through nothing.
1263    /// §19 of `docs/non-parity.md` predicted the unsoundness as a blocker for
1264    /// upstream's vertex-interpolated gradient; it was already live.
1265    #[test]
1266    fn a_translucent_vertex_color_is_not_an_occluder() {
1267        let mut batch = Batch::new();
1268        batch
1269            .push(
1270                &TRI,
1271                &[0, 1, 2],
1272                Material::solid([1.0; 4]),
1273                BlendMode::SrcOver,
1274            )
1275            .unwrap();
1276        let draw = batch.draws().first().expect("one draw").clone();
1277
1278        let opaque = vec![Vertex::at([0.0, 0.0]); 3];
1279        assert!(
1280            draw.occludes(&opaque, &WHITE_TRI_INDICES),
1281            "white vertices leave an opaque solid fill an occluder"
1282        );
1283
1284        let translucent = vec![Vertex::at([0.0, 0.0]).with_color([1.0, 0.0, 0.0, 0.25]); 3];
1285        assert!(
1286            !draw.occludes(&translucent, &WHITE_TRI_INDICES),
1287            "a translucent vertex color was called an occluder"
1288        );
1289
1290        // Opaque but colored is refused too: knowing it is opaque means reading
1291        // it, and the predicate says no to anything it has to reason about.
1292        let tinted = vec![Vertex::at([0.0, 0.0]).with_color([1.0, 0.0, 0.0, 1.0]); 3];
1293        assert!(
1294            !draw.occludes(&tinted, &WHITE_TRI_INDICES),
1295            "a colored vertex was called an occluder"
1296        );
1297    }
1298
1299    /// An interpolated gradient splits when its vertex alphas are opaque, and
1300    /// not otherwise.
1301    ///
1302    /// `Material::is_opaque` answers no for `VertexGradient` -- the colors are
1303    /// not in the material -- so without the vertex test above it this draw
1304    /// would never be confined by culling, and a wash under an interface would
1305    /// paint every pixel the panels over it cover. The bench notices:
1306    /// `the_stacked_frame_overdraws_by_what_its_prose_claims` reads 131 draws
1307    /// with this and 127 without.
1308    #[test]
1309    fn an_interpolated_gradient_splits_when_its_vertices_are_opaque() {
1310        let mut batch = Batch::new();
1311        batch
1312            .push(
1313                &TRI,
1314                &[0, 1, 2],
1315                Material::VertexGradient,
1316                BlendMode::SrcOver,
1317            )
1318            .unwrap();
1319        let draw = batch.draws().first().expect("one draw").clone();
1320
1321        let opaque = vec![Vertex::at([0.0, 0.0]).with_color([0.2, 0.4, 0.8, 1.0]); 3];
1322        assert!(
1323            draw.splits_safely(&opaque, &WHITE_TRI_INDICES),
1324            "every vertex alpha is one, so the result is opaque"
1325        );
1326
1327        let translucent = vec![Vertex::at([0.0, 0.0]).with_color([0.2, 0.4, 0.8, 0.5]); 3];
1328        assert!(
1329            !draw.splits_safely(&translucent, &WHITE_TRI_INDICES),
1330            "a translucent vertex makes part of the draw translucent"
1331        );
1332
1333        // Still not an *occluder*: `occludes` admits only `Material::Solid`,
1334        // and this one's color is not in the material.
1335        assert!(!draw.occludes(&opaque, &WHITE_TRI_INDICES));
1336    }
1337
1338    /// A draw naming a vertex or an index that is not there answers no.
1339    #[test]
1340    fn a_draw_naming_absent_vertices_is_not_an_occluder() {
1341        let mut batch = Batch::new();
1342        batch
1343            .push(
1344                &TRI,
1345                &[0, 1, 2],
1346                Material::solid([1.0; 4]),
1347                BlendMode::SrcOver,
1348            )
1349            .unwrap();
1350        let draw = batch.draws().first().expect("one draw").clone();
1351        assert!(!draw.occludes(&[], &WHITE_TRI_INDICES), "no vertices");
1352        assert!(!draw.occludes(&white_tri(), &[]), "no indices");
1353        assert!(
1354            !draw.occludes(&white_tri(), &[0, 1]),
1355            "fewer indices than the draw names"
1356        );
1357    }
1358
1359    const TARGET: Extent2D = Extent2D::new(100, 80);
1360
1361    /// A quad over the given clip-space box, as `fan_fill` emits one.
1362    fn quad(min: [f32; 2], max: [f32; 2]) -> ([[f32; 2]; 4], [u32; 6]) {
1363        (
1364            [
1365                [min[0], min[1]],
1366                [max[0], min[1]],
1367                [max[0], max[1]],
1368                [min[0], max[1]],
1369            ],
1370            [0, 1, 2, 0, 2, 3],
1371        )
1372    }
1373
1374    fn solid_quad(min: [f32; 2], max: [f32; 2]) -> Batch {
1375        let (vertices, indices) = quad(min, max);
1376        let mut batch = Batch::new();
1377        batch
1378            .push(
1379                &vertices,
1380                &indices,
1381                Material::solid([1.0; 4]),
1382                BlendMode::Src,
1383            )
1384            .expect("a quad");
1385        batch
1386    }
1387
1388    /// The whole target, since clip space runs from -1 to 1 on both axes.
1389    #[test]
1390    fn a_full_target_quad_covers_the_whole_target() {
1391        let batch = solid_quad([-1.0, -1.0], [1.0, 1.0]);
1392        let draw = batch.draws().first().expect("one draw");
1393        assert_eq!(
1394            draw.covered(batch.vertices(), batch.indices(), TARGET),
1395            Some(Scissor::covering(TARGET))
1396        );
1397    }
1398
1399    /// Off-center in both axes, which is what pins the orientation.
1400    ///
1401    /// A y convention the wrong way round is invisible in a target symmetric about its
1402    /// center line, and it was wrong here first. Clip space runs bottom to top against
1403    /// the target, so a clip y of -1 is the *last* row and this quad is the target's
1404    /// bottom-left quarter. `viewport_projection` in `emblema-geometry` is the authority;
1405    /// what caught the mistake was a scene rather than this test, which is why there is
1406    /// also an end-to-end one over a known rectangle in `emblema`'s `public_api`.
1407    #[test]
1408    fn a_quarter_quad_covers_the_quarter_it_sits_on() {
1409        let batch = solid_quad([-1.0, -1.0], [0.0, -0.5]);
1410        let draw = batch.draws().first().expect("one draw");
1411        assert_eq!(
1412            draw.covered(batch.vertices(), batch.indices(), TARGET),
1413            Some(Scissor::new(0, 60, 50, 20)),
1414            "the bottom-left quarter, not the top-left"
1415        );
1416    }
1417
1418    /// Everything `covered` refuses, each for its own reason.
1419    #[test]
1420    fn nothing_but_a_quad_on_its_own_box_reports_coverage() {
1421        // A triangle: three indices, not six.
1422        let batch = {
1423            let mut b = Batch::new();
1424            b.push(&TRI, &[0, 1, 2], Material::solid([1.0; 4]), BlendMode::Src)
1425                .unwrap();
1426            b
1427        };
1428        let draw = batch.draws()[0].clone();
1429        assert_eq!(
1430            draw.covered(batch.vertices(), batch.indices(), TARGET),
1431            None
1432        );
1433
1434        // Two triangles sharing a *side* rather than the diagonal. Six indices over
1435        // four vertices, and it covers half the box.
1436        let (vertices, _) = quad([-1.0, -1.0], [1.0, 1.0]);
1437        let mut batch = Batch::new();
1438        batch
1439            .push(
1440                &vertices,
1441                &[0, 1, 2, 0, 1, 3],
1442                Material::solid([1.0; 4]),
1443                BlendMode::Src,
1444            )
1445            .unwrap();
1446        let draw = batch.draws()[0].clone();
1447        assert_eq!(
1448            draw.covered(batch.vertices(), batch.indices(), TARGET),
1449            None
1450        );
1451
1452        // A corner pulled inside the box, which is any rotated or sheared rectangle.
1453        let mut batch = Batch::new();
1454        batch
1455            .push(
1456                &[[-1.0, -1.0], [1.0, -1.0], [0.5, 1.0], [-1.0, 1.0]],
1457                &[0, 1, 2, 0, 2, 3],
1458                Material::solid([1.0; 4]),
1459                BlendMode::Src,
1460            )
1461            .unwrap();
1462        let draw = batch.draws()[0].clone();
1463        assert_eq!(
1464            draw.covered(batch.vertices(), batch.indices(), TARGET),
1465            None
1466        );
1467
1468        // A degenerate triangle, which has no area to contribute.
1469        let mut batch = Batch::new();
1470        batch
1471            .push(
1472                &vertices,
1473                &[0, 1, 1, 0, 2, 3],
1474                Material::solid([1.0; 4]),
1475                BlendMode::Src,
1476            )
1477            .unwrap();
1478        let draw = batch.draws()[0].clone();
1479        assert_eq!(
1480            draw.covered(batch.vertices(), batch.indices(), TARGET),
1481            None
1482        );
1483    }
1484
1485    /// A quad narrower than a pixel covers nothing rather than rounding up to one.
1486    #[test]
1487    fn a_subpixel_quad_covers_nothing() {
1488        // Two hundredths of clip space is one pixel across a hundred, and this is a
1489        // fifth of that.
1490        let batch = solid_quad([0.0, 0.0], [0.004, 0.004]);
1491        let draw = batch.draws().first().expect("one draw");
1492        assert_eq!(
1493            draw.covered(batch.vertices(), batch.indices(), TARGET),
1494            Some(Scissor::EMPTY)
1495        );
1496    }
1497
1498    /// A wash under a bar, which is the stacked frame in miniature.
1499    #[test]
1500    fn a_wash_is_narrowed_to_what_the_bar_leaves() {
1501        let (full, indices) = quad([-1.0, -1.0], [1.0, 1.0]);
1502        // The target's top quarter, opaque and solid, so it occludes. Clip y near +1
1503        // is the first row -- see `a_quarter_quad_covers_the_quarter_it_sits_on`.
1504        let (bar, _) = quad([-1.0, 0.5], [1.0, 1.0]);
1505
1506        let mut batch = Batch::new();
1507        batch
1508            .push(
1509                &full,
1510                &indices,
1511                Material::solid([0.1, 0.2, 0.3, 1.0]),
1512                BlendMode::SrcOver,
1513            )
1514            .expect("the wash");
1515        batch
1516            .push(&bar, &indices, Material::solid([1.0; 4]), BlendMode::Src)
1517            .expect("the bar");
1518
1519        assert_eq!(batch.cull_occluded(TARGET), 1);
1520        let draws = batch.draws();
1521        assert_eq!(draws.len(), 2, "one piece plus the bar");
1522        assert_eq!(
1523            draws[0].clip,
1524            Some(Scissor::new(0, 20, 100, 60)),
1525            "the wash keeps only what the bar leaves"
1526        );
1527        assert_eq!(draws[1].clip, None, "the bar is untouched");
1528    }
1529
1530    /// A draw entirely hidden is dropped rather than clipped to nothing.
1531    #[test]
1532    fn a_fully_covered_draw_is_dropped() {
1533        let (full, indices) = quad([-1.0, -1.0], [1.0, 1.0]);
1534        let mut batch = Batch::new();
1535        batch
1536            .push(
1537                &full,
1538                &indices,
1539                Material::solid([0.1, 0.2, 0.3, 1.0]),
1540                BlendMode::SrcOver,
1541            )
1542            .expect("the wash");
1543        batch
1544            .push(&full, &indices, Material::solid([1.0; 4]), BlendMode::Src)
1545            .expect("the cover");
1546
1547        assert_eq!(batch.cull_occluded(TARGET), 1);
1548        assert_eq!(batch.draws().len(), 1, "only the cover is left");
1549    }
1550
1551    /// Order is what decides, and the earlier draw is the one that loses pixels.
1552    ///
1553    /// The same two draws the other way round must leave both alone: a wash drawn
1554    /// *over* a bar hides the bar, and the bar is not a safe occluder for it.
1555    #[test]
1556    fn a_draw_in_front_of_an_opaque_one_is_left_alone() {
1557        let (full, indices) = quad([-1.0, -1.0], [1.0, 1.0]);
1558        let (bar, _) = quad([-1.0, -1.0], [1.0, -0.5]);
1559
1560        let mut batch = Batch::new();
1561        batch
1562            .push(&bar, &indices, Material::solid([1.0; 4]), BlendMode::Src)
1563            .expect("the bar");
1564        batch
1565            .push(
1566                &full,
1567                &indices,
1568                Material::solid([0.1, 0.2, 0.3, 1.0]),
1569                BlendMode::SrcOver,
1570            )
1571            .expect("the wash");
1572
1573        // The wash is opaque and covers the bar outright, so the bar goes.
1574        assert_eq!(batch.cull_occluded(TARGET), 1);
1575        assert_eq!(batch.draws().len(), 1);
1576        assert_eq!(batch.draws()[0].clip, None, "the wash is untouched");
1577    }
1578
1579    /// An occluder that cannot prove itself culls nothing.
1580    #[test]
1581    fn a_translucent_cover_narrows_nothing() {
1582        let (full, indices) = quad([-1.0, -1.0], [1.0, 1.0]);
1583        let (bar, _) = quad([-1.0, -1.0], [1.0, -0.5]);
1584
1585        let mut batch = Batch::new();
1586        batch
1587            .push(
1588                &full,
1589                &indices,
1590                Material::solid([1.0; 4]),
1591                BlendMode::SrcOver,
1592            )
1593            .expect("the wash");
1594        batch
1595            .push(
1596                &bar,
1597                &indices,
1598                Material::solid([1.0, 1.0, 1.0, 0.5]),
1599                BlendMode::SrcOver,
1600            )
1601            .expect("a half-transparent bar");
1602
1603        assert_eq!(batch.cull_occluded(TARGET), 0);
1604        assert_eq!(batch.draws().len(), 2);
1605        assert!(batch.draws().iter().all(|d| d.clip.is_none()));
1606    }
1607
1608    /// A draw that writes the stencil keeps every pixel it was given.
1609    ///
1610    /// Its scissor decides which pixels get a stencil value, not just which get a
1611    /// color, so narrowing it would change what a later clipped draw is clipped to.
1612    #[test]
1613    fn a_stencil_writing_draw_is_never_narrowed() {
1614        let (full, indices) = quad([-1.0, -1.0], [1.0, 1.0]);
1615        let mut batch = Batch::new();
1616        batch
1617            .push_with(
1618                &full,
1619                &indices,
1620                Material::solid([1.0; 4]),
1621                ColorFilter::None,
1622                BlendMode::Src,
1623                None,
1624                ClipState::narrow(1),
1625            )
1626            .expect("a clip being built");
1627        batch
1628            .push(&full, &indices, Material::solid([1.0; 4]), BlendMode::Src)
1629            .expect("an opaque cover");
1630
1631        assert_eq!(batch.cull_occluded(TARGET), 0);
1632        assert_eq!(batch.draws().len(), 2);
1633        assert_eq!(batch.draws()[0].clip, None);
1634    }
1635
1636    /// An opaque solid fill is what the predicate exists to admit.
1637    #[test]
1638    fn an_opaque_solid_fill_occludes() {
1639        assert!(one(Material::solid([1.0; 4]), BlendMode::SrcOver)
1640            .occludes(&white_tri(), &WHITE_TRI_INDICES));
1641        assert!(one(Material::solid([0.2, 0.3, 0.4, 1.0]), BlendMode::Src)
1642            .occludes(&white_tri(), &WHITE_TRI_INDICES));
1643    }
1644
1645    /// And every reason to refuse is refused, each on its own.
1646    ///
1647    /// Written out one condition at a time rather than as a table, because the
1648    /// point of each row is *why* it is unsafe and a table would carry the
1649    /// values without the reason. A wrong yes here is a wrong picture.
1650    #[test]
1651    fn nothing_the_predicate_cannot_prove_occludes() {
1652        // Translucent: the destination shows through, which is the whole
1653        // question.
1654        assert!(
1655            !one(Material::solid([1.0, 1.0, 1.0, 0.5]), BlendMode::SrcOver)
1656                .occludes(&white_tri(), &WHITE_TRI_INDICES)
1657        );
1658        assert!(!one(Material::solid([0.0; 4]), BlendMode::SrcOver)
1659            .occludes(&white_tri(), &WHITE_TRI_INDICES));
1660
1661        // A mode that reads the destination cannot have the destination moved
1662        // out from under it.
1663        for blend in [
1664            BlendMode::Multiply,
1665            BlendMode::Screen,
1666            BlendMode::DstOver,
1667            BlendMode::Xor,
1668            BlendMode::Plus,
1669        ] {
1670            assert!(
1671                !one(Material::solid([1.0; 4]), blend).occludes(&white_tri(), &WHITE_TRI_INDICES),
1672                "{blend:?} reads what it is drawn over"
1673            );
1674        }
1675
1676        // The analytic materials blend their own coverage, so an opaque color
1677        // still leaves a soft edge. This is the case the predicate is really
1678        // for: every one of these would pass a naive "is the color opaque" test.
1679        let analytic = [
1680            Material::RoundedRect {
1681                color: [1.0; 4],
1682                half_size: [4.0, 4.0],
1683                to_local: ToLocal::default(),
1684                radius: 1.0,
1685                outer_radius: 1.0,
1686                stroke: 0.0,
1687            },
1688            Material::Ellipse {
1689                color: [1.0; 4],
1690                half_size: [4.0, 4.0],
1691                to_local: ToLocal::default(),
1692                stroke: 0.0,
1693            },
1694        ];
1695        for material in analytic {
1696            assert!(
1697                !one(material, BlendMode::SrcOver).occludes(&white_tri(), &WHITE_TRI_INDICES),
1698                "an analytic shape computes coverage and blends it"
1699            );
1700        }
1701    }
1702
1703    /// A filter or a tint can take alpha down after the material produced it.
1704    #[test]
1705    fn a_filter_or_a_tint_refuses_it() {
1706        let mut filtered = one(Material::solid([1.0; 4]), BlendMode::SrcOver);
1707        assert!(
1708            filtered.occludes(&white_tri(), &WHITE_TRI_INDICES),
1709            "the draw is otherwise admissible"
1710        );
1711
1712        filtered.filter = ColorFilter::Blend {
1713            color: [1.0, 1.0, 1.0, 0.25],
1714            mode: BlendMode::SrcOver,
1715        };
1716        assert!(
1717            !filtered.occludes(&white_tri(), &WHITE_TRI_INDICES),
1718            "a blend filter can lower alpha"
1719        );
1720
1721        let mut tinted = one(Material::solid([1.0; 4]), BlendMode::SrcOver);
1722        tinted.tint_blend = BlendMode::Plus;
1723        assert!(
1724            !tinted.occludes(&white_tri(), &WHITE_TRI_INDICES),
1725            "only Modulate is the identity against a solid fill's white"
1726        );
1727
1728        let mut sampled = one(Material::solid([1.0; 4]), BlendMode::SrcOver);
1729        sampled.paint_at_texture_coords = true;
1730        assert!(
1731            !sampled.occludes(&white_tri(), &WHITE_TRI_INDICES),
1732            "reading the paint elsewhere is a value this cannot see"
1733        );
1734    }
1735
1736    /// A stencil-writing draw is sequencing, and a clipped one depends on it.
1737    #[test]
1738    fn anything_touching_the_stencil_refuses_it() {
1739        for stencil in [
1740            ClipState::narrow(0),
1741            ClipState::widen(1),
1742            ClipState::content(1),
1743        ] {
1744            let mut draw = one(Material::solid([1.0; 4]), BlendMode::SrcOver);
1745            draw.stencil = stencil;
1746            assert!(
1747                !draw.occludes(&white_tri(), &WHITE_TRI_INDICES),
1748                "{stencil:?} either writes the stencil or depends on it"
1749            );
1750        }
1751    }
1752}