Skip to main content

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}