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}