frust_engine/gpu/atlas.rs
1//! The image atlas array, and the encoded-image record the strip shader reads
2//! it through.
3//!
4//! Two halves, split on the same line the rest of [`crate::gpu`] is split on.
5//!
6//! - **Pure decisions over plain values.** The texture descriptor, the packing
7//! of an [`AtlasRegion`] and a sampler into a
8//! [`GpuEncodedImage`](super::GpuEncodedImage), and the natural-to-device
9//! transform an image paint carries — all host-testable with no device.
10//! - **One live-GPU type.** [`AtlasArray`] owns the `Rgba8Unorm` `D2Array`
11//! texture the shader's `atlas_texture_array` binding samples, grows it a
12//! layer at a time, writes newly resident regions and clears evicted ones.
13//!
14//! ## Why the atlas is not pooled
15//!
16//! [`crate::gpu::targets`] pools every *transient* the engine allocates, on the
17//! rule that a target nothing outlives the frame should be reused rather than
18//! reallocated. The atlas is the opposite kind of resource: its whole purpose
19//! is that an image uploaded on one frame is still there on the next thousand,
20//! so it is owned outright for the life of the renderer and reclaimed a region
21//! at a time by [`crate::cache::images`]'s age-based reap. Handing it to the
22//! pool would make residency a lie.
23//!
24//! ## Growth and clearing
25//!
26//! A `wgpu` texture's array-layer count is fixed at creation, so growing the
27//! array means creating a deeper texture and copying every existing layer
28//! across — which is why the array carries `COPY_SRC` alongside `COPY_DST`.
29//! Growth is rare (a layer holds a whole mobile budget's worth of images) and
30//! never shrinks: an atlas that grew to four layers under load keeps them.
31//!
32//! ### Why growth submits a command buffer of its own
33//!
34//! That copy is the one piece of engine work that cannot ride the frame's
35//! encoder. `wgpu` flushes the queued writes pending at a submit *before* the
36//! command buffers of that same submit, so a copy recorded into the frame's
37//! encoder would execute after the frame's own `write_texture` uploads and
38//! clears — restoring the pre-growth contents of every layer they had just
39//! written, permanently (residency schedules an upload on a miss, and the image
40//! is not a miss any more). Ordering is therefore established by submitting the
41//! copy on its own, ahead of the frame's writes being issued at all.
42//!
43//! That is a **maintenance** submit, not scene work: it carries one texture
44//! copy, no pass and no draw, and it happens only on the rare frame that grows
45//! the array. [`crate::renderer::EngineRenderer::encode`]'s contract that the
46//! engine never submits *the caller's* encoder, and records no scene work
47//! anywhere else, is untouched — the caller's encoder is neither read nor
48//! finished here.
49//!
50//! Clearing an evicted region writes transparent texels through the queue
51//! rather than drawing a scissored pass. Both reach the same result; the queue
52//! write needs no pipeline, no render pass and no bind group, and eviction is
53//! a once-in-sixty-frames event whose cost is a zeroed staging buffer the size
54//! of the region.
55//!
56//! ## Rendering *into* the atlas
57//!
58//! Everything above writes the atlas from the host. A glyph is different: it
59//! has no pixels until something rasterizes its outline, and `glifo`'s
60//! [`AtlasCacher::Enabled`](glifo::AtlasCacher::Enabled) path does not
61//! rasterize one — it records the fills into a per-page
62//! [`AtlasCommandRecorder`] and leaves the pixels to whoever owns the atlas.
63//! [`AtlasRenderer`] is that owner on this tier: it replays each dirty page's
64//! commands into a strip pass whose colour attachment is that page's own array
65//! layer.
66//!
67//! Three orderings make the difference between a correct glyph and a stale
68//! one, and all three are this type's to keep.
69//!
70//! 1. **Clears before uploads.** A rectangle an eviction freed can be handed
71//! straight back out to a different glyph on the same frame, so zeroing it
72//! after that glyph's pixels landed would erase the glyph that just moved
73//! in. Both are queue writes, which execute in the order they are issued —
74//! so the order they are issued in is the whole guarantee.
75//! 2. **Both before the pass.** `wgpu` flushes the writes queued at a submit
76//! before that submit's command buffers, so a page's replay pass sees the
77//! clears and the bitmap uploads already applied to the layer it composites
78//! onto, without anything having to be said about it.
79//! 3. **The pass before the scene's.** A glyph the scene pass samples out of
80//! the atlas has to be *in* the atlas by then, and the scene pass lives in
81//! an encoder this crate does not own and never submits. So the replay pass
82//! goes into an encoder of [`AtlasRenderer`]'s own and is submitted before
83//! it returns — the sanctioned exception to `frust_gpu::CommandBuffer`'s
84//! single-submit borrowing contract, named there as the glyph-atlas upload
85//! carve-out and the same one [`AtlasArray::ensure_layers`] already takes
86//! for the growth copy.
87//!
88//! One submit per dirty page rather than one for all of them: the coverage a
89//! page's strips index is uploaded to a shared alpha texture, and a second
90//! queue write to that texture in the same submit would overwrite the first
91//! before either pass ran. Pages are dirty only on a frame that missed a glyph,
92//! and there is one page in the overwhelming case, so the extra submit is a
93//! per-miss cost rather than a per-frame one.
94//!
95//! ### What the replay does not lower
96//!
97//! Turning a recorded command stream into strips is compiler work, and
98//! [`crate::compile`] already depends on this module — so the lowering is a
99//! closure the caller supplies ([`AtlasRenderer::render_pending`]) rather than
100//! a dependency taken the other way. What this module contributes to it is
101//! [`push_solid_strips`], the pure expansion of one solid-painted strip run
102//! into instances, which is the whole of an outline glyph's lowering.
103
104use vello_common::encode::EncodedImage;
105use vello_common::kurbo::{Affine, Rect, Vec2};
106use vello_common::paint::{ImageSource, Tint, TintMode};
107use vello_common::strip::Strip;
108
109use frust_gpu::TierCaps;
110use glifo::atlas::PendingBitmapUpload;
111use glifo::{AtlasCommandRecorder, GlyphAtlas, PendingClearRect};
112
113use crate::EngineError;
114use crate::cache::images::{ATLAS_FORMAT_BYTES, AtlasRegion, ResidentImage};
115use crate::diag::{EngineSpan, FrameTimestamps};
116
117use super::GpuEncodedPaint;
118use super::config::GpuConfig;
119use super::paint_texture::GpuEncodedImage;
120use super::strips::{GpuStrip, PaintType, StripDraw, pack_paint_descriptor};
121
122/// The texture format the atlas array stores premultiplied image texels in.
123pub const ATLAS_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
124
125/// The usages the atlas array is created with.
126///
127/// `TEXTURE_BINDING` for the strip shader's own sampling, `COPY_DST` for a
128/// region upload or clear, `COPY_SRC` for the layer copy that growth performs,
129/// and `RENDER_ATTACHMENT` so a scissored clear pass remains available to a
130/// later caller that wants one — the same four the reference renderer creates
131/// its atlas with.
132pub const ATLAS_USAGES: wgpu::TextureUsages = wgpu::TextureUsages::TEXTURE_BINDING
133 .union(wgpu::TextureUsages::COPY_DST)
134 .union(wgpu::TextureUsages::COPY_SRC)
135 .union(wgpu::TextureUsages::RENDER_ATTACHMENT);
136
137/// The most atlas layers the encoded-image record's `atlas_index` field can
138/// name (eight bits).
139pub const MAX_ATLAS_INDEX: u32 = 0xFF;
140
141/// The descriptor for an atlas array of `width` x `height` texels over
142/// `layers` array layers.
143///
144/// `layers` is raised to at least **two**, not one. A texture with zero array
145/// layers cannot be created at all, which would be reason enough for a floor
146/// of one — but wgpu-hal 30.0.1's GLES backend picks a texture's GL target
147/// from the descriptor alone and never consults the view dimension a caller
148/// binds it through (`get_info_from_desc`, wgpu-hal `src/gles/mod.rs:513-530`:
149/// `(false, 1) => TEXTURE_2D`). A one-layer array descriptor therefore binds
150/// as plain `GL_TEXTURE_2D` while [`super::atlas`]'s strip shader always
151/// samples this texture as a `sampler2DArray`
152/// (`crates/frust-engine/shaders/strip.wgsl`'s `texture_2d_array<f32>`
153/// binding) — target and sampler disagree, the texture reads as incomplete
154/// per GLES 3.0 §3.8.2, and every sample returns `(0, 0, 0, 1)`: a solid box
155/// instead of a glyph, a solid black rect instead of an image. Two layers is
156/// the smallest depth the heuristic reads as `TEXTURE_2D_ARRAY`, so it is the
157/// floor a renderer with a single resident image or glyph page still has to
158/// allocate at. See wgpu upstream issues #1614 and #1574; this is a
159/// workaround, not the fix, and is meant to come out once wgpu-hal honours
160/// the view dimension (tracked in `docs/LIMITATIONS.md`).
161#[must_use]
162pub fn atlas_texture_descriptor(
163 width: u32,
164 height: u32,
165 layers: u32,
166) -> wgpu::TextureDescriptor<'static> {
167 wgpu::TextureDescriptor {
168 label: Some("frust-engine image atlas array"),
169 size: wgpu::Extent3d {
170 width: width.max(1),
171 height: height.max(1),
172 depth_or_array_layers: layers.max(2),
173 },
174 mip_level_count: 1,
175 sample_count: 1,
176 dimension: wgpu::TextureDimension::D2,
177 format: ATLAS_FORMAT,
178 usage: ATLAS_USAGES,
179 view_formats: &[],
180 }
181}
182
183/// The `D2Array` view descriptor the strip shader's atlas binding expects.
184#[must_use]
185pub fn atlas_view_descriptor() -> wgpu::TextureViewDescriptor<'static> {
186 wgpu::TextureViewDescriptor {
187 label: Some("frust-engine image atlas array view"),
188 format: None,
189 dimension: Some(wgpu::TextureViewDimension::D2Array),
190 aspect: wgpu::TextureAspect::All,
191 base_mip_level: 0,
192 mip_level_count: None,
193 base_array_layer: 0,
194 array_layer_count: None,
195 usage: None,
196 }
197}
198
199/// The single-layer `D2` view descriptor a render pass attaches one atlas
200/// layer through.
201///
202/// Deliberately not [`atlas_view_descriptor`]'s shape: a colour attachment has
203/// to name exactly one layer, so this is a plain 2D view over
204/// `layer`, not the `D2Array` view the shader samples the whole array through.
205/// The two coexist on the same texture — one bound for sampling by the scene
206/// pass, one attached for writing by the replay pass — which is why
207/// [`ATLAS_USAGES`] carries both `TEXTURE_BINDING` and `RENDER_ATTACHMENT`.
208#[must_use]
209pub fn atlas_layer_view_descriptor(layer: u32) -> wgpu::TextureViewDescriptor<'static> {
210 wgpu::TextureViewDescriptor {
211 label: Some("frust-engine image atlas layer target"),
212 format: None,
213 dimension: Some(wgpu::TextureViewDimension::D2),
214 aspect: wgpu::TextureAspect::All,
215 base_mip_level: 0,
216 mip_level_count: None,
217 base_array_layer: layer,
218 array_layer_count: Some(1),
219 usage: None,
220 }
221}
222
223/// The atlas array texture, its view, and the layer count both were created
224/// at.
225///
226/// Created lazily by the first frame that makes an image resident, then grown
227/// only. The view is kept beside the texture because a bind group is built
228/// against it and has to be rebuilt whenever growth replaces the texture —
229/// [`generation`](Self::generation) is what tells a caller that happened.
230#[derive(Debug)]
231pub struct AtlasArray {
232 texture: wgpu::Texture,
233 view: wgpu::TextureView,
234 width: u32,
235 height: u32,
236 layers: u32,
237 generation: u64,
238}
239
240impl AtlasArray {
241 /// An atlas array of `width` x `height` texels with at least two layers due to
242 /// the floor raised by wgpu-hal 30.0.1's GLES backend heuristic — one resident
243 /// layer is still the logical minimum, but a second layer is allocated to work
244 /// around a target-selection bug in `get_info_from_desc` (see [`atlas_texture_descriptor`]).
245 #[must_use]
246 pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
247 Self::with_layers(device, width, height, 1)
248 }
249
250 /// An atlas array of `width` x `height` texels over `layers` layers.
251 #[must_use]
252 pub fn with_layers(device: &wgpu::Device, width: u32, height: u32, layers: u32) -> Self {
253 let descriptor = atlas_texture_descriptor(width, height, layers);
254 let texture = device.create_texture(&descriptor);
255 let view = texture.create_view(&atlas_view_descriptor());
256
257 Self {
258 texture,
259 view,
260 width: descriptor.size.width,
261 height: descriptor.size.height,
262 layers: descriptor.size.depth_or_array_layers,
263 generation: 0,
264 }
265 }
266
267 /// The array texture the shader samples.
268 #[must_use]
269 pub fn texture(&self) -> &wgpu::Texture {
270 &self.texture
271 }
272
273 /// The `D2Array` view a bind group binds.
274 #[must_use]
275 pub fn view(&self) -> &wgpu::TextureView {
276 &self.view
277 }
278
279 /// Extent of each layer, in texels.
280 #[must_use]
281 pub fn size(&self) -> (u32, u32) {
282 (self.width, self.height)
283 }
284
285 /// How many array layers currently exist.
286 #[must_use]
287 pub fn layers(&self) -> u32 {
288 self.layers
289 }
290
291 /// A render-attachment view over one array layer, or `None` when the array
292 /// has no such layer.
293 ///
294 /// Minted per use rather than cached alongside [`view`](Self::view): a
295 /// layer target is wanted only on a frame that has glyph pixels to
296 /// rasterize, while the sampling view is bound by every frame, and holding
297 /// one view per layer for the array's lifetime would keep a handle alive
298 /// per layer for a path most frames never take.
299 #[must_use]
300 pub fn layer_view(&self, layer: u32) -> Option<wgpu::TextureView> {
301 (layer < self.layers).then(|| {
302 self.texture
303 .create_view(&atlas_layer_view_descriptor(layer))
304 })
305 }
306
307 /// How many times growth has replaced the underlying texture.
308 ///
309 /// A bind group built against [`view`](Self::view) stays valid for as long
310 /// as this value does not change.
311 #[must_use]
312 pub fn generation(&self) -> u64 {
313 self.generation
314 }
315
316 /// Grow the array to hold at least `layers` layers, preserving every
317 /// existing layer's texels.
318 ///
319 /// Returns whether the texture was replaced — the signal a caller needs to
320 /// rebuild its bind group. A request at or below the current depth, or one
321 /// past [`MAX_ATLAS_INDEX`], is a no-op: the encoded-image record cannot
322 /// name a layer the shader could not address, so refusing here is what
323 /// keeps an unaddressable layer from being created at all.
324 ///
325 /// The old-to-new copy is recorded into a command encoder of this method's
326 /// own and submitted before returning, rather than into the frame's. That is
327 /// an ordering requirement rather than a convenience — see the module doc's
328 /// *Why growth submits a command buffer of its own*. Call it before the
329 /// frame's atlas writes are issued; anything already queued is flushed by
330 /// this submit and so lands in the *old* texture.
331 pub fn ensure_layers(
332 &mut self,
333 device: &wgpu::Device,
334 queue: &wgpu::Queue,
335 layers: u32,
336 ) -> bool {
337 if layers <= self.layers || layers > MAX_ATLAS_INDEX + 1 {
338 return false;
339 }
340
341 let grown = Self::with_layers(device, self.width, self.height, layers);
342 let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
343 label: Some("frust-engine image atlas growth"),
344 });
345 encoder.copy_texture_to_texture(
346 self.texture.as_image_copy(),
347 grown.texture.as_image_copy(),
348 wgpu::Extent3d {
349 width: self.width,
350 height: self.height,
351 depth_or_array_layers: self.layers,
352 },
353 );
354 queue.submit(std::iter::once(encoder.finish()));
355
356 let generation = self.generation.saturating_add(1);
357 *self = grown;
358 self.generation = generation;
359 true
360 }
361
362 /// Write `pixels` into `region`.
363 ///
364 /// `pixels` must be `region`'s own extent in premultiplied `Rgba8Unorm`,
365 /// row-major and unpadded — exactly what
366 /// [`crate::cache::images::ImageUpload`] carries. A slice that does not
367 /// match, or a region outside the array, is refused rather than handed to
368 /// the queue, which would validate it into a device error mid-frame.
369 pub fn write_region(&self, queue: &wgpu::Queue, region: AtlasRegion, pixels: &[u8]) -> bool {
370 if !self.contains(region) || pixels.len() != region.byte_len() {
371 return false;
372 }
373
374 queue.write_texture(
375 wgpu::TexelCopyTextureInfo {
376 texture: &self.texture,
377 mip_level: 0,
378 origin: wgpu::Origin3d {
379 x: region.offset[0],
380 y: region.offset[1],
381 z: region.layer,
382 },
383 aspect: wgpu::TextureAspect::All,
384 },
385 pixels,
386 wgpu::TexelCopyBufferLayout {
387 offset: 0,
388 bytes_per_row: Some(region.bytes_per_row()),
389 rows_per_image: Some(region.size[1]),
390 },
391 wgpu::Extent3d {
392 width: region.size[0],
393 height: region.size[1],
394 depth_or_array_layers: 1,
395 },
396 );
397 true
398 }
399
400 /// Clear `region` to transparent texels.
401 ///
402 /// This is what makes an eviction observable as *absence* rather than as a
403 /// stale image: the rectangle a reaped entry gave back is zeroed before a
404 /// later allocation can hand part of it to something smaller, so a sample
405 /// that strays into the unwritten remainder reads transparent black rather
406 /// than the previous tenant's pixels.
407 pub fn clear_region(&self, queue: &wgpu::Queue, region: AtlasRegion) -> bool {
408 self.clear_region_with(queue, region, &mut Vec::new())
409 }
410
411 /// [`clear_region`](Self::clear_region) over a caller-owned zero buffer.
412 ///
413 /// `zeros` only ever grows and is only ever zero-filled, so a caller
414 /// clearing many rectangles per frame pays for the largest one once
415 /// instead of one allocation per rectangle.
416 pub fn clear_region_with(
417 &self,
418 queue: &wgpu::Queue,
419 region: AtlasRegion,
420 zeros: &mut Vec<u8>,
421 ) -> bool {
422 if !self.contains(region) {
423 return false;
424 }
425 let len = region.byte_len();
426 if zeros.len() < len {
427 zeros.resize(len, 0);
428 }
429 self.write_region(queue, region, &zeros[..len])
430 }
431
432 /// Whether `region` lies wholly inside this array.
433 #[must_use]
434 pub fn contains(&self, region: AtlasRegion) -> bool {
435 !region.is_empty()
436 && region.layer < self.layers
437 && region.offset[0].saturating_add(region.size[0]) <= self.width
438 && region.offset[1].saturating_add(region.size[1]) <= self.height
439 }
440}
441
442/// The bytes a region's texels occupy — the length
443/// [`AtlasArray::write_region`] requires of its slice.
444#[must_use]
445pub fn region_byte_len(region: AtlasRegion) -> usize {
446 (region.size[0] as usize)
447 .saturating_mul(region.size[1] as usize)
448 .saturating_mul(ATLAS_FORMAT_BYTES as usize)
449}
450
451/// The affine mapping an image's natural pixel rectangle
452/// `(0, 0, width, height)` onto `dest`, composed under `transform`.
453///
454/// The same composition `frust-render`'s CPU-tier lowering applies, so the two
455/// tiers place an image identically: a natural-size draw under the widget's own
456/// transform, translated to `dest`'s origin and scaled to `dest`'s extent.
457/// `None` for a degenerate natural size, which has no scale to derive.
458#[must_use]
459pub fn natural_to_dest(transform: Affine, natural: (u32, u32), dest: Rect) -> Option<Affine> {
460 let natural_w = f64::from(natural.0);
461 let natural_h = f64::from(natural.1);
462 if natural_w <= 0.0 || natural_h <= 0.0 {
463 return None;
464 }
465
466 Some(
467 transform
468 * Affine::translate((dest.x0, dest.y0))
469 * Affine::scale_non_uniform(dest.width() / natural_w, dest.height() / natural_h),
470 )
471}
472
473/// The per-pixel advances in image space an encoded image carries, derived
474/// from its already-inverted transform.
475///
476/// The linear part only: an advance is a direction, so the translation is
477/// dropped. `vello_common` computes the same pair internally and keeps it
478/// private, so it is restated here rather than reached for.
479#[must_use]
480pub fn x_y_advances(transform: Affine) -> (Vec2, Vec2) {
481 let c = transform.as_coeffs();
482 (Vec2::new(c[0], c[1]), Vec2::new(c[2], c[3]))
483}
484
485/// Packs an image's width and height into one word, width in the high half.
486#[must_use]
487pub const fn pack_image_size(width: u16, height: u16) -> u32 {
488 ((width as u32) << 16) | (height as u32)
489}
490
491/// Packs an image's atlas offset into one word, x in the high half.
492#[must_use]
493pub const fn pack_image_offset(x: u16, y: u16) -> u32 {
494 ((x as u32) << 16) | (y as u32)
495}
496
497/// Packs sampling quality (bits 0-1), the two extend modes (bits 2-3 and 4-5),
498/// the atlas layer (bits 6-13) and the source kind (bit 14) into one word.
499///
500/// Each field is masked to its width rather than reported: every input is
501/// produced by this crate's own encoding, and the preconditions are checked in
502/// debug builds.
503#[must_use]
504pub fn pack_image_params(
505 quality: u32,
506 extend_x: u32,
507 extend_y: u32,
508 atlas_index: u32,
509 is_external: bool,
510) -> u32 {
511 debug_assert!(quality <= 3, "quality must fit two bits");
512 debug_assert!(extend_x <= 3, "extend_x must fit two bits");
513 debug_assert!(extend_y <= 3, "extend_y must fit two bits");
514 debug_assert!(
515 atlas_index <= MAX_ATLAS_INDEX,
516 "atlas index {atlas_index} exceeds {MAX_ATLAS_INDEX}",
517 );
518
519 (u32::from(is_external) << 14)
520 | ((atlas_index & MAX_ATLAS_INDEX) << 6)
521 | ((extend_y & 0b11) << 4)
522 | ((extend_x & 0b11) << 2)
523 | (quality & 0b11)
524}
525
526/// The premultiplied colour and mode an optional tint packs to.
527///
528/// With no tint the colour is all-ones under [`TintMode::Multiply`], which
529/// leaves the sampled texel exactly as it was — the shader always applies a
530/// tint, so "no tint" has to be expressed as an identity one rather than as a
531/// branch.
532#[must_use]
533pub fn pack_tint(tint: Option<Tint>) -> (u32, u32) {
534 match tint {
535 Some(tint) => (
536 tint.color.premultiply().to_rgba8().to_u32(),
537 tint.mode.as_u32(),
538 ),
539 None => (u32::MAX, TintMode::Multiply.as_u32()),
540 }
541}
542
543/// The shader's extend-mode numbering.
544pub const fn extend_mode(extend: peniko::Extend) -> u32 {
545 match extend {
546 peniko::Extend::Pad => 0,
547 peniko::Extend::Repeat => 1,
548 peniko::Extend::Reflect => 2,
549 }
550}
551
552/// Lower one encoded image paint into the record the strip shader samples.
553///
554/// `resident` is where the image's texels were made resident, which only a
555/// caller that already serviced the frame's residency can supply — the same
556/// shape [`super::paint_texture::lower_encoded_paint`] uses for a gradient's
557/// baked ramp. An image whose source is not the handle `resident` names
558/// answers `None`, so a paint encoded against a different residency becomes a
559/// dropped draw rather than a wrongly-addressed one.
560///
561/// The encoded transform is already the inverse mapping — device space into
562/// image space — because that is the direction the shader applies it in;
563/// narrowing it to `f32` here is what the record and the WGSL both read it at.
564#[must_use]
565pub fn lower_encoded_image(
566 image: &EncodedImage,
567 resident: &ResidentImage,
568) -> Option<GpuEncodedPaint> {
569 match &image.source {
570 ImageSource::OpaqueId { id, .. } if *id == resident.id => {}
571 _ => return None,
572 }
573
574 let region = resident.region;
575 let (tint, tint_mode) = pack_tint(image.tint);
576
577 Some(GpuEncodedPaint::Image(GpuEncodedImage {
578 image_params: pack_image_params(
579 image.sampler.quality as u32,
580 extend_mode(image.sampler.x_extend),
581 extend_mode(image.sampler.y_extend),
582 region.layer,
583 false,
584 ),
585 image_size: pack_image_size(truncate_u16(region.size[0]), truncate_u16(region.size[1])),
586 image_offset: pack_image_offset(
587 truncate_u16(region.offset[0]),
588 truncate_u16(region.offset[1]),
589 ),
590 transform: image.transform.as_coeffs().map(|coeff| coeff as f32),
591 tint,
592 tint_mode,
593 image_padding: resident.padding,
594 }))
595}
596
597/// `value` narrowed to the `u16` the record's packed halves hold.
598///
599/// Saturating rather than wrapping: every caller has already passed the
600/// residency's own `u16` ceiling, so this can only ever be the identity, and a
601/// saturation is the harmless reading if that ever stops being true.
602const fn truncate_u16(value: u32) -> u16 {
603 if value > u16::MAX as u32 {
604 u16::MAX
605 } else {
606 value as u16
607 }
608}
609
610/// The width of the stand-in encoded-paint texture an atlas pass binds.
611///
612/// One texel, because nothing an atlas pass draws reads that texture: a
613/// replayed glyph outline paints solid, and a solid instance carries its colour
614/// in its own payload. A binding still has to be filled for the pass to
615/// validate, and the config the shader reconstructs the texture's width from
616/// has to agree with it — a power of two, which one is.
617const PLACEHOLDER_PAINT_TEX_WIDTH: u32 = 1;
618
619/// Instances an atlas pass's buffer holds before it is grown for a page that
620/// needs more.
621///
622/// A page of glyph outlines is a few hundred strips in the ordinary case; this
623/// floor keeps the first miss from allocating a buffer measured in single
624/// instances and then reallocating it four times on the way up.
625const MIN_ATLAS_INSTANCES: u64 = 256;
626
627/// Expands one solid-painted strip run into the instances that draw it,
628/// appending to `out`.
629///
630/// This is the whole of an outline glyph's lowering: `glifo` records a glyph as
631/// a fill of a path in one colour, so every instance of the run repeats the
632/// same premultiplied `payload` and the same solid paint descriptor, and a
633/// position-sampled paint's per-instance re-evaluation never arises.
634///
635/// `strips` is a *generation's* strips, sentinel included: a strip's extent is
636/// carried by the strip after it, so instances come from consecutive pairs and
637/// the trailing sentinel becomes none. Passing a run without its sentinel
638/// silently drops the last span.
639///
640/// Returns how many instances were appended, which is neither `strips.len()`
641/// nor bounded by it — a pair can contribute a span, a winding gap fill, both,
642/// or neither.
643pub fn push_solid_strips(
644 strips: &[Strip],
645 payload: u32,
646 depth: u32,
647 out: &mut Vec<GpuStrip>,
648) -> u32 {
649 let draw = StripDraw {
650 payload,
651 paint: pack_paint_descriptor(PaintType::Solid, 0),
652 depth_index: depth,
653 };
654
655 let before = out.len();
656 for pair in strips.windows(2) {
657 let span = GpuStrip::from_strip_pair(&pair[0], &pair[1], draw);
658 if span.width > 0 {
659 out.push(span);
660 }
661 if let Some(gap) = GpuStrip::gap_fill(&pair[0], &pair[1], draw) {
662 out.push(gap);
663 }
664 }
665 u32::try_from(out.len().saturating_sub(before)).unwrap_or(u32::MAX)
666}
667
668/// One page's lowered replay: the instances to draw and the coverage they
669/// index.
670///
671/// Reused across pages and across frames rather than allocated per page — the
672/// two vectors are the only heap a replay costs once they have grown, which is
673/// the point of handing them to the lowering closure instead of taking a fresh
674/// pair back from it.
675///
676/// The two are kept together because a strip instance's alpha column is only
677/// meaningful against the coverage buffer generated alongside it, the same
678/// pairing [`crate::compile::CompiledFrame`] keeps for a frame.
679#[derive(Debug, Default)]
680pub struct AtlasPageBuffers {
681 /// The page's strip instances, in the order they are drawn.
682 pub instances: Vec<GpuStrip>,
683 /// The coverage bytes [`instances`](Self::instances) index.
684 pub alphas: Vec<u8>,
685}
686
687impl AtlasPageBuffers {
688 /// Empties both buffers, keeping their capacity for the next page.
689 pub fn clear(&mut self) {
690 self.instances.clear();
691 self.alphas.clear();
692 }
693
694 /// Whether this page would draw nothing.
695 #[must_use]
696 pub fn is_empty(&self) -> bool {
697 self.instances.is_empty()
698 }
699}
700
701/// What one call to [`AtlasRenderer::render_pending`] serviced.
702///
703/// Every field is a count rather than a flag because each names work that
704/// either reached the atlas or did not, and a glyph that silently failed to
705/// rasterize is otherwise indistinguishable from one the text never asked for.
706#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
707pub struct AtlasRenderReport {
708 /// Evicted rectangles zeroed, ahead of every upload.
709 pub cleared: u32,
710 /// Bitmap (and COLR pixmap) glyphs written through the queue.
711 pub uploaded: u32,
712 /// Pages whose recorded commands were replayed into a pass.
713 pub pages: u32,
714 /// Strip instances those passes drew.
715 pub instances: u32,
716 /// Pages, clears and uploads that were refused — a page the lowering
717 /// declined, a layer the array does not have, coverage past the alpha
718 /// texture's ceiling, or a region the array would not take.
719 pub refused: u32,
720}
721
722impl AtlasRenderReport {
723 /// Whether this call did anything at all.
724 ///
725 /// The steady state: text that hit the cache on every glyph queues no
726 /// upload, frees no rectangle and dirties no page, so an atlas renderer
727 /// driven every frame submits nothing on almost all of them.
728 #[must_use]
729 pub fn is_empty(&self) -> bool {
730 *self == Self::default()
731 }
732}
733
734/// One resource texture the atlas pass owns, and the extent it holds.
735#[derive(Debug)]
736struct AtlasResourceTexture {
737 texture: wgpu::Texture,
738 view: wgpu::TextureView,
739 width: u32,
740 height: u32,
741}
742
743impl AtlasResourceTexture {
744 fn new(device: &wgpu::Device, descriptor: &wgpu::TextureDescriptor<'_>) -> Self {
745 let texture = device.create_texture(descriptor);
746 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
747 Self {
748 texture,
749 view,
750 width: descriptor.size.width,
751 height: descriptor.size.height,
752 }
753 }
754
755 fn copy_target(&self) -> wgpu::TexelCopyTextureInfo<'_> {
756 wgpu::TexelCopyTextureInfo {
757 texture: &self.texture,
758 mip_level: 0,
759 origin: wgpu::Origin3d::ZERO,
760 aspect: wgpu::TextureAspect::All,
761 }
762 }
763
764 fn extent(&self) -> wgpu::Extent3d {
765 wgpu::Extent3d {
766 width: self.width,
767 height: self.height,
768 depth_or_array_layers: 1,
769 }
770 }
771}
772
773/// The 1x1 stand-ins for the strip shader's bindings an atlas pass has nothing
774/// real for.
775///
776/// Every declared binding has to be filled for a pass to validate, whether or
777/// not an instance samples it. Three of these five are stand-ins for the same
778/// reason the frame path's are — no layer input, no external texture, no
779/// gradient ramp. The other two are stand-ins for a reason particular to this
780/// pass: the *atlas array* itself is deliberately not bound here, because the
781/// pass is writing one of its layers, and a texture attached for writing cannot
782/// also be bound for sampling in the same pass; and the encoded-paint texture
783/// is unused because a replayed outline paints solid.
784#[derive(Debug)]
785struct AtlasPlaceholders {
786 layer_input: wgpu::TextureView,
787 array: wgpu::TextureView,
788 external: wgpu::TextureView,
789 paints: wgpu::TextureView,
790 gradients: wgpu::TextureView,
791}
792
793impl AtlasPlaceholders {
794 fn new(device: &wgpu::Device) -> Self {
795 Self {
796 layer_input: placeholder(
797 device,
798 "frust-engine atlas pass layer input",
799 ATLAS_FORMAT,
800 false,
801 ),
802 array: placeholder(device, "frust-engine atlas pass array", ATLAS_FORMAT, true),
803 external: placeholder(
804 device,
805 "frust-engine atlas pass external",
806 ATLAS_FORMAT,
807 false,
808 ),
809 paints: placeholder(
810 device,
811 "frust-engine atlas pass paints",
812 super::RESOURCE_TEXTURE_FORMAT,
813 false,
814 ),
815 gradients: placeholder(
816 device,
817 "frust-engine atlas pass gradients",
818 ATLAS_FORMAT,
819 false,
820 ),
821 }
822 }
823}
824
825/// A 1x1 sampled-only texture's view, as a plain 2D texture or a 2D array.
826///
827/// The array variant allocates **two** layers, not one, for the same reason
828/// [`atlas_texture_descriptor`] does: wgpu-hal 30.0.1's GLES backend derives
829/// the GL target from the descriptor's layer count alone, and a one-layer
830/// array descriptor binds as `GL_TEXTURE_2D` rather than
831/// `GL_TEXTURE_2D_ARRAY` (see that function's doc comment for the file:line
832/// and upstream issue refs). Only layer zero of the two is ever sampled here.
833fn placeholder(
834 device: &wgpu::Device,
835 label: &'static str,
836 format: wgpu::TextureFormat,
837 array: bool,
838) -> wgpu::TextureView {
839 let texture = device.create_texture(&wgpu::TextureDescriptor {
840 label: Some(label),
841 size: wgpu::Extent3d {
842 width: 1,
843 height: 1,
844 depth_or_array_layers: if array { 2 } else { 1 },
845 },
846 mip_level_count: 1,
847 sample_count: 1,
848 dimension: wgpu::TextureDimension::D2,
849 format,
850 usage: wgpu::TextureUsages::TEXTURE_BINDING,
851 view_formats: &[],
852 });
853 texture.create_view(&wgpu::TextureViewDescriptor {
854 label: Some(label),
855 dimension: Some(if array {
856 wgpu::TextureViewDimension::D2Array
857 } else {
858 wgpu::TextureViewDimension::D2
859 }),
860 ..Default::default()
861 })
862}
863
864/// The GPU home for glyph pixels: replays `glifo`'s recorded atlas commands
865/// into the atlas array's own layers, and services the two pixel drains that
866/// have to be ordered around them.
867///
868/// Owned for the life of a renderer, beside the [`AtlasArray`] it writes rather
869/// than inside it — the array is a texture a scene pass samples, this is the
870/// machinery a *replay* pass needs, and most renderers never take the replay
871/// path at all. See the module doc's *Rendering into the atlas* for the three
872/// orderings this type exists to keep.
873///
874/// Nothing here is reached by the frame path until the text backend turns
875/// `glifo`'s atlas cacher on; a renderer that holds one and drives it every
876/// frame submits nothing on any frame that missed no glyph
877/// ([`AtlasRenderReport::is_empty`]).
878#[derive(Debug)]
879pub struct AtlasRenderer {
880 /// Coverage for the page currently being drawn. Shared across pages and
881 /// rewritten per page, which is why each page is its own submit.
882 alphas: AtlasResourceTexture,
883 /// The viewport uniform, rewritten per page — identical for every layer of
884 /// one array, since every layer has the array's own extent.
885 config: wgpu::Buffer,
886 /// The instance buffer every page's strips are written to the head of.
887 instances: wgpu::Buffer,
888 instance_capacity: u64,
889 placeholders: AtlasPlaceholders,
890 /// Handed to the lowering closure page by page, so a replay allocates
891 /// nothing once these have grown.
892 buffers: AtlasPageBuffers,
893}
894
895impl AtlasRenderer {
896 /// An atlas renderer for `caps`' adapter.
897 ///
898 /// The coverage texture is created at its minimum height and grown by the
899 /// first page that needs more, the same growth-only rule every other
900 /// resource texture in this crate follows.
901 #[must_use]
902 pub fn new(device: &wgpu::Device, caps: &TierCaps) -> Self {
903 let dim = caps.resource_texture_dim;
904 Self {
905 alphas: AtlasResourceTexture::new(
906 device,
907 &super::alpha_texture_descriptor(dim, super::MIN_RESOURCE_TEXTURE_HEIGHT),
908 ),
909 config: device.create_buffer(&wgpu::BufferDescriptor {
910 label: Some("frust-engine atlas pass config uniform"),
911 size: GpuConfig::SIZE,
912 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
913 mapped_at_creation: false,
914 }),
915 instances: device.create_buffer(&instance_descriptor(
916 MIN_ATLAS_INSTANCES * size_of::<GpuStrip>() as u64,
917 )),
918 instance_capacity: MIN_ATLAS_INSTANCES * size_of::<GpuStrip>() as u64,
919 placeholders: AtlasPlaceholders::new(device),
920 buffers: AtlasPageBuffers::default(),
921 }
922 }
923
924 /// The coverage texture's current extent in texels.
925 #[must_use]
926 pub fn alpha_texture_size(&self) -> (u32, u32) {
927 (self.alphas.width, self.alphas.height)
928 }
929
930 /// The instance buffer's current capacity in bytes.
931 #[must_use]
932 pub fn instance_capacity(&self) -> u64 {
933 self.instance_capacity
934 }
935
936 /// Zero one evicted rectangle, answering whether the array took it.
937 ///
938 /// A queue write, not a scissored pass, for [`AtlasArray::clear_region`]'s
939 /// reason — and issued through the queue rather than an encoder so that it
940 /// is guaranteed to land before any pass of the same submit, which is what
941 /// lets a rectangle freed this frame be drawn into this frame.
942 pub fn clear_rect(
943 &self,
944 queue: &wgpu::Queue,
945 atlas: &AtlasArray,
946 rect: PendingClearRect,
947 ) -> bool {
948 atlas.clear_region(queue, clear_rect_region(rect))
949 }
950
951 /// Write one already-rasterized glyph pixmap into its slot, answering
952 /// whether the array took it.
953 ///
954 /// The slot's extent is what is written, not the pixmap's: a pixmap whose
955 /// dimensions disagree with the slot it was allocated for would be written
956 /// at the wrong stride and smear across the page, so the mismatch is
957 /// refused here rather than handed to the queue.
958 pub fn upload_pixmap(
959 &self,
960 queue: &wgpu::Queue,
961 atlas: &AtlasArray,
962 upload: &PendingBitmapUpload,
963 ) -> bool {
964 let region = slot_region(upload);
965 if u32::from(upload.pixmap.width()) != region.size[0]
966 || u32::from(upload.pixmap.height()) != region.size[1]
967 {
968 return false;
969 }
970 atlas.write_region(queue, region, upload.pixmap.data_as_u8_slice())
971 }
972
973 /// Draw one page's lowered replay into array layer `layer`, on a command
974 /// encoder of this method's own, submitted before it returns.
975 ///
976 /// Returns how many instances were drawn — zero for an empty page, which
977 /// costs no submit at all.
978 ///
979 /// `timestamps` charges this pass to [`EngineSpan::Prepass`] — the atlas
980 /// replay's own recording site, one own-encoder submit per dirty page (see
981 /// this module's header), so several pages in one frame are several passes
982 /// summed into the one span. The fresh pair is asked for only once the
983 /// empty-page early return is behind us, so a page with nothing to draw
984 /// never spends a query pair on a pass that was never opened.
985 ///
986 /// # Errors
987 ///
988 /// [`EngineError::AtlasError`] when the array has no layer `layer`;
989 /// [`EngineError::AlphaCapacity`] when the page's coverage is past what a
990 /// resource texture of this adapter's dimension can hold. Both leave the
991 /// atlas exactly as it was — nothing is recorded before either is checked.
992 #[expect(
993 clippy::too_many_arguments,
994 reason = "one page's whole draw call: device/queue, the pipeline, the \
995 array it draws into, which layer, the page's own instances, \
996 and the timestamp sink, each owned by a different caller"
997 )]
998 pub fn render_page(
999 &mut self,
1000 device: &wgpu::Device,
1001 queue: &wgpu::Queue,
1002 pipeline: &wgpu::RenderPipeline,
1003 atlas: &AtlasArray,
1004 layer: u32,
1005 page: &mut AtlasPageBuffers,
1006 timestamps: FrameTimestamps<'_>,
1007 ) -> Result<u32, EngineError> {
1008 if page.is_empty() {
1009 return Ok(0);
1010 }
1011 let Some(target) = atlas.layer_view(layer) else {
1012 return Err(EngineError::AtlasError);
1013 };
1014 let height = super::alpha_texture_height(page.alphas.len(), self.alphas.width)?;
1015
1016 if height > self.alphas.height {
1017 self.alphas = AtlasResourceTexture::new(
1018 device,
1019 &super::alpha_texture_descriptor(self.alphas.width, height),
1020 );
1021 }
1022 let alphas = &self.alphas;
1023 super::with_padded_alphas(&mut page.alphas, alphas.width, alphas.height, |bytes| {
1024 queue.write_texture(
1025 alphas.copy_target(),
1026 bytes,
1027 wgpu::TexelCopyBufferLayout {
1028 offset: 0,
1029 bytes_per_row: Some(super::resource_bytes_per_row(alphas.width)),
1030 rows_per_image: Some(alphas.height),
1031 },
1032 alphas.extent(),
1033 );
1034 });
1035
1036 let config = super::targets::atlas_layer_config(
1037 atlas.size(),
1038 self.alphas.width,
1039 PLACEHOLDER_PAINT_TEX_WIDTH,
1040 );
1041 queue.write_buffer(&self.config, 0, bytemuck::bytes_of(&config));
1042
1043 let bytes: &[u8] = bytemuck::cast_slice(&page.instances);
1044 self.grow_instances(device, bytes.len() as u64);
1045 queue.write_buffer(&self.instances, 0, bytes);
1046
1047 let groups = self.bind_groups(device, pipeline);
1048 let count = u32::try_from(page.instances.len()).unwrap_or(u32::MAX);
1049
1050 let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1051 label: Some("frust-engine atlas replay"),
1052 });
1053 {
1054 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1055 label: Some("frust-engine atlas replay pass"),
1056 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1057 view: &target,
1058 depth_slice: None,
1059 resolve_target: None,
1060 ops: wgpu::Operations {
1061 // Loaded, never cleared: the layer holds every glyph
1062 // resident in it, and this pass adds one page's worth
1063 // to them. A clear here would evict the whole atlas
1064 // every time one glyph missed.
1065 load: wgpu::LoadOp::Load,
1066 store: wgpu::StoreOp::Store,
1067 },
1068 })],
1069 depth_stencil_attachment: None,
1070 timestamp_writes: timestamps.writes(EngineSpan::Prepass),
1071 occlusion_query_set: None,
1072 multiview_mask: None,
1073 });
1074 pass.set_pipeline(pipeline);
1075 pass.set_vertex_buffer(0, self.instances.slice(..));
1076 for (index, group) in groups.iter().enumerate() {
1077 pass.set_bind_group(index as u32, group, &[]);
1078 }
1079 pass.draw(GpuStrip::vertex_range(), GpuStrip::instance_range(0, count));
1080 }
1081 queue.submit(std::iter::once(encoder.finish()));
1082
1083 Ok(count)
1084 }
1085
1086 /// Service every drain `glyphs` has pending and replay every page it
1087 /// dirtied, strictly ahead of the caller's own scene pass.
1088 ///
1089 /// The order is the contract: evicted rectangles are zeroed, then bitmap
1090 /// pixmaps are written, then each dirty page's commands are lowered by
1091 /// `lower` and drawn. `lower` answers `false` for a page it declines — a
1092 /// command stream carrying a shape this tier has no lowering for, which is
1093 /// how a colour glyph goes *missing* rather than landing wrong — and the
1094 /// page is counted in [`AtlasRenderReport::refused`] and left undrawn.
1095 ///
1096 /// `pipeline` must be the one built from
1097 /// [`super::pipelines::atlas_strip_desc`]: a bind group is built against
1098 /// the pipeline's own derived layout, so another variant's is rejected even
1099 /// where the two layouts are structurally identical.
1100 ///
1101 /// `timestamps` is passed straight through to [`Self::render_page`] for
1102 /// each dirty page replayed — see that method's docs for the [`EngineSpan`]
1103 /// it charges to and why an empty page spends no query pair on it.
1104 #[expect(
1105 clippy::too_many_arguments,
1106 reason = "the whole replay pass's inputs: device/queue, the pipeline, \
1107 the array and the glyph source it drains, the timestamp \
1108 sink, and the caller's own lowering closure, each owned by \
1109 a different part of the renderer"
1110 )]
1111 pub fn render_pending<L>(
1112 &mut self,
1113 device: &wgpu::Device,
1114 queue: &wgpu::Queue,
1115 pipeline: &wgpu::RenderPipeline,
1116 atlas: &AtlasArray,
1117 glyphs: &mut GlyphAtlas,
1118 timestamps: FrameTimestamps<'_>,
1119 mut lower: L,
1120 ) -> AtlasRenderReport
1121 where
1122 L: FnMut(&AtlasCommandRecorder, &mut AtlasPageBuffers) -> bool,
1123 {
1124 let mut report = AtlasRenderReport::default();
1125
1126 for rect in glyphs.drain_pending_clear_rects() {
1127 if self.clear_rect(queue, atlas, rect) {
1128 report.cleared = report.cleared.saturating_add(1);
1129 } else {
1130 report.refused = report.refused.saturating_add(1);
1131 }
1132 }
1133 for upload in glyphs.drain_pending_uploads() {
1134 if self.upload_pixmap(queue, atlas, &upload) {
1135 report.uploaded = report.uploaded.saturating_add(1);
1136 } else {
1137 report.refused = report.refused.saturating_add(1);
1138 }
1139 }
1140
1141 // Taken out and put back so the closure below can hold the scratch
1142 // mutably while `self` records the pass it feeds.
1143 let mut buffers = core::mem::take(&mut self.buffers);
1144 glyphs.replay_pending_atlas_commands(|recorder| {
1145 buffers.clear();
1146 if !lower(recorder, &mut buffers) {
1147 report.refused = report.refused.saturating_add(1);
1148 return;
1149 }
1150 match self.render_page(
1151 device,
1152 queue,
1153 pipeline,
1154 atlas,
1155 recorder.page_index,
1156 &mut buffers,
1157 timestamps,
1158 ) {
1159 Ok(0) => {}
1160 Ok(instances) => {
1161 report.pages = report.pages.saturating_add(1);
1162 report.instances = report.instances.saturating_add(instances);
1163 }
1164 Err(_) => report.refused = report.refused.saturating_add(1),
1165 }
1166 });
1167 self.buffers = buffers;
1168
1169 report
1170 }
1171
1172 /// Grows the instance buffer if `required` bytes outgrew it.
1173 fn grow_instances(&mut self, device: &wgpu::Device, required: u64) {
1174 if self.instance_capacity >= required {
1175 return;
1176 }
1177 let capacity = required
1178 .checked_next_power_of_two()
1179 .unwrap_or(required)
1180 .max(MIN_ATLAS_INSTANCES * size_of::<GpuStrip>() as u64);
1181 self.instances = device.create_buffer(&instance_descriptor(capacity));
1182 self.instance_capacity = capacity;
1183 }
1184
1185 /// The four bind groups an atlas pass sets, built against `pipeline`'s own
1186 /// derived layout.
1187 ///
1188 /// Built per pass rather than cached: a replay happens only on a frame that
1189 /// missed a glyph, and caching would have to be invalidated against both
1190 /// the coverage texture's growth and the pipeline it was derived from — two
1191 /// invalidation sources for a path that is not the frame path.
1192 fn bind_groups(
1193 &self,
1194 device: &wgpu::Device,
1195 pipeline: &wgpu::RenderPipeline,
1196 ) -> [wgpu::BindGroup; 4] {
1197 [
1198 device.create_bind_group(&wgpu::BindGroupDescriptor {
1199 label: Some("frust-engine atlas pass resources"),
1200 layout: &pipeline.get_bind_group_layout(0),
1201 entries: &[
1202 wgpu::BindGroupEntry {
1203 binding: 0,
1204 resource: wgpu::BindingResource::TextureView(&self.alphas.view),
1205 },
1206 wgpu::BindGroupEntry {
1207 binding: 1,
1208 resource: self.config.as_entire_binding(),
1209 },
1210 wgpu::BindGroupEntry {
1211 binding: 2,
1212 resource: wgpu::BindingResource::TextureView(
1213 &self.placeholders.layer_input,
1214 ),
1215 },
1216 ],
1217 }),
1218 device.create_bind_group(&wgpu::BindGroupDescriptor {
1219 label: Some("frust-engine atlas pass images"),
1220 layout: &pipeline.get_bind_group_layout(1),
1221 entries: &[
1222 wgpu::BindGroupEntry {
1223 binding: 0,
1224 resource: wgpu::BindingResource::TextureView(&self.placeholders.array),
1225 },
1226 wgpu::BindGroupEntry {
1227 binding: 1,
1228 resource: wgpu::BindingResource::TextureView(&self.placeholders.external),
1229 },
1230 ],
1231 }),
1232 device.create_bind_group(&wgpu::BindGroupDescriptor {
1233 label: Some("frust-engine atlas pass paints"),
1234 layout: &pipeline.get_bind_group_layout(2),
1235 entries: &[wgpu::BindGroupEntry {
1236 binding: 0,
1237 resource: wgpu::BindingResource::TextureView(&self.placeholders.paints),
1238 }],
1239 }),
1240 device.create_bind_group(&wgpu::BindGroupDescriptor {
1241 label: Some("frust-engine atlas pass gradients"),
1242 layout: &pipeline.get_bind_group_layout(3),
1243 entries: &[wgpu::BindGroupEntry {
1244 binding: 0,
1245 resource: wgpu::BindingResource::TextureView(&self.placeholders.gradients),
1246 }],
1247 }),
1248 ]
1249 }
1250}
1251
1252/// The instance buffer's descriptor at `size` bytes.
1253fn instance_descriptor(size: u64) -> wgpu::BufferDescriptor<'static> {
1254 wgpu::BufferDescriptor {
1255 label: Some("frust-engine atlas strip instances"),
1256 size,
1257 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1258 mapped_at_creation: false,
1259 }
1260}
1261
1262/// The atlas rectangle one of `glifo`'s pending clear rects names.
1263///
1264/// The rect is already the *padded* region — the allocation an evicted glyph
1265/// gave back, transparent border included — which is exactly the rectangle that
1266/// has to be zeroed: leaving the padding behind would let a later, smaller
1267/// tenant's `Extend::Pad` sampling read the previous glyph's edge texels.
1268#[must_use]
1269pub fn clear_rect_region(rect: PendingClearRect) -> AtlasRegion {
1270 AtlasRegion {
1271 layer: rect.page_index,
1272 offset: [u32::from(rect.x), u32::from(rect.y)],
1273 size: [u32::from(rect.width), u32::from(rect.height)],
1274 }
1275}
1276
1277/// The atlas rectangle one pending bitmap upload's slot occupies.
1278///
1279/// The slot's own extent, not the padded allocation's: the padding around a
1280/// glyph is transparent by construction (a fresh page starts zeroed, an evicted
1281/// one is zeroed by its clear rect), so an upload writes the glyph and leaves
1282/// the border alone.
1283#[must_use]
1284pub fn slot_region(upload: &PendingBitmapUpload) -> AtlasRegion {
1285 let slot = upload.atlas_slot;
1286 AtlasRegion {
1287 layer: slot.page_index,
1288 offset: [u32::from(slot.x), u32::from(slot.y)],
1289 size: [u32::from(slot.width), u32::from(slot.height)],
1290 }
1291}
1292
1293#[cfg(test)]
1294mod tests {
1295 use super::*;
1296 use vello_common::kurbo::Point;
1297
1298 fn region(layer: u32, offset: [u32; 2], size: [u32; 2]) -> AtlasRegion {
1299 AtlasRegion {
1300 layer,
1301 offset,
1302 size,
1303 }
1304 }
1305
1306 #[test]
1307 fn the_atlas_is_a_sampled_copyable_renderable_rgba8_array() {
1308 let descriptor = atlas_texture_descriptor(1024, 1024, 4);
1309
1310 assert_eq!(descriptor.format, ATLAS_FORMAT);
1311 assert_eq!(descriptor.dimension, wgpu::TextureDimension::D2);
1312 assert_eq!(descriptor.size.depth_or_array_layers, 4);
1313 assert_eq!(descriptor.mip_level_count, 1);
1314 for usage in [
1315 wgpu::TextureUsages::TEXTURE_BINDING,
1316 wgpu::TextureUsages::COPY_DST,
1317 wgpu::TextureUsages::COPY_SRC,
1318 wgpu::TextureUsages::RENDER_ATTACHMENT,
1319 ] {
1320 assert!(descriptor.usage.contains(usage));
1321 }
1322 }
1323
1324 #[test]
1325 fn a_zero_extent_descriptor_is_raised_to_a_creatable_one() {
1326 let descriptor = atlas_texture_descriptor(0, 0, 0);
1327 assert_eq!(descriptor.size.width, 1);
1328 assert_eq!(descriptor.size.height, 1);
1329 assert_eq!(descriptor.size.depth_or_array_layers, 2);
1330 }
1331
1332 /// The layer floor is two, not one — a fix for wgpu-hal 30.0.1's GLES
1333 /// backend, not an arbitrary minimum.
1334 ///
1335 /// `get_info_from_desc` (wgpu-hal `src/gles/mod.rs:513-530`) chooses the GL
1336 /// target from `TextureDescriptor::size.depth_or_array_layers` alone —
1337 /// `(false, 1) => TEXTURE_2D`, never consulting the view dimension a caller
1338 /// later binds the texture through. A one-layer atlas array descriptor
1339 /// therefore creates a plain `GL_TEXTURE_2D`, while
1340 /// `crates/frust-engine/shaders/strip.wgsl` always samples this texture as
1341 /// `texture_2d_array<f32>` (`sampler2DArray` once naga lowers it to GLSL).
1342 /// Target and sampler disagreeing makes the texture incomplete per GLES
1343 /// 3.0 §3.8.2, so every sample reads `(0, 0, 0, 1)`: a solid box in place
1344 /// of a glyph, a solid black rect in place of an image — reproduced on
1345 /// Chrome's WebGL2 backend by the examples/web-spike probe and fixed
1346 /// by this floor. Tracked upstream as wgpu issues #1614 and #1574; a later
1347 /// tidy-up must not "simplify" this back to `max(1)` without wgpu-hal
1348 /// fixing the heuristic first (see `docs/LIMITATIONS.md`).
1349 #[test]
1350 fn the_layer_floor_is_two_because_wgpu_hal_gles_ignores_the_view_dimension() {
1351 assert_eq!(
1352 atlas_texture_descriptor(64, 64, 0)
1353 .size
1354 .depth_or_array_layers,
1355 2
1356 );
1357 assert_eq!(
1358 atlas_texture_descriptor(64, 64, 1)
1359 .size
1360 .depth_or_array_layers,
1361 2
1362 );
1363 assert_eq!(
1364 atlas_texture_descriptor(64, 64, 3)
1365 .size
1366 .depth_or_array_layers,
1367 3,
1368 "a request already past the floor is not clamped down to it"
1369 );
1370 }
1371
1372 #[test]
1373 fn the_view_the_shader_binds_is_a_layered_one() {
1374 assert_eq!(
1375 atlas_view_descriptor().dimension,
1376 Some(wgpu::TextureViewDimension::D2Array)
1377 );
1378 }
1379
1380 #[test]
1381 fn image_params_round_trip_through_the_shaders_own_field_widths() {
1382 let packed = pack_image_params(1, 2, 3, 200, false);
1383
1384 assert_eq!(packed & 0b11, 1, "quality");
1385 assert_eq!((packed >> 2) & 0b11, 2, "extend_x");
1386 assert_eq!((packed >> 4) & 0b11, 3, "extend_y");
1387 assert_eq!((packed >> 6) & 0xFF, 200, "atlas index");
1388 assert_eq!((packed >> 14) & 1, 0, "source kind");
1389
1390 assert_eq!(pack_image_params(0, 0, 0, 0, true) >> 14 & 1, 1);
1391 }
1392
1393 #[test]
1394 fn size_and_offset_pack_with_the_first_component_high() {
1395 assert_eq!(pack_image_size(0x1234, 0x5678), 0x1234_5678);
1396 assert_eq!(pack_image_offset(0x00FF, 0xAB00), 0x00FF_AB00);
1397 }
1398
1399 #[test]
1400 fn an_absent_tint_is_the_identity_multiply() {
1401 let (color, mode) = pack_tint(None);
1402 assert_eq!(color, u32::MAX);
1403 assert_eq!(mode, TintMode::Multiply.as_u32());
1404 }
1405
1406 #[test]
1407 fn natural_to_dest_lands_the_natural_corners_on_the_dest_corners() {
1408 let dest = Rect::new(5.0, 6.0, 45.0, 46.0);
1409 let transform = natural_to_dest(Affine::IDENTITY, (2, 2), dest).expect("non-degenerate");
1410
1411 assert_eq!(transform * Point::new(0.0, 0.0), Point::new(5.0, 6.0));
1412 assert_eq!(transform * Point::new(2.0, 2.0), Point::new(45.0, 46.0));
1413 }
1414
1415 #[test]
1416 fn natural_to_dest_composes_the_widgets_own_transform_outermost() {
1417 let dest = Rect::new(0.0, 0.0, 4.0, 4.0);
1418 let transform =
1419 natural_to_dest(Affine::translate((10.0, 20.0)), (2, 2), dest).expect("non-degenerate");
1420
1421 assert_eq!(transform * Point::new(0.0, 0.0), Point::new(10.0, 20.0));
1422 }
1423
1424 #[test]
1425 fn natural_to_dest_refuses_a_degenerate_natural_size() {
1426 let dest = Rect::new(0.0, 0.0, 10.0, 10.0);
1427 assert!(natural_to_dest(Affine::IDENTITY, (0, 4), dest).is_none());
1428 assert!(natural_to_dest(Affine::IDENTITY, (4, 0), dest).is_none());
1429 }
1430
1431 #[test]
1432 fn advances_are_the_linear_part_only() {
1433 let transform = Affine::new([2.0, 3.0, 4.0, 5.0, 100.0, 200.0]);
1434 let (x_advance, y_advance) = x_y_advances(transform);
1435
1436 assert_eq!(x_advance, Vec2::new(2.0, 3.0));
1437 assert_eq!(y_advance, Vec2::new(4.0, 5.0));
1438 }
1439
1440 #[test]
1441 fn a_layer_target_names_exactly_one_layer_as_a_plain_2d_view() {
1442 let descriptor = atlas_layer_view_descriptor(3);
1443
1444 assert_eq!(descriptor.dimension, Some(wgpu::TextureViewDimension::D2));
1445 assert_eq!(descriptor.base_array_layer, 3);
1446 assert_eq!(
1447 descriptor.array_layer_count,
1448 Some(1),
1449 "a colour attachment must name exactly one layer"
1450 );
1451 assert_eq!(descriptor.base_mip_level, 0);
1452 // Distinct from the sampling view in the one axis that matters: the
1453 // same texture is bound array-wide for reading and 2D for writing.
1454 assert_ne!(descriptor.dimension, atlas_view_descriptor().dimension);
1455 }
1456
1457 #[test]
1458 fn a_clear_rect_becomes_its_whole_padded_rectangle() {
1459 let region = clear_rect_region(PendingClearRect {
1460 page_index: 2,
1461 x: 40,
1462 y: 8,
1463 width: 18,
1464 height: 22,
1465 });
1466
1467 assert_eq!(region.layer, 2);
1468 assert_eq!(region.offset, [40, 8]);
1469 assert_eq!(region.size, [18, 22]);
1470 assert_eq!(region.byte_len(), 18 * 22 * ATLAS_FORMAT_BYTES as usize);
1471 }
1472
1473 /// One solid-painted run's instances, checked against the shape the shader
1474 /// reads them at.
1475 ///
1476 /// The run carries a trailing sentinel: a strip's width is the *coverage*
1477 /// distance to the strip after it (`Strip::width_to`), not the distance
1478 /// between their x positions, which is why the generator emits a sentinel
1479 /// and why a caller that trimmed it would silently lose the last span.
1480 #[test]
1481 fn a_solid_run_expands_to_alpha_sampled_spans_carrying_one_colour() {
1482 let run = [
1483 Strip::new(4, 0, 0, false),
1484 Strip::new(20, 0, 32, false),
1485 Strip::new(64, 0, 80, false),
1486 ];
1487 let mut out = Vec::new();
1488
1489 let pushed = push_solid_strips(&run, 0xDEAD_BEEF, 7, &mut out);
1490
1491 assert_eq!(pushed, 2, "two pairs, each contributing one span");
1492 assert_eq!(out.len(), 2);
1493 for instance in &out {
1494 assert_eq!(instance.payload, 0xDEAD_BEEF, "a solid colour is per draw");
1495 assert_eq!(instance.depth_index, 7);
1496 assert!(!instance.is_rect());
1497 assert_eq!(
1498 instance.paint(),
1499 pack_paint_descriptor(PaintType::Solid, 0),
1500 "a solid instance indexes no encoded-paint record"
1501 );
1502 assert_eq!(
1503 instance.dense_width_or_rect_height, instance.width,
1504 "every column of a replayed glyph samples coverage"
1505 );
1506 }
1507 assert_eq!(out[0].x, 4);
1508 assert_eq!(
1509 out[0].width, 8,
1510 "32 coverage bytes at 4 per column is 8 pixels"
1511 );
1512 assert_eq!(out[0].col_idx_or_rect_frac, 0);
1513 assert_eq!(out[1].x, 20);
1514 assert_eq!(out[1].width, 12);
1515 assert_eq!(
1516 out[1].col_idx_or_rect_frac, 8,
1517 "the second span starts at the column its coverage does"
1518 );
1519 }
1520
1521 #[test]
1522 fn a_winding_gap_between_two_strips_is_filled_solid() {
1523 // The second strip carries the fill-gap flag on the same row, so the
1524 // span between them is inside the glyph and gets a coverage-free fill.
1525 let run = [
1526 Strip::new(0, 0, 0, false),
1527 Strip::new(32, 0, 16, true),
1528 Strip::new(48, 0, 32, false),
1529 ];
1530 let mut out = Vec::new();
1531
1532 let pushed = push_solid_strips(&run, 0x11, 0, &mut out);
1533
1534 assert_eq!(pushed, 3, "two spans plus the gap between them");
1535 let gap = out
1536 .iter()
1537 .find(|instance| instance.dense_width_or_rect_height == 0)
1538 .copied();
1539 let gap = match gap {
1540 Some(gap) => gap,
1541 None => unreachable!("the flagged pair fills its gap"),
1542 };
1543 assert_eq!(gap.x, 4, "the gap starts where the first strip ends");
1544 assert_eq!(gap.width, 28);
1545 assert_eq!(gap.payload, 0x11, "the fill takes the run's own colour");
1546 assert_eq!(gap.col_idx_or_rect_frac, 0, "a gap samples no coverage");
1547 }
1548
1549 #[test]
1550 fn a_run_with_no_pair_in_it_draws_nothing() {
1551 let mut out = Vec::new();
1552 assert_eq!(push_solid_strips(&[], 0, 0, &mut out), 0);
1553 assert_eq!(
1554 push_solid_strips(&[Strip::new(0, 0, 0, false)], 0, 0, &mut out),
1555 0,
1556 "a lone sentinel is not a span"
1557 );
1558 assert!(out.is_empty());
1559 }
1560
1561 #[test]
1562 fn an_emptied_page_keeps_its_capacity_for_the_next_one() {
1563 let mut buffers = AtlasPageBuffers::default();
1564 assert!(buffers.is_empty());
1565
1566 buffers.instances.push(GpuStrip::solid_fill(
1567 0,
1568 0,
1569 8,
1570 StripDraw {
1571 payload: 0,
1572 paint: 0,
1573 depth_index: 0,
1574 },
1575 ));
1576 buffers.alphas.extend_from_slice(&[1, 2, 3, 4]);
1577 assert!(!buffers.is_empty());
1578
1579 let instances = buffers.instances.capacity();
1580 let alphas = buffers.alphas.capacity();
1581 buffers.clear();
1582
1583 assert!(buffers.is_empty());
1584 assert!(buffers.alphas.is_empty());
1585 assert_eq!(buffers.instances.capacity(), instances);
1586 assert_eq!(buffers.alphas.capacity(), alphas);
1587 }
1588
1589 #[test]
1590 fn a_report_that_serviced_nothing_reads_as_empty() {
1591 let mut report = AtlasRenderReport::default();
1592 assert!(report.is_empty());
1593
1594 report.refused = 1;
1595 assert!(
1596 !report.is_empty(),
1597 "a refusal is work that happened, not an idle frame"
1598 );
1599 }
1600
1601 #[test]
1602 fn the_stand_in_paint_texture_width_is_reconstructable_by_the_shader() {
1603 // The shader rebuilds the width as `1 << bits`, so a stand-in that was
1604 // not a power of two would make the config disagree with the texture
1605 // actually bound.
1606 assert!(PLACEHOLDER_PAINT_TEX_WIDTH.is_power_of_two());
1607 assert_eq!(
1608 super::super::config::tex_width_bits(PLACEHOLDER_PAINT_TEX_WIDTH),
1609 0
1610 );
1611 }
1612
1613 #[test]
1614 fn a_regions_byte_footprint_and_stride_agree_with_rgba8() {
1615 let populated = region(0, [4, 8], [16, 32]);
1616 assert_eq!(populated.bytes_per_row(), 64);
1617 assert_eq!(populated.byte_len(), 64 * 32);
1618 assert_eq!(region_byte_len(populated), populated.byte_len());
1619 assert!(!populated.is_empty());
1620 assert!(region(0, [0, 0], [0, 4]).is_empty());
1621 }
1622}