frust_engine/cache/images.rs
1//! Image residency: which decoded images own atlas space, and for how long.
2//!
3//! A widget hands the display list the *same* decoded image every frame — the
4//! `peniko::ImageData` in a [`Command::Image`](frust_scene::Command::Image) is a
5//! reference-counted handle cloned per frame, never a re-decode — so the pixels
6//! behind it are stable across frames and belong on the GPU once rather than
7//! per draw. [`ImageResidency`] is what makes that "once" true: it keys an
8//! image on its blob's process-unique id ([`peniko::Blob::id`]) and hands back
9//! the same [`ImageId`] and the same atlas rectangle for every later frame that
10//! draws it.
11//!
12//! Allocation itself is pure — a rectangle packer over
13//! [`vello_common::multi_atlas::MultiAtlasManager`], no device in the loop — so
14//! everything here is host-testable. The two GPU-side halves it *describes*
15//! (writing a newly allocated region's texels, clearing an evicted one's) are
16//! carried out by [`crate::gpu::atlas`] against the plan this module produces:
17//! [`ImageResidency::evictions`] first, [`ImageResidency::uploads`] second. That
18//! order is a contract, not a preference — a rectangle freed this frame can be
19//! re-allocated in the same frame, so clearing after uploading would erase the
20//! image that just moved in.
21//!
22//! ## Residency is committed only once the plan is serviced
23//!
24//! The cache decides residency long before anything reaches a queue: a
25//! compiled frame can still be refused afterwards (a layer shape the scheduler
26//! will not serve, a page past the pool's ceiling, coverage or paints outgrowing
27//! their textures), and a frame refused after compiling is never drawn and never
28//! uploaded. So the plan is *pending* rather than taken: [`ImageResidency::plan`]
29//! hands out a copy, [`begin_frame`](ImageResidency::begin_frame) leaves it
30//! alone, and only [`acknowledge_plan`](ImageResidency::acknowledge_plan) —
31//! called by a consumer that has actually written the regions into a live atlas
32//! — clears it.
33//!
34//! That is what makes the entry map and the atlas's real contents agree. Without
35//! it, a refused frame leaves an image *recorded as resident* whose texels were
36//! never written: every later frame resolves the entry, schedules no upload
37//! (residency is a hit), and either drops the draw for the life of the process
38//! or samples a rectangle nothing ever wrote. Re-emitting the same plan until it
39//! is acknowledged costs a vector copy per frame in the steady state — where the
40//! plan is empty — and is what makes "an image drawn on a thousand frames
41//! uploads once" true of the *atlas* rather than only of the cache.
42//!
43//! ## An image larger than the atlas is minified, not dropped
44//!
45//! A source whose own extent will not fit one atlas layer is downsampled to fit
46//! ([`fit_extent`], [`minify`]) instead of being refused. A photograph decoded
47//! at its capture resolution is routinely several times the atlas budget while
48//! its destination rectangle is a thumbnail, so refusing it drops a draw the
49//! display list plainly describes; and every texel past the destination's own
50//! scale is one the sampler would have thrown away anyway.
51//!
52//! The filter is a plain box average over the premultiplied texels, hand-written
53//! rather than pulled in: the workspace's version pins are law, an image decoder
54//! is not a dependency this crate carries, and a box average is exactly right
55//! for the minification direction (every output texel is the mean of the source
56//! texels it covers). The aspect ratio is preserved, so the paint transform's
57//! two axes stay in step; what changes is the resident rectangle's extent, which
58//! [`ResidentImage::natural`] records alongside it so the consumer can rescale
59//! the encoded paint's own transform into the smaller rectangle.
60//!
61//! ## Two things are refused rather than attempted
62//!
63//! 1. **A format or size `vello_common` panics on.**
64//! [`vello_common::paint::ImageSource::from_peniko_image_data`] asserts on a
65//! dimension past `u16::MAX` and `unimplemented!()`s on a format outside
66//! `Rgba8`/`Bgra8`, and a pixel buffer whose length disagrees with the
67//! declared extent trips [`Pixmap::from_parts_with_opacity`]'s own assertion.
68//! All three are checked here, *before* the call, so an oversized or
69//! malformed image is an [`ImageSkip`] the frame path reports rather than a
70//! panic it takes (E17). These, plus an atlas with no room left in it, are
71//! now the only things [`ImageResidency::skipped`] counts: an image that is
72//! merely *big* is minified and drawn.
73//! 2. **Anything at all, when `FRUST_ENGINE_NO_ATLAS` is set.** The kill switch
74//! ([`crate::config::atlas_disabled`]) makes every resolution answer
75//! [`ImageSkip::AtlasDisabled`], so no atlas is allocated, nothing is
76//! uploaded, and image draws fall back to painting nothing — the switch's
77//! whole point being to take the atlas out of a frame under diagnosis.
78//!
79//! ## Residency is bounded by age, and by pressure
80//!
81//! An entry unseen for [`MAX_UNSEEN_FRAMES`] consecutive frames is deallocated
82//! and its rectangle reported for clearing — the same age-based reap
83//! `frust-render`'s shader-effect cache uses, and for the same reason: a screen
84//! that stops drawing an image should give its texels back promptly, while an
85//! image drawn every other frame must never be mistaken for gone.
86//!
87//! Age alone is not a bound on *population*, though: it says nothing about how
88//! many distinct images a screen draws inside one age window. A list scrolling
89//! faster than [`MAX_UNSEEN_FRAMES`] streams more of them past the viewport
90//! than the atlas has rectangles for, and every one past the last free
91//! rectangle used to be an [`ImageSkip::NoAtlasSpace`] — a draw that painted
92//! nothing, on a screen whose solid fills all still landed, which reads as a
93//! hole rather than as a cache miss. So an allocation the packer refuses now
94//! *evicts* instead of giving up: [`ImageResidency::resolve`] works out which
95//! least-recently-seen rectangles would make room, frees exactly those, and
96//! each one whose rectangle was taken re-uploads on the next frame that draws
97//! it. A working set larger than the atlas therefore degrades to re-uploads —
98//! bandwidth — rather than to missing content, and
99//! [`ImageResidency::pressure_evictions`] counts how often that trade was made.
100//!
101//! ## An eviction is planned on paper before anything is freed
102//!
103//! An eviction that does not produce the allocation is pure loss. The draw is
104//! skipped anyway, so the frame pays a clear per freed rectangle *and* the
105//! re-upload of every displaced image *and* the hole it was trying to avoid.
106//! And the condition recurs: a request the atlas can never satisfy would strip
107//! and rebuild the residency on every frame that asks for it, which on a mobile
108//! budget is megabytes of texture traffic each way, per frame, buying nothing.
109//!
110//! So the pressure path commits nothing speculatively. `allocate_under_pressure`
111//! walks the candidates in least-recently-seen order over a *model* of the
112//! layers — the rectangles this residency knows are occupied, and each layer's
113//! free area as the packer itself reports it — and stops at the first prefix
114//! that would admit the request. Only then is anything released, and only the
115//! candidates on the layer the fit was found in. A request no bounded set of
116//! candidates can satisfy therefore stays the cheap [`ImageSkip::NoAtlasSpace`]
117//! it was before eviction existed, with the residency untouched.
118//!
119//! The model is deliberately *optimistic*: it can see this residency's own
120//! rectangles but not the glyph pages sharing the same allocator (see below),
121//! so it never refuses a request eviction could have satisfied on a given
122//! layer — a placement that clears the real occupants clears the model's
123//! subset of them too. The reverse can happen: the packer may still refuse a
124//! plan the model approved, which costs the planned evictions and no
125//! allocation. That case is bounded by the same plan budget rather than
126//! open-ended, and it is the one residual the planner cannot rule out without
127//! knowing where every glyph page sits.
128//!
129//! The bounded search itself is scoped *per layer*: each atlas layer gets its
130//! own candidate-count and area allowance rather than the whole residency
131//! sharing one pool, so a request only one layer could ever satisfy is not
132//! starved by candidates on layers that were never going to host it — each
133//! layer is either cleared within its own allowance or ruled out on its own
134//! terms, never on account of what another layer's candidates already cost.
135//!
136//! The one entry never freed on either route is one *this frame* has already
137//! resolved. The plan clears before it uploads, so handing back a rectangle the
138//! frame is about to sample would erase exactly the image the eviction was
139//! meant to make room for. A working set that outgrows the atlas inside a
140//! single frame is therefore still a skip, and honestly so: nothing is
141//! evictable. The same guard covers the *extent-mismatch* release in
142//! [`ImageResidency::resolve`], where a blob redrawn at a new extent gives its
143//! old rectangle back: on a frame that already sampled that rectangle the
144//! second draw is skipped instead, as
145//! [`ImageSkip::SameFrameExtentConflict`] rather than as a capacity refusal —
146//! the atlas may hold nothing at all beside the first extent's own rectangle.
147//!
148//! ## One allocator for images *and* glyphs
149//!
150//! The [`ImageCache`] this module holds is the process's only atlas allocator:
151//! the glyph policy (`crate::text::atlas_policy`) owns no cache of its own and
152//! allocates its slots through [`ImageResidency::allocator_mut`].
153//!
154//! That is a correctness requirement, not tidiness. An [`ImageId`] is a *slot
155//! index into one cache* — `ImageCache::allocate` hands out the next free index
156//! of its own `slots` vector — and the strip shader has exactly one atlas
157//! texture array to sample from. Two caches over the same page geometry would
158//! therefore mint the same small integers for unrelated occupants and pack them
159//! into overlapping rectangles of the same layers, so a glyph and an image would
160//! address each other's texels by construction. Sharing one cache makes both
161//! collisions unrepresentable: one id space, one packer, one set of live
162//! rectangles.
163//!
164//! It is also what upstream does — `vello_hybrid`'s `Resources` holds a single
165//! `image_cache` and hands it to its glyph atlas at every call — and it is what
166//! keeps the budget honest: a glyph page and an image page come out of the same
167//! [`AtlasBudget::max_atlases`] allowance, and [`ImageResidency::layers`] counts
168//! whichever of the two created a layer.
169//!
170//! The one rule a borrower must keep is ownership of its own handles: each side
171//! deallocates only the ids it allocated. Nothing enforces that beyond the two
172//! call sites, and it is the reason [`ImageResidency::allocator_mut`] is
173//! documented as a contract rather than a plain accessor.
174
175use std::collections::{HashMap, HashSet};
176use std::sync::Arc;
177#[cfg(feature = "perf-trace")]
178use std::sync::atomic::{AtomicBool, Ordering};
179
180use frust_gpu::{DownlevelProfile, TierCaps};
181use peniko::color::PremulRgba8;
182use peniko::{ImageData, ImageFormat};
183use thiserror::Error;
184use vello_common::image_cache::ImageCache;
185use vello_common::multi_atlas::{AllocationStrategy, AtlasConfig};
186use vello_common::paint::{ImageId, ImageSource};
187use vello_common::pixmap::Pixmap;
188
189use crate::config;
190
191/// Consecutive frames an image may go undrawn before its atlas rectangle is
192/// reclaimed.
193///
194/// 60 frames is half a second at 120Hz and a whole one at 60Hz: long enough
195/// that an image drawn intermittently (an every-other-frame animation, a brief
196/// scene-diff hiccup) never loses its residency, short enough that navigating
197/// away from an image-heavy screen returns its texels within a frame budget's
198/// worth of frames rather than at surface teardown.
199pub const MAX_UNSEEN_FRAMES: u64 = 60;
200
201/// Transparent padding pixels placed around each image in the atlas.
202///
203/// Zero, deliberately. Padding exists to stop a filtered sample from reading a
204/// neighbour's texels, and the strip shader's atlas samplers cannot do that:
205/// both the bilinear and the bicubic path clamp every tap into
206/// `[offset, offset + size - 1]` — the image's own rectangle — before it
207/// reaches `textureLoad`. Paying a two-pixel border per image would buy nothing
208/// and cost atlas space that a mobile budget does not have.
209pub const ATLAS_PADDING: u16 = 0;
210
211/// The largest image edge that can be made resident at all.
212///
213/// `vello_common`'s image cache addresses an offset and an extent in `u16`, and
214/// its `ImageSource` conversion asserts on anything larger, so this is the
215/// ceiling the pre-check enforces rather than a policy of ours.
216pub const MAX_IMAGE_DIMENSION: u32 = u16::MAX as u32;
217
218/// The most atlas layers the shader's encoded-image record can name.
219///
220/// `GpuEncodedImage::image_params` gives `atlas_index` eight bits, so a layer
221/// past 255 could not be addressed even if the adapter allowed it.
222pub const MAX_ATLAS_LAYERS: usize = 256;
223
224/// Why one image could not be made resident.
225///
226/// Every variant is a *skip*, never a frame failure: the draw paints nothing
227/// and the rest of the frame proceeds. They are distinguished because the
228/// remedies differ — a too-large image is an application decision, an exhausted
229/// atlas is a budget one, and a disabled atlas is the operator's own doing.
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
231pub enum ImageSkip {
232 /// `FRUST_ENGINE_NO_ATLAS` is set; the atlas is out of the frame entirely.
233 #[error("image residency is disabled by FRUST_ENGINE_NO_ATLAS")]
234 AtlasDisabled,
235 /// The image declares a format the atlas does not store.
236 #[error("unsupported image format {0:?}: only Rgba8 and Bgra8 are uploaded")]
237 UnsupportedFormat(ImageFormat),
238 /// The image has no area, so there is nothing to make resident.
239 #[error("degenerate image extent {width}x{height}")]
240 DegenerateExtent {
241 /// Declared width in pixels.
242 width: u32,
243 /// Declared height in pixels.
244 height: u32,
245 },
246 /// The image is larger than [`MAX_IMAGE_DIMENSION`] on at least one axis.
247 #[error("image {width}x{height} exceeds the {max}-pixel residency ceiling")]
248 TooLarge {
249 /// Declared width in pixels.
250 width: u32,
251 /// Declared height in pixels.
252 height: u32,
253 /// The per-axis ceiling that refused it.
254 max: u32,
255 },
256 /// The pixel buffer's length disagrees with the declared extent and format.
257 #[error("image pixel buffer is {actual} bytes; {width}x{height} needs exactly {expected}")]
258 MalformedPixels {
259 /// Declared width in pixels.
260 width: u32,
261 /// Declared height in pixels.
262 height: u32,
263 /// Bytes the declared extent and format require.
264 expected: usize,
265 /// Bytes the blob actually holds.
266 actual: usize,
267 },
268 /// The paint transform cannot be inverted, so there is no device-to-image
269 /// mapping for the shader to sample through.
270 #[error("image paint transform is singular or non-finite")]
271 SingularTransform,
272 /// The image fits the ceiling but not the configured atlas budget, and no
273 /// resident image could be evicted to make room for it — either the atlas
274 /// holds nothing but images this same frame drew, or what it holds beside
275 /// them still leaves no rectangle this one fits in.
276 #[error("image {width}x{height} does not fit the {atlas_width}x{atlas_height} atlas budget")]
277 NoAtlasSpace {
278 /// Declared width in pixels.
279 width: u32,
280 /// Declared height in pixels.
281 height: u32,
282 /// Configured atlas width.
283 atlas_width: u32,
284 /// Configured atlas height.
285 atlas_height: u32,
286 },
287 /// The same blob was already resolved earlier in this same frame at a
288 /// different extent.
289 ///
290 /// Not an atlas-capacity refusal: the atlas may be nearly empty. The plan
291 /// clears before it uploads (see the module doc), so releasing this
292 /// blob's rectangle to make room for its own second extent would schedule
293 /// a clear over texels this frame already sampled — the first extent's
294 /// draw keeps its texels and the second is skipped until the next frame
295 /// instead.
296 #[error(
297 "image {width}x{height} already resolved this frame at a different extent; the first \
298 extent's texels are kept until the next frame"
299 )]
300 SameFrameExtentConflict {
301 /// The refused extent's width, after [`fit_extent`] the same way
302 /// [`NoAtlasSpace`](Self::NoAtlasSpace) reports it.
303 width: u32,
304 /// The refused extent's height, after [`fit_extent`].
305 height: u32,
306 },
307}
308
309/// The atlas geometry a tier of adapter is given.
310///
311/// Never `vello_common`'s own [`AtlasConfig`] default: that is 4096 square
312/// across eight layers, 64 MiB of `Rgba8Unorm` per layer and half a gigabyte in
313/// total, which is a desktop-only figure that a phone would pay for the first
314/// image it drew.
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub struct AtlasBudget {
317 /// Extent of each atlas layer, in pixels.
318 pub atlas_size: (u32, u32),
319 /// Maximum number of layers the array may grow to.
320 pub max_atlases: usize,
321}
322
323impl AtlasBudget {
324 /// The budget a tile-based or downlevel-clamped adapter gets: 4 MiB a
325 /// layer, 16 MiB in total.
326 pub const MOBILE: Self = Self {
327 atlas_size: (1024, 1024),
328 max_atlases: 4,
329 };
330
331 /// The budget a full-profile immediate-mode adapter gets: 16 MiB a layer,
332 /// 128 MiB in total.
333 pub const DESKTOP: Self = Self {
334 atlas_size: (2048, 2048),
335 max_atlases: 8,
336 };
337
338 /// The budget for `caps`' adapter, clamped to what it can actually create.
339 ///
340 /// The tier choice is [`is_mobile_tier`]; the clamps that follow are the
341 /// adapter's own texture-edge and array-layer ceilings plus the eight-bit
342 /// [`MAX_ATLAS_LAYERS`] the encoded-image record can address.
343 ///
344 /// `FRUST_ENGINE_ATLAS_SIZE` overrides the tier's extent, and the override
345 /// is **budgeted, not unbudgeted**: it passes through
346 /// [`within_total_bytes`](Self::within_total_bytes) against the tier's own
347 /// [`total_bytes`](Self::total_bytes) before the adapter clamps. So the knob
348 /// redistributes a tier's memory between extent and depth — one large layer
349 /// instead of eight small ones — and cannot spend more of it than the tier
350 /// itself would. Clamped to the adapter last, because an override is a
351 /// request and a request the adapter cannot honour is still narrowed to what
352 /// it can.
353 ///
354 /// The resolved budget is reported once per process through
355 /// `atlas_tier_line`, because which of the two tiers a given phone takes is
356 /// otherwise an inference from behaviour rather than an observation: the
357 /// signals [`is_mobile_tier`] reads are the driver's own answers, and a
358 /// device whose driver answers differently from the one it resembles is
359 /// exactly the case a benchmark capture has to be able to name. That line
360 /// is `perf-trace`-only, like every other `frust-perf` line the engine
361 /// emits — a build without the feature resolves the same budget and writes
362 /// nothing.
363 #[must_use]
364 pub fn for_caps(caps: &TierCaps) -> Self {
365 let tier = if is_mobile_tier(caps) {
366 Self::MOBILE
367 } else {
368 Self::DESKTOP
369 };
370 let resolved = match config::atlas_size() {
371 None => tier.clamped(caps),
372 Some(atlas_size) => Self {
373 atlas_size,
374 max_atlases: tier.max_atlases,
375 }
376 .within_total_bytes(tier.total_bytes())
377 .clamped(caps),
378 };
379
380 #[cfg(feature = "perf-trace")]
381 ATLAS_TIER_LOG.emit(caps, resolved);
382 resolved
383 }
384
385 /// This budget narrowed so a fully grown array costs at most `total_bytes`.
386 ///
387 /// The extent goes first, scaled uniformly until one layer fits inside the
388 /// whole allowance; the layer count then takes whatever multiple of that
389 /// layer is left, never below one. Scaling the extent rather than only the
390 /// depth is what makes the ceiling hold at all: a single 16384-square layer
391 /// is a gigabyte on its own, so a clamp that could only reduce the layer
392 /// *count* would have nothing left to reduce.
393 ///
394 /// Both axes are floored after the uniform scale, which can only take the
395 /// product below the allowance — except where an extreme aspect ratio floors
396 /// one axis to nothing and it is raised back to a texel, so each axis is
397 /// then held against what the other leaves.
398 #[must_use]
399 pub fn within_total_bytes(self, total_bytes: u64) -> Self {
400 // A single texel is the smallest atlas that exists, so an allowance
401 // below one is treated as one rather than producing a zero extent the
402 // packer could not address.
403 let allowance = total_bytes.max(ATLAS_FORMAT_BYTES);
404 let max_texels = allowance / ATLAS_FORMAT_BYTES;
405
406 let (width, height) = self.atlas_size;
407 let texels = u64::from(width).saturating_mul(u64::from(height));
408 let atlas_size = if texels <= max_texels {
409 (width, height)
410 } else {
411 // `as` on a float saturates rather than wrapping, and both factors
412 // are finite and positive, so the narrowing is a plain floor.
413 let scale = (max_texels as f64 / texels.max(1) as f64).sqrt();
414 let scaled_w = ((f64::from(width) * scale) as u32).max(1);
415 let scaled_h = ((f64::from(height) * scale) as u32).max(1);
416 let fitted_w = fit_axis(scaled_w, max_texels / u64::from(scaled_h));
417 let fitted_h = fit_axis(scaled_h, max_texels / u64::from(fitted_w));
418 (fitted_w, fitted_h)
419 };
420
421 let layer_bytes = u64::from(atlas_size.0)
422 .saturating_mul(u64::from(atlas_size.1))
423 .saturating_mul(ATLAS_FORMAT_BYTES)
424 .max(1);
425 let layers = usize::try_from(allowance / layer_bytes).unwrap_or(MAX_ATLAS_LAYERS);
426
427 Self {
428 atlas_size,
429 max_atlases: self.max_atlases.min(layers).max(1),
430 }
431 }
432
433 /// Whether `region` lies inside an atlas array of `layers` layers at this
434 /// budget's per-layer extent.
435 ///
436 /// The pure counterpart of [`crate::gpu::atlas::AtlasArray::contains`],
437 /// answerable with no device: a consumer that records where an image lives
438 /// before the array exists checks the rectangle here, so it can never come
439 /// to name a region the array will refuse to write and then sample it as
440 /// whatever the texture happened to hold.
441 #[must_use]
442 pub fn contains(self, region: AtlasRegion, layers: u32) -> bool {
443 !region.is_empty()
444 && region.layer < layers
445 && region.offset[0].saturating_add(region.size[0]) <= self.atlas_size.0
446 && region.offset[1].saturating_add(region.size[1]) <= self.atlas_size.1
447 }
448
449 /// This budget narrowed to what `caps`' adapter can create.
450 #[must_use]
451 pub fn clamped(self, caps: &TierCaps) -> Self {
452 let edge = caps.max_texture_dimension_2d.max(1);
453 let layers = usize::try_from(caps.max_texture_array_layers).unwrap_or(MAX_ATLAS_LAYERS);
454
455 Self {
456 atlas_size: (
457 self.atlas_size.0.clamp(1, edge),
458 self.atlas_size.1.clamp(1, edge),
459 ),
460 max_atlases: self.max_atlases.clamp(1, layers.min(MAX_ATLAS_LAYERS)),
461 }
462 }
463
464 /// The `vello_common` configuration this budget describes.
465 ///
466 /// `initial_atlas_count` is zero so the first layer is created by the first
467 /// allocation that needs one: an application drawing no images pays for no
468 /// atlas at all, and [`vello_common::multi_atlas::MultiAtlasManager::new`]'s
469 /// own `expect` on eager creation can never be reached.
470 #[must_use]
471 pub fn config(self) -> AtlasConfig {
472 AtlasConfig {
473 initial_atlas_count: 0,
474 max_atlases: self.max_atlases,
475 atlas_size: self.atlas_size,
476 auto_grow: true,
477 allocation_strategy: AllocationStrategy::FirstFit,
478 }
479 }
480
481 /// The bytes one fully populated layer costs at [`ATLAS_FORMAT_BYTES`].
482 #[must_use]
483 pub fn layer_bytes(self) -> u64 {
484 u64::from(self.atlas_size.0)
485 .saturating_mul(u64::from(self.atlas_size.1))
486 .saturating_mul(ATLAS_FORMAT_BYTES)
487 }
488
489 /// The bytes every layer of this budget costs once fully grown.
490 #[must_use]
491 pub fn total_bytes(self) -> u64 {
492 self.layer_bytes()
493 .saturating_mul(self.max_atlases.try_into().unwrap_or(u64::MAX))
494 }
495}
496
497/// Bytes per texel of the atlas array (`Rgba8Unorm`).
498pub const ATLAS_FORMAT_BYTES: u64 = 4;
499
500/// Whether `caps`' adapter takes the mobile atlas budget.
501///
502/// Two signals, either of which is enough:
503///
504/// - its usable limits are clamped to the GLES-3.0/WebGL2 downlevel defaults,
505/// which is the profile every GL-backed target reports; or
506/// - it reports `transient_saves_memory`, wgpu's own answer to "is this a
507/// tile-based renderer keeping attachments on-chip" — true on the mobile
508/// TBDR parts and false on desktop immediate-mode ones.
509///
510/// Neither reads the adapter *name*: a name match is a denylist that ages, and
511/// both signals above are capability answers the driver gives directly.
512#[must_use]
513pub fn is_mobile_tier(caps: &TierCaps) -> bool {
514 caps.downlevel_profile != DownlevelProfile::Full || caps.transient_saves_memory
515}
516
517/// The process's latch for the resolved-tier line, held by
518/// [`AtlasBudget::for_caps`].
519#[cfg(feature = "perf-trace")]
520static ATLAS_TIER_LOG: TierLogOnce = TierLogOnce::new();
521
522/// A one-shot latch for the resolved-tier line.
523///
524/// Once per *process* rather than once per call: a budget is resolved once per
525/// surface, and the question the line answers — which tier this adapter took —
526/// is about the device, so one line per run answers it completely while a line
527/// per surface would repeat it.
528///
529/// A latch value rather than a bare [`std::sync::Once`] because the rule it
530/// keeps is worth testing: [`emit`](Self::emit) answers whether *this* call was
531/// the one that wrote the line, so a test constructs a latch of its own and
532/// pins "the first call writes and no later one does" without installing a
533/// process-global logger.
534///
535/// Compiled only under `perf-trace`, with the line it writes: the release-lean
536/// gate asserts a shipping binary carries no `frust-perf` bytes at all, and a
537/// `#[cfg]` is what makes that the compiler's answer rather than the
538/// optimizer's.
539#[cfg(feature = "perf-trace")]
540#[derive(Debug, Default)]
541pub struct TierLogOnce {
542 emitted: AtomicBool,
543}
544
545#[cfg(feature = "perf-trace")]
546impl TierLogOnce {
547 /// A latch that has not emitted yet.
548 #[must_use]
549 pub const fn new() -> Self {
550 Self {
551 emitted: AtomicBool::new(false),
552 }
553 }
554
555 /// Log [`atlas_tier_line`] for `caps` and `budget` if this latch has not
556 /// fired, answering whether this call was the one that did.
557 pub fn emit(&self, caps: &TierCaps, budget: AtlasBudget) -> bool {
558 if self.emitted.swap(true, Ordering::Relaxed) {
559 return false;
560 }
561
562 log::info!("{}", atlas_tier_line(caps, budget));
563 true
564 }
565}
566
567/// The one line a run records the resolved atlas tier as.
568///
569/// `perf-trace`-only, with [`TierLogOnce`] — see its own doc for why the gate
570/// is a `#[cfg]` rather than a branch.
571///
572/// `frust-perf`-prefixed deliberately: a benchmark capture keeps every line
573/// carrying that prefix and drops the rest, so this is what lets a run's own
574/// log say which budget the device was given rather than leaving it to be
575/// inferred from whether images went missing. The adapter name is last because
576/// it is the one field that can contain spaces, which keeps every `key=value`
577/// field ahead of it parseable by splitting on whitespace.
578#[cfg(feature = "perf-trace")]
579#[must_use]
580pub fn atlas_tier_line(caps: &TierCaps, budget: AtlasBudget) -> String {
581 let tier = if is_mobile_tier(caps) {
582 "mobile"
583 } else {
584 "desktop"
585 };
586
587 format!(
588 "frust-perf atlas tier={tier} budget={}x{}x{} downlevel={:?} \
589 transient_saves_memory={} adapter={}",
590 budget.atlas_size.0,
591 budget.atlas_size.1,
592 budget.max_atlases,
593 caps.downlevel_profile,
594 caps.transient_saves_memory,
595 caps.adapter_name,
596 )
597}
598
599/// Where one image's texels live in the atlas array.
600///
601/// Hashable because the pending-clear list carries a set index beside it: the
602/// same rectangle can reach [`ImageResidency::evictions`] twice before a single
603/// clear has been serviced, and the dedup that keeps it reported once must not
604/// be a linear scan of a list that grows with the churn rate.
605#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
606pub struct AtlasRegion {
607 /// Array layer holding the region.
608 pub layer: u32,
609 /// Pixel offset of the region's top-left corner within that layer.
610 pub offset: [u32; 2],
611 /// Region extent in pixels.
612 pub size: [u32; 2],
613}
614
615impl AtlasRegion {
616 /// The bytes this region's texels occupy at [`ATLAS_FORMAT_BYTES`].
617 #[must_use]
618 pub fn byte_len(self) -> usize {
619 (self.size[0] as usize)
620 .saturating_mul(self.size[1] as usize)
621 .saturating_mul(ATLAS_FORMAT_BYTES as usize)
622 }
623
624 /// The row stride a texel copy of this region uses.
625 #[must_use]
626 pub fn bytes_per_row(self) -> u32 {
627 self.size[0].saturating_mul(ATLAS_FORMAT_BYTES as u32)
628 }
629
630 /// Whether this region has any texels at all.
631 #[must_use]
632 pub fn is_empty(self) -> bool {
633 self.size[0] == 0 || self.size[1] == 0
634 }
635}
636
637/// A resident image: the handle a paint names it by, and where its texels are.
638#[derive(Debug, Clone, Copy, PartialEq, Eq)]
639pub struct ResidentImage {
640 /// The handle an [`ImageSource::OpaqueId`] carries.
641 pub id: ImageId,
642 /// The atlas rectangle holding the image's own pixels, padding excluded.
643 ///
644 /// Equal to [`natural`](Self::natural) unless the source was minified to
645 /// fit the atlas, in which case it is the smaller rectangle actually
646 /// uploaded.
647 pub region: AtlasRegion,
648 /// The source's own declared extent, before any minification.
649 ///
650 /// Carried because it is the extent a paint transform was composed against
651 /// — `compile::paint::encode_image_command` derives its natural-to-dest
652 /// mapping from the `ImageData`'s declared size, which it reads before this
653 /// residency is consulted. A consumer that ends up sampling a *minified*
654 /// rectangle has to fold [`minify_scale`](Self::minify_scale) into that
655 /// transform, and this is the half of the ratio the atlas rectangle does
656 /// not carry.
657 pub natural: [u32; 2],
658 /// Transparent padding pixels around `region` in the atlas.
659 pub padding: u32,
660 /// Whether the image's premultiplied pixels include any non-opaque texel.
661 pub may_have_transparency: bool,
662}
663
664impl ResidentImage {
665 /// This image as the paint-side source a `vello_common` encoding carries.
666 #[must_use]
667 pub fn source(&self) -> ImageSource {
668 ImageSource::opaque_id_with_transparency_hint(self.id, self.may_have_transparency)
669 }
670
671 /// The per-axis factor mapping a natural-space texel coordinate onto the
672 /// resident rectangle, or `None` when the source was stored at full size.
673 ///
674 /// `None` rather than `Some((1.0, 1.0))` so a caller can skip the rescale
675 /// entirely on the overwhelmingly common path, and so "was this image
676 /// minified?" is one question rather than a float comparison.
677 #[must_use]
678 pub fn minify_scale(&self) -> Option<(f32, f32)> {
679 if self.region.size == self.natural {
680 return None;
681 }
682 let natural_w = self.natural[0].max(1) as f32;
683 let natural_h = self.natural[1].max(1) as f32;
684 Some((
685 self.region.size[0] as f32 / natural_w,
686 self.region.size[1] as f32 / natural_h,
687 ))
688 }
689}
690
691/// One region's texels, waiting to be written into the atlas array.
692///
693/// The pixels are the premultiplied `Rgba8Unorm` the atlas stores, produced
694/// once at allocation — an image made resident on frame 1 and drawn on frames
695/// 1..1000 is converted exactly once.
696#[derive(Debug, Clone)]
697pub struct ImageUpload {
698 /// The handle the paint naming these pixels carries.
699 ///
700 /// Carried so a consumer rebuilding its own view of residency can key this
701 /// upload directly rather than pairing the frame's uploads positionally
702 /// against its encoded paints. A plan re-emitted until it is acknowledged
703 /// breaks that pairing (an upload can outlive the frame whose draw order
704 /// produced it), and an upload that names itself needs no ordering to be
705 /// read correctly.
706 pub id: ImageId,
707 /// Where the texels go.
708 pub region: AtlasRegion,
709 /// The source's own declared extent, before any minification — see
710 /// [`ResidentImage::natural`], whose value this is.
711 ///
712 /// Carried on the upload because a consumer rebuilding its own view of
713 /// residency from a compiled frame has the upload and nothing else, and
714 /// `region` alone cannot say whether it holds a minified copy.
715 pub natural: [u32; 2],
716 /// The premultiplied pixels, row-major and exactly `region`'s extent.
717 pub pixels: Arc<Pixmap>,
718 /// Whether those pixels include any non-opaque texel — the same hint
719 /// [`ResidentImage::may_have_transparency`] carries, so a consumer can
720 /// rebuild the whole residency record from this upload alone.
721 pub may_have_transparency: bool,
722}
723
724/// One cache entry: the handle, its rectangle, and when it was last drawn.
725#[derive(Debug, Clone, Copy)]
726struct Entry {
727 id: ImageId,
728 region: AtlasRegion,
729 /// The source extent this entry was allocated for, which is what a later
730 /// frame's `ImageData` is matched against — `region` may be the minified
731 /// rectangle and so says nothing about the source.
732 natural: [u32; 2],
733 may_have_transparency: bool,
734 last_seen: u64,
735}
736
737/// The images resident in the atlas array, keyed by blob identity.
738///
739/// One per compiler (and so one per surface). Its frame clock is advanced by
740/// [`begin_frame`](Self::begin_frame), which is also where the age-based reap
741/// runs — before any of the frame's own resolutions, so a rectangle freed this
742/// frame is available to this frame's allocations and its clear is ordered
743/// ahead of their uploads.
744///
745/// It also owns the atlas allocator the *glyph* policy packs into — see this
746/// module's doc and [`allocator_mut`](Self::allocator_mut).
747#[derive(Debug)]
748pub struct ImageResidency {
749 cache: ImageCache,
750 budget: AtlasBudget,
751 entries: HashMap<u64, Entry>,
752 frame: u64,
753 disabled: bool,
754 uploads: Vec<ImageUpload>,
755 evictions: Vec<AtlasRegion>,
756 /// The pending clears' own set index: exactly the rectangles `evictions`
757 /// holds, kept so [`release`](Self::release)'s "report a rectangle once"
758 /// dedup is a hash lookup rather than a scan of a list whose length grows
759 /// with the churn rate.
760 eviction_set: HashSet<AtlasRegion>,
761 skipped: u64,
762 /// Rectangles taken back from a resident image because the packer had no
763 /// room for a new one, over this residency's lifetime.
764 pressure_evictions: u64,
765 /// The same count for the current frame alone, zeroed by
766 /// [`begin_frame`](Self::begin_frame) — what a per-frame report reads, since
767 /// the lifetime total says nothing about whether the atlas is churning
768 /// *now*.
769 frame_pressure_evictions: u64,
770 minified: u64,
771 /// Blob keys whose minification has already been reported, so a source that
772 /// is redrawn — or reaped and made resident again — logs once rather than
773 /// once per residency.
774 reported_minify: HashSet<u64>,
775 /// Scratch for the reap's key list, kept so a frame that evicts allocates
776 /// nothing.
777 reaped: Vec<u64>,
778 /// Scratch for the pressure planner's view of the layers — one rectangle
779 /// per resident entry, grouped by layer — kept for the same reason: the
780 /// frame that has to evict is the one least able to afford an allocation.
781 pressure_rects: Vec<PressureRect>,
782 /// Scratch for the candidate order over [`Self::pressure_rects`], least
783 /// recently seen first. Built and sorted once per refused allocation, never
784 /// once per eviction.
785 pressure_order: Vec<usize>,
786 /// Scratch for each layer's modelled free area, indexed by layer.
787 pressure_free_area: Vec<u64>,
788 /// Scratch for the distinct layers [`Self::pressure_order`] names, in the
789 /// order their stalest candidate appears — the per-layer walk's own visit
790 /// order, kept for the same no-allocation-while-evicting reason as the
791 /// other pressure scratch.
792 pressure_layers: Vec<u32>,
793 /// Scratch for the keys an accepted plan releases.
794 pressure_plan: Vec<u64>,
795}
796
797impl ImageResidency {
798 /// A residency over `budget`'s atlas geometry.
799 ///
800 /// `FRUST_ENGINE_NO_ATLAS` is consulted here, once, rather than per
801 /// resolution: the switch is process-global and cached, so re-reading it on
802 /// a hot path would buy nothing but a lock. A residency constructed with
803 /// the switch set is exactly [`disabled`](Self::disabled).
804 #[must_use]
805 pub fn new(budget: AtlasBudget) -> Self {
806 Self::with_enabled(budget, !config::atlas_disabled())
807 }
808
809 /// A residency that refuses every image, allocating no atlas at all.
810 ///
811 /// What `FRUST_ENGINE_NO_ATLAS` selects, and what a caller wanting the same
812 /// effect programmatically constructs. Every [`resolve`](Self::resolve)
813 /// answers [`ImageSkip::AtlasDisabled`], so image draws are dropped with a
814 /// logged reason rather than painted from an atlas that does not exist.
815 #[must_use]
816 pub fn disabled(budget: AtlasBudget) -> Self {
817 Self::with_enabled(budget, false)
818 }
819
820 fn with_enabled(budget: AtlasBudget, enabled: bool) -> Self {
821 Self {
822 cache: ImageCache::new_with_config(budget.config()),
823 budget,
824 entries: HashMap::new(),
825 frame: 0,
826 disabled: !enabled,
827 uploads: Vec::new(),
828 evictions: Vec::new(),
829 eviction_set: HashSet::new(),
830 skipped: 0,
831 pressure_evictions: 0,
832 frame_pressure_evictions: 0,
833 minified: 0,
834 reported_minify: HashSet::new(),
835 reaped: Vec::new(),
836 pressure_rects: Vec::new(),
837 pressure_order: Vec::new(),
838 pressure_free_area: Vec::new(),
839 pressure_layers: Vec::new(),
840 pressure_plan: Vec::new(),
841 }
842 }
843
844 /// A residency sized for `caps`' adapter.
845 #[must_use]
846 pub fn for_caps(caps: &TierCaps) -> Self {
847 Self::new(AtlasBudget::for_caps(caps))
848 }
849
850 /// The atlas geometry this residency allocates within.
851 #[must_use]
852 pub fn budget(&self) -> AtlasBudget {
853 self.budget
854 }
855
856 /// The single atlas allocator, for reading.
857 ///
858 /// Enough to resolve a handle either side allocated
859 /// ([`ImageCache::get`]) or to count the layers created so far, without
860 /// the mutable borrow allocation needs.
861 #[must_use]
862 pub fn allocator(&self) -> &ImageCache {
863 &self.cache
864 }
865
866 /// The single atlas allocator, for allocating through.
867 ///
868 /// This is the seam the glyph policy takes its slots from — see this
869 /// module's doc for why one cache rather than two is a correctness
870 /// requirement. `ImageId`s and rectangles handed out through here and
871 /// through [`resolve`](Self::resolve) come from the same packer and the
872 /// same id space, so a glyph and an image can never be given the same
873 /// handle or overlapping texels.
874 ///
875 /// **The contract a borrower keeps:** allocate what you like, and
876 /// deallocate *only handles you allocated yourself*. This residency's entry
877 /// map records the rectangles it allocated and nothing re-checks them;
878 /// freeing one of them from outside would leave an entry claiming a
879 /// rectangle the packer has since handed to someone else. Nothing here
880 /// enforces that, because the type the two sides must share is
881 /// `vello_common`'s own and cannot carry an ownership tag — the glyph
882 /// policy deallocates strictly inside `glifo`'s eviction, over the handles
883 /// in its own entry map.
884 #[must_use]
885 pub fn allocator_mut(&mut self) -> &mut ImageCache {
886 &mut self.cache
887 }
888
889 /// Whether this residency refuses every image.
890 #[must_use]
891 pub fn is_disabled(&self) -> bool {
892 self.disabled
893 }
894
895 /// How many images are currently resident.
896 #[must_use]
897 pub fn entry_count(&self) -> usize {
898 self.entries.len()
899 }
900
901 /// The frame clock entries age against.
902 #[must_use]
903 pub fn frame(&self) -> u64 {
904 self.frame
905 }
906
907 /// How many atlas layers have been created so far.
908 ///
909 /// The array texture must be at least this deep before the frame's uploads
910 /// are written.
911 ///
912 /// Read off the shared allocator rather than accumulated from this
913 /// residency's own allocations, so a layer the *glyph* policy created
914 /// counts too: the two classes pack into one array, and an array sized to
915 /// the image half alone would refuse to hold the glyph half's pages. The
916 /// count only ever grows — `MultiAtlasManager` appends layers and never
917 /// drops one — so a depth reported on an earlier frame stays valid.
918 #[must_use]
919 pub fn layers(&self) -> u32 {
920 u32::try_from(self.cache.atlas_count()).unwrap_or(u32::MAX)
921 }
922
923 /// How many resolutions have been refused over this residency's lifetime.
924 ///
925 /// Counts only images that could not be uploaded at all — a format or
926 /// extent the conversion refuses, a disabled atlas, or an atlas with no
927 /// room left *and nothing evictable to make room with*. A source merely
928 /// larger than one atlas layer is minified to fit, and one that displaced
929 /// another image is counted by
930 /// [`pressure_evictions`](Self::pressure_evictions) instead; neither
931 /// reaches this counter.
932 #[must_use]
933 pub fn skipped(&self) -> u64 {
934 self.skipped
935 }
936
937 /// How many resident images have been evicted to make room for another
938 /// over this residency's lifetime.
939 ///
940 /// The price of never refusing a draw the atlas could hold *something*
941 /// for: each one is an image that will re-upload the next frame it is
942 /// drawn on. A steady screen leaves this at zero; a scroll through more
943 /// distinct images than the atlas has rectangles for grows it once per
944 /// displaced image, which is the signal that the budget — not the
945 /// mechanism — is what a frame is paying for.
946 #[must_use]
947 pub fn pressure_evictions(&self) -> u64 {
948 self.pressure_evictions
949 }
950
951 /// How many resident images the *current* frame has evicted to make room.
952 ///
953 /// Zeroed by [`begin_frame`](Self::begin_frame), so a per-frame report reads
954 /// this rather than differencing
955 /// [`pressure_evictions`](Self::pressure_evictions) itself.
956 #[must_use]
957 pub fn frame_pressure_evictions(&self) -> u64 {
958 self.frame_pressure_evictions
959 }
960
961 /// How many sources have been downsampled to fit the atlas over this
962 /// residency's lifetime.
963 ///
964 /// Observational, and the counter that makes "an image too large for the
965 /// atlas is minified, not dropped" measurable rather than asserted.
966 #[must_use]
967 pub fn minified(&self) -> u64 {
968 self.minified
969 }
970
971 /// The regions whose texels must be cleared before the pending uploads.
972 ///
973 /// Pending rather than per-frame: an entry freed on a frame nobody
974 /// serviced is still waiting to be cleared on the next one. Both kinds of
975 /// eviction land here — the age-based reap at the head of a frame and a
976 /// rectangle taken back under pressure mid-frame — so the consumer's
977 /// clear-then-upload contract covers the second without knowing it exists.
978 #[must_use]
979 pub fn evictions(&self) -> &[AtlasRegion] {
980 &self.evictions
981 }
982
983 /// The regions whose texels must be written after the pending evictions.
984 ///
985 /// Pending rather than per-frame: an image made resident on a frame that was
986 /// refused is still waiting to be written on the next one.
987 #[must_use]
988 pub fn uploads(&self) -> &[ImageUpload] {
989 &self.uploads
990 }
991
992 /// A copy of the pending clear-then-write plan, evictions first.
993 ///
994 /// A *copy*, not a drain: the plan stays pending until
995 /// [`acknowledge_plan`](Self::acknowledge_plan) says it was serviced against
996 /// a live atlas, so a frame refused after compiling re-emits the same plan
997 /// on the next frame rather than losing it (see the module doc). The upload
998 /// pixels are behind an `Arc`, so a re-emitted plan copies handles rather
999 /// than texels, and the steady-state plan is empty on both counts.
1000 #[must_use]
1001 pub fn plan(&self) -> (Vec<AtlasRegion>, Vec<ImageUpload>) {
1002 (self.evictions.clone(), self.uploads.clone())
1003 }
1004
1005 /// Whether any part of the pending plan is still unserviced.
1006 #[must_use]
1007 pub fn has_pending_plan(&self) -> bool {
1008 !self.evictions.is_empty() || !self.uploads.is_empty()
1009 }
1010
1011 /// Record that the pending plan reached the atlas array, clearing it.
1012 ///
1013 /// Call this only once every region it names has actually been written or
1014 /// cleared. Calling it on a frame that was refused, or whose writes the
1015 /// array declined, is exactly the defect the pending plan exists to prevent:
1016 /// the entry map would go on claiming an image is resident whose texels are
1017 /// whatever the atlas texture happened to hold.
1018 pub fn acknowledge_plan(&mut self) {
1019 self.evictions.clear();
1020 self.eviction_set.clear();
1021 self.uploads.clear();
1022 }
1023
1024 /// Advance the frame clock and reclaim everything unseen for
1025 /// [`MAX_UNSEEN_FRAMES`].
1026 ///
1027 /// Call once at the head of a frame, before any [`resolve`](Self::resolve).
1028 /// An unacknowledged plan deliberately survives this: the frame that was to
1029 /// service it may have been refused, and dropping it here is what would turn
1030 /// a refused frame into a permanently unwritten atlas region.
1031 pub fn begin_frame(&mut self) {
1032 self.frame = self.frame.saturating_add(1);
1033 self.frame_pressure_evictions = 0;
1034 self.reap();
1035 }
1036
1037 /// The atlas rectangle for `data`'s pixels, allocating and scheduling an
1038 /// upload on the first frame that asks for it.
1039 ///
1040 /// # Errors
1041 ///
1042 /// Returns the [`ImageSkip`] naming why the image was refused. Every
1043 /// refusal happens before any `vello_common` call that could assert on the
1044 /// same condition, so an image the renderer cannot hold is a skipped draw
1045 /// rather than a panicked frame (E17).
1046 pub fn resolve(&mut self, data: &ImageData) -> Result<ResidentImage, ImageSkip> {
1047 let result = self.resolve_inner(data);
1048 if result.is_err() {
1049 self.skipped = self.skipped.saturating_add(1);
1050 }
1051 result
1052 }
1053
1054 fn resolve_inner(&mut self, data: &ImageData) -> Result<ResidentImage, ImageSkip> {
1055 if self.disabled {
1056 return Err(ImageSkip::AtlasDisabled);
1057 }
1058
1059 check_supported(data)?;
1060
1061 let key = data.data.id();
1062 if let Some(entry) = self.entries.get_mut(&key)
1063 && entry.natural == [data.width, data.height]
1064 {
1065 entry.last_seen = self.frame;
1066 return Ok(ResidentImage {
1067 id: entry.id,
1068 region: entry.region,
1069 natural: entry.natural,
1070 padding: u32::from(ATLAS_PADDING),
1071 may_have_transparency: entry.may_have_transparency,
1072 });
1073 }
1074
1075 // A blob id recorded at a different extent is a caller that rebuilt the
1076 // metadata around the same buffer. Its old rectangle describes the
1077 // wrong pixels, so it is released before the new one is taken rather
1078 // than left to age out holding both — unless *this* frame has already
1079 // resolved it at the other extent. The plan clears before it uploads,
1080 // so freeing that rectangle would schedule a clear over texels a draw
1081 // this same frame already recorded, which is exactly what the pressure
1082 // path refuses to do. The second extent is skipped instead, on the same
1083 // terms as a frame whose own working set outgrows the atlas — but this
1084 // is a same-frame ordering conflict on one blob key, not an atlas
1085 // capacity shortage, so it is reported as its own reason rather than
1086 // folded into [`ImageSkip::NoAtlasSpace`].
1087 if self
1088 .entries
1089 .get(&key)
1090 .is_some_and(|entry| entry.last_seen == self.frame)
1091 {
1092 let (width, height) = fit_extent([data.width, data.height], self.budget.atlas_size);
1093 return Err(ImageSkip::SameFrameExtentConflict { width, height });
1094 }
1095 self.release(key);
1096
1097 // Checked above, so this cannot assert; the conversion is also the one
1098 // place the straight-alpha premultiply happens, and it happens once per
1099 // image rather than once per frame.
1100 let ImageSource::Pixmap(pixels) = ImageSource::from_peniko_image_data(data) else {
1101 // `from_peniko_image_data` only ever yields a pixmap; an id-backed
1102 // source would mean a `vello_common` change, which is a skip rather
1103 // than an unreachable.
1104 return Err(ImageSkip::UnsupportedFormat(data.format));
1105 };
1106
1107 let natural = [data.width, data.height];
1108 // Minified before the allocation rather than after it: the packer is
1109 // asked for the rectangle that is actually going to be uploaded, so an
1110 // over-ceiling source never takes (and then has to give back) space at
1111 // its declared extent.
1112 let (width, height) = fit_extent(natural, self.budget.atlas_size);
1113 let pixels = if [width, height] == natural {
1114 pixels
1115 } else {
1116 self.note_minify(key, natural, [width, height]);
1117 Arc::new(minify(&pixels, width, height))
1118 };
1119
1120 let no_space = ImageSkip::NoAtlasSpace {
1121 width,
1122 height,
1123 atlas_width: self.budget.atlas_size.0,
1124 atlas_height: self.budget.atlas_size.1,
1125 };
1126
1127 let id = match self.cache.allocate(width, height, ATLAS_PADDING) {
1128 Ok(id) => id,
1129 // The atlas is full. Take a rectangle back from an image no longer
1130 // on screen rather than dropping this draw — see
1131 // [`allocate_under_pressure`](Self::allocate_under_pressure), and
1132 // the module doc for why a hole is worse than a re-upload.
1133 Err(_) => self
1134 .allocate_under_pressure(width, height)
1135 .ok_or(no_space)?,
1136 };
1137
1138 let Some(resource) = self.cache.get(id) else {
1139 // A successful allocation always populates its slot; refusing here
1140 // keeps the frame path free of the `expect` the reference renderer
1141 // takes at this same point.
1142 return Err(no_space);
1143 };
1144
1145 let region = AtlasRegion {
1146 layer: resource.atlas_id.as_u32(),
1147 offset: resource.offsets(),
1148 size: resource.size(),
1149 };
1150 let may_have_transparency = pixels.may_have_transparency();
1151
1152 self.entries.insert(
1153 key,
1154 Entry {
1155 id,
1156 region,
1157 natural,
1158 may_have_transparency,
1159 last_seen: self.frame,
1160 },
1161 );
1162 self.uploads.push(ImageUpload {
1163 id,
1164 region,
1165 natural,
1166 pixels,
1167 may_have_transparency,
1168 });
1169
1170 Ok(ResidentImage {
1171 id,
1172 region,
1173 natural,
1174 padding: u32::from(ATLAS_PADDING),
1175 may_have_transparency,
1176 })
1177 }
1178
1179 /// Allocate `width` x `height` by taking rectangles back from resident
1180 /// images, least recently seen first, once the packer has refused.
1181 ///
1182 /// Answers the handle that finally fit, or `None` when no bounded set of
1183 /// candidates would have made room — which is what
1184 /// [`ImageSkip::NoAtlasSpace`] then reports, with the residency exactly as
1185 /// it was found.
1186 ///
1187 /// **Nothing is released until a plan that fits has been found.** An
1188 /// eviction that does not produce the allocation buys nothing and costs a
1189 /// clear plus a re-upload per rectangle, every frame the condition recurs
1190 /// (see the module doc). So the search runs over a *model* of the layers
1191 /// and commits only when the model says the request would then be placed:
1192 ///
1193 /// 1. **Candidates.** Only an entry last seen on an earlier frame. The
1194 /// frame path services [`evictions`](Self::evictions) *before*
1195 /// [`uploads`](Self::uploads), so freeing a rectangle this frame has
1196 /// already resolved would schedule a clear over texels the same frame is
1197 /// about to sample — painting the very hole this eviction exists to
1198 /// prevent. A frame whose own working set outgrows the atlas therefore
1199 /// still skips, because everything in the atlas belongs to it.
1200 /// 2. **Order.** `(last_seen, slot, key)`, least recently seen first, built
1201 /// and sorted *once* per refused allocation rather than once per
1202 /// eviction. The slot index breaks a tie between two entries last drawn
1203 /// on the same frame, so which image is displaced is a property of the
1204 /// residency rather than of the entry map's iteration order. Because the
1205 /// order is by age, an entry drawn on the *immediately preceding* frame
1206 /// is reached only after every staler one has already been planned —
1207 /// which is what keeps a large image redrawn every frame from thrashing
1208 /// a working set that is merely idle. The candidates are then walked one
1209 /// *layer* at a time, layers visited in that same staleness order (the
1210 /// layer whose stalest candidate sorts earliest goes first).
1211 /// 3. **Fit.** After each candidate joins the plan, the layer it sat on is
1212 /// re-tested: the request must fit inside the freed area that layer now
1213 /// models, and must clear every rectangle still occupying it
1214 /// ([`layer_admits`]). The first prefix that passes ends the search.
1215 /// 4. **Budget, per layer.** Each layer's own walk stops after
1216 /// [`MAX_PRESSURE_EVICTION_CANDIDATES`] of *that layer's* candidates, or
1217 /// once *that layer's* plan has freed [`PRESSURE_EVICTION_AREA_BUDGET`]
1218 /// times the requested area — a fresh allowance per layer rather than one
1219 /// pool the whole residency shares, so candidates on a layer too
1220 /// fragmented to ever admit the request cannot spend the allowance a
1221 /// later layer needed to prove it could. A layer that ends on its own
1222 /// budget contributes nothing and the walk moves to the next layer.
1223 /// 5. **Commit.** Only the planned rectangles *on the layer the fit was
1224 /// found in* are released — a candidate the walk passed over on another
1225 /// layer contributed nothing and is left resident.
1226 ///
1227 /// The model is optimistic about occupants it cannot see: the glyph policy
1228 /// packs through the same allocator (see the module doc) and its pages are
1229 /// not in this entry map, so [`layer_admits`] tests a subset of the real
1230 /// obstacles. That direction is the safe one — a placement clearing every
1231 /// real occupant clears the subset too, so a request eviction could satisfy
1232 /// on a *given layer* is never refused for that reason. The converse is
1233 /// possible: the packer may refuse a plan the model approved, which costs
1234 /// the plan's evictions and no allocation. It is bounded by the same
1235 /// per-layer budget as the plan itself, and it is the one residual the
1236 /// planner cannot rule out without knowing where every glyph page sits.
1237 fn allocate_under_pressure(&mut self, width: u32, height: u32) -> Option<ImageId> {
1238 // The overwhelmingly common refusal — a frame that filled the atlas
1239 // with its own draws — costs one pass over the entry map and no model
1240 // at all.
1241 if !self
1242 .entries
1243 .values()
1244 .any(|entry| entry.last_seen < self.frame)
1245 {
1246 return None;
1247 }
1248
1249 // Taken from the residency's own scratch, so the frame least able to
1250 // afford an allocation does not make one to evict with.
1251 let mut rects = std::mem::take(&mut self.pressure_rects);
1252 let mut order = std::mem::take(&mut self.pressure_order);
1253 let mut free_area = std::mem::take(&mut self.pressure_free_area);
1254 let mut layers = std::mem::take(&mut self.pressure_layers);
1255 let mut plan = std::mem::take(&mut self.pressure_plan);
1256
1257 self.model_layers(&mut rects, &mut order, &mut free_area);
1258 let fit = plan_pressure_eviction(
1259 width,
1260 height,
1261 self.budget.atlas_size,
1262 &mut rects,
1263 &order,
1264 &mut free_area,
1265 &mut layers,
1266 );
1267
1268 plan.clear();
1269 if let Some(layer) = fit {
1270 plan.extend(
1271 rects
1272 .iter()
1273 .filter(|rect| rect.freed && rect.layer == layer)
1274 .map(|rect| rect.key),
1275 );
1276 }
1277
1278 let allocated = if plan.is_empty() {
1279 None
1280 } else {
1281 for key in &plan {
1282 self.release(*key);
1283 self.pressure_evictions = self.pressure_evictions.saturating_add(1);
1284 self.frame_pressure_evictions = self.frame_pressure_evictions.saturating_add(1);
1285 }
1286 self.cache.allocate(width, height, ATLAS_PADDING).ok()
1287 };
1288
1289 rects.clear();
1290 order.clear();
1291 free_area.clear();
1292 layers.clear();
1293 plan.clear();
1294 self.pressure_rects = rects;
1295 self.pressure_order = order;
1296 self.pressure_free_area = free_area;
1297 self.pressure_layers = layers;
1298 self.pressure_plan = plan;
1299 allocated
1300 }
1301
1302 /// Fill the planner's scratch with this residency's own view of the atlas.
1303 ///
1304 /// `rects` takes one entry per resident image, grouped by layer so a fit
1305 /// test reads one layer's slice; `order` takes the candidate indices, least
1306 /// recently seen first across the *whole* residency — [`plan_pressure_eviction`]
1307 /// is what regroups it per layer for its own bounded walk; `free_area` takes
1308 /// each layer's free area *as the packer reports it*, which is the one
1309 /// figure that accounts for the glyph pages this map cannot see.
1310 fn model_layers(
1311 &self,
1312 rects: &mut Vec<PressureRect>,
1313 order: &mut Vec<usize>,
1314 free_area: &mut Vec<u64>,
1315 ) {
1316 rects.clear();
1317 rects.extend(self.entries.iter().map(|(key, entry)| {
1318 let region = padded(entry.region, ATLAS_PADDING);
1319 PressureRect {
1320 key: *key,
1321 layer: region.layer,
1322 x0: region.offset[0],
1323 y0: region.offset[1],
1324 x1: region.offset[0].saturating_add(region.size[0]),
1325 y1: region.offset[1].saturating_add(region.size[1]),
1326 area: u64::from(region.size[0]).saturating_mul(u64::from(region.size[1])),
1327 last_seen: entry.last_seen,
1328 slot: entry.id.as_u32(),
1329 freed: false,
1330 }
1331 }));
1332 rects.sort_unstable_by_key(|rect| rect.layer);
1333
1334 order.clear();
1335 order.extend(
1336 rects
1337 .iter()
1338 .enumerate()
1339 .filter_map(|(index, rect)| (rect.last_seen < self.frame).then_some(index)),
1340 );
1341 order.sort_unstable_by_key(|index| {
1342 let rect = &rects[*index];
1343 (rect.last_seen, rect.slot, rect.key)
1344 });
1345
1346 free_area.clear();
1347 free_area.resize(self.cache.atlas_count(), 0);
1348 for (id, stats) in self.cache.atlas_manager().atlas_stats() {
1349 if let Some(free) = free_area.get_mut(id.as_u32() as usize) {
1350 *free = u64::from(stats.total_area.saturating_sub(stats.allocated_area));
1351 }
1352 }
1353 }
1354
1355 /// Count and report one source downsampled to fit the atlas.
1356 ///
1357 /// Once per blob id, at warning level: a minified image is a silent quality
1358 /// decision the application may not have intended (a full-resolution photo
1359 /// where a thumbnail was wanted), so it says so — but a scene redrawing it
1360 /// every frame must not repeat the message.
1361 fn note_minify(&mut self, key: u64, natural: [u32; 2], fitted: [u32; 2]) {
1362 self.minified = self.minified.saturating_add(1);
1363 if !self.reported_minify.insert(key) {
1364 return;
1365 }
1366 log::warn!(
1367 "image {}x{} is larger than the {}x{} atlas budget; uploading a {}x{} box-filtered \
1368 copy instead (reported once per image)",
1369 natural[0],
1370 natural[1],
1371 self.budget.atlas_size.0,
1372 self.budget.atlas_size.1,
1373 fitted[0],
1374 fitted[1],
1375 );
1376 }
1377
1378 /// Deallocate every entry unseen for [`MAX_UNSEEN_FRAMES`], recording each
1379 /// one's rectangle for clearing.
1380 ///
1381 /// The age half of the two bounds; the population half is
1382 /// [`allocate_under_pressure`](Self::allocate_under_pressure), which runs
1383 /// only when an allocation has already failed. Keeping them separate is
1384 /// what makes an idle screen give its texels back promptly whether or not
1385 /// anything is asking for them.
1386 fn reap(&mut self) {
1387 let mut reaped = std::mem::take(&mut self.reaped);
1388 reaped.clear();
1389 reaped.extend(self.entries.iter().filter_map(|(key, entry)| {
1390 (self.frame.saturating_sub(entry.last_seen) > MAX_UNSEEN_FRAMES).then_some(*key)
1391 }));
1392
1393 for key in reaped.drain(..) {
1394 self.release(key);
1395 }
1396 self.reaped = reaped;
1397 }
1398
1399 /// Drop `key`'s entry, freeing its atlas rectangle and recording it for
1400 /// clearing.
1401 ///
1402 /// The padded rectangle is what is cleared: an allocation reserves its
1403 /// padding too, so leaving the border behind would leave stale texels a
1404 /// later allocation could sample through.
1405 ///
1406 /// An upload still pending for that rectangle goes with the entry. It
1407 /// describes pixels no live entry claims any more, and writing them after
1408 /// the clear would put an evicted image back into a rectangle the packer has
1409 /// already handed to something else.
1410 fn release(&mut self, key: u64) {
1411 let Some(entry) = self.entries.remove(&key) else {
1412 return;
1413 };
1414 // The handle came from this cache's own `allocate`, so the atlas it
1415 // names exists and the deallocation cannot fail.
1416 self.cache.deallocate(entry.id);
1417 self.uploads.retain(|upload| upload.id != entry.id);
1418
1419 // The plan survives an unacknowledged frame, so the same rectangle can
1420 // reach here twice before a single clear has been issued for it; one
1421 // clear is what the second one would write anyway.
1422 let region = padded(entry.region, ATLAS_PADDING);
1423 if self.eviction_set.insert(region) {
1424 self.evictions.push(region);
1425 }
1426 }
1427}
1428
1429/// The most rectangles one refused allocation may plan to take back from a
1430/// single atlas layer.
1431///
1432/// A bound on the *search*, not only on the damage: a layer whose walk
1433/// reaches it without finding a fit contributes nothing to the plan, so the
1434/// cost of one hopeless layer stays bounded rather than eating into the
1435/// allowance a later, satisfiable layer needs to prove itself. Sixty-four is
1436/// far past what any satisfiable request has needed — a fit is normally found
1437/// in one, since the rectangle handed back is usually the size of the one
1438/// being asked for — while still being a small fraction of what a
1439/// mobile-tier layer can hold. Applied once per layer a refused allocation
1440/// considers, never once across every layer together — see
1441/// [`plan_pressure_eviction`].
1442const MAX_PRESSURE_EVICTION_CANDIDATES: usize = 64;
1443
1444/// How much area one refused allocation may plan to free from a single atlas
1445/// layer, as a multiple of the area it asked for.
1446///
1447/// The other half of the bound, and the one that scales with the request: a
1448/// layer that has already given back four times the texels the request wants
1449/// and still has nowhere to put them is fighting fragmentation, not scarcity,
1450/// and freeing more of that same layer will not fix it. Applied per layer,
1451/// the same way [`MAX_PRESSURE_EVICTION_CANDIDATES`] is.
1452const PRESSURE_EVICTION_AREA_BUDGET: u64 = 4;
1453
1454/// One resident rectangle as the pressure planner sees it.
1455///
1456/// A flat copy rather than a borrow of the entry map, because the plan is drawn
1457/// up while the map is still intact and the release that follows needs it
1458/// mutably. `x1`/`y1` are exclusive, and every field is the *padded* allocation
1459/// — the rectangle the packer actually holds — rather than the image's own.
1460#[derive(Debug, Clone, Copy)]
1461struct PressureRect {
1462 /// Blob key of the entry holding this rectangle.
1463 key: u64,
1464 /// Array layer the rectangle sits in.
1465 layer: u32,
1466 /// Left edge, inclusive.
1467 x0: u32,
1468 /// Top edge, inclusive.
1469 y0: u32,
1470 /// Right edge, exclusive.
1471 x1: u32,
1472 /// Bottom edge, exclusive.
1473 y1: u32,
1474 /// Texels the rectangle covers.
1475 area: u64,
1476 /// The entry's frame stamp, which is what makes it a candidate or not.
1477 last_seen: u64,
1478 /// The entry's slot index, which breaks a tie between two entries last
1479 /// drawn on the same frame.
1480 slot: u32,
1481 /// Whether the plan under construction has given this rectangle back.
1482 freed: bool,
1483}
1484
1485/// The layer a bounded prefix of `order` would make room in, or `None`.
1486///
1487/// Frees nothing: it marks `rects` and grows `free_area` in the model only, so
1488/// a request no prefix satisfies leaves the residency untouched. `order` is
1489/// least-recently-seen-first across the *whole* residency; this regroups it
1490/// one layer at a time — `layers` scratch, cleared and refilled here, in the
1491/// order each layer's stalest candidate appears in `order` — and gives each
1492/// layer its own [`MAX_PRESSURE_EVICTION_CANDIDATES`]/
1493/// [`PRESSURE_EVICTION_AREA_BUDGET`] allowance rather than pooling one across
1494/// every layer. Without that split, a layer with many candidates that could
1495/// never admit the request (too fragmented, or blocked by an occupant no
1496/// eviction touches) spends the whole budget before a later, satisfiable
1497/// layer is ever tried — the request is then refused even though a bounded
1498/// eviction on that later layer would have placed it. See
1499/// [`ImageResidency::allocate_under_pressure`] for the whole shape and for why
1500/// the answer is a layer rather than a bool — only the planned rectangles on
1501/// the fitting layer are worth releasing.
1502fn plan_pressure_eviction(
1503 width: u32,
1504 height: u32,
1505 atlas_size: (u32, u32),
1506 rects: &mut [PressureRect],
1507 order: &[usize],
1508 free_area: &mut [u64],
1509 layers: &mut Vec<u32>,
1510) -> Option<u32> {
1511 let padding = u32::from(ATLAS_PADDING).saturating_mul(2);
1512 let request_w = width.saturating_add(padding);
1513 let request_h = height.saturating_add(padding);
1514 let request_area = u64::from(request_w).saturating_mul(u64::from(request_h));
1515 let area_budget = request_area.saturating_mul(PRESSURE_EVICTION_AREA_BUDGET);
1516
1517 layers.clear();
1518 for &index in order {
1519 let Some(rect) = rects.get(index) else {
1520 continue;
1521 };
1522 if !layers.contains(&rect.layer) {
1523 layers.push(rect.layer);
1524 }
1525 }
1526
1527 for &layer in layers.iter() {
1528 let mut layer_freed_area = 0_u64;
1529 let mut step = 0_usize;
1530
1531 for &index in order {
1532 let on_this_layer = rects.get(index).is_some_and(|rect| rect.layer == layer);
1533 if !on_this_layer {
1534 continue;
1535 }
1536 if step >= MAX_PRESSURE_EVICTION_CANDIDATES || layer_freed_area > area_budget {
1537 break;
1538 }
1539 step += 1;
1540
1541 let area = {
1542 let Some(rect) = rects.get_mut(index) else {
1543 continue;
1544 };
1545 rect.freed = true;
1546 rect.area
1547 };
1548 layer_freed_area = layer_freed_area.saturating_add(area);
1549
1550 let Some(free) = free_area.get_mut(layer as usize) else {
1551 continue;
1552 };
1553 *free = free.saturating_add(area);
1554 // Area first because it is exact — it comes off the packer's own
1555 // accounting, so it counts the glyph pages `layer_admits` cannot see —
1556 // and because it is the cheaper of the two questions.
1557 if *free >= request_area && layer_admits(rects, layer, request_w, request_h, atlas_size)
1558 {
1559 return Some(layer);
1560 }
1561 }
1562 }
1563
1564 None
1565}
1566
1567/// Whether a `width` x `height` rectangle could still be placed in `layer` once
1568/// every rectangle the plan has marked freed is out of the way.
1569///
1570/// A *necessary* condition rather than an exact packing answer. Any placement
1571/// has to avoid each occupant whole, so it must lie entirely to one side of it:
1572/// if some occupant leaves less than the requested extent on all four sides,
1573/// there is nowhere for the rectangle to go and the plan cannot succeed. A plan
1574/// this refuses is therefore certainly hopeless; one it admits may still be
1575/// refused by the packer, which is the direction that costs a bounded number of
1576/// evictions rather than a wrong picture (see
1577/// [`ImageResidency::allocate_under_pressure`]).
1578///
1579/// `rects` is grouped by layer, so this reads one layer's slice rather than the
1580/// whole residency.
1581fn layer_admits(
1582 rects: &[PressureRect],
1583 layer: u32,
1584 width: u32,
1585 height: u32,
1586 atlas_size: (u32, u32),
1587) -> bool {
1588 if width > atlas_size.0 || height > atlas_size.1 {
1589 return false;
1590 }
1591
1592 let start = rects.partition_point(|rect| rect.layer < layer);
1593 let end = rects.partition_point(|rect| rect.layer <= layer);
1594 rects[start..end]
1595 .iter()
1596 .filter(|rect| !rect.freed)
1597 .all(|rect| {
1598 width <= rect.x0
1599 || width <= atlas_size.0.saturating_sub(rect.x1)
1600 || height <= rect.y0
1601 || height <= atlas_size.1.saturating_sub(rect.y1)
1602 })
1603}
1604
1605/// The extent `natural` is stored at inside an `atlas`-sized layer: itself when
1606/// it already fits, and otherwise the largest rectangle of the same aspect ratio
1607/// that does.
1608///
1609/// The scale is taken as the smaller of the two axis ratios and applied to both,
1610/// so the two axes of the paint transform stay in step — a per-axis fit would
1611/// stretch the image. Each axis is floored (never rounding *up* past the layer)
1612/// and then held at one texel: a source that scales below a whole texel still
1613/// has to have somewhere to be, and a zero-extent allocation is not a rectangle
1614/// the packer or the shader can address.
1615#[must_use]
1616pub fn fit_extent(natural: [u32; 2], atlas: (u32, u32)) -> (u32, u32) {
1617 let (width, height) = (natural[0], natural[1]);
1618 if width <= atlas.0 && height <= atlas.1 {
1619 return (width, height);
1620 }
1621
1622 let scale = (f64::from(atlas.0) / f64::from(width.max(1)))
1623 .min(f64::from(atlas.1) / f64::from(height.max(1)));
1624 let axis = |value: u32, ceiling: u32| {
1625 let scaled = (f64::from(value) * scale).floor();
1626 // `as` on a non-finite or out-of-range float saturates in Rust, so the
1627 // clamps below are the whole check rather than a second line of defence.
1628 (scaled as u32).clamp(1, ceiling.max(1))
1629 };
1630
1631 (axis(width, atlas.0), axis(height, atlas.1))
1632}
1633
1634/// `source` box-filtered down to `width` x `height`.
1635///
1636/// Every output texel is the unweighted mean of the half-open source rectangle
1637/// it covers, computed on the premultiplied bytes the atlas stores — which is
1638/// the space the samples are composited in, so averaging there is what keeps a
1639/// partially transparent source from bleeding its colour outward.
1640///
1641/// Minification only: a request at or above the source's own extent copies the
1642/// texels straight across rather than inventing any, since a box filter has no
1643/// magnification arm and the caller never asks for one ([`fit_extent`] only ever
1644/// shrinks).
1645#[must_use]
1646pub fn minify(source: &Pixmap, width: u32, height: u32) -> Pixmap {
1647 let source_w = u32::from(source.width());
1648 let source_h = u32::from(source.height());
1649 let width = width.clamp(1, source_w.max(1));
1650 let height = height.clamp(1, source_h.max(1));
1651
1652 let texels = source.data();
1653 let mut out = Vec::with_capacity((width as usize).saturating_mul(height as usize));
1654 let mut may_have_transparency = false;
1655
1656 for y in 0..height {
1657 // Half-open source rows, derived from the output row rather than
1658 // accumulated, so rounding never leaves a gap or an overlap between
1659 // consecutive rows.
1660 let y0 = (u64::from(y) * u64::from(source_h) / u64::from(height)) as u32;
1661 let y1 = ((u64::from(y) + 1) * u64::from(source_h) / u64::from(height)) as u32;
1662 let y1 = y1.max(y0.saturating_add(1)).min(source_h);
1663
1664 for x in 0..width {
1665 let x0 = (u64::from(x) * u64::from(source_w) / u64::from(width)) as u32;
1666 let x1 = ((u64::from(x) + 1) * u64::from(source_w) / u64::from(width)) as u32;
1667 let x1 = x1.max(x0.saturating_add(1)).min(source_w);
1668
1669 let mut sum = [0_u64; 4];
1670 let mut count = 0_u64;
1671 for row in y0..y1 {
1672 let base = (row as usize).saturating_mul(source_w as usize);
1673 for column in x0..x1 {
1674 let Some(texel) = texels.get(base.saturating_add(column as usize)) else {
1675 continue;
1676 };
1677 sum[0] += u64::from(texel.r);
1678 sum[1] += u64::from(texel.g);
1679 sum[2] += u64::from(texel.b);
1680 sum[3] += u64::from(texel.a);
1681 count += 1;
1682 }
1683 }
1684
1685 // A count of zero is unreachable — both ranges are non-empty by
1686 // construction — and flooring it at one rather than branching keeps
1687 // the divide-by-zero panic off the frame path (E17) while answering
1688 // a transparent texel if it ever were reachable, since the sums
1689 // would then be zero too.
1690 let divisor = count.max(1);
1691 // Round to nearest rather than truncating: a uniform source has to
1692 // come back bit-identical, which truncating an exact integer mean
1693 // already gives, but a near-uniform one must not drift a level
1694 // darker.
1695 let mean = |channel: usize| {
1696 ((sum[channel] + divisor / 2) / divisor).min(u64::from(u8::MAX)) as u8
1697 };
1698
1699 let texel = PremulRgba8 {
1700 r: mean(0),
1701 g: mean(1),
1702 b: mean(2),
1703 a: mean(3),
1704 };
1705 may_have_transparency |= texel.a != u8::MAX;
1706 out.push(texel);
1707 }
1708 }
1709
1710 // Both extents were clamped into `1..=u16::MAX` above (a source pixmap's own
1711 // extents are `u16`), so neither conversion can lose information.
1712 Pixmap::from_parts_with_opacity(
1713 out,
1714 width.min(u32::from(u16::MAX)) as u16,
1715 height.min(u32::from(u16::MAX)) as u16,
1716 may_have_transparency,
1717 )
1718}
1719
1720/// `axis` held at or below `ceiling`, and never below the one texel an
1721/// addressable rectangle needs.
1722fn fit_axis(axis: u32, ceiling: u64) -> u32 {
1723 axis.min(u32::try_from(ceiling).unwrap_or(u32::MAX)).max(1)
1724}
1725
1726/// `region` grown by `padding` on every side, clamped at the layer origin.
1727fn padded(region: AtlasRegion, padding: u16) -> AtlasRegion {
1728 let padding = u32::from(padding);
1729 let pad_x = padding.min(region.offset[0]);
1730 let pad_y = padding.min(region.offset[1]);
1731
1732 AtlasRegion {
1733 layer: region.layer,
1734 offset: [region.offset[0] - pad_x, region.offset[1] - pad_y],
1735 size: [
1736 region.size[0].saturating_add(pad_x + padding),
1737 region.size[1].saturating_add(pad_y + padding),
1738 ],
1739 }
1740}
1741
1742/// Refuse an image `vello_common`'s own conversion would assert on.
1743///
1744/// The three conditions are exactly the three assertions downstream: the
1745/// format `unimplemented!()`, the `u16::MAX` dimension check, and
1746/// `Pixmap::from_parts_with_opacity`'s length equality. Checking them here is
1747/// what keeps the panic off the frame path.
1748fn check_supported(data: &ImageData) -> Result<(), ImageSkip> {
1749 match data.format {
1750 ImageFormat::Rgba8 | ImageFormat::Bgra8 => {}
1751 format => return Err(ImageSkip::UnsupportedFormat(format)),
1752 }
1753
1754 if data.width == 0 || data.height == 0 {
1755 return Err(ImageSkip::DegenerateExtent {
1756 width: data.width,
1757 height: data.height,
1758 });
1759 }
1760
1761 if data.width > MAX_IMAGE_DIMENSION || data.height > MAX_IMAGE_DIMENSION {
1762 return Err(ImageSkip::TooLarge {
1763 width: data.width,
1764 height: data.height,
1765 max: MAX_IMAGE_DIMENSION,
1766 });
1767 }
1768
1769 let actual = data.data.data().len();
1770 let expected = data
1771 .format
1772 .size_in_bytes(data.width, data.height)
1773 .unwrap_or(usize::MAX);
1774 if actual != expected {
1775 return Err(ImageSkip::MalformedPixels {
1776 width: data.width,
1777 height: data.height,
1778 expected,
1779 actual,
1780 });
1781 }
1782
1783 Ok(())
1784}
1785
1786#[cfg(test)]
1787mod tests {
1788 use super::*;
1789 use peniko::{Blob, ImageAlphaType};
1790
1791 fn image(width: u32, height: u32) -> ImageData {
1792 let len = (width as usize) * (height as usize) * 4;
1793 ImageData {
1794 data: Blob::new(Arc::new(vec![255_u8; len])),
1795 format: ImageFormat::Rgba8,
1796 alpha_type: ImageAlphaType::Alpha,
1797 width,
1798 height,
1799 }
1800 }
1801
1802 fn residency() -> ImageResidency {
1803 ImageResidency::new(AtlasBudget {
1804 atlas_size: (64, 64),
1805 max_atlases: 2,
1806 })
1807 }
1808
1809 #[test]
1810 fn a_mobile_budget_is_never_the_vello_default() {
1811 let default = AtlasConfig::default();
1812 for budget in [AtlasBudget::MOBILE, AtlasBudget::DESKTOP] {
1813 assert_ne!(budget.atlas_size, default.atlas_size);
1814 assert!(budget.total_bytes() < 4096 * 4096 * ATLAS_FORMAT_BYTES * 8);
1815 }
1816 assert_eq!(AtlasBudget::MOBILE.total_bytes(), 16 << 20);
1817 assert_eq!(AtlasBudget::DESKTOP.total_bytes(), 128 << 20);
1818 }
1819
1820 #[test]
1821 fn a_downlevel_adapter_takes_the_mobile_budget() {
1822 let webgl2 = TierCaps::fake(DownlevelProfile::WebGl2);
1823 assert!(is_mobile_tier(&webgl2));
1824
1825 let mut tiler = TierCaps::fake(DownlevelProfile::Full);
1826 assert!(!is_mobile_tier(&tiler));
1827 tiler.transient_saves_memory = true;
1828 assert!(is_mobile_tier(&tiler));
1829 }
1830
1831 #[test]
1832 fn a_budget_is_clamped_to_the_adapters_own_ceilings() {
1833 let mut caps = TierCaps::fake(DownlevelProfile::Full);
1834 caps.max_texture_dimension_2d = 1024;
1835 caps.max_texture_array_layers = 2;
1836
1837 let budget = AtlasBudget::DESKTOP.clamped(&caps);
1838 assert_eq!(budget.atlas_size, (1024, 1024));
1839 assert_eq!(budget.max_atlases, 2);
1840
1841 // The eight-bit `atlas_index` field is a ceiling of its own.
1842 caps.max_texture_array_layers = 4096;
1843 let budget = AtlasBudget {
1844 atlas_size: (256, 256),
1845 max_atlases: 4096,
1846 }
1847 .clamped(&caps);
1848 assert_eq!(budget.max_atlases, MAX_ATLAS_LAYERS);
1849 }
1850
1851 #[test]
1852 fn the_first_layer_is_created_lazily() {
1853 let residency = residency();
1854 assert_eq!(residency.layers(), 0);
1855 assert_eq!(residency.entry_count(), 0);
1856 }
1857
1858 #[test]
1859 fn one_image_over_many_frames_uploads_exactly_once() {
1860 let mut residency = residency();
1861 let data = image(8, 8);
1862 let mut uploads = 0;
1863
1864 for _ in 0..60 {
1865 residency.begin_frame();
1866 residency.resolve(&data).expect("8x8 fits a 64x64 atlas");
1867 uploads += residency.uploads().len();
1868 // Every frame here is a frame that reached the atlas.
1869 residency.acknowledge_plan();
1870 }
1871
1872 assert_eq!(uploads, 1, "residency survives every frame that draws it");
1873 assert_eq!(residency.entry_count(), 1);
1874 assert_eq!(residency.layers(), 1);
1875 }
1876
1877 #[test]
1878 fn an_unseen_image_is_reaped_and_its_padded_region_cleared() {
1879 let mut residency = residency();
1880 let data = image(8, 8);
1881
1882 residency.begin_frame();
1883 let resident = residency.resolve(&data).expect("fits");
1884 residency.acknowledge_plan();
1885
1886 for _ in 0..MAX_UNSEEN_FRAMES {
1887 residency.begin_frame();
1888 assert!(residency.evictions().is_empty(), "still inside the window");
1889 }
1890
1891 residency.begin_frame();
1892 assert_eq!(residency.entry_count(), 0);
1893 assert_eq!(
1894 residency.evictions(),
1895 &[padded(resident.region, ATLAS_PADDING)]
1896 );
1897
1898 // The freed rectangle is available again, and the reallocation
1899 // schedules a fresh upload.
1900 residency.resolve(&data).expect("fits");
1901 assert_eq!(residency.uploads().len(), 1);
1902 }
1903
1904 #[test]
1905 fn an_image_larger_than_the_atlas_is_minified_to_fit_rather_than_skipped() {
1906 let mut residency = residency();
1907 residency.begin_frame();
1908
1909 // Twice the layer's width at a 16:1 aspect ratio: the width sets the
1910 // scale, and the height follows it rather than being fitted on its own.
1911 let resident = residency.resolve(&image(128, 8)).expect("minified to fit");
1912
1913 assert_eq!(resident.region.size, [64, 4]);
1914 assert_eq!(resident.natural, [128, 8]);
1915 assert_eq!(resident.minify_scale(), Some((0.5, 0.5)));
1916 assert_eq!(residency.minified(), 1);
1917 assert_eq!(residency.skipped(), 0, "a big image is not a skipped one");
1918 assert_eq!(residency.uploads().len(), 1);
1919 let upload = &residency.uploads()[0];
1920 assert_eq!(
1921 (upload.pixels.width(), upload.pixels.height()),
1922 (64, 4),
1923 "the upload carries exactly the region's texels"
1924 );
1925 }
1926
1927 #[test]
1928 fn an_atlas_with_no_room_left_is_still_a_skip() {
1929 // Minification fits an image to a LAYER, not to the space left in one:
1930 // a budget already full refuses the next allocation as it always did.
1931 let mut residency = ImageResidency::new(AtlasBudget {
1932 atlas_size: (64, 64),
1933 max_atlases: 1,
1934 });
1935 residency.begin_frame();
1936 residency
1937 .resolve(&image(64, 64))
1938 .expect("fills the one layer");
1939
1940 let skip = residency
1941 .resolve(&image(32, 32))
1942 .expect_err("nothing is left to allocate from");
1943 assert!(matches!(skip, ImageSkip::NoAtlasSpace { .. }));
1944 assert_eq!(residency.skipped(), 1);
1945 assert_eq!(residency.minified(), 0);
1946 }
1947
1948 #[test]
1949 fn a_minified_image_stays_resident_across_frames_at_its_declared_extent() {
1950 // Residency is keyed on the SOURCE extent, not the resident one: an
1951 // entry matched against its own minified rectangle would miss every
1952 // frame and re-upload the image on each of them.
1953 let mut residency = residency();
1954 let data = image(128, 8);
1955
1956 for _ in 0..8 {
1957 residency.begin_frame();
1958 let resident = residency.resolve(&data).expect("minified to fit");
1959 assert_eq!(resident.natural, [128, 8]);
1960 residency.acknowledge_plan();
1961 }
1962
1963 assert_eq!(residency.entry_count(), 1);
1964 assert_eq!(
1965 residency.minified(),
1966 1,
1967 "downsampled once, not once a frame"
1968 );
1969 assert!(
1970 residency.uploads().is_empty(),
1971 "no re-upload after the first"
1972 );
1973 }
1974
1975 #[test]
1976 fn a_fit_extent_preserves_the_aspect_ratio_and_never_grows() {
1977 let atlas = (64, 64);
1978 // Already inside the layer: unchanged, both axes.
1979 assert_eq!(fit_extent([64, 64], atlas), (64, 64));
1980 assert_eq!(fit_extent([8, 4], atlas), (8, 4));
1981
1982 // The `adv-huge-image` shape, at this budget's scale.
1983 assert_eq!(fit_extent([5000, 5000], (2048, 2048)), (2048, 2048));
1984
1985 // The tighter axis sets the scale for both.
1986 assert_eq!(fit_extent([128, 8], atlas), (64, 4));
1987 assert_eq!(fit_extent([8, 128], atlas), (4, 64));
1988 assert_eq!(fit_extent([1000, 10], atlas), (64, 1));
1989
1990 // A non-square layer is fitted per axis and still uniformly scaled.
1991 assert_eq!(fit_extent([400, 400], (100, 50)), (50, 50));
1992 }
1993
1994 #[test]
1995 fn an_extreme_aspect_ratio_still_fits_at_one_texel_rather_than_none() {
1996 // 10000:1 into a 64-square layer scales the short axis to 0.0064 texels.
1997 // A zero extent is not a rectangle the packer or the shader can
1998 // address, so it is held at one.
1999 let fitted = fit_extent([10_000, 1], (64, 64));
2000 assert_eq!(fitted, (64, 1));
2001
2002 let mut residency = residency();
2003 residency.begin_frame();
2004 let resident = residency
2005 .resolve(&image(10_000, 1))
2006 .expect("a sliver still becomes a rectangle");
2007 assert_eq!(resident.region.size, [64, 1]);
2008 }
2009
2010 #[test]
2011 fn a_uniform_source_minifies_to_exactly_its_own_colour() {
2012 // What `adv-huge-image` rests on: a uniform source comes back bit
2013 // identical whatever the scale, so the case's `Exact` probe is a
2014 // property of the filter rather than a tolerance.
2015 let texel = PremulRgba8 {
2016 r: 255,
2017 g: 0,
2018 b: 255,
2019 a: 255,
2020 };
2021 let source = Pixmap::from_parts_with_opacity(vec![texel; 100 * 100], 100, 100, false);
2022
2023 let small = minify(&source, 7, 3);
2024 assert_eq!((small.width(), small.height()), (7, 3));
2025 assert!(small.data().iter().all(|out| *out == texel));
2026 assert!(
2027 !small.may_have_transparency(),
2028 "an opaque source stays opaque"
2029 );
2030 }
2031
2032 #[test]
2033 fn a_box_filter_averages_the_source_texels_each_output_covers() {
2034 // A 2x1 source of black and white halves down to one texel, which must
2035 // be their mean rather than either end.
2036 let black = PremulRgba8 {
2037 r: 0,
2038 g: 0,
2039 b: 0,
2040 a: 255,
2041 };
2042 let white = PremulRgba8 {
2043 r: 255,
2044 g: 255,
2045 b: 255,
2046 a: 255,
2047 };
2048 let source = Pixmap::from_parts_with_opacity(vec![black, white], 2, 1, false);
2049
2050 let small = minify(&source, 1, 1);
2051 assert_eq!((small.width(), small.height()), (1, 1));
2052 // 255 / 2 rounded to nearest.
2053 assert_eq!(small.data()[0].r, 128);
2054 assert_eq!(small.data()[0].a, 255);
2055
2056 // Transparency is recomputed from the result, not inherited: averaging
2057 // an opaque and a fully transparent texel produces a partial one.
2058 let clear = PremulRgba8 {
2059 r: 0,
2060 g: 0,
2061 b: 0,
2062 a: 0,
2063 };
2064 let mixed = Pixmap::from_parts_with_opacity(vec![white, clear], 2, 1, true);
2065 let small = minify(&mixed, 1, 1);
2066 assert_eq!(small.data()[0].a, 128);
2067 assert!(small.may_have_transparency());
2068 }
2069
2070 #[test]
2071 fn minify_never_magnifies() {
2072 let texel = PremulRgba8 {
2073 r: 1,
2074 g: 2,
2075 b: 3,
2076 a: 255,
2077 };
2078 let source = Pixmap::from_parts_with_opacity(vec![texel; 4], 2, 2, false);
2079
2080 // A request above the source's own extent is clamped to it rather than
2081 // inventing texels the box filter has no arm for.
2082 let same = minify(&source, 8, 8);
2083 assert_eq!((same.width(), same.height()), (2, 2));
2084 assert!(same.data().iter().all(|out| *out == texel));
2085 }
2086
2087 #[test]
2088 fn a_malformed_or_degenerate_image_is_refused_before_the_conversion() {
2089 let mut short = image(4, 4);
2090 short.data = Blob::new(Arc::new(vec![0_u8; 8]));
2091 assert!(matches!(
2092 check_supported(&short),
2093 Err(ImageSkip::MalformedPixels { .. })
2094 ));
2095
2096 assert!(matches!(
2097 check_supported(&image(0, 4)),
2098 Err(ImageSkip::DegenerateExtent { .. })
2099 ));
2100
2101 let mut huge = image(1, 1);
2102 huge.width = MAX_IMAGE_DIMENSION + 1;
2103 assert!(matches!(
2104 check_supported(&huge),
2105 Err(ImageSkip::TooLarge { .. })
2106 ));
2107 }
2108
2109 #[test]
2110 fn two_images_share_a_layer_and_hold_distinct_rectangles() {
2111 let mut residency = residency();
2112 residency.begin_frame();
2113
2114 let first = residency.resolve(&image(8, 8)).expect("fits");
2115 let second = residency.resolve(&image(16, 16)).expect("fits");
2116
2117 assert_ne!(first.id, second.id);
2118 assert_eq!(first.region.layer, second.region.layer);
2119 assert_ne!(first.region.offset, second.region.offset);
2120 assert_eq!(residency.uploads().len(), 2);
2121 assert_eq!(residency.layers(), 1);
2122 }
2123
2124 #[test]
2125 fn a_second_layer_is_created_when_the_first_is_full() {
2126 let mut residency = residency();
2127 residency.begin_frame();
2128
2129 // Two 64x48 images cannot share one 64x64 layer.
2130 let first = residency.resolve(&image(64, 48)).expect("fits a layer");
2131 let second = residency.resolve(&image(64, 48)).expect("fits the next");
2132
2133 assert_eq!(first.region.layer, 0);
2134 assert_eq!(second.region.layer, 1);
2135 assert_eq!(residency.layers(), 2);
2136 }
2137
2138 #[test]
2139 fn a_slot_taken_through_the_shared_allocator_is_a_distinct_id_on_the_same_budget() {
2140 // The allocator half of the glyph/image seam, stated without the glyph
2141 // policy: whatever else packs into this cache draws from the same id
2142 // space and the same layer allowance, and its pages are visible in the
2143 // depth the array is built to.
2144 let mut residency = residency();
2145 residency.begin_frame();
2146 let resident = residency.resolve(&image(64, 48)).expect("fills a layer");
2147
2148 // Big enough that it cannot share the layer the image took, so it must
2149 // create the second one rather than report the first.
2150 let borrowed = residency
2151 .allocator_mut()
2152 .allocate(64, 48, ATLAS_PADDING)
2153 .expect("the second layer is free");
2154
2155 assert_ne!(
2156 resident.id, borrowed,
2157 "one cache never hands the same slot index to two live occupants"
2158 );
2159 assert_eq!(
2160 residency.layers(),
2161 2,
2162 "a layer created by the other class still has to be in the array"
2163 );
2164 assert_eq!(
2165 residency
2166 .allocator()
2167 .get(resident.id)
2168 .map(|resource| resource.size()),
2169 Some(resident.region.size),
2170 "and neither allocation disturbed the other's rectangle"
2171 );
2172 }
2173
2174 #[test]
2175 fn a_disabled_residency_refuses_every_image_and_touches_no_atlas() {
2176 let mut residency = ImageResidency::disabled(AtlasBudget::MOBILE);
2177 assert!(residency.is_disabled());
2178
2179 residency.begin_frame();
2180 let skip = residency
2181 .resolve(&image(8, 8))
2182 .expect_err("the kill switch refuses every image");
2183
2184 assert_eq!(skip, ImageSkip::AtlasDisabled);
2185 assert_eq!(residency.entry_count(), 0);
2186 assert_eq!(residency.layers(), 0, "no atlas layer is ever created");
2187 assert!(residency.uploads().is_empty());
2188 assert_eq!(residency.skipped(), 1);
2189 }
2190
2191 #[test]
2192 fn an_enabled_residency_is_what_an_unset_kill_switch_produces() {
2193 // The default test environment leaves `FRUST_ENGINE_NO_ATLAS` unset, so
2194 // this pins that `new` consults the switch rather than hardcoding
2195 // either answer; the disabled half is pinned above through the
2196 // constructor the switch selects.
2197 assert!(!ImageResidency::new(AtlasBudget::MOBILE).is_disabled());
2198 }
2199
2200 #[test]
2201 fn reading_the_plan_leaves_it_pending_and_acknowledging_it_clears_it() {
2202 let mut residency = residency();
2203 residency.begin_frame();
2204 residency.resolve(&image(8, 8)).expect("fits");
2205
2206 let (evictions, uploads) = residency.plan();
2207 assert!(evictions.is_empty());
2208 assert_eq!(uploads.len(), 1);
2209 assert!(
2210 residency.has_pending_plan(),
2211 "reading the plan is not servicing it"
2212 );
2213
2214 residency.acknowledge_plan();
2215 assert!(!residency.has_pending_plan());
2216 assert!(residency.uploads().is_empty());
2217 assert!(residency.evictions().is_empty());
2218 }
2219
2220 #[test]
2221 fn an_unacknowledged_upload_is_re_emitted_until_it_is_serviced() {
2222 // The invariant the whole pending plan exists for: a frame refused
2223 // after compiling never wrote these texels, so the entry map claiming
2224 // the image is resident has to stay answerable by a later frame's plan.
2225 let mut residency = residency();
2226 let data = image(8, 8);
2227
2228 residency.begin_frame();
2229 let first = residency.resolve(&data).expect("fits");
2230 let region = residency.uploads()[0].region;
2231
2232 for _ in 0..4 {
2233 residency.begin_frame();
2234 let again = residency.resolve(&data).expect("still resident");
2235 assert_eq!(again.region, first.region, "residency does not move");
2236 assert_eq!(
2237 residency.uploads().len(),
2238 1,
2239 "the same upload is re-offered, never duplicated"
2240 );
2241 assert_eq!(residency.uploads()[0].region, region);
2242 }
2243
2244 residency.acknowledge_plan();
2245 residency.begin_frame();
2246 residency.resolve(&data).expect("still resident");
2247 assert!(
2248 residency.uploads().is_empty(),
2249 "a serviced upload is never offered again"
2250 );
2251 }
2252
2253 #[test]
2254 fn reaping_an_unacknowledged_entry_withdraws_its_upload_with_it() {
2255 // Otherwise the plan would clear the rectangle and then write the
2256 // evicted image straight back into it, over whatever the packer handed
2257 // that space to next.
2258 let mut residency = residency();
2259 let data = image(8, 8);
2260
2261 residency.begin_frame();
2262 let resident = residency.resolve(&data).expect("fits");
2263 assert_eq!(residency.uploads().len(), 1);
2264
2265 for _ in 0..=MAX_UNSEEN_FRAMES {
2266 residency.begin_frame();
2267 }
2268
2269 assert_eq!(residency.entry_count(), 0);
2270 assert!(
2271 residency.uploads().is_empty(),
2272 "the reaped entry's unserviced upload goes with it"
2273 );
2274 assert_eq!(
2275 residency.evictions(),
2276 &[padded(resident.region, ATLAS_PADDING)]
2277 );
2278 }
2279
2280 #[test]
2281 fn every_pending_upload_lies_inside_the_atlas_its_layer_count_asks_for() {
2282 // What keeps a consumer from recording where an image lives and then
2283 // sampling a rectangle the array refused to write: the plan can only
2284 // ever name regions the budget and the reported depth already admit.
2285 let mut residency = residency();
2286 residency.begin_frame();
2287 residency.resolve(&image(64, 48)).expect("fills a layer");
2288 residency.resolve(&image(64, 48)).expect("takes the next");
2289
2290 let budget = residency.budget();
2291 let layers = residency.layers();
2292 assert_eq!(layers, 2);
2293 for upload in residency.uploads() {
2294 assert!(
2295 budget.contains(upload.region, layers),
2296 "{:?} is outside a {layers}-layer {budget:?}",
2297 upload.region
2298 );
2299 }
2300 }
2301
2302 /// A budget holding exactly two 64-square images: one per layer, with no
2303 /// room to pack a third anywhere, so "the atlas is full" is reachable in
2304 /// two allocations and needs no fragmentation reasoning.
2305 fn two_slot_residency() -> ImageResidency {
2306 ImageResidency::new(AtlasBudget {
2307 atlas_size: (64, 64),
2308 max_atlases: 2,
2309 })
2310 }
2311
2312 #[test]
2313 fn a_working_set_larger_than_the_atlas_re_uploads_rather_than_skipping() {
2314 // The scrolling-list shape: more distinct images pass the viewport
2315 // inside one age window than the atlas has rectangles for. Every draw
2316 // must still resolve — the price is a re-upload, never a hole.
2317 let mut residency = two_slot_residency();
2318 let images: Vec<ImageData> = (0..12).map(|_| image(64, 64)).collect();
2319
2320 for data in &images {
2321 residency.begin_frame();
2322 residency
2323 .resolve(data)
2324 .expect("a full atlas gives a rectangle back rather than refusing");
2325 residency.acknowledge_plan();
2326 }
2327
2328 assert_eq!(residency.skipped(), 0, "no draw was ever dropped");
2329 assert_eq!(
2330 residency.pressure_evictions(),
2331 10,
2332 "the ten images past the two slots each displaced one"
2333 );
2334 assert_eq!(residency.entry_count(), 2, "and residency stays bounded");
2335 }
2336
2337 #[test]
2338 fn an_image_resolved_this_frame_is_never_the_one_evicted() {
2339 // The ordering contract's own boundary: the plan clears before it
2340 // uploads, so a rectangle this frame already resolved cannot be handed
2341 // back inside it. A frame whose own working set outgrows the atlas is
2342 // still a skip, and nothing is scheduled for clearing.
2343 let mut residency = two_slot_residency();
2344 residency.begin_frame();
2345 residency.resolve(&image(64, 64)).expect("takes layer 0");
2346 residency.resolve(&image(64, 64)).expect("takes layer 1");
2347
2348 let skip = residency
2349 .resolve(&image(64, 64))
2350 .expect_err("nothing this frame drew is evictable");
2351
2352 assert!(matches!(skip, ImageSkip::NoAtlasSpace { .. }));
2353 assert_eq!(residency.skipped(), 1);
2354 assert_eq!(residency.pressure_evictions(), 0);
2355 assert_eq!(residency.entry_count(), 2, "both occupants stayed put");
2356 assert!(
2357 residency.evictions().is_empty(),
2358 "no region a frame is about to sample was scheduled for clearing"
2359 );
2360 }
2361
2362 #[test]
2363 fn the_least_recently_seen_image_is_the_one_displaced() {
2364 let mut residency = two_slot_residency();
2365 let old = image(64, 64);
2366 let refreshed = image(64, 64);
2367
2368 residency.begin_frame();
2369 let refreshed_region = residency.resolve(&refreshed).expect("takes a layer").region;
2370 residency.begin_frame();
2371 let old_region = residency.resolve(&old).expect("takes the other").region;
2372 residency.acknowledge_plan();
2373
2374 // The older entry is drawn again, which makes the *other* one the least
2375 // recently seen despite having been made resident second.
2376 residency.begin_frame();
2377 residency.resolve(&refreshed).expect("still resident");
2378 let arrival = residency.resolve(&image(64, 64)).expect("displaces one");
2379
2380 assert_eq!(residency.pressure_evictions(), 1);
2381 assert_eq!(
2382 arrival.region, old_region,
2383 "the rectangle taken is the least recently seen one"
2384 );
2385 assert_eq!(
2386 residency.resolve(&refreshed).map(|again| again.region),
2387 Ok(refreshed_region),
2388 "the image drawn this frame kept its own"
2389 );
2390 }
2391
2392 #[test]
2393 fn a_pressure_eviction_reports_its_rectangle_once_and_withdraws_its_upload() {
2394 let mut residency = two_slot_residency();
2395 let displaced = image(64, 64);
2396
2397 residency.begin_frame();
2398 let displaced_region = residency.resolve(&displaced).expect("takes a layer").region;
2399 residency.resolve(&image(64, 64)).expect("takes the other");
2400 // Deliberately NOT acknowledged: the displaced image still has an
2401 // upload pending, which must go with its entry rather than be written
2402 // into a rectangle its new occupant now owns.
2403 assert_eq!(residency.uploads().len(), 2);
2404
2405 residency.begin_frame();
2406 let arrival = residency
2407 .resolve(&image(64, 64))
2408 .expect("displaces the oldest");
2409
2410 assert_eq!(
2411 residency.evictions(),
2412 &[padded(displaced_region, ATLAS_PADDING)],
2413 "exactly one rectangle, reported exactly once"
2414 );
2415 assert_eq!(
2416 arrival.region, displaced_region,
2417 "and the freed rectangle is what the new occupant took"
2418 );
2419 assert_eq!(
2420 residency.uploads().len(),
2421 2,
2422 "the displaced image's unserviced upload went with its entry — \
2423 writing it would put an evicted image back over its successor"
2424 );
2425 }
2426
2427 #[test]
2428 fn an_age_reap_and_a_pressure_eviction_are_counted_separately() {
2429 // The two bounds answer different questions: one says a screen moved
2430 // on, the other says the atlas is too small for what is on it.
2431 let mut residency = two_slot_residency();
2432 let data = image(64, 64);
2433
2434 residency.begin_frame();
2435 residency.resolve(&data).expect("fits");
2436 for _ in 0..=MAX_UNSEEN_FRAMES {
2437 residency.begin_frame();
2438 }
2439
2440 assert_eq!(residency.entry_count(), 0, "the age reap ran");
2441 assert_eq!(
2442 residency.pressure_evictions(),
2443 0,
2444 "an age reap is not a pressure eviction"
2445 );
2446 assert_eq!(residency.frame_pressure_evictions(), 0);
2447 }
2448
2449 #[test]
2450 fn the_per_frame_eviction_count_is_this_frames_alone() {
2451 let mut residency = two_slot_residency();
2452 residency.begin_frame();
2453 residency.resolve(&image(64, 64)).expect("takes a layer");
2454 residency.resolve(&image(64, 64)).expect("takes the other");
2455
2456 residency.begin_frame();
2457 residency.resolve(&image(64, 64)).expect("displaces one");
2458 assert_eq!(residency.frame_pressure_evictions(), 1);
2459 assert_eq!(residency.pressure_evictions(), 1);
2460
2461 residency.begin_frame();
2462 assert_eq!(
2463 residency.frame_pressure_evictions(),
2464 0,
2465 "a new frame starts from nothing"
2466 );
2467 assert_eq!(
2468 residency.pressure_evictions(),
2469 1,
2470 "while the lifetime total keeps counting"
2471 );
2472 }
2473
2474 /// A budget of exactly four 32-square cells in one layer, so "the atlas is
2475 /// full" needs no reasoning about how a packer splits free space and a
2476 /// request that fits *no* arrangement of the holes is one rectangle.
2477 fn four_slot_residency() -> ImageResidency {
2478 ImageResidency::new(AtlasBudget {
2479 atlas_size: (64, 64),
2480 max_atlases: 1,
2481 })
2482 }
2483
2484 #[test]
2485 fn a_request_no_candidate_set_can_place_is_refused_before_anything_is_freed() {
2486 // The storm, at the cache seam. One 32-square is this frame's and three
2487 // are last frame's; a 48-square fits none of the holes those three
2488 // would leave while the fourth is resident. Freeing them buys nothing
2489 // — the draw is skipped either way — and costs three clears plus three
2490 // re-uploads, every frame the scene repeats. So the refusal has to come
2491 // before the first rectangle is handed back.
2492 let mut residency = four_slot_residency();
2493 let resident: Vec<ImageData> = (0..4).map(|_| image(32, 32)).collect();
2494 residency.begin_frame();
2495 for data in &resident {
2496 residency
2497 .resolve(data)
2498 .expect("the four cells take four images");
2499 }
2500 residency.acknowledge_plan();
2501
2502 residency.begin_frame();
2503 residency.resolve(&resident[0]).expect("still resident");
2504
2505 let skip = residency
2506 .resolve(&image(48, 48))
2507 .expect_err("no arrangement of the three candidates admits a 48-square");
2508
2509 assert!(matches!(skip, ImageSkip::NoAtlasSpace { .. }));
2510 assert_eq!(
2511 residency.pressure_evictions(),
2512 0,
2513 "a request that could not have been placed displaced nothing"
2514 );
2515 assert_eq!(residency.entry_count(), 4, "the residency is untouched");
2516 assert!(
2517 residency.evictions().is_empty(),
2518 "and nothing was scheduled for clearing on the way to failing"
2519 );
2520 assert!(residency.uploads().is_empty());
2521 }
2522
2523 #[test]
2524 fn a_request_larger_than_every_candidate_together_is_refused_on_area_alone() {
2525 // The cheaper half of the same precheck: freeing all three candidates
2526 // leaves three quarters of one layer, and the request wants all of it.
2527 // The area comes off the packer's own accounting, so it counts what the
2528 // glyph half of the allocator holds as well as this residency's own.
2529 let mut residency = four_slot_residency();
2530 let resident: Vec<ImageData> = (0..4).map(|_| image(32, 32)).collect();
2531 residency.begin_frame();
2532 for data in &resident {
2533 residency
2534 .resolve(data)
2535 .expect("the four cells take four images");
2536 }
2537 residency.acknowledge_plan();
2538
2539 residency.begin_frame();
2540 residency.resolve(&resident[0]).expect("still resident");
2541 residency
2542 .resolve(&image(64, 64))
2543 .expect_err("a whole layer is more than the three candidates hold");
2544
2545 assert_eq!(residency.pressure_evictions(), 0);
2546 assert_eq!(residency.entry_count(), 4);
2547 assert!(residency.evictions().is_empty());
2548 }
2549
2550 #[test]
2551 fn a_satisfiable_request_under_pressure_still_evicts_the_minimum() {
2552 // The other side of the precheck: it must not turn a working eviction
2553 // into a skip. One 32-square arriving against four resident ones is
2554 // satisfiable by freeing exactly one, and that is what it costs.
2555 let mut residency = four_slot_residency();
2556 let resident: Vec<ImageData> = (0..4).map(|_| image(32, 32)).collect();
2557 residency.begin_frame();
2558 for data in &resident {
2559 residency
2560 .resolve(data)
2561 .expect("the four cells take four images");
2562 }
2563 residency.acknowledge_plan();
2564
2565 residency.begin_frame();
2566 residency.resolve(&image(32, 32)).expect("displaces one");
2567
2568 assert_eq!(
2569 residency.pressure_evictions(),
2570 1,
2571 "the minimum, not the lot"
2572 );
2573 assert_eq!(residency.entry_count(), 4);
2574 assert_eq!(residency.evictions().len(), 1);
2575 }
2576
2577 #[test]
2578 fn a_blob_redrawn_at_a_new_extent_never_frees_what_this_frame_resolved() {
2579 // The extent-mismatch release under the pressure path's own rule. The
2580 // plan clears before it uploads, so giving the rectangle back on a
2581 // frame whose first draw already resolved it would zero texels that
2582 // draw is about to sample.
2583 let square = image(16, 8);
2584 let transposed = ImageData {
2585 data: square.data.clone(),
2586 format: square.format,
2587 alpha_type: square.alpha_type,
2588 width: 8,
2589 height: 16,
2590 };
2591
2592 let mut residency = residency();
2593 residency.begin_frame();
2594 let first = residency.resolve(&square).expect("takes a rectangle");
2595 let skip = residency
2596 .resolve(&transposed)
2597 .expect_err("the second extent cannot take the first one's rectangle");
2598
2599 assert!(
2600 matches!(skip, ImageSkip::SameFrameExtentConflict { .. }),
2601 "a same-frame ordering conflict, not an atlas-capacity refusal: {skip:?}"
2602 );
2603 assert!(
2604 residency.evictions().is_empty(),
2605 "no clear over a rectangle this frame already sampled"
2606 );
2607 assert_eq!(residency.entry_count(), 1);
2608 assert_eq!(residency.uploads().len(), 1);
2609
2610 // On a later frame nothing has sampled it yet, so the release is
2611 // ordinary again and the new extent takes its place.
2612 residency.acknowledge_plan();
2613 residency.begin_frame();
2614 let second = residency.resolve(&transposed).expect("the new extent fits");
2615 assert_eq!(second.natural, [8, 16]);
2616 assert_eq!(
2617 residency.evictions(),
2618 &[padded(first.region, ATLAS_PADDING)],
2619 "the rectangle the old extent held is reported for clearing"
2620 );
2621 }
2622
2623 #[test]
2624 fn a_layer_admits_only_what_every_occupant_leaves_room_for() {
2625 // The planner's own necessary condition, in isolation. A placement has
2626 // to lie entirely to one side of each occupant, so a 32-square in one
2627 // corner of a 64-square layer leaves nothing a 48-square can occupy —
2628 // while a 32-square still has three cells to choose from.
2629 let corner = PressureRect {
2630 key: 1,
2631 layer: 0,
2632 x0: 0,
2633 y0: 0,
2634 x1: 32,
2635 y1: 32,
2636 area: 1024,
2637 last_seen: 0,
2638 slot: 0,
2639 freed: false,
2640 };
2641 let rects = [corner];
2642
2643 assert!(!layer_admits(&rects, 0, 48, 48, (64, 64)));
2644 assert!(layer_admits(&rects, 0, 32, 32, (64, 64)));
2645 assert!(layer_admits(&rects, 0, 32, 64, (64, 64)));
2646 // A freed rectangle is not an obstacle, and a layer nothing occupies
2647 // admits anything the layer itself can hold.
2648 assert!(layer_admits(
2649 &[PressureRect {
2650 freed: true,
2651 ..corner
2652 }],
2653 0,
2654 64,
2655 64,
2656 (64, 64)
2657 ));
2658 assert!(!layer_admits(&[], 0, 65, 64, (64, 64)));
2659 // An occupant on another layer says nothing about this one.
2660 assert!(layer_admits(
2661 &[PressureRect { layer: 1, ..corner }],
2662 0,
2663 64,
2664 64,
2665 (64, 64)
2666 ));
2667 }
2668
2669 #[test]
2670 fn a_plan_that_would_outrun_its_budget_frees_nothing() {
2671 // The bound is on the *search*, not only on the damage: a walk that
2672 // reaches the budget without finding a fit answers `None`, and the
2673 // caller has released nothing by then.
2674 let mut rects: Vec<PressureRect> = (0..MAX_PRESSURE_EVICTION_CANDIDATES + 8)
2675 .map(|index| {
2676 let index = u32::try_from(index).expect("fits");
2677 PressureRect {
2678 key: u64::from(index),
2679 layer: 0,
2680 x0: index,
2681 y0: 0,
2682 x1: index + 1,
2683 y1: 1,
2684 area: 1,
2685 last_seen: 0,
2686 slot: index,
2687 freed: false,
2688 }
2689 })
2690 .collect();
2691 let order: Vec<usize> = (0..rects.len()).collect();
2692 let mut free_area = vec![0_u64];
2693 let mut layers = Vec::new();
2694
2695 assert_eq!(
2696 plan_pressure_eviction(
2697 64,
2698 64,
2699 (64, 64),
2700 &mut rects,
2701 &order,
2702 &mut free_area,
2703 &mut layers
2704 ),
2705 None,
2706 "a walk that cannot fit the request inside its budget plans nothing"
2707 );
2708 assert!(
2709 rects.iter().filter(|rect| rect.freed).count() <= MAX_PRESSURE_EVICTION_CANDIDATES + 1,
2710 "and it stopped walking rather than marking the whole residency"
2711 );
2712 }
2713
2714 #[test]
2715 fn a_satisfiable_layer_is_not_starved_by_a_hopeless_ones_candidates() {
2716 // The multi-layer shape the per-layer budget exists for. Layer 0 is
2717 // "this frame's" 64x40 anchor plus more than
2718 // `MAX_PRESSURE_EVICTION_CANDIDATES` one-pixel stale fillers packed
2719 // into the 64x24 strip left over — every one of them stale (older
2720 // than the incoming request's frame) and none of them, individually
2721 // or together, ever able to free a 32-tall rectangle, since the strip
2722 // they sit in is only 24 pixels tall. Layer 1 is four 32-square
2723 // quadrants exactly tiling the layer, three "this frame's" and one
2724 // stale. Layers 2 and 3 are each one "this frame's" 64-square image,
2725 // filling them outright so the incoming request cannot simply grow
2726 // into an unused layer.
2727 //
2728 // A single pooled budget spends its whole allowance walking layer 0's
2729 // fillers — none of which can ever satisfy the request — and never
2730 // reaches layer 1's one satisfiable candidate at all. A per-layer
2731 // budget exhausts layer 0's own allowance the same way, then gives
2732 // layer 1 a fresh one and finds the fit immediately.
2733 let mut residency = ImageResidency::new(AtlasBudget {
2734 atlas_size: (64, 64),
2735 max_atlases: 4,
2736 });
2737
2738 let anchor = image(64, 40);
2739 let fillers: Vec<ImageData> = (0..MAX_PRESSURE_EVICTION_CANDIDATES + 6)
2740 .map(|_| image(1, 1))
2741 .collect();
2742 let quadrants: Vec<ImageData> = (0..4).map(|_| image(32, 32)).collect();
2743 let layer2_filler = image(64, 64);
2744 let layer3_filler = image(64, 64);
2745
2746 residency.begin_frame();
2747 residency
2748 .resolve(&anchor)
2749 .expect("the anchor takes layer 0's top");
2750 for filler in &fillers {
2751 residency
2752 .resolve(filler)
2753 .expect("a one-pixel filler always finds room in the leftover strip");
2754 }
2755 for quadrant in &quadrants {
2756 residency
2757 .resolve(quadrant)
2758 .expect("a 32-square quadrant packs layer 1's empty corners");
2759 }
2760 residency
2761 .resolve(&layer2_filler)
2762 .expect("a whole-layer image grows into a fresh layer 2");
2763 residency
2764 .resolve(&layer3_filler)
2765 .expect("a whole-layer image grows into a fresh layer 3");
2766 residency.acknowledge_plan();
2767 assert_eq!(
2768 residency.layers(),
2769 4,
2770 "fixture precondition: all four layers exist"
2771 );
2772
2773 // Refresh everything except the fillers and the first quadrant, so
2774 // only they are stale candidates when the request arrives — the
2775 // fillers sort ahead of the stale quadrant because they were
2776 // resolved first and share the same `last_seen`.
2777 residency.begin_frame();
2778 residency.resolve(&anchor).expect("still resident");
2779 residency.resolve(&quadrants[1]).expect("still resident");
2780 residency.resolve(&quadrants[2]).expect("still resident");
2781 residency.resolve(&quadrants[3]).expect("still resident");
2782 residency.resolve(&layer2_filler).expect("still resident");
2783 residency.resolve(&layer3_filler).expect("still resident");
2784
2785 let arrival = residency
2786 .resolve(&image(32, 32))
2787 .expect("a per-layer budget reaches layer 1's satisfiable candidate");
2788
2789 assert_eq!(
2790 residency.pressure_evictions(),
2791 1,
2792 "exactly the one quadrant needed, not layer 0's hopeless fillers"
2793 );
2794 assert_eq!(residency.evictions().len(), 1);
2795 assert_eq!(
2796 residency.evictions()[0].layer,
2797 arrival.region.layer,
2798 "the eviction and the arrival land on the same, single layer"
2799 );
2800 assert_ne!(
2801 arrival.region.layer, 0,
2802 "the winning layer is not the one full of hopeless fillers"
2803 );
2804 }
2805
2806 /// The tier line's own text, which only a `perf-trace` build has.
2807 #[cfg(feature = "perf-trace")]
2808 #[test]
2809 fn the_resolved_tier_line_names_the_tier_and_is_written_once() {
2810 let mobile = TierCaps::fake(DownlevelProfile::WebGl2);
2811 assert_eq!(
2812 atlas_tier_line(&mobile, AtlasBudget::MOBILE),
2813 "frust-perf atlas tier=mobile budget=1024x1024x4 downlevel=WebGl2 \
2814 transient_saves_memory=false adapter=fake-webgl2"
2815 );
2816
2817 let mut tiler = TierCaps::fake(DownlevelProfile::Full);
2818 tiler.adapter_name = "Adreno (TM) 660".to_string();
2819 assert_eq!(
2820 atlas_tier_line(&tiler, AtlasBudget::DESKTOP),
2821 "frust-perf atlas tier=desktop budget=2048x2048x8 downlevel=Full \
2822 transient_saves_memory=false adapter=Adreno (TM) 660"
2823 );
2824
2825 // The signal that decides the tier is the one the line reports, so a
2826 // tile-based adapter reading `Full` still says `mobile`.
2827 tiler.transient_saves_memory = true;
2828 assert!(atlas_tier_line(&tiler, AtlasBudget::MOBILE).contains("tier=mobile"));
2829 assert!(
2830 atlas_tier_line(&tiler, AtlasBudget::MOBILE).contains("transient_saves_memory=true")
2831 );
2832
2833 let latch = TierLogOnce::new();
2834 assert!(
2835 latch.emit(&mobile, AtlasBudget::MOBILE),
2836 "the first call writes the line"
2837 );
2838 assert!(
2839 !latch.emit(&mobile, AtlasBudget::MOBILE),
2840 "and no later one repeats it"
2841 );
2842 }
2843
2844 #[test]
2845 fn an_override_redistributes_a_tiers_memory_but_never_exceeds_it() {
2846 let tier = AtlasBudget::DESKTOP;
2847
2848 // The pathological override the adapter alone would admit: a
2849 // 16384-square layer is a gibibyte on its own, so clamping the layer
2850 // count could not have brought it inside the tier's 128 MiB.
2851 let huge = AtlasBudget {
2852 atlas_size: (16_384, 16_384),
2853 max_atlases: tier.max_atlases,
2854 }
2855 .within_total_bytes(tier.total_bytes());
2856 assert!(huge.total_bytes() <= tier.total_bytes());
2857 assert_eq!(huge.atlas_size.0, huge.atlas_size.1, "aspect preserved");
2858 assert!(huge.max_atlases >= 1);
2859
2860 // A modest override is left exactly as asked for.
2861 let modest = AtlasBudget {
2862 atlas_size: (1024, 1024),
2863 max_atlases: tier.max_atlases,
2864 }
2865 .within_total_bytes(tier.total_bytes());
2866 assert_eq!(modest.atlas_size, (1024, 1024));
2867 assert_eq!(modest.max_atlases, tier.max_atlases);
2868
2869 // An extreme aspect ratio floors one axis to nothing; it is raised back
2870 // to a texel and the other axis gives the room up, so the pair still
2871 // fits and neither axis is zero.
2872 let sliver = AtlasBudget {
2873 atlas_size: (65_535, 4),
2874 max_atlases: 1,
2875 }
2876 .within_total_bytes(64);
2877 assert!(sliver.atlas_size.0 >= 1 && sliver.atlas_size.1 >= 1);
2878 assert!(sliver.total_bytes() <= 64);
2879 }
2880}