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 if !self.contains(region) {
409 return false;
410 }
411 let zeros = vec![0_u8; region.byte_len()];
412 self.write_region(queue, region, &zeros)
413 }
414
415 /// Whether `region` lies wholly inside this array.
416 #[must_use]
417 pub fn contains(&self, region: AtlasRegion) -> bool {
418 !region.is_empty()
419 && region.layer < self.layers
420 && region.offset[0].saturating_add(region.size[0]) <= self.width
421 && region.offset[1].saturating_add(region.size[1]) <= self.height
422 }
423}
424
425/// The bytes a region's texels occupy — the length
426/// [`AtlasArray::write_region`] requires of its slice.
427#[must_use]
428pub fn region_byte_len(region: AtlasRegion) -> usize {
429 (region.size[0] as usize)
430 .saturating_mul(region.size[1] as usize)
431 .saturating_mul(ATLAS_FORMAT_BYTES as usize)
432}
433
434/// The affine mapping an image's natural pixel rectangle
435/// `(0, 0, width, height)` onto `dest`, composed under `transform`.
436///
437/// The same composition `frust-render`'s CPU-tier lowering applies, so the two
438/// tiers place an image identically: a natural-size draw under the widget's own
439/// transform, translated to `dest`'s origin and scaled to `dest`'s extent.
440/// `None` for a degenerate natural size, which has no scale to derive.
441#[must_use]
442pub fn natural_to_dest(transform: Affine, natural: (u32, u32), dest: Rect) -> Option<Affine> {
443 let natural_w = f64::from(natural.0);
444 let natural_h = f64::from(natural.1);
445 if natural_w <= 0.0 || natural_h <= 0.0 {
446 return None;
447 }
448
449 Some(
450 transform
451 * Affine::translate((dest.x0, dest.y0))
452 * Affine::scale_non_uniform(dest.width() / natural_w, dest.height() / natural_h),
453 )
454}
455
456/// The per-pixel advances in image space an encoded image carries, derived
457/// from its already-inverted transform.
458///
459/// The linear part only: an advance is a direction, so the translation is
460/// dropped. `vello_common` computes the same pair internally and keeps it
461/// private, so it is restated here rather than reached for.
462#[must_use]
463pub fn x_y_advances(transform: Affine) -> (Vec2, Vec2) {
464 let c = transform.as_coeffs();
465 (Vec2::new(c[0], c[1]), Vec2::new(c[2], c[3]))
466}
467
468/// Packs an image's width and height into one word, width in the high half.
469#[must_use]
470pub const fn pack_image_size(width: u16, height: u16) -> u32 {
471 ((width as u32) << 16) | (height as u32)
472}
473
474/// Packs an image's atlas offset into one word, x in the high half.
475#[must_use]
476pub const fn pack_image_offset(x: u16, y: u16) -> u32 {
477 ((x as u32) << 16) | (y as u32)
478}
479
480/// Packs sampling quality (bits 0-1), the two extend modes (bits 2-3 and 4-5),
481/// the atlas layer (bits 6-13) and the source kind (bit 14) into one word.
482///
483/// Each field is masked to its width rather than reported: every input is
484/// produced by this crate's own encoding, and the preconditions are checked in
485/// debug builds.
486#[must_use]
487pub fn pack_image_params(
488 quality: u32,
489 extend_x: u32,
490 extend_y: u32,
491 atlas_index: u32,
492 is_external: bool,
493) -> u32 {
494 debug_assert!(quality <= 3, "quality must fit two bits");
495 debug_assert!(extend_x <= 3, "extend_x must fit two bits");
496 debug_assert!(extend_y <= 3, "extend_y must fit two bits");
497 debug_assert!(
498 atlas_index <= MAX_ATLAS_INDEX,
499 "atlas index {atlas_index} exceeds {MAX_ATLAS_INDEX}",
500 );
501
502 (u32::from(is_external) << 14)
503 | ((atlas_index & MAX_ATLAS_INDEX) << 6)
504 | ((extend_y & 0b11) << 4)
505 | ((extend_x & 0b11) << 2)
506 | (quality & 0b11)
507}
508
509/// The premultiplied colour and mode an optional tint packs to.
510///
511/// With no tint the colour is all-ones under [`TintMode::Multiply`], which
512/// leaves the sampled texel exactly as it was — the shader always applies a
513/// tint, so "no tint" has to be expressed as an identity one rather than as a
514/// branch.
515#[must_use]
516pub fn pack_tint(tint: Option<Tint>) -> (u32, u32) {
517 match tint {
518 Some(tint) => (
519 tint.color.premultiply().to_rgba8().to_u32(),
520 tint.mode.as_u32(),
521 ),
522 None => (u32::MAX, TintMode::Multiply.as_u32()),
523 }
524}
525
526/// The shader's extend-mode numbering.
527pub const fn extend_mode(extend: peniko::Extend) -> u32 {
528 match extend {
529 peniko::Extend::Pad => 0,
530 peniko::Extend::Repeat => 1,
531 peniko::Extend::Reflect => 2,
532 }
533}
534
535/// Lower one encoded image paint into the record the strip shader samples.
536///
537/// `resident` is where the image's texels were made resident, which only a
538/// caller that already serviced the frame's residency can supply — the same
539/// shape [`super::paint_texture::lower_encoded_paint`] uses for a gradient's
540/// baked ramp. An image whose source is not the handle `resident` names
541/// answers `None`, so a paint encoded against a different residency becomes a
542/// dropped draw rather than a wrongly-addressed one.
543///
544/// The encoded transform is already the inverse mapping — device space into
545/// image space — because that is the direction the shader applies it in;
546/// narrowing it to `f32` here is what the record and the WGSL both read it at.
547#[must_use]
548pub fn lower_encoded_image(
549 image: &EncodedImage,
550 resident: &ResidentImage,
551) -> Option<GpuEncodedPaint> {
552 match &image.source {
553 ImageSource::OpaqueId { id, .. } if *id == resident.id => {}
554 _ => return None,
555 }
556
557 let region = resident.region;
558 let (tint, tint_mode) = pack_tint(image.tint);
559
560 Some(GpuEncodedPaint::Image(GpuEncodedImage {
561 image_params: pack_image_params(
562 image.sampler.quality as u32,
563 extend_mode(image.sampler.x_extend),
564 extend_mode(image.sampler.y_extend),
565 region.layer,
566 false,
567 ),
568 image_size: pack_image_size(truncate_u16(region.size[0]), truncate_u16(region.size[1])),
569 image_offset: pack_image_offset(
570 truncate_u16(region.offset[0]),
571 truncate_u16(region.offset[1]),
572 ),
573 transform: image.transform.as_coeffs().map(|coeff| coeff as f32),
574 tint,
575 tint_mode,
576 image_padding: resident.padding,
577 }))
578}
579
580/// `value` narrowed to the `u16` the record's packed halves hold.
581///
582/// Saturating rather than wrapping: every caller has already passed the
583/// residency's own `u16` ceiling, so this can only ever be the identity, and a
584/// saturation is the harmless reading if that ever stops being true.
585const fn truncate_u16(value: u32) -> u16 {
586 if value > u16::MAX as u32 {
587 u16::MAX
588 } else {
589 value as u16
590 }
591}
592
593/// The width of the stand-in encoded-paint texture an atlas pass binds.
594///
595/// One texel, because nothing an atlas pass draws reads that texture: a
596/// replayed glyph outline paints solid, and a solid instance carries its colour
597/// in its own payload. A binding still has to be filled for the pass to
598/// validate, and the config the shader reconstructs the texture's width from
599/// has to agree with it — a power of two, which one is.
600const PLACEHOLDER_PAINT_TEX_WIDTH: u32 = 1;
601
602/// Instances an atlas pass's buffer holds before it is grown for a page that
603/// needs more.
604///
605/// A page of glyph outlines is a few hundred strips in the ordinary case; this
606/// floor keeps the first miss from allocating a buffer measured in single
607/// instances and then reallocating it four times on the way up.
608const MIN_ATLAS_INSTANCES: u64 = 256;
609
610/// Expands one solid-painted strip run into the instances that draw it,
611/// appending to `out`.
612///
613/// This is the whole of an outline glyph's lowering: `glifo` records a glyph as
614/// a fill of a path in one colour, so every instance of the run repeats the
615/// same premultiplied `payload` and the same solid paint descriptor, and a
616/// position-sampled paint's per-instance re-evaluation never arises.
617///
618/// `strips` is a *generation's* strips, sentinel included: a strip's extent is
619/// carried by the strip after it, so instances come from consecutive pairs and
620/// the trailing sentinel becomes none. Passing a run without its sentinel
621/// silently drops the last span.
622///
623/// Returns how many instances were appended, which is neither `strips.len()`
624/// nor bounded by it — a pair can contribute a span, a winding gap fill, both,
625/// or neither.
626pub fn push_solid_strips(
627 strips: &[Strip],
628 payload: u32,
629 depth: u32,
630 out: &mut Vec<GpuStrip>,
631) -> u32 {
632 let draw = StripDraw {
633 payload,
634 paint: pack_paint_descriptor(PaintType::Solid, 0),
635 depth_index: depth,
636 };
637
638 let before = out.len();
639 for pair in strips.windows(2) {
640 let span = GpuStrip::from_strip_pair(&pair[0], &pair[1], draw);
641 if span.width > 0 {
642 out.push(span);
643 }
644 if let Some(gap) = GpuStrip::gap_fill(&pair[0], &pair[1], draw) {
645 out.push(gap);
646 }
647 }
648 u32::try_from(out.len().saturating_sub(before)).unwrap_or(u32::MAX)
649}
650
651/// One page's lowered replay: the instances to draw and the coverage they
652/// index.
653///
654/// Reused across pages and across frames rather than allocated per page — the
655/// two vectors are the only heap a replay costs once they have grown, which is
656/// the point of handing them to the lowering closure instead of taking a fresh
657/// pair back from it.
658///
659/// The two are kept together because a strip instance's alpha column is only
660/// meaningful against the coverage buffer generated alongside it, the same
661/// pairing [`crate::compile::CompiledFrame`] keeps for a frame.
662#[derive(Debug, Default)]
663pub struct AtlasPageBuffers {
664 /// The page's strip instances, in the order they are drawn.
665 pub instances: Vec<GpuStrip>,
666 /// The coverage bytes [`instances`](Self::instances) index.
667 pub alphas: Vec<u8>,
668}
669
670impl AtlasPageBuffers {
671 /// Empties both buffers, keeping their capacity for the next page.
672 pub fn clear(&mut self) {
673 self.instances.clear();
674 self.alphas.clear();
675 }
676
677 /// Whether this page would draw nothing.
678 #[must_use]
679 pub fn is_empty(&self) -> bool {
680 self.instances.is_empty()
681 }
682}
683
684/// What one call to [`AtlasRenderer::render_pending`] serviced.
685///
686/// Every field is a count rather than a flag because each names work that
687/// either reached the atlas or did not, and a glyph that silently failed to
688/// rasterize is otherwise indistinguishable from one the text never asked for.
689#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
690pub struct AtlasRenderReport {
691 /// Evicted rectangles zeroed, ahead of every upload.
692 pub cleared: u32,
693 /// Bitmap (and COLR pixmap) glyphs written through the queue.
694 pub uploaded: u32,
695 /// Pages whose recorded commands were replayed into a pass.
696 pub pages: u32,
697 /// Strip instances those passes drew.
698 pub instances: u32,
699 /// Pages, clears and uploads that were refused — a page the lowering
700 /// declined, a layer the array does not have, coverage past the alpha
701 /// texture's ceiling, or a region the array would not take.
702 pub refused: u32,
703}
704
705impl AtlasRenderReport {
706 /// Whether this call did anything at all.
707 ///
708 /// The steady state: text that hit the cache on every glyph queues no
709 /// upload, frees no rectangle and dirties no page, so an atlas renderer
710 /// driven every frame submits nothing on almost all of them.
711 #[must_use]
712 pub fn is_empty(&self) -> bool {
713 *self == Self::default()
714 }
715}
716
717/// One resource texture the atlas pass owns, and the extent it holds.
718#[derive(Debug)]
719struct AtlasResourceTexture {
720 texture: wgpu::Texture,
721 view: wgpu::TextureView,
722 width: u32,
723 height: u32,
724}
725
726impl AtlasResourceTexture {
727 fn new(device: &wgpu::Device, descriptor: &wgpu::TextureDescriptor<'_>) -> Self {
728 let texture = device.create_texture(descriptor);
729 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
730 Self {
731 texture,
732 view,
733 width: descriptor.size.width,
734 height: descriptor.size.height,
735 }
736 }
737
738 fn copy_target(&self) -> wgpu::TexelCopyTextureInfo<'_> {
739 wgpu::TexelCopyTextureInfo {
740 texture: &self.texture,
741 mip_level: 0,
742 origin: wgpu::Origin3d::ZERO,
743 aspect: wgpu::TextureAspect::All,
744 }
745 }
746
747 fn extent(&self) -> wgpu::Extent3d {
748 wgpu::Extent3d {
749 width: self.width,
750 height: self.height,
751 depth_or_array_layers: 1,
752 }
753 }
754}
755
756/// The 1x1 stand-ins for the strip shader's bindings an atlas pass has nothing
757/// real for.
758///
759/// Every declared binding has to be filled for a pass to validate, whether or
760/// not an instance samples it. Three of these five are stand-ins for the same
761/// reason the frame path's are — no layer input, no external texture, no
762/// gradient ramp. The other two are stand-ins for a reason particular to this
763/// pass: the *atlas array* itself is deliberately not bound here, because the
764/// pass is writing one of its layers, and a texture attached for writing cannot
765/// also be bound for sampling in the same pass; and the encoded-paint texture
766/// is unused because a replayed outline paints solid.
767#[derive(Debug)]
768struct AtlasPlaceholders {
769 layer_input: wgpu::TextureView,
770 array: wgpu::TextureView,
771 external: wgpu::TextureView,
772 paints: wgpu::TextureView,
773 gradients: wgpu::TextureView,
774}
775
776impl AtlasPlaceholders {
777 fn new(device: &wgpu::Device) -> Self {
778 Self {
779 layer_input: placeholder(
780 device,
781 "frust-engine atlas pass layer input",
782 ATLAS_FORMAT,
783 false,
784 ),
785 array: placeholder(device, "frust-engine atlas pass array", ATLAS_FORMAT, true),
786 external: placeholder(
787 device,
788 "frust-engine atlas pass external",
789 ATLAS_FORMAT,
790 false,
791 ),
792 paints: placeholder(
793 device,
794 "frust-engine atlas pass paints",
795 super::RESOURCE_TEXTURE_FORMAT,
796 false,
797 ),
798 gradients: placeholder(
799 device,
800 "frust-engine atlas pass gradients",
801 ATLAS_FORMAT,
802 false,
803 ),
804 }
805 }
806}
807
808/// A 1x1 sampled-only texture's view, as a plain 2D texture or a 2D array.
809///
810/// The array variant allocates **two** layers, not one, for the same reason
811/// [`atlas_texture_descriptor`] does: wgpu-hal 30.0.1's GLES backend derives
812/// the GL target from the descriptor's layer count alone, and a one-layer
813/// array descriptor binds as `GL_TEXTURE_2D` rather than
814/// `GL_TEXTURE_2D_ARRAY` (see that function's doc comment for the file:line
815/// and upstream issue refs). Only layer zero of the two is ever sampled here.
816fn placeholder(
817 device: &wgpu::Device,
818 label: &'static str,
819 format: wgpu::TextureFormat,
820 array: bool,
821) -> wgpu::TextureView {
822 let texture = device.create_texture(&wgpu::TextureDescriptor {
823 label: Some(label),
824 size: wgpu::Extent3d {
825 width: 1,
826 height: 1,
827 depth_or_array_layers: if array { 2 } else { 1 },
828 },
829 mip_level_count: 1,
830 sample_count: 1,
831 dimension: wgpu::TextureDimension::D2,
832 format,
833 usage: wgpu::TextureUsages::TEXTURE_BINDING,
834 view_formats: &[],
835 });
836 texture.create_view(&wgpu::TextureViewDescriptor {
837 label: Some(label),
838 dimension: Some(if array {
839 wgpu::TextureViewDimension::D2Array
840 } else {
841 wgpu::TextureViewDimension::D2
842 }),
843 ..Default::default()
844 })
845}
846
847/// The GPU home for glyph pixels: replays `glifo`'s recorded atlas commands
848/// into the atlas array's own layers, and services the two pixel drains that
849/// have to be ordered around them.
850///
851/// Owned for the life of a renderer, beside the [`AtlasArray`] it writes rather
852/// than inside it — the array is a texture a scene pass samples, this is the
853/// machinery a *replay* pass needs, and most renderers never take the replay
854/// path at all. See the module doc's *Rendering into the atlas* for the three
855/// orderings this type exists to keep.
856///
857/// Nothing here is reached by the frame path until the text backend turns
858/// `glifo`'s atlas cacher on; a renderer that holds one and drives it every
859/// frame submits nothing on any frame that missed no glyph
860/// ([`AtlasRenderReport::is_empty`]).
861#[derive(Debug)]
862pub struct AtlasRenderer {
863 /// Coverage for the page currently being drawn. Shared across pages and
864 /// rewritten per page, which is why each page is its own submit.
865 alphas: AtlasResourceTexture,
866 /// The viewport uniform, rewritten per page — identical for every layer of
867 /// one array, since every layer has the array's own extent.
868 config: wgpu::Buffer,
869 /// The instance buffer every page's strips are written to the head of.
870 instances: wgpu::Buffer,
871 instance_capacity: u64,
872 placeholders: AtlasPlaceholders,
873 /// Handed to the lowering closure page by page, so a replay allocates
874 /// nothing once these have grown.
875 buffers: AtlasPageBuffers,
876}
877
878impl AtlasRenderer {
879 /// An atlas renderer for `caps`' adapter.
880 ///
881 /// The coverage texture is created at its minimum height and grown by the
882 /// first page that needs more, the same growth-only rule every other
883 /// resource texture in this crate follows.
884 #[must_use]
885 pub fn new(device: &wgpu::Device, caps: &TierCaps) -> Self {
886 let dim = caps.resource_texture_dim;
887 Self {
888 alphas: AtlasResourceTexture::new(
889 device,
890 &super::alpha_texture_descriptor(dim, super::MIN_RESOURCE_TEXTURE_HEIGHT),
891 ),
892 config: device.create_buffer(&wgpu::BufferDescriptor {
893 label: Some("frust-engine atlas pass config uniform"),
894 size: GpuConfig::SIZE,
895 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
896 mapped_at_creation: false,
897 }),
898 instances: device.create_buffer(&instance_descriptor(
899 MIN_ATLAS_INSTANCES * size_of::<GpuStrip>() as u64,
900 )),
901 instance_capacity: MIN_ATLAS_INSTANCES * size_of::<GpuStrip>() as u64,
902 placeholders: AtlasPlaceholders::new(device),
903 buffers: AtlasPageBuffers::default(),
904 }
905 }
906
907 /// The coverage texture's current extent in texels.
908 #[must_use]
909 pub fn alpha_texture_size(&self) -> (u32, u32) {
910 (self.alphas.width, self.alphas.height)
911 }
912
913 /// The instance buffer's current capacity in bytes.
914 #[must_use]
915 pub fn instance_capacity(&self) -> u64 {
916 self.instance_capacity
917 }
918
919 /// Zero one evicted rectangle, answering whether the array took it.
920 ///
921 /// A queue write, not a scissored pass, for [`AtlasArray::clear_region`]'s
922 /// reason — and issued through the queue rather than an encoder so that it
923 /// is guaranteed to land before any pass of the same submit, which is what
924 /// lets a rectangle freed this frame be drawn into this frame.
925 pub fn clear_rect(
926 &self,
927 queue: &wgpu::Queue,
928 atlas: &AtlasArray,
929 rect: PendingClearRect,
930 ) -> bool {
931 atlas.clear_region(queue, clear_rect_region(rect))
932 }
933
934 /// Write one already-rasterized glyph pixmap into its slot, answering
935 /// whether the array took it.
936 ///
937 /// The slot's extent is what is written, not the pixmap's: a pixmap whose
938 /// dimensions disagree with the slot it was allocated for would be written
939 /// at the wrong stride and smear across the page, so the mismatch is
940 /// refused here rather than handed to the queue.
941 pub fn upload_pixmap(
942 &self,
943 queue: &wgpu::Queue,
944 atlas: &AtlasArray,
945 upload: &PendingBitmapUpload,
946 ) -> bool {
947 let region = slot_region(upload);
948 if u32::from(upload.pixmap.width()) != region.size[0]
949 || u32::from(upload.pixmap.height()) != region.size[1]
950 {
951 return false;
952 }
953 atlas.write_region(queue, region, upload.pixmap.data_as_u8_slice())
954 }
955
956 /// Draw one page's lowered replay into array layer `layer`, on a command
957 /// encoder of this method's own, submitted before it returns.
958 ///
959 /// Returns how many instances were drawn — zero for an empty page, which
960 /// costs no submit at all.
961 ///
962 /// `timestamps` charges this pass to [`EngineSpan::Prepass`] — the atlas
963 /// replay's own recording site, one own-encoder submit per dirty page (see
964 /// this module's header), so several pages in one frame are several passes
965 /// summed into the one span. The fresh pair is asked for only once the
966 /// empty-page early return is behind us, so a page with nothing to draw
967 /// never spends a query pair on a pass that was never opened.
968 ///
969 /// # Errors
970 ///
971 /// [`EngineError::AtlasError`] when the array has no layer `layer`;
972 /// [`EngineError::AlphaCapacity`] when the page's coverage is past what a
973 /// resource texture of this adapter's dimension can hold. Both leave the
974 /// atlas exactly as it was — nothing is recorded before either is checked.
975 #[expect(
976 clippy::too_many_arguments,
977 reason = "one page's whole draw call: device/queue, the pipeline, the \
978 array it draws into, which layer, the page's own instances, \
979 and the timestamp sink, each owned by a different caller"
980 )]
981 pub fn render_page(
982 &mut self,
983 device: &wgpu::Device,
984 queue: &wgpu::Queue,
985 pipeline: &wgpu::RenderPipeline,
986 atlas: &AtlasArray,
987 layer: u32,
988 page: &mut AtlasPageBuffers,
989 timestamps: FrameTimestamps<'_>,
990 ) -> Result<u32, EngineError> {
991 if page.is_empty() {
992 return Ok(0);
993 }
994 let Some(target) = atlas.layer_view(layer) else {
995 return Err(EngineError::AtlasError);
996 };
997 let height = super::alpha_texture_height(page.alphas.len(), self.alphas.width)?;
998
999 if height > self.alphas.height {
1000 self.alphas = AtlasResourceTexture::new(
1001 device,
1002 &super::alpha_texture_descriptor(self.alphas.width, height),
1003 );
1004 }
1005 let alphas = &self.alphas;
1006 super::with_padded_alphas(&mut page.alphas, alphas.width, alphas.height, |bytes| {
1007 queue.write_texture(
1008 alphas.copy_target(),
1009 bytes,
1010 wgpu::TexelCopyBufferLayout {
1011 offset: 0,
1012 bytes_per_row: Some(super::resource_bytes_per_row(alphas.width)),
1013 rows_per_image: Some(alphas.height),
1014 },
1015 alphas.extent(),
1016 );
1017 });
1018
1019 let config = super::targets::atlas_layer_config(
1020 atlas.size(),
1021 self.alphas.width,
1022 PLACEHOLDER_PAINT_TEX_WIDTH,
1023 );
1024 queue.write_buffer(&self.config, 0, bytemuck::bytes_of(&config));
1025
1026 let bytes: &[u8] = bytemuck::cast_slice(&page.instances);
1027 self.grow_instances(device, bytes.len() as u64);
1028 queue.write_buffer(&self.instances, 0, bytes);
1029
1030 let groups = self.bind_groups(device, pipeline);
1031 let count = u32::try_from(page.instances.len()).unwrap_or(u32::MAX);
1032
1033 let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1034 label: Some("frust-engine atlas replay"),
1035 });
1036 {
1037 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1038 label: Some("frust-engine atlas replay pass"),
1039 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1040 view: &target,
1041 depth_slice: None,
1042 resolve_target: None,
1043 ops: wgpu::Operations {
1044 // Loaded, never cleared: the layer holds every glyph
1045 // resident in it, and this pass adds one page's worth
1046 // to them. A clear here would evict the whole atlas
1047 // every time one glyph missed.
1048 load: wgpu::LoadOp::Load,
1049 store: wgpu::StoreOp::Store,
1050 },
1051 })],
1052 depth_stencil_attachment: None,
1053 timestamp_writes: timestamps.writes(EngineSpan::Prepass),
1054 occlusion_query_set: None,
1055 multiview_mask: None,
1056 });
1057 pass.set_pipeline(pipeline);
1058 pass.set_vertex_buffer(0, self.instances.slice(..));
1059 for (index, group) in groups.iter().enumerate() {
1060 pass.set_bind_group(index as u32, group, &[]);
1061 }
1062 pass.draw(GpuStrip::vertex_range(), GpuStrip::instance_range(0, count));
1063 }
1064 queue.submit(std::iter::once(encoder.finish()));
1065
1066 Ok(count)
1067 }
1068
1069 /// Service every drain `glyphs` has pending and replay every page it
1070 /// dirtied, strictly ahead of the caller's own scene pass.
1071 ///
1072 /// The order is the contract: evicted rectangles are zeroed, then bitmap
1073 /// pixmaps are written, then each dirty page's commands are lowered by
1074 /// `lower` and drawn. `lower` answers `false` for a page it declines — a
1075 /// command stream carrying a shape this tier has no lowering for, which is
1076 /// how a colour glyph goes *missing* rather than landing wrong — and the
1077 /// page is counted in [`AtlasRenderReport::refused`] and left undrawn.
1078 ///
1079 /// `pipeline` must be the one built from
1080 /// [`super::pipelines::atlas_strip_desc`]: a bind group is built against
1081 /// the pipeline's own derived layout, so another variant's is rejected even
1082 /// where the two layouts are structurally identical.
1083 ///
1084 /// `timestamps` is passed straight through to [`Self::render_page`] for
1085 /// each dirty page replayed — see that method's docs for the [`EngineSpan`]
1086 /// it charges to and why an empty page spends no query pair on it.
1087 #[expect(
1088 clippy::too_many_arguments,
1089 reason = "the whole replay pass's inputs: device/queue, the pipeline, \
1090 the array and the glyph source it drains, the timestamp \
1091 sink, and the caller's own lowering closure, each owned by \
1092 a different part of the renderer"
1093 )]
1094 pub fn render_pending<L>(
1095 &mut self,
1096 device: &wgpu::Device,
1097 queue: &wgpu::Queue,
1098 pipeline: &wgpu::RenderPipeline,
1099 atlas: &AtlasArray,
1100 glyphs: &mut GlyphAtlas,
1101 timestamps: FrameTimestamps<'_>,
1102 mut lower: L,
1103 ) -> AtlasRenderReport
1104 where
1105 L: FnMut(&AtlasCommandRecorder, &mut AtlasPageBuffers) -> bool,
1106 {
1107 let mut report = AtlasRenderReport::default();
1108
1109 for rect in glyphs.drain_pending_clear_rects() {
1110 if self.clear_rect(queue, atlas, rect) {
1111 report.cleared = report.cleared.saturating_add(1);
1112 } else {
1113 report.refused = report.refused.saturating_add(1);
1114 }
1115 }
1116 for upload in glyphs.drain_pending_uploads() {
1117 if self.upload_pixmap(queue, atlas, &upload) {
1118 report.uploaded = report.uploaded.saturating_add(1);
1119 } else {
1120 report.refused = report.refused.saturating_add(1);
1121 }
1122 }
1123
1124 // Taken out and put back so the closure below can hold the scratch
1125 // mutably while `self` records the pass it feeds.
1126 let mut buffers = core::mem::take(&mut self.buffers);
1127 glyphs.replay_pending_atlas_commands(|recorder| {
1128 buffers.clear();
1129 if !lower(recorder, &mut buffers) {
1130 report.refused = report.refused.saturating_add(1);
1131 return;
1132 }
1133 match self.render_page(
1134 device,
1135 queue,
1136 pipeline,
1137 atlas,
1138 recorder.page_index,
1139 &mut buffers,
1140 timestamps,
1141 ) {
1142 Ok(0) => {}
1143 Ok(instances) => {
1144 report.pages = report.pages.saturating_add(1);
1145 report.instances = report.instances.saturating_add(instances);
1146 }
1147 Err(_) => report.refused = report.refused.saturating_add(1),
1148 }
1149 });
1150 self.buffers = buffers;
1151
1152 report
1153 }
1154
1155 /// Grows the instance buffer if `required` bytes outgrew it.
1156 fn grow_instances(&mut self, device: &wgpu::Device, required: u64) {
1157 if self.instance_capacity >= required {
1158 return;
1159 }
1160 let capacity = required
1161 .checked_next_power_of_two()
1162 .unwrap_or(required)
1163 .max(MIN_ATLAS_INSTANCES * size_of::<GpuStrip>() as u64);
1164 self.instances = device.create_buffer(&instance_descriptor(capacity));
1165 self.instance_capacity = capacity;
1166 }
1167
1168 /// The four bind groups an atlas pass sets, built against `pipeline`'s own
1169 /// derived layout.
1170 ///
1171 /// Built per pass rather than cached: a replay happens only on a frame that
1172 /// missed a glyph, and caching would have to be invalidated against both
1173 /// the coverage texture's growth and the pipeline it was derived from — two
1174 /// invalidation sources for a path that is not the frame path.
1175 fn bind_groups(
1176 &self,
1177 device: &wgpu::Device,
1178 pipeline: &wgpu::RenderPipeline,
1179 ) -> [wgpu::BindGroup; 4] {
1180 [
1181 device.create_bind_group(&wgpu::BindGroupDescriptor {
1182 label: Some("frust-engine atlas pass resources"),
1183 layout: &pipeline.get_bind_group_layout(0),
1184 entries: &[
1185 wgpu::BindGroupEntry {
1186 binding: 0,
1187 resource: wgpu::BindingResource::TextureView(&self.alphas.view),
1188 },
1189 wgpu::BindGroupEntry {
1190 binding: 1,
1191 resource: self.config.as_entire_binding(),
1192 },
1193 wgpu::BindGroupEntry {
1194 binding: 2,
1195 resource: wgpu::BindingResource::TextureView(
1196 &self.placeholders.layer_input,
1197 ),
1198 },
1199 ],
1200 }),
1201 device.create_bind_group(&wgpu::BindGroupDescriptor {
1202 label: Some("frust-engine atlas pass images"),
1203 layout: &pipeline.get_bind_group_layout(1),
1204 entries: &[
1205 wgpu::BindGroupEntry {
1206 binding: 0,
1207 resource: wgpu::BindingResource::TextureView(&self.placeholders.array),
1208 },
1209 wgpu::BindGroupEntry {
1210 binding: 1,
1211 resource: wgpu::BindingResource::TextureView(&self.placeholders.external),
1212 },
1213 ],
1214 }),
1215 device.create_bind_group(&wgpu::BindGroupDescriptor {
1216 label: Some("frust-engine atlas pass paints"),
1217 layout: &pipeline.get_bind_group_layout(2),
1218 entries: &[wgpu::BindGroupEntry {
1219 binding: 0,
1220 resource: wgpu::BindingResource::TextureView(&self.placeholders.paints),
1221 }],
1222 }),
1223 device.create_bind_group(&wgpu::BindGroupDescriptor {
1224 label: Some("frust-engine atlas pass gradients"),
1225 layout: &pipeline.get_bind_group_layout(3),
1226 entries: &[wgpu::BindGroupEntry {
1227 binding: 0,
1228 resource: wgpu::BindingResource::TextureView(&self.placeholders.gradients),
1229 }],
1230 }),
1231 ]
1232 }
1233}
1234
1235/// The instance buffer's descriptor at `size` bytes.
1236fn instance_descriptor(size: u64) -> wgpu::BufferDescriptor<'static> {
1237 wgpu::BufferDescriptor {
1238 label: Some("frust-engine atlas strip instances"),
1239 size,
1240 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1241 mapped_at_creation: false,
1242 }
1243}
1244
1245/// The atlas rectangle one of `glifo`'s pending clear rects names.
1246///
1247/// The rect is already the *padded* region — the allocation an evicted glyph
1248/// gave back, transparent border included — which is exactly the rectangle that
1249/// has to be zeroed: leaving the padding behind would let a later, smaller
1250/// tenant's `Extend::Pad` sampling read the previous glyph's edge texels.
1251#[must_use]
1252pub fn clear_rect_region(rect: PendingClearRect) -> AtlasRegion {
1253 AtlasRegion {
1254 layer: rect.page_index,
1255 offset: [u32::from(rect.x), u32::from(rect.y)],
1256 size: [u32::from(rect.width), u32::from(rect.height)],
1257 }
1258}
1259
1260/// The atlas rectangle one pending bitmap upload's slot occupies.
1261///
1262/// The slot's own extent, not the padded allocation's: the padding around a
1263/// glyph is transparent by construction (a fresh page starts zeroed, an evicted
1264/// one is zeroed by its clear rect), so an upload writes the glyph and leaves
1265/// the border alone.
1266#[must_use]
1267pub fn slot_region(upload: &PendingBitmapUpload) -> AtlasRegion {
1268 let slot = upload.atlas_slot;
1269 AtlasRegion {
1270 layer: slot.page_index,
1271 offset: [u32::from(slot.x), u32::from(slot.y)],
1272 size: [u32::from(slot.width), u32::from(slot.height)],
1273 }
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278 use super::*;
1279 use vello_common::kurbo::Point;
1280
1281 fn region(layer: u32, offset: [u32; 2], size: [u32; 2]) -> AtlasRegion {
1282 AtlasRegion {
1283 layer,
1284 offset,
1285 size,
1286 }
1287 }
1288
1289 #[test]
1290 fn the_atlas_is_a_sampled_copyable_renderable_rgba8_array() {
1291 let descriptor = atlas_texture_descriptor(1024, 1024, 4);
1292
1293 assert_eq!(descriptor.format, ATLAS_FORMAT);
1294 assert_eq!(descriptor.dimension, wgpu::TextureDimension::D2);
1295 assert_eq!(descriptor.size.depth_or_array_layers, 4);
1296 assert_eq!(descriptor.mip_level_count, 1);
1297 for usage in [
1298 wgpu::TextureUsages::TEXTURE_BINDING,
1299 wgpu::TextureUsages::COPY_DST,
1300 wgpu::TextureUsages::COPY_SRC,
1301 wgpu::TextureUsages::RENDER_ATTACHMENT,
1302 ] {
1303 assert!(descriptor.usage.contains(usage));
1304 }
1305 }
1306
1307 #[test]
1308 fn a_zero_extent_descriptor_is_raised_to_a_creatable_one() {
1309 let descriptor = atlas_texture_descriptor(0, 0, 0);
1310 assert_eq!(descriptor.size.width, 1);
1311 assert_eq!(descriptor.size.height, 1);
1312 assert_eq!(descriptor.size.depth_or_array_layers, 2);
1313 }
1314
1315 /// The layer floor is two, not one — a fix for wgpu-hal 30.0.1's GLES
1316 /// backend, not an arbitrary minimum.
1317 ///
1318 /// `get_info_from_desc` (wgpu-hal `src/gles/mod.rs:513-530`) chooses the GL
1319 /// target from `TextureDescriptor::size.depth_or_array_layers` alone —
1320 /// `(false, 1) => TEXTURE_2D`, never consulting the view dimension a caller
1321 /// later binds the texture through. A one-layer atlas array descriptor
1322 /// therefore creates a plain `GL_TEXTURE_2D`, while
1323 /// `crates/frust-engine/shaders/strip.wgsl` always samples this texture as
1324 /// `texture_2d_array<f32>` (`sampler2DArray` once naga lowers it to GLSL).
1325 /// Target and sampler disagreeing makes the texture incomplete per GLES
1326 /// 3.0 §3.8.2, so every sample reads `(0, 0, 0, 1)`: a solid box in place
1327 /// of a glyph, a solid black rect in place of an image — reproduced on
1328 /// Chrome's WebGL2 backend by the examples/web-spike probe and fixed
1329 /// by this floor. Tracked upstream as wgpu issues #1614 and #1574; a later
1330 /// tidy-up must not "simplify" this back to `max(1)` without wgpu-hal
1331 /// fixing the heuristic first (see `docs/LIMITATIONS.md`).
1332 #[test]
1333 fn the_layer_floor_is_two_because_wgpu_hal_gles_ignores_the_view_dimension() {
1334 assert_eq!(
1335 atlas_texture_descriptor(64, 64, 0)
1336 .size
1337 .depth_or_array_layers,
1338 2
1339 );
1340 assert_eq!(
1341 atlas_texture_descriptor(64, 64, 1)
1342 .size
1343 .depth_or_array_layers,
1344 2
1345 );
1346 assert_eq!(
1347 atlas_texture_descriptor(64, 64, 3)
1348 .size
1349 .depth_or_array_layers,
1350 3,
1351 "a request already past the floor is not clamped down to it"
1352 );
1353 }
1354
1355 #[test]
1356 fn the_view_the_shader_binds_is_a_layered_one() {
1357 assert_eq!(
1358 atlas_view_descriptor().dimension,
1359 Some(wgpu::TextureViewDimension::D2Array)
1360 );
1361 }
1362
1363 #[test]
1364 fn image_params_round_trip_through_the_shaders_own_field_widths() {
1365 let packed = pack_image_params(1, 2, 3, 200, false);
1366
1367 assert_eq!(packed & 0b11, 1, "quality");
1368 assert_eq!((packed >> 2) & 0b11, 2, "extend_x");
1369 assert_eq!((packed >> 4) & 0b11, 3, "extend_y");
1370 assert_eq!((packed >> 6) & 0xFF, 200, "atlas index");
1371 assert_eq!((packed >> 14) & 1, 0, "source kind");
1372
1373 assert_eq!(pack_image_params(0, 0, 0, 0, true) >> 14 & 1, 1);
1374 }
1375
1376 #[test]
1377 fn size_and_offset_pack_with_the_first_component_high() {
1378 assert_eq!(pack_image_size(0x1234, 0x5678), 0x1234_5678);
1379 assert_eq!(pack_image_offset(0x00FF, 0xAB00), 0x00FF_AB00);
1380 }
1381
1382 #[test]
1383 fn an_absent_tint_is_the_identity_multiply() {
1384 let (color, mode) = pack_tint(None);
1385 assert_eq!(color, u32::MAX);
1386 assert_eq!(mode, TintMode::Multiply.as_u32());
1387 }
1388
1389 #[test]
1390 fn natural_to_dest_lands_the_natural_corners_on_the_dest_corners() {
1391 let dest = Rect::new(5.0, 6.0, 45.0, 46.0);
1392 let transform = natural_to_dest(Affine::IDENTITY, (2, 2), dest).expect("non-degenerate");
1393
1394 assert_eq!(transform * Point::new(0.0, 0.0), Point::new(5.0, 6.0));
1395 assert_eq!(transform * Point::new(2.0, 2.0), Point::new(45.0, 46.0));
1396 }
1397
1398 #[test]
1399 fn natural_to_dest_composes_the_widgets_own_transform_outermost() {
1400 let dest = Rect::new(0.0, 0.0, 4.0, 4.0);
1401 let transform =
1402 natural_to_dest(Affine::translate((10.0, 20.0)), (2, 2), dest).expect("non-degenerate");
1403
1404 assert_eq!(transform * Point::new(0.0, 0.0), Point::new(10.0, 20.0));
1405 }
1406
1407 #[test]
1408 fn natural_to_dest_refuses_a_degenerate_natural_size() {
1409 let dest = Rect::new(0.0, 0.0, 10.0, 10.0);
1410 assert!(natural_to_dest(Affine::IDENTITY, (0, 4), dest).is_none());
1411 assert!(natural_to_dest(Affine::IDENTITY, (4, 0), dest).is_none());
1412 }
1413
1414 #[test]
1415 fn advances_are_the_linear_part_only() {
1416 let transform = Affine::new([2.0, 3.0, 4.0, 5.0, 100.0, 200.0]);
1417 let (x_advance, y_advance) = x_y_advances(transform);
1418
1419 assert_eq!(x_advance, Vec2::new(2.0, 3.0));
1420 assert_eq!(y_advance, Vec2::new(4.0, 5.0));
1421 }
1422
1423 #[test]
1424 fn a_layer_target_names_exactly_one_layer_as_a_plain_2d_view() {
1425 let descriptor = atlas_layer_view_descriptor(3);
1426
1427 assert_eq!(descriptor.dimension, Some(wgpu::TextureViewDimension::D2));
1428 assert_eq!(descriptor.base_array_layer, 3);
1429 assert_eq!(
1430 descriptor.array_layer_count,
1431 Some(1),
1432 "a colour attachment must name exactly one layer"
1433 );
1434 assert_eq!(descriptor.base_mip_level, 0);
1435 // Distinct from the sampling view in the one axis that matters: the
1436 // same texture is bound array-wide for reading and 2D for writing.
1437 assert_ne!(descriptor.dimension, atlas_view_descriptor().dimension);
1438 }
1439
1440 #[test]
1441 fn a_clear_rect_becomes_its_whole_padded_rectangle() {
1442 let region = clear_rect_region(PendingClearRect {
1443 page_index: 2,
1444 x: 40,
1445 y: 8,
1446 width: 18,
1447 height: 22,
1448 });
1449
1450 assert_eq!(region.layer, 2);
1451 assert_eq!(region.offset, [40, 8]);
1452 assert_eq!(region.size, [18, 22]);
1453 assert_eq!(region.byte_len(), 18 * 22 * ATLAS_FORMAT_BYTES as usize);
1454 }
1455
1456 /// One solid-painted run's instances, checked against the shape the shader
1457 /// reads them at.
1458 ///
1459 /// The run carries a trailing sentinel: a strip's width is the *coverage*
1460 /// distance to the strip after it (`Strip::width_to`), not the distance
1461 /// between their x positions, which is why the generator emits a sentinel
1462 /// and why a caller that trimmed it would silently lose the last span.
1463 #[test]
1464 fn a_solid_run_expands_to_alpha_sampled_spans_carrying_one_colour() {
1465 let run = [
1466 Strip::new(4, 0, 0, false),
1467 Strip::new(20, 0, 32, false),
1468 Strip::new(64, 0, 80, false),
1469 ];
1470 let mut out = Vec::new();
1471
1472 let pushed = push_solid_strips(&run, 0xDEAD_BEEF, 7, &mut out);
1473
1474 assert_eq!(pushed, 2, "two pairs, each contributing one span");
1475 assert_eq!(out.len(), 2);
1476 for instance in &out {
1477 assert_eq!(instance.payload, 0xDEAD_BEEF, "a solid colour is per draw");
1478 assert_eq!(instance.depth_index, 7);
1479 assert!(!instance.is_rect());
1480 assert_eq!(
1481 instance.paint(),
1482 pack_paint_descriptor(PaintType::Solid, 0),
1483 "a solid instance indexes no encoded-paint record"
1484 );
1485 assert_eq!(
1486 instance.dense_width_or_rect_height, instance.width,
1487 "every column of a replayed glyph samples coverage"
1488 );
1489 }
1490 assert_eq!(out[0].x, 4);
1491 assert_eq!(
1492 out[0].width, 8,
1493 "32 coverage bytes at 4 per column is 8 pixels"
1494 );
1495 assert_eq!(out[0].col_idx_or_rect_frac, 0);
1496 assert_eq!(out[1].x, 20);
1497 assert_eq!(out[1].width, 12);
1498 assert_eq!(
1499 out[1].col_idx_or_rect_frac, 8,
1500 "the second span starts at the column its coverage does"
1501 );
1502 }
1503
1504 #[test]
1505 fn a_winding_gap_between_two_strips_is_filled_solid() {
1506 // The second strip carries the fill-gap flag on the same row, so the
1507 // span between them is inside the glyph and gets a coverage-free fill.
1508 let run = [
1509 Strip::new(0, 0, 0, false),
1510 Strip::new(32, 0, 16, true),
1511 Strip::new(48, 0, 32, false),
1512 ];
1513 let mut out = Vec::new();
1514
1515 let pushed = push_solid_strips(&run, 0x11, 0, &mut out);
1516
1517 assert_eq!(pushed, 3, "two spans plus the gap between them");
1518 let gap = out
1519 .iter()
1520 .find(|instance| instance.dense_width_or_rect_height == 0)
1521 .copied();
1522 let gap = match gap {
1523 Some(gap) => gap,
1524 None => unreachable!("the flagged pair fills its gap"),
1525 };
1526 assert_eq!(gap.x, 4, "the gap starts where the first strip ends");
1527 assert_eq!(gap.width, 28);
1528 assert_eq!(gap.payload, 0x11, "the fill takes the run's own colour");
1529 assert_eq!(gap.col_idx_or_rect_frac, 0, "a gap samples no coverage");
1530 }
1531
1532 #[test]
1533 fn a_run_with_no_pair_in_it_draws_nothing() {
1534 let mut out = Vec::new();
1535 assert_eq!(push_solid_strips(&[], 0, 0, &mut out), 0);
1536 assert_eq!(
1537 push_solid_strips(&[Strip::new(0, 0, 0, false)], 0, 0, &mut out),
1538 0,
1539 "a lone sentinel is not a span"
1540 );
1541 assert!(out.is_empty());
1542 }
1543
1544 #[test]
1545 fn an_emptied_page_keeps_its_capacity_for_the_next_one() {
1546 let mut buffers = AtlasPageBuffers::default();
1547 assert!(buffers.is_empty());
1548
1549 buffers.instances.push(GpuStrip::solid_fill(
1550 0,
1551 0,
1552 8,
1553 StripDraw {
1554 payload: 0,
1555 paint: 0,
1556 depth_index: 0,
1557 },
1558 ));
1559 buffers.alphas.extend_from_slice(&[1, 2, 3, 4]);
1560 assert!(!buffers.is_empty());
1561
1562 let instances = buffers.instances.capacity();
1563 let alphas = buffers.alphas.capacity();
1564 buffers.clear();
1565
1566 assert!(buffers.is_empty());
1567 assert!(buffers.alphas.is_empty());
1568 assert_eq!(buffers.instances.capacity(), instances);
1569 assert_eq!(buffers.alphas.capacity(), alphas);
1570 }
1571
1572 #[test]
1573 fn a_report_that_serviced_nothing_reads_as_empty() {
1574 let mut report = AtlasRenderReport::default();
1575 assert!(report.is_empty());
1576
1577 report.refused = 1;
1578 assert!(
1579 !report.is_empty(),
1580 "a refusal is work that happened, not an idle frame"
1581 );
1582 }
1583
1584 #[test]
1585 fn the_stand_in_paint_texture_width_is_reconstructable_by_the_shader() {
1586 // The shader rebuilds the width as `1 << bits`, so a stand-in that was
1587 // not a power of two would make the config disagree with the texture
1588 // actually bound.
1589 assert!(PLACEHOLDER_PAINT_TEX_WIDTH.is_power_of_two());
1590 assert_eq!(
1591 super::super::config::tex_width_bits(PLACEHOLDER_PAINT_TEX_WIDTH),
1592 0
1593 );
1594 }
1595
1596 #[test]
1597 fn a_regions_byte_footprint_and_stride_agree_with_rgba8() {
1598 let populated = region(0, [4, 8], [16, 32]);
1599 assert_eq!(populated.bytes_per_row(), 64);
1600 assert_eq!(populated.byte_len(), 64 * 32);
1601 assert_eq!(region_byte_len(populated), populated.byte_len());
1602 assert!(!populated.is_empty());
1603 assert!(region(0, [0, 0], [0, 4]).is_empty());
1604 }
1605}