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 large-mobile budget's preferred shape: four 2048-square layers,
339 /// 64 MiB in total — a quarter as many layers as
340 /// [`MOBILE_LARGE_DEEP`](Self::MOBILE_LARGE_DEEP) for the same texels, so
341 /// the array grows (and copies itself) a quarter as often.
342 pub const MOBILE_LARGE_WIDE: Self = Self {
343 atlas_size: (2048, 2048),
344 max_atlases: 4,
345 };
346
347 /// The large-mobile budget's shape on an adapter whose texture edge stops
348 /// short of 2048: sixteen 1024-square layers, the same 64 MiB.
349 pub const MOBILE_LARGE_DEEP: Self = Self {
350 atlas_size: (1024, 1024),
351 max_atlases: 16,
352 };
353
354 /// The large-mobile shape `caps`' adapter can create unclamped, or `None`
355 /// when it can create neither — the tier then falls back to
356 /// [`MOBILE`](Self::MOBILE) rather than to a large budget its ceilings
357 /// would have narrowed below the plain one's.
358 #[must_use]
359 pub fn mobile_large(caps: &TierCaps) -> Option<Self> {
360 [Self::MOBILE_LARGE_WIDE, Self::MOBILE_LARGE_DEEP]
361 .into_iter()
362 .find(|shape| shape.clamped(caps) == *shape)
363 }
364
365 /// The unclamped budget `tier` stands for on `caps`' adapter.
366 #[must_use]
367 pub fn for_tier(tier: AtlasTier, caps: &TierCaps) -> Self {
368 match tier {
369 AtlasTier::Mobile => Self::MOBILE,
370 AtlasTier::MobileLarge => Self::mobile_large(caps).unwrap_or(Self::MOBILE),
371 AtlasTier::Desktop => Self::DESKTOP,
372 }
373 }
374
375 /// The budget for `caps`' adapter, clamped to what it can actually create.
376 ///
377 /// The tier choice is [`choose_tier`] over the adapter and the device's
378 /// own memory ([`device_memory_bytes`]); the clamps that follow are the
379 /// adapter's own texture-edge and array-layer ceilings plus the eight-bit
380 /// [`MAX_ATLAS_LAYERS`] the encoded-image record can address.
381 ///
382 /// `FRUST_ENGINE_ATLAS_SIZE` overrides the tier's extent, and the override
383 /// is **budgeted, not unbudgeted**: it passes through
384 /// [`within_total_bytes`](Self::within_total_bytes) against the tier's own
385 /// [`total_bytes`](Self::total_bytes) before the adapter clamps. So the knob
386 /// redistributes a tier's memory between extent and depth — one large layer
387 /// instead of eight small ones — and cannot spend more of it than the tier
388 /// itself would. Clamped to the adapter last, because an override is a
389 /// request and a request the adapter cannot honour is still narrowed to what
390 /// it can.
391 ///
392 /// The resolved budget is reported once per process through
393 /// `atlas_tier_line`, because which tier a given phone takes is otherwise
394 /// an inference from behaviour rather than an observation: the adapter
395 /// name, the driver's own capability answers and the memory reading are
396 /// all device-reported, and a device answering differently from the one
397 /// it resembles is exactly the case a benchmark capture has to be able to
398 /// name. That line is `perf-trace`-only, like every other `frust-perf`
399 /// line the engine emits — a build without the feature resolves the same
400 /// budget and writes nothing.
401 #[must_use]
402 pub fn for_caps(caps: &TierCaps) -> Self {
403 let choice = choose_tier(caps, device_memory_bytes());
404 let resolved = Self::for_choice(choice, caps);
405
406 #[cfg(feature = "perf-trace")]
407 ATLAS_TIER_LOG.emit(caps, choice, resolved);
408 resolved
409 }
410
411 /// The budget `choice` resolves to on `caps`' adapter — [`for_caps`]
412 /// without reading the device's memory or writing the tier line, so the
413 /// table's outcome is testable for any adapter and memory size.
414 ///
415 /// [`for_caps`]: Self::for_caps
416 #[must_use]
417 pub fn for_choice(choice: TierChoice, caps: &TierCaps) -> Self {
418 let tier = Self::for_tier(choice.tier, caps);
419 match config::atlas_size() {
420 None => tier.clamped(caps),
421 Some(atlas_size) => Self {
422 atlas_size,
423 max_atlases: tier.max_atlases,
424 }
425 .within_total_bytes(tier.total_bytes())
426 .clamped(caps),
427 }
428 }
429
430 /// This budget narrowed so a fully grown array costs at most `total_bytes`.
431 ///
432 /// The extent goes first, scaled uniformly until one layer fits inside the
433 /// whole allowance; the layer count then takes whatever multiple of that
434 /// layer is left, never below one. Scaling the extent rather than only the
435 /// depth is what makes the ceiling hold at all: a single 16384-square layer
436 /// is a gigabyte on its own, so a clamp that could only reduce the layer
437 /// *count* would have nothing left to reduce.
438 ///
439 /// Both axes are floored after the uniform scale, which can only take the
440 /// product below the allowance — except where an extreme aspect ratio floors
441 /// one axis to nothing and it is raised back to a texel, so each axis is
442 /// then held against what the other leaves.
443 #[must_use]
444 pub fn within_total_bytes(self, total_bytes: u64) -> Self {
445 // A single texel is the smallest atlas that exists, so an allowance
446 // below one is treated as one rather than producing a zero extent the
447 // packer could not address.
448 let allowance = total_bytes.max(ATLAS_FORMAT_BYTES);
449 let max_texels = allowance / ATLAS_FORMAT_BYTES;
450
451 let (width, height) = self.atlas_size;
452 let texels = u64::from(width).saturating_mul(u64::from(height));
453 let atlas_size = if texels <= max_texels {
454 (width, height)
455 } else {
456 // `as` on a float saturates rather than wrapping, and both factors
457 // are finite and positive, so the narrowing is a plain floor.
458 let scale = (max_texels as f64 / texels.max(1) as f64).sqrt();
459 let scaled_w = ((f64::from(width) * scale) as u32).max(1);
460 let scaled_h = ((f64::from(height) * scale) as u32).max(1);
461 let fitted_w = fit_axis(scaled_w, max_texels / u64::from(scaled_h));
462 let fitted_h = fit_axis(scaled_h, max_texels / u64::from(fitted_w));
463 (fitted_w, fitted_h)
464 };
465
466 let layer_bytes = u64::from(atlas_size.0)
467 .saturating_mul(u64::from(atlas_size.1))
468 .saturating_mul(ATLAS_FORMAT_BYTES)
469 .max(1);
470 let layers = usize::try_from(allowance / layer_bytes).unwrap_or(MAX_ATLAS_LAYERS);
471
472 Self {
473 atlas_size,
474 max_atlases: self.max_atlases.min(layers).max(1),
475 }
476 }
477
478 /// Whether `region` lies inside an atlas array of `layers` layers at this
479 /// budget's per-layer extent.
480 ///
481 /// The pure counterpart of [`crate::gpu::atlas::AtlasArray::contains`],
482 /// answerable with no device: a consumer that records where an image lives
483 /// before the array exists checks the rectangle here, so it can never come
484 /// to name a region the array will refuse to write and then sample it as
485 /// whatever the texture happened to hold.
486 #[must_use]
487 pub fn contains(self, region: AtlasRegion, layers: u32) -> bool {
488 !region.is_empty()
489 && region.layer < layers
490 && region.offset[0].saturating_add(region.size[0]) <= self.atlas_size.0
491 && region.offset[1].saturating_add(region.size[1]) <= self.atlas_size.1
492 }
493
494 /// This budget narrowed to what `caps`' adapter can create.
495 #[must_use]
496 pub fn clamped(self, caps: &TierCaps) -> Self {
497 let edge = caps.max_texture_dimension_2d.max(1);
498 let layers = usize::try_from(caps.max_texture_array_layers).unwrap_or(MAX_ATLAS_LAYERS);
499
500 Self {
501 atlas_size: (
502 self.atlas_size.0.clamp(1, edge),
503 self.atlas_size.1.clamp(1, edge),
504 ),
505 max_atlases: self.max_atlases.clamp(1, layers.min(MAX_ATLAS_LAYERS)),
506 }
507 }
508
509 /// The `vello_common` configuration this budget describes.
510 ///
511 /// `initial_atlas_count` is zero so the first layer is created by the first
512 /// allocation that needs one: an application drawing no images pays for no
513 /// atlas at all, and [`vello_common::multi_atlas::MultiAtlasManager::new`]'s
514 /// own `expect` on eager creation can never be reached.
515 #[must_use]
516 pub fn config(self) -> AtlasConfig {
517 AtlasConfig {
518 initial_atlas_count: 0,
519 max_atlases: self.max_atlases,
520 atlas_size: self.atlas_size,
521 auto_grow: true,
522 allocation_strategy: AllocationStrategy::FirstFit,
523 }
524 }
525
526 /// The bytes one fully populated layer costs at [`ATLAS_FORMAT_BYTES`].
527 #[must_use]
528 pub fn layer_bytes(self) -> u64 {
529 u64::from(self.atlas_size.0)
530 .saturating_mul(u64::from(self.atlas_size.1))
531 .saturating_mul(ATLAS_FORMAT_BYTES)
532 }
533
534 /// The bytes every layer of this budget costs once fully grown.
535 #[must_use]
536 pub fn total_bytes(self) -> u64 {
537 self.layer_bytes()
538 .saturating_mul(self.max_atlases.try_into().unwrap_or(u64::MAX))
539 }
540}
541
542/// Bytes per texel of the atlas array (`Rgba8Unorm`).
543pub const ATLAS_FORMAT_BYTES: u64 = 4;
544
545/// `evictions` less every rectangle one of `uploads` covers exactly.
546///
547/// Exact cover only — same layer, offset and extent: an upload writes every
548/// texel of its own rectangle (its pixels are exactly the region's extent), so
549/// a clear of that same rectangle ordered ahead of it is overwritten in full.
550/// A rectangle covered only partly, or only by several uploads together, keeps
551/// its clear: whatever texels no upload reaches must still read as zero to the
552/// next tenant. Order is preserved, so the clears stay in the order the
553/// residency reported them.
554#[must_use]
555pub fn uncovered_clears(evictions: &[AtlasRegion], uploads: &[ImageUpload]) -> Vec<AtlasRegion> {
556 if evictions.is_empty() || uploads.is_empty() {
557 return evictions.to_vec();
558 }
559 let written: HashSet<AtlasRegion> = uploads.iter().map(|upload| upload.region).collect();
560 evictions
561 .iter()
562 .copied()
563 .filter(|region| !written.contains(region))
564 .collect()
565}
566
567/// Whether `caps`' adapter reads as a mobile part by its capability answers
568/// alone.
569///
570/// Two signals, either of which is enough:
571///
572/// - its usable limits are clamped to the GLES-3.0/WebGL2 downlevel defaults,
573/// which is the profile every GL-backed target reports; or
574/// - it reports `transient_saves_memory`, wgpu's own answer to "is this a
575/// tile-based renderer keeping attachments on-chip" — true on the mobile
576/// TBDR parts and false on desktop immediate-mode ones.
577///
578/// This is the device-class read text hinting keys on, and the atlas tier's
579/// fallback for an adapter [`ADAPTER_FAMILIES`] does not name. It is not the
580/// atlas tier itself: some mobile drivers answer `transient_saves_memory`
581/// false (Adreno 618/620 do), which this read alone would put on the desktop
582/// budget — see [`choose_tier`].
583#[must_use]
584pub fn is_mobile_tier(caps: &TierCaps) -> bool {
585 caps.downlevel_profile != DownlevelProfile::Full || caps.transient_saves_memory
586}
587
588/// Which class of part an adapter's name places it in.
589#[derive(Debug, Clone, Copy, PartialEq, Eq)]
590pub enum AdapterFamily {
591 /// A phone/tablet GPU family: the atlas competes with the rest of a
592 /// shared-memory device's budget.
593 Mobile,
594 /// A discrete or desktop-integrated GPU family.
595 Desktop,
596}
597
598/// The adapter-name table the atlas tier is read from: a lowercase fragment
599/// and the family an adapter whose name contains it belongs to, first match
600/// winning.
601///
602/// A name table rather than capability answers alone because the capability
603/// answers disagree with the hardware: Adreno 618 and 620 report a full
604/// profile and `transient_saves_memory` false — the desktop answers — while
605/// sharing their device's memory with everything else on it. An adapter the
606/// table does not name keeps the capability read ([`is_mobile_tier`]), so a
607/// family missing here costs the old behaviour rather than a wrong one.
608///
609/// Apple is listed whole: Apple-silicon Macs already took the mobile budget
610/// through `transient_saves_memory`, so naming them changes nothing there.
611pub const ADAPTER_FAMILIES: &[(&str, AdapterFamily)] = &[
612 ("adreno", AdapterFamily::Mobile),
613 ("mali", AdapterFamily::Mobile),
614 ("immortalis", AdapterFamily::Mobile),
615 ("powervr", AdapterFamily::Mobile),
616 ("xclipse", AdapterFamily::Mobile),
617 ("maleoon", AdapterFamily::Mobile),
618 ("apple", AdapterFamily::Mobile),
619 ("tegra", AdapterFamily::Mobile),
620 ("nvidia", AdapterFamily::Desktop),
621 ("geforce", AdapterFamily::Desktop),
622 ("quadro", AdapterFamily::Desktop),
623 ("radeon", AdapterFamily::Desktop),
624 ("amd", AdapterFamily::Desktop),
625 ("intel", AdapterFamily::Desktop),
626];
627
628/// The family [`ADAPTER_FAMILIES`] places `adapter_name` in, matched
629/// case-insensitively; `None` for a name the table does not know.
630#[must_use]
631pub fn adapter_family(adapter_name: &str) -> Option<AdapterFamily> {
632 let name = adapter_name.to_ascii_lowercase();
633 ADAPTER_FAMILIES
634 .iter()
635 .find(|(fragment, _)| name.contains(fragment))
636 .map(|(_, family)| *family)
637}
638
639/// The atlas budget tiers.
640#[derive(Debug, Clone, Copy, PartialEq, Eq)]
641pub enum AtlasTier {
642 /// [`AtlasBudget::MOBILE`].
643 Mobile,
644 /// [`AtlasBudget::mobile_large`]: a mobile family part on a device with at
645 /// least [`MOBILE_LARGE_MIN_MEMORY`] of memory.
646 MobileLarge,
647 /// [`AtlasBudget::DESKTOP`].
648 Desktop,
649}
650
651impl AtlasTier {
652 /// The tier's name as the tier line spells it.
653 #[must_use]
654 pub fn label(self) -> &'static str {
655 match self {
656 Self::Mobile => "mobile",
657 Self::MobileLarge => "mobile_large",
658 Self::Desktop => "desktop",
659 }
660 }
661}
662
663/// Which rule of [`choose_tier`] decided a tier.
664#[derive(Debug, Clone, Copy, PartialEq, Eq)]
665pub enum TierRule {
666 /// The adapter's limits are clamped to the WebGL2 downlevel defaults.
667 Downlevel,
668 /// [`ADAPTER_FAMILIES`] named the adapter.
669 Table,
670 /// The table did not; [`is_mobile_tier`]'s capability read decided.
671 Heuristic,
672}
673
674impl TierRule {
675 /// The rule's name as the tier line spells it.
676 #[must_use]
677 pub fn label(self) -> &'static str {
678 match self {
679 Self::Downlevel => "downlevel",
680 Self::Table => "table",
681 Self::Heuristic => "heuristic",
682 }
683 }
684}
685
686/// The resolved tier, with what decided it.
687#[derive(Debug, Clone, Copy, PartialEq, Eq)]
688pub struct TierChoice {
689 /// The tier.
690 pub tier: AtlasTier,
691 /// The rule that chose it.
692 pub rule: TierRule,
693 /// The device memory the choice was made against, when it was known.
694 pub device_memory: Option<u64>,
695}
696
697/// The device memory from which a mobile family part takes the large budget:
698/// 4 GiB.
699///
700/// The large budget is 64 MiB of atlas, a sixteenth of that; below it the
701/// plain 16 MiB budget stays.
702pub const MOBILE_LARGE_MIN_MEMORY: u64 = 4 << 30;
703
704/// The atlas tier for `caps`' adapter on a device with `device_memory` bytes
705/// of memory (`None` when unknown).
706///
707/// In order:
708///
709/// 1. A downlevel (WebGL2/GLES) profile is [`AtlasTier::Mobile`], whatever the
710/// name: its limits are the browser ceiling, and the budget follows them.
711/// 2. An adapter [`ADAPTER_FAMILIES`] names takes its family's tier — a
712/// desktop family [`AtlasTier::Desktop`]; a mobile family
713/// [`AtlasTier::MobileLarge`] when the device reports at least
714/// [`MOBILE_LARGE_MIN_MEMORY`] and the adapter can create a large shape
715/// unclamped ([`AtlasBudget::mobile_large`]), else [`AtlasTier::Mobile`].
716/// Unknown memory is the small tier: the large one is earned, not assumed.
717/// 3. Any other adapter keeps [`is_mobile_tier`]'s capability read, mobile or
718/// desktop, exactly as before the table existed.
719#[must_use]
720pub fn choose_tier(caps: &TierCaps, device_memory: Option<u64>) -> TierChoice {
721 let (tier, rule) = if caps.downlevel_profile != DownlevelProfile::Full {
722 (AtlasTier::Mobile, TierRule::Downlevel)
723 } else {
724 match adapter_family(&caps.adapter_name) {
725 Some(AdapterFamily::Desktop) => (AtlasTier::Desktop, TierRule::Table),
726 Some(AdapterFamily::Mobile) => {
727 let large = device_memory.is_some_and(|bytes| bytes >= MOBILE_LARGE_MIN_MEMORY)
728 && AtlasBudget::mobile_large(caps).is_some();
729 let tier = if large {
730 AtlasTier::MobileLarge
731 } else {
732 AtlasTier::Mobile
733 };
734 (tier, TierRule::Table)
735 }
736 None if is_mobile_tier(caps) => (AtlasTier::Mobile, TierRule::Heuristic),
737 None => (AtlasTier::Desktop, TierRule::Heuristic),
738 }
739 };
740 TierChoice {
741 tier,
742 rule,
743 device_memory,
744 }
745}
746
747/// The device's total memory in bytes, when this platform reports it.
748///
749/// Read once per process from `/proc/meminfo`'s `MemTotal` on Linux and
750/// Android — the kernel's own figure, readable without a permission on both —
751/// and `None` everywhere else, which keeps every other platform on the tier
752/// it took before the large one existed.
753#[must_use]
754pub fn device_memory_bytes() -> Option<u64> {
755 #[cfg(any(target_os = "linux", target_os = "android"))]
756 {
757 use std::sync::OnceLock;
758 static MEMORY: OnceLock<Option<u64>> = OnceLock::new();
759 *MEMORY.get_or_init(|| {
760 std::fs::read_to_string("/proc/meminfo")
761 .ok()
762 .and_then(|text| parse_mem_total(&text))
763 })
764 }
765 #[cfg(not(any(target_os = "linux", target_os = "android")))]
766 {
767 None
768 }
769}
770
771/// `MemTotal` from a `/proc/meminfo` text, in bytes; `None` when the line is
772/// missing or not the `<n> kB` the kernel writes.
773#[must_use]
774pub fn parse_mem_total(meminfo: &str) -> Option<u64> {
775 let line = meminfo
776 .lines()
777 .find_map(|line| line.strip_prefix("MemTotal:"))?;
778 let mut fields = line.split_whitespace();
779 let kib: u64 = fields.next()?.parse().ok()?;
780 match fields.next() {
781 Some("kB") => kib.checked_mul(1024),
782 _ => None,
783 }
784}
785
786/// The process's latch for the resolved-tier line, held by
787/// [`AtlasBudget::for_caps`].
788#[cfg(feature = "perf-trace")]
789static ATLAS_TIER_LOG: TierLogOnce = TierLogOnce::new();
790
791/// A one-shot latch for the resolved-tier line.
792///
793/// Once per *process* rather than once per call: a budget is resolved once per
794/// surface, and the question the line answers — which tier this adapter took —
795/// is about the device, so one line per run answers it completely while a line
796/// per surface would repeat it.
797///
798/// A latch value rather than a bare [`std::sync::Once`] because the rule it
799/// keeps is worth testing: [`emit`](Self::emit) answers whether *this* call was
800/// the one that wrote the line, so a test constructs a latch of its own and
801/// pins "the first call writes and no later one does" without installing a
802/// process-global logger.
803///
804/// Compiled only under `perf-trace`, with the line it writes: the release-lean
805/// gate asserts a shipping binary carries no `frust-perf` bytes at all, and a
806/// `#[cfg]` is what makes that the compiler's answer rather than the
807/// optimizer's.
808#[cfg(feature = "perf-trace")]
809#[derive(Debug, Default)]
810pub struct TierLogOnce {
811 emitted: AtomicBool,
812}
813
814#[cfg(feature = "perf-trace")]
815impl TierLogOnce {
816 /// A latch that has not emitted yet.
817 #[must_use]
818 pub const fn new() -> Self {
819 Self {
820 emitted: AtomicBool::new(false),
821 }
822 }
823
824 /// Log [`atlas_tier_line`] for `caps`, `choice` and `budget` if this latch
825 /// has not fired, answering whether this call was the one that did.
826 pub fn emit(&self, caps: &TierCaps, choice: TierChoice, budget: AtlasBudget) -> bool {
827 if self.emitted.swap(true, Ordering::Relaxed) {
828 return false;
829 }
830
831 log::info!("{}", atlas_tier_line(caps, choice, budget));
832 true
833 }
834}
835
836/// The one line a run records the resolved atlas tier as.
837///
838/// `perf-trace`-only, with [`TierLogOnce`] — see its own doc for why the gate
839/// is a `#[cfg]` rather than a branch.
840///
841/// `frust-perf`-prefixed deliberately: a benchmark capture keeps every line
842/// carrying that prefix and drops the rest, so this is what lets a run's own
843/// log say which budget the device was given rather than leaving it to be
844/// inferred from whether images went missing. `rule` names which of
845/// [`choose_tier`]'s rules decided, and `memory_mib` the device memory it was
846/// decided against (`unknown` where the platform does not report it). The
847/// adapter name is last because it is the one field that can contain spaces,
848/// which keeps every `key=value` field ahead of it parseable by splitting on
849/// whitespace.
850#[cfg(feature = "perf-trace")]
851#[must_use]
852pub fn atlas_tier_line(caps: &TierCaps, choice: TierChoice, budget: AtlasBudget) -> String {
853 let memory = choice
854 .device_memory
855 .map_or_else(|| "unknown".to_string(), |bytes| (bytes >> 20).to_string());
856
857 format!(
858 "frust-perf atlas tier={} rule={} budget={}x{}x{} downlevel={:?} \
859 transient_saves_memory={} memory_mib={memory} adapter={}",
860 choice.tier.label(),
861 choice.rule.label(),
862 budget.atlas_size.0,
863 budget.atlas_size.1,
864 budget.max_atlases,
865 caps.downlevel_profile,
866 caps.transient_saves_memory,
867 caps.adapter_name,
868 )
869}
870
871/// Where one image's texels live in the atlas array.
872///
873/// Hashable because the pending-clear list carries a set index beside it: the
874/// same rectangle can reach [`ImageResidency::evictions`] twice before a single
875/// clear has been serviced, and the dedup that keeps it reported once must not
876/// be a linear scan of a list that grows with the churn rate.
877#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
878pub struct AtlasRegion {
879 /// Array layer holding the region.
880 pub layer: u32,
881 /// Pixel offset of the region's top-left corner within that layer.
882 pub offset: [u32; 2],
883 /// Region extent in pixels.
884 pub size: [u32; 2],
885}
886
887impl AtlasRegion {
888 /// The bytes this region's texels occupy at [`ATLAS_FORMAT_BYTES`].
889 #[must_use]
890 pub fn byte_len(self) -> usize {
891 (self.size[0] as usize)
892 .saturating_mul(self.size[1] as usize)
893 .saturating_mul(ATLAS_FORMAT_BYTES as usize)
894 }
895
896 /// The row stride a texel copy of this region uses.
897 #[must_use]
898 pub fn bytes_per_row(self) -> u32 {
899 self.size[0].saturating_mul(ATLAS_FORMAT_BYTES as u32)
900 }
901
902 /// Whether this region has any texels at all.
903 #[must_use]
904 pub fn is_empty(self) -> bool {
905 self.size[0] == 0 || self.size[1] == 0
906 }
907}
908
909/// A resident image: the handle a paint names it by, and where its texels are.
910#[derive(Debug, Clone, Copy, PartialEq, Eq)]
911pub struct ResidentImage {
912 /// The handle an [`ImageSource::OpaqueId`] carries.
913 pub id: ImageId,
914 /// The atlas rectangle holding the image's own pixels, padding excluded.
915 ///
916 /// Equal to [`natural`](Self::natural) unless the source was minified to
917 /// fit the atlas, in which case it is the smaller rectangle actually
918 /// uploaded.
919 pub region: AtlasRegion,
920 /// The source's own declared extent, before any minification.
921 ///
922 /// Carried because it is the extent a paint transform was composed against
923 /// — `compile::paint::encode_image_command` derives its natural-to-dest
924 /// mapping from the `ImageData`'s declared size, which it reads before this
925 /// residency is consulted. A consumer that ends up sampling a *minified*
926 /// rectangle has to fold [`minify_scale`](Self::minify_scale) into that
927 /// transform, and this is the half of the ratio the atlas rectangle does
928 /// not carry.
929 pub natural: [u32; 2],
930 /// Transparent padding pixels around `region` in the atlas.
931 pub padding: u32,
932 /// Whether the image's premultiplied pixels include any non-opaque texel.
933 pub may_have_transparency: bool,
934}
935
936impl ResidentImage {
937 /// This image as the paint-side source a `vello_common` encoding carries.
938 #[must_use]
939 pub fn source(&self) -> ImageSource {
940 ImageSource::opaque_id_with_transparency_hint(self.id, self.may_have_transparency)
941 }
942
943 /// The per-axis factor mapping a natural-space texel coordinate onto the
944 /// resident rectangle, or `None` when the source was stored at full size.
945 ///
946 /// `None` rather than `Some((1.0, 1.0))` so a caller can skip the rescale
947 /// entirely on the overwhelmingly common path, and so "was this image
948 /// minified?" is one question rather than a float comparison.
949 #[must_use]
950 pub fn minify_scale(&self) -> Option<(f32, f32)> {
951 if self.region.size == self.natural {
952 return None;
953 }
954 let natural_w = self.natural[0].max(1) as f32;
955 let natural_h = self.natural[1].max(1) as f32;
956 Some((
957 self.region.size[0] as f32 / natural_w,
958 self.region.size[1] as f32 / natural_h,
959 ))
960 }
961}
962
963/// One region's texels, waiting to be written into the atlas array.
964///
965/// The pixels are the premultiplied `Rgba8Unorm` the atlas stores, produced
966/// once at allocation — an image made resident on frame 1 and drawn on frames
967/// 1..1000 is converted exactly once.
968#[derive(Debug, Clone)]
969pub struct ImageUpload {
970 /// The handle the paint naming these pixels carries.
971 ///
972 /// Carried so a consumer rebuilding its own view of residency can key this
973 /// upload directly rather than pairing the frame's uploads positionally
974 /// against its encoded paints. A plan re-emitted until it is acknowledged
975 /// breaks that pairing (an upload can outlive the frame whose draw order
976 /// produced it), and an upload that names itself needs no ordering to be
977 /// read correctly.
978 pub id: ImageId,
979 /// Where the texels go.
980 pub region: AtlasRegion,
981 /// The source's own declared extent, before any minification — see
982 /// [`ResidentImage::natural`], whose value this is.
983 ///
984 /// Carried on the upload because a consumer rebuilding its own view of
985 /// residency from a compiled frame has the upload and nothing else, and
986 /// `region` alone cannot say whether it holds a minified copy.
987 pub natural: [u32; 2],
988 /// The premultiplied pixels, row-major and exactly `region`'s extent.
989 pub pixels: Arc<Pixmap>,
990 /// Whether those pixels include any non-opaque texel — the same hint
991 /// [`ResidentImage::may_have_transparency`] carries, so a consumer can
992 /// rebuild the whole residency record from this upload alone.
993 pub may_have_transparency: bool,
994}
995
996/// One cache entry: the handle, its rectangle, and when it was last drawn.
997#[derive(Debug, Clone, Copy)]
998struct Entry {
999 id: ImageId,
1000 region: AtlasRegion,
1001 /// The source extent this entry was allocated for, which is what a later
1002 /// frame's `ImageData` is matched against — `region` may be the minified
1003 /// rectangle and so says nothing about the source.
1004 natural: [u32; 2],
1005 may_have_transparency: bool,
1006 last_seen: u64,
1007}
1008
1009/// The images resident in the atlas array, keyed by blob identity.
1010///
1011/// One per compiler (and so one per surface). Its frame clock is advanced by
1012/// [`begin_frame`](Self::begin_frame), which is also where the age-based reap
1013/// runs — before any of the frame's own resolutions, so a rectangle freed this
1014/// frame is available to this frame's allocations and its clear is ordered
1015/// ahead of their uploads.
1016///
1017/// It also owns the atlas allocator the *glyph* policy packs into — see this
1018/// module's doc and [`allocator_mut`](Self::allocator_mut).
1019#[derive(Debug)]
1020pub struct ImageResidency {
1021 cache: ImageCache,
1022 budget: AtlasBudget,
1023 entries: HashMap<u64, Entry>,
1024 frame: u64,
1025 disabled: bool,
1026 uploads: Vec<ImageUpload>,
1027 evictions: Vec<AtlasRegion>,
1028 /// The pending clears' own set index: exactly the rectangles `evictions`
1029 /// holds, kept so [`release`](Self::release)'s "report a rectangle once"
1030 /// dedup is a hash lookup rather than a scan of a list whose length grows
1031 /// with the churn rate.
1032 eviction_set: HashSet<AtlasRegion>,
1033 skipped: u64,
1034 /// Rectangles taken back from a resident image because the packer had no
1035 /// room for a new one, over this residency's lifetime.
1036 pressure_evictions: u64,
1037 /// The same count for the current frame alone, zeroed by
1038 /// [`begin_frame`](Self::begin_frame) — what a per-frame report reads, since
1039 /// the lifetime total says nothing about whether the atlas is churning
1040 /// *now*.
1041 frame_pressure_evictions: u64,
1042 minified: u64,
1043 /// Blob keys whose minification has already been reported, so a source that
1044 /// is redrawn — or reaped and made resident again — logs once rather than
1045 /// once per residency.
1046 reported_minify: HashSet<u64>,
1047 /// Scratch for the reap's key list, kept so a frame that evicts allocates
1048 /// nothing.
1049 reaped: Vec<u64>,
1050 /// Scratch for the pressure planner's view of the layers — one rectangle
1051 /// per resident entry, grouped by layer — kept for the same reason: the
1052 /// frame that has to evict is the one least able to afford an allocation.
1053 pressure_rects: Vec<PressureRect>,
1054 /// Scratch for the candidate order over [`Self::pressure_rects`], least
1055 /// recently seen first. Built and sorted once per refused allocation, never
1056 /// once per eviction.
1057 pressure_order: Vec<usize>,
1058 /// Scratch for each layer's modelled free area, indexed by layer.
1059 pressure_free_area: Vec<u64>,
1060 /// Scratch for the distinct layers [`Self::pressure_order`] names, in the
1061 /// order their stalest candidate appears — the per-layer walk's own visit
1062 /// order, kept for the same no-allocation-while-evicting reason as the
1063 /// other pressure scratch.
1064 pressure_layers: Vec<u32>,
1065 /// Scratch for the keys an accepted plan releases.
1066 pressure_plan: Vec<u64>,
1067}
1068
1069impl ImageResidency {
1070 /// A residency over `budget`'s atlas geometry.
1071 ///
1072 /// `FRUST_ENGINE_NO_ATLAS` is consulted here, once, rather than per
1073 /// resolution: the switch is process-global and cached, so re-reading it on
1074 /// a hot path would buy nothing but a lock. A residency constructed with
1075 /// the switch set is exactly [`disabled`](Self::disabled).
1076 #[must_use]
1077 pub fn new(budget: AtlasBudget) -> Self {
1078 Self::with_enabled(budget, !config::atlas_disabled())
1079 }
1080
1081 /// A residency that refuses every image, allocating no atlas at all.
1082 ///
1083 /// What `FRUST_ENGINE_NO_ATLAS` selects, and what a caller wanting the same
1084 /// effect programmatically constructs. Every [`resolve`](Self::resolve)
1085 /// answers [`ImageSkip::AtlasDisabled`], so image draws are dropped with a
1086 /// logged reason rather than painted from an atlas that does not exist.
1087 #[must_use]
1088 pub fn disabled(budget: AtlasBudget) -> Self {
1089 Self::with_enabled(budget, false)
1090 }
1091
1092 fn with_enabled(budget: AtlasBudget, enabled: bool) -> Self {
1093 Self {
1094 cache: ImageCache::new_with_config(budget.config()),
1095 budget,
1096 entries: HashMap::new(),
1097 frame: 0,
1098 disabled: !enabled,
1099 uploads: Vec::new(),
1100 evictions: Vec::new(),
1101 eviction_set: HashSet::new(),
1102 skipped: 0,
1103 pressure_evictions: 0,
1104 frame_pressure_evictions: 0,
1105 minified: 0,
1106 reported_minify: HashSet::new(),
1107 reaped: Vec::new(),
1108 pressure_rects: Vec::new(),
1109 pressure_order: Vec::new(),
1110 pressure_free_area: Vec::new(),
1111 pressure_layers: Vec::new(),
1112 pressure_plan: Vec::new(),
1113 }
1114 }
1115
1116 /// A residency sized for `caps`' adapter.
1117 #[must_use]
1118 pub fn for_caps(caps: &TierCaps) -> Self {
1119 Self::new(AtlasBudget::for_caps(caps))
1120 }
1121
1122 /// The atlas geometry this residency allocates within.
1123 #[must_use]
1124 pub fn budget(&self) -> AtlasBudget {
1125 self.budget
1126 }
1127
1128 /// The single atlas allocator, for reading.
1129 ///
1130 /// Enough to resolve a handle either side allocated
1131 /// ([`ImageCache::get`]) or to count the layers created so far, without
1132 /// the mutable borrow allocation needs.
1133 #[must_use]
1134 pub fn allocator(&self) -> &ImageCache {
1135 &self.cache
1136 }
1137
1138 /// The single atlas allocator, for allocating through.
1139 ///
1140 /// This is the seam the glyph policy takes its slots from — see this
1141 /// module's doc for why one cache rather than two is a correctness
1142 /// requirement. `ImageId`s and rectangles handed out through here and
1143 /// through [`resolve`](Self::resolve) come from the same packer and the
1144 /// same id space, so a glyph and an image can never be given the same
1145 /// handle or overlapping texels.
1146 ///
1147 /// **The contract a borrower keeps:** allocate what you like, and
1148 /// deallocate *only handles you allocated yourself*. This residency's entry
1149 /// map records the rectangles it allocated and nothing re-checks them;
1150 /// freeing one of them from outside would leave an entry claiming a
1151 /// rectangle the packer has since handed to someone else. Nothing here
1152 /// enforces that, because the type the two sides must share is
1153 /// `vello_common`'s own and cannot carry an ownership tag — the glyph
1154 /// policy deallocates strictly inside `glifo`'s eviction, over the handles
1155 /// in its own entry map.
1156 #[must_use]
1157 pub fn allocator_mut(&mut self) -> &mut ImageCache {
1158 &mut self.cache
1159 }
1160
1161 /// Whether this residency refuses every image.
1162 #[must_use]
1163 pub fn is_disabled(&self) -> bool {
1164 self.disabled
1165 }
1166
1167 /// How many images are currently resident.
1168 #[must_use]
1169 pub fn entry_count(&self) -> usize {
1170 self.entries.len()
1171 }
1172
1173 /// The frame clock entries age against.
1174 #[must_use]
1175 pub fn frame(&self) -> u64 {
1176 self.frame
1177 }
1178
1179 /// How many atlas layers have been created so far.
1180 ///
1181 /// The array texture must be at least this deep before the frame's uploads
1182 /// are written.
1183 ///
1184 /// Read off the shared allocator rather than accumulated from this
1185 /// residency's own allocations, so a layer the *glyph* policy created
1186 /// counts too: the two classes pack into one array, and an array sized to
1187 /// the image half alone would refuse to hold the glyph half's pages. The
1188 /// count only ever grows — `MultiAtlasManager` appends layers and never
1189 /// drops one — so a depth reported on an earlier frame stays valid.
1190 #[must_use]
1191 pub fn layers(&self) -> u32 {
1192 u32::try_from(self.cache.atlas_count()).unwrap_or(u32::MAX)
1193 }
1194
1195 /// How many resolutions have been refused over this residency's lifetime.
1196 ///
1197 /// Counts only images that could not be uploaded at all — a format or
1198 /// extent the conversion refuses, a disabled atlas, or an atlas with no
1199 /// room left *and nothing evictable to make room with*. A source merely
1200 /// larger than one atlas layer is minified to fit, and one that displaced
1201 /// another image is counted by
1202 /// [`pressure_evictions`](Self::pressure_evictions) instead; neither
1203 /// reaches this counter.
1204 #[must_use]
1205 pub fn skipped(&self) -> u64 {
1206 self.skipped
1207 }
1208
1209 /// How many resident images have been evicted to make room for another
1210 /// over this residency's lifetime.
1211 ///
1212 /// The price of never refusing a draw the atlas could hold *something*
1213 /// for: each one is an image that will re-upload the next frame it is
1214 /// drawn on. A steady screen leaves this at zero; a scroll through more
1215 /// distinct images than the atlas has rectangles for grows it once per
1216 /// displaced image, which is the signal that the budget — not the
1217 /// mechanism — is what a frame is paying for.
1218 #[must_use]
1219 pub fn pressure_evictions(&self) -> u64 {
1220 self.pressure_evictions
1221 }
1222
1223 /// How many resident images the *current* frame has evicted to make room.
1224 ///
1225 /// Zeroed by [`begin_frame`](Self::begin_frame), so a per-frame report reads
1226 /// this rather than differencing
1227 /// [`pressure_evictions`](Self::pressure_evictions) itself.
1228 #[must_use]
1229 pub fn frame_pressure_evictions(&self) -> u64 {
1230 self.frame_pressure_evictions
1231 }
1232
1233 /// How many sources have been downsampled to fit the atlas over this
1234 /// residency's lifetime.
1235 ///
1236 /// Observational, and the counter that makes "an image too large for the
1237 /// atlas is minified, not dropped" measurable rather than asserted.
1238 #[must_use]
1239 pub fn minified(&self) -> u64 {
1240 self.minified
1241 }
1242
1243 /// The regions whose texels must be cleared before the pending uploads.
1244 ///
1245 /// Pending rather than per-frame: an entry freed on a frame nobody
1246 /// serviced is still waiting to be cleared on the next one. Both kinds of
1247 /// eviction land here — the age-based reap at the head of a frame and a
1248 /// rectangle taken back under pressure mid-frame — so the consumer's
1249 /// clear-then-upload contract covers the second without knowing it exists.
1250 #[must_use]
1251 pub fn evictions(&self) -> &[AtlasRegion] {
1252 &self.evictions
1253 }
1254
1255 /// The regions whose texels must be written after the pending evictions.
1256 ///
1257 /// Pending rather than per-frame: an image made resident on a frame that was
1258 /// refused is still waiting to be written on the next one.
1259 #[must_use]
1260 pub fn uploads(&self) -> &[ImageUpload] {
1261 &self.uploads
1262 }
1263
1264 /// A copy of the pending clear-then-write plan, evictions first.
1265 ///
1266 /// A *copy*, not a drain: the plan stays pending until
1267 /// [`acknowledge_plan`](Self::acknowledge_plan) says it was serviced against
1268 /// a live atlas, so a frame refused after compiling re-emits the same plan
1269 /// on the next frame rather than losing it (see the module doc). The upload
1270 /// pixels are behind an `Arc`, so a re-emitted plan copies handles rather
1271 /// than texels, and the steady-state plan is empty on both counts.
1272 ///
1273 /// The clears are [`evictions`](Self::evictions) less every rectangle a
1274 /// pending upload covers exactly (see [`uncovered_clears`]): that upload
1275 /// writes every texel the clear would have zeroed, after it, in the same
1276 /// plan — so the clear is pure cost, a rectangle's worth of zeroes per
1277 /// churned image. Dropped from the plan rather than from `evictions`, so
1278 /// the pending set and its acknowledgement are unchanged; a consumer
1279 /// forgetting the evicted tenant by rectangle (the renderer's registry) is
1280 /// left holding the old handle on that rectangle beside the new one, which
1281 /// nothing reads — a handle is only looked up for a draw this frame
1282 /// resolved, and a resolved handle is re-registered by its own upload — and
1283 /// which the rectangle's next reported eviction removes with the new one.
1284 #[must_use]
1285 pub fn plan(&self) -> (Vec<AtlasRegion>, Vec<ImageUpload>) {
1286 (
1287 uncovered_clears(&self.evictions, &self.uploads),
1288 self.uploads.clone(),
1289 )
1290 }
1291
1292 /// Whether any part of the pending plan is still unserviced.
1293 #[must_use]
1294 pub fn has_pending_plan(&self) -> bool {
1295 !self.evictions.is_empty() || !self.uploads.is_empty()
1296 }
1297
1298 /// Record that the pending plan reached the atlas array, clearing it.
1299 ///
1300 /// Call this only once every region it names has actually been written or
1301 /// cleared. Calling it on a frame that was refused, or whose writes the
1302 /// array declined, is exactly the defect the pending plan exists to prevent:
1303 /// the entry map would go on claiming an image is resident whose texels are
1304 /// whatever the atlas texture happened to hold.
1305 pub fn acknowledge_plan(&mut self) {
1306 self.evictions.clear();
1307 self.eviction_set.clear();
1308 self.uploads.clear();
1309 }
1310
1311 /// Advance the frame clock and reclaim everything unseen for
1312 /// [`MAX_UNSEEN_FRAMES`].
1313 ///
1314 /// Call once at the head of a frame, before any [`resolve`](Self::resolve).
1315 /// An unacknowledged plan deliberately survives this: the frame that was to
1316 /// service it may have been refused, and dropping it here is what would turn
1317 /// a refused frame into a permanently unwritten atlas region.
1318 pub fn begin_frame(&mut self) {
1319 self.frame = self.frame.saturating_add(1);
1320 self.frame_pressure_evictions = 0;
1321 self.reap();
1322 }
1323
1324 /// The atlas rectangle for `data`'s pixels, allocating and scheduling an
1325 /// upload on the first frame that asks for it.
1326 ///
1327 /// # Errors
1328 ///
1329 /// Returns the [`ImageSkip`] naming why the image was refused. Every
1330 /// refusal happens before any `vello_common` call that could assert on the
1331 /// same condition, so an image the renderer cannot hold is a skipped draw
1332 /// rather than a panicked frame (E17).
1333 pub fn resolve(&mut self, data: &ImageData) -> Result<ResidentImage, ImageSkip> {
1334 let result = self.resolve_inner(data);
1335 if result.is_err() {
1336 self.skipped = self.skipped.saturating_add(1);
1337 }
1338 result
1339 }
1340
1341 fn resolve_inner(&mut self, data: &ImageData) -> Result<ResidentImage, ImageSkip> {
1342 if self.disabled {
1343 return Err(ImageSkip::AtlasDisabled);
1344 }
1345
1346 check_supported(data)?;
1347
1348 let key = data.data.id();
1349 if let Some(entry) = self.entries.get_mut(&key)
1350 && entry.natural == [data.width, data.height]
1351 {
1352 entry.last_seen = self.frame;
1353 return Ok(ResidentImage {
1354 id: entry.id,
1355 region: entry.region,
1356 natural: entry.natural,
1357 padding: u32::from(ATLAS_PADDING),
1358 may_have_transparency: entry.may_have_transparency,
1359 });
1360 }
1361
1362 // A blob id recorded at a different extent is a caller that rebuilt the
1363 // metadata around the same buffer. Its old rectangle describes the
1364 // wrong pixels, so it is released before the new one is taken rather
1365 // than left to age out holding both — unless *this* frame has already
1366 // resolved it at the other extent. The plan clears before it uploads,
1367 // so freeing that rectangle would schedule a clear over texels a draw
1368 // this same frame already recorded, which is exactly what the pressure
1369 // path refuses to do. The second extent is skipped instead, on the same
1370 // terms as a frame whose own working set outgrows the atlas — but this
1371 // is a same-frame ordering conflict on one blob key, not an atlas
1372 // capacity shortage, so it is reported as its own reason rather than
1373 // folded into [`ImageSkip::NoAtlasSpace`].
1374 if self
1375 .entries
1376 .get(&key)
1377 .is_some_and(|entry| entry.last_seen == self.frame)
1378 {
1379 let (width, height) = fit_extent([data.width, data.height], self.budget.atlas_size);
1380 return Err(ImageSkip::SameFrameExtentConflict { width, height });
1381 }
1382 self.release(key);
1383
1384 // Checked above, so this cannot assert; the conversion is also the one
1385 // place the straight-alpha premultiply happens, and it happens once per
1386 // image rather than once per frame.
1387 let ImageSource::Pixmap(pixels) = ImageSource::from_peniko_image_data(data) else {
1388 // `from_peniko_image_data` only ever yields a pixmap; an id-backed
1389 // source would mean a `vello_common` change, which is a skip rather
1390 // than an unreachable.
1391 return Err(ImageSkip::UnsupportedFormat(data.format));
1392 };
1393
1394 let natural = [data.width, data.height];
1395 // Minified before the allocation rather than after it: the packer is
1396 // asked for the rectangle that is actually going to be uploaded, so an
1397 // over-ceiling source never takes (and then has to give back) space at
1398 // its declared extent.
1399 let (width, height) = fit_extent(natural, self.budget.atlas_size);
1400 let pixels = if [width, height] == natural {
1401 pixels
1402 } else {
1403 self.note_minify(key, natural, [width, height]);
1404 Arc::new(minify(&pixels, width, height))
1405 };
1406
1407 let no_space = ImageSkip::NoAtlasSpace {
1408 width,
1409 height,
1410 atlas_width: self.budget.atlas_size.0,
1411 atlas_height: self.budget.atlas_size.1,
1412 };
1413
1414 let id = match self.cache.allocate(width, height, ATLAS_PADDING) {
1415 Ok(id) => id,
1416 // The atlas is full. Take a rectangle back from an image no longer
1417 // on screen rather than dropping this draw — see
1418 // [`allocate_under_pressure`](Self::allocate_under_pressure), and
1419 // the module doc for why a hole is worse than a re-upload.
1420 Err(_) => self
1421 .allocate_under_pressure(width, height)
1422 .ok_or(no_space)?,
1423 };
1424
1425 let Some(resource) = self.cache.get(id) else {
1426 // A successful allocation always populates its slot; refusing here
1427 // keeps the frame path free of the `expect` the reference renderer
1428 // takes at this same point.
1429 return Err(no_space);
1430 };
1431
1432 let region = AtlasRegion {
1433 layer: resource.atlas_id.as_u32(),
1434 offset: resource.offsets(),
1435 size: resource.size(),
1436 };
1437 let may_have_transparency = pixels.may_have_transparency();
1438
1439 self.entries.insert(
1440 key,
1441 Entry {
1442 id,
1443 region,
1444 natural,
1445 may_have_transparency,
1446 last_seen: self.frame,
1447 },
1448 );
1449 self.uploads.push(ImageUpload {
1450 id,
1451 region,
1452 natural,
1453 pixels,
1454 may_have_transparency,
1455 });
1456
1457 Ok(ResidentImage {
1458 id,
1459 region,
1460 natural,
1461 padding: u32::from(ATLAS_PADDING),
1462 may_have_transparency,
1463 })
1464 }
1465
1466 /// Allocate `width` x `height` by taking rectangles back from resident
1467 /// images, least recently seen first, once the packer has refused.
1468 ///
1469 /// Answers the handle that finally fit, or `None` when no bounded set of
1470 /// candidates would have made room — which is what
1471 /// [`ImageSkip::NoAtlasSpace`] then reports, with the residency exactly as
1472 /// it was found.
1473 ///
1474 /// **Nothing is released until a plan that fits has been found.** An
1475 /// eviction that does not produce the allocation buys nothing and costs a
1476 /// clear plus a re-upload per rectangle, every frame the condition recurs
1477 /// (see the module doc). So the search runs over a *model* of the layers
1478 /// and commits only when the model says the request would then be placed:
1479 ///
1480 /// 1. **Candidates.** Only an entry last seen on an earlier frame. The
1481 /// frame path services [`evictions`](Self::evictions) *before*
1482 /// [`uploads`](Self::uploads), so freeing a rectangle this frame has
1483 /// already resolved would schedule a clear over texels the same frame is
1484 /// about to sample — painting the very hole this eviction exists to
1485 /// prevent. A frame whose own working set outgrows the atlas therefore
1486 /// still skips, because everything in the atlas belongs to it.
1487 /// 2. **Order.** `(last_seen, slot, key)`, least recently seen first, built
1488 /// and sorted *once* per refused allocation rather than once per
1489 /// eviction. The slot index breaks a tie between two entries last drawn
1490 /// on the same frame, so which image is displaced is a property of the
1491 /// residency rather than of the entry map's iteration order. Because the
1492 /// order is by age, an entry drawn on the *immediately preceding* frame
1493 /// is reached only after every staler one has already been planned —
1494 /// which is what keeps a large image redrawn every frame from thrashing
1495 /// a working set that is merely idle. The candidates are then walked one
1496 /// *layer* at a time, layers visited in that same staleness order (the
1497 /// layer whose stalest candidate sorts earliest goes first).
1498 /// 3. **Fit.** After each candidate joins the plan, the layer it sat on is
1499 /// re-tested: the request must fit inside the freed area that layer now
1500 /// models, and must clear every rectangle still occupying it
1501 /// ([`layer_admits`]). The first prefix that passes ends the search.
1502 /// 4. **Budget, per layer.** Each layer's own walk stops after
1503 /// [`MAX_PRESSURE_EVICTION_CANDIDATES`] of *that layer's* candidates, or
1504 /// once *that layer's* plan has freed [`PRESSURE_EVICTION_AREA_BUDGET`]
1505 /// times the requested area — a fresh allowance per layer rather than one
1506 /// pool the whole residency shares, so candidates on a layer too
1507 /// fragmented to ever admit the request cannot spend the allowance a
1508 /// later layer needed to prove it could. A layer that ends on its own
1509 /// budget contributes nothing and the walk moves to the next layer.
1510 /// 5. **Commit.** Only the planned rectangles *on the layer the fit was
1511 /// found in* are released — a candidate the walk passed over on another
1512 /// layer contributed nothing and is left resident.
1513 ///
1514 /// The model is optimistic about occupants it cannot see: the glyph policy
1515 /// packs through the same allocator (see the module doc) and its pages are
1516 /// not in this entry map, so [`layer_admits`] tests a subset of the real
1517 /// obstacles. That direction is the safe one — a placement clearing every
1518 /// real occupant clears the subset too, so a request eviction could satisfy
1519 /// on a *given layer* is never refused for that reason. The converse is
1520 /// possible: the packer may refuse a plan the model approved, which costs
1521 /// the plan's evictions and no allocation. It is bounded by the same
1522 /// per-layer budget as the plan itself, and it is the one residual the
1523 /// planner cannot rule out without knowing where every glyph page sits.
1524 fn allocate_under_pressure(&mut self, width: u32, height: u32) -> Option<ImageId> {
1525 // The overwhelmingly common refusal — a frame that filled the atlas
1526 // with its own draws — costs one pass over the entry map and no model
1527 // at all.
1528 if !self
1529 .entries
1530 .values()
1531 .any(|entry| entry.last_seen < self.frame)
1532 {
1533 return None;
1534 }
1535
1536 // Taken from the residency's own scratch, so the frame least able to
1537 // afford an allocation does not make one to evict with.
1538 let mut rects = std::mem::take(&mut self.pressure_rects);
1539 let mut order = std::mem::take(&mut self.pressure_order);
1540 let mut free_area = std::mem::take(&mut self.pressure_free_area);
1541 let mut layers = std::mem::take(&mut self.pressure_layers);
1542 let mut plan = std::mem::take(&mut self.pressure_plan);
1543
1544 self.model_layers(&mut rects, &mut order, &mut free_area);
1545 let fit = plan_pressure_eviction(
1546 width,
1547 height,
1548 self.budget.atlas_size,
1549 &mut rects,
1550 &order,
1551 &mut free_area,
1552 &mut layers,
1553 );
1554
1555 plan.clear();
1556 if let Some(layer) = fit {
1557 plan.extend(
1558 rects
1559 .iter()
1560 .filter(|rect| rect.freed && rect.layer == layer)
1561 .map(|rect| rect.key),
1562 );
1563 }
1564
1565 let allocated = if plan.is_empty() {
1566 None
1567 } else {
1568 for key in &plan {
1569 self.release(*key);
1570 self.pressure_evictions = self.pressure_evictions.saturating_add(1);
1571 self.frame_pressure_evictions = self.frame_pressure_evictions.saturating_add(1);
1572 }
1573 self.cache.allocate(width, height, ATLAS_PADDING).ok()
1574 };
1575
1576 rects.clear();
1577 order.clear();
1578 free_area.clear();
1579 layers.clear();
1580 plan.clear();
1581 self.pressure_rects = rects;
1582 self.pressure_order = order;
1583 self.pressure_free_area = free_area;
1584 self.pressure_layers = layers;
1585 self.pressure_plan = plan;
1586 allocated
1587 }
1588
1589 /// Fill the planner's scratch with this residency's own view of the atlas.
1590 ///
1591 /// `rects` takes one entry per resident image, grouped by layer so a fit
1592 /// test reads one layer's slice; `order` takes the candidate indices, least
1593 /// recently seen first across the *whole* residency — [`plan_pressure_eviction`]
1594 /// is what regroups it per layer for its own bounded walk; `free_area` takes
1595 /// each layer's free area *as the packer reports it*, which is the one
1596 /// figure that accounts for the glyph pages this map cannot see.
1597 fn model_layers(
1598 &self,
1599 rects: &mut Vec<PressureRect>,
1600 order: &mut Vec<usize>,
1601 free_area: &mut Vec<u64>,
1602 ) {
1603 rects.clear();
1604 rects.extend(self.entries.iter().map(|(key, entry)| {
1605 let region = padded(entry.region, ATLAS_PADDING);
1606 PressureRect {
1607 key: *key,
1608 layer: region.layer,
1609 x0: region.offset[0],
1610 y0: region.offset[1],
1611 x1: region.offset[0].saturating_add(region.size[0]),
1612 y1: region.offset[1].saturating_add(region.size[1]),
1613 area: u64::from(region.size[0]).saturating_mul(u64::from(region.size[1])),
1614 last_seen: entry.last_seen,
1615 slot: entry.id.as_u32(),
1616 freed: false,
1617 }
1618 }));
1619 rects.sort_unstable_by_key(|rect| rect.layer);
1620
1621 order.clear();
1622 order.extend(
1623 rects
1624 .iter()
1625 .enumerate()
1626 .filter_map(|(index, rect)| (rect.last_seen < self.frame).then_some(index)),
1627 );
1628 order.sort_unstable_by_key(|index| {
1629 let rect = &rects[*index];
1630 (rect.last_seen, rect.slot, rect.key)
1631 });
1632
1633 free_area.clear();
1634 free_area.resize(self.cache.atlas_count(), 0);
1635 for (id, stats) in self.cache.atlas_manager().atlas_stats() {
1636 if let Some(free) = free_area.get_mut(id.as_u32() as usize) {
1637 *free = u64::from(stats.total_area.saturating_sub(stats.allocated_area));
1638 }
1639 }
1640 }
1641
1642 /// Count and report one source downsampled to fit the atlas.
1643 ///
1644 /// Once per blob id, at warning level: a minified image is a silent quality
1645 /// decision the application may not have intended (a full-resolution photo
1646 /// where a thumbnail was wanted), so it says so — but a scene redrawing it
1647 /// every frame must not repeat the message.
1648 fn note_minify(&mut self, key: u64, natural: [u32; 2], fitted: [u32; 2]) {
1649 self.minified = self.minified.saturating_add(1);
1650 if !self.reported_minify.insert(key) {
1651 return;
1652 }
1653 log::warn!(
1654 "image {}x{} is larger than the {}x{} atlas budget; uploading a {}x{} box-filtered \
1655 copy instead (reported once per image)",
1656 natural[0],
1657 natural[1],
1658 self.budget.atlas_size.0,
1659 self.budget.atlas_size.1,
1660 fitted[0],
1661 fitted[1],
1662 );
1663 }
1664
1665 /// Deallocate every entry unseen for [`MAX_UNSEEN_FRAMES`], recording each
1666 /// one's rectangle for clearing.
1667 ///
1668 /// The age half of the two bounds; the population half is
1669 /// [`allocate_under_pressure`](Self::allocate_under_pressure), which runs
1670 /// only when an allocation has already failed. Keeping them separate is
1671 /// what makes an idle screen give its texels back promptly whether or not
1672 /// anything is asking for them.
1673 fn reap(&mut self) {
1674 let mut reaped = std::mem::take(&mut self.reaped);
1675 reaped.clear();
1676 reaped.extend(self.entries.iter().filter_map(|(key, entry)| {
1677 (self.frame.saturating_sub(entry.last_seen) > MAX_UNSEEN_FRAMES).then_some(*key)
1678 }));
1679
1680 for key in reaped.drain(..) {
1681 self.release(key);
1682 }
1683 self.reaped = reaped;
1684 }
1685
1686 /// Drop `key`'s entry, freeing its atlas rectangle and recording it for
1687 /// clearing.
1688 ///
1689 /// The padded rectangle is what is cleared: an allocation reserves its
1690 /// padding too, so leaving the border behind would leave stale texels a
1691 /// later allocation could sample through.
1692 ///
1693 /// An upload still pending for that rectangle goes with the entry. It
1694 /// describes pixels no live entry claims any more, and writing them after
1695 /// the clear would put an evicted image back into a rectangle the packer has
1696 /// already handed to something else.
1697 fn release(&mut self, key: u64) {
1698 let Some(entry) = self.entries.remove(&key) else {
1699 return;
1700 };
1701 // The handle came from this cache's own `allocate`, so the atlas it
1702 // names exists and the deallocation cannot fail.
1703 self.cache.deallocate(entry.id);
1704 self.uploads.retain(|upload| upload.id != entry.id);
1705
1706 // The plan survives an unacknowledged frame, so the same rectangle can
1707 // reach here twice before a single clear has been issued for it; one
1708 // clear is what the second one would write anyway.
1709 let region = padded(entry.region, ATLAS_PADDING);
1710 if self.eviction_set.insert(region) {
1711 self.evictions.push(region);
1712 }
1713 }
1714}
1715
1716/// The most rectangles one refused allocation may plan to take back from a
1717/// single atlas layer.
1718///
1719/// A bound on the *search*, not only on the damage: a layer whose walk
1720/// reaches it without finding a fit contributes nothing to the plan, so the
1721/// cost of one hopeless layer stays bounded rather than eating into the
1722/// allowance a later, satisfiable layer needs to prove itself. Sixty-four is
1723/// far past what any satisfiable request has needed — a fit is normally found
1724/// in one, since the rectangle handed back is usually the size of the one
1725/// being asked for — while still being a small fraction of what a
1726/// mobile-tier layer can hold. Applied once per layer a refused allocation
1727/// considers, never once across every layer together — see
1728/// [`plan_pressure_eviction`].
1729const MAX_PRESSURE_EVICTION_CANDIDATES: usize = 64;
1730
1731/// How much area one refused allocation may plan to free from a single atlas
1732/// layer, as a multiple of the area it asked for.
1733///
1734/// The other half of the bound, and the one that scales with the request: a
1735/// layer that has already given back four times the texels the request wants
1736/// and still has nowhere to put them is fighting fragmentation, not scarcity,
1737/// and freeing more of that same layer will not fix it. Applied per layer,
1738/// the same way [`MAX_PRESSURE_EVICTION_CANDIDATES`] is.
1739const PRESSURE_EVICTION_AREA_BUDGET: u64 = 4;
1740
1741/// One resident rectangle as the pressure planner sees it.
1742///
1743/// A flat copy rather than a borrow of the entry map, because the plan is drawn
1744/// up while the map is still intact and the release that follows needs it
1745/// mutably. `x1`/`y1` are exclusive, and every field is the *padded* allocation
1746/// — the rectangle the packer actually holds — rather than the image's own.
1747#[derive(Debug, Clone, Copy)]
1748struct PressureRect {
1749 /// Blob key of the entry holding this rectangle.
1750 key: u64,
1751 /// Array layer the rectangle sits in.
1752 layer: u32,
1753 /// Left edge, inclusive.
1754 x0: u32,
1755 /// Top edge, inclusive.
1756 y0: u32,
1757 /// Right edge, exclusive.
1758 x1: u32,
1759 /// Bottom edge, exclusive.
1760 y1: u32,
1761 /// Texels the rectangle covers.
1762 area: u64,
1763 /// The entry's frame stamp, which is what makes it a candidate or not.
1764 last_seen: u64,
1765 /// The entry's slot index, which breaks a tie between two entries last
1766 /// drawn on the same frame.
1767 slot: u32,
1768 /// Whether the plan under construction has given this rectangle back.
1769 freed: bool,
1770}
1771
1772/// The layer a bounded prefix of `order` would make room in, or `None`.
1773///
1774/// Frees nothing: it marks `rects` and grows `free_area` in the model only, so
1775/// a request no prefix satisfies leaves the residency untouched. `order` is
1776/// least-recently-seen-first across the *whole* residency; this regroups it
1777/// one layer at a time — `layers` scratch, cleared and refilled here, in the
1778/// order each layer's stalest candidate appears in `order` — and gives each
1779/// layer its own [`MAX_PRESSURE_EVICTION_CANDIDATES`]/
1780/// [`PRESSURE_EVICTION_AREA_BUDGET`] allowance rather than pooling one across
1781/// every layer. Without that split, a layer with many candidates that could
1782/// never admit the request (too fragmented, or blocked by an occupant no
1783/// eviction touches) spends the whole budget before a later, satisfiable
1784/// layer is ever tried — the request is then refused even though a bounded
1785/// eviction on that later layer would have placed it. See
1786/// [`ImageResidency::allocate_under_pressure`] for the whole shape and for why
1787/// the answer is a layer rather than a bool — only the planned rectangles on
1788/// the fitting layer are worth releasing.
1789fn plan_pressure_eviction(
1790 width: u32,
1791 height: u32,
1792 atlas_size: (u32, u32),
1793 rects: &mut [PressureRect],
1794 order: &[usize],
1795 free_area: &mut [u64],
1796 layers: &mut Vec<u32>,
1797) -> Option<u32> {
1798 let padding = u32::from(ATLAS_PADDING).saturating_mul(2);
1799 let request_w = width.saturating_add(padding);
1800 let request_h = height.saturating_add(padding);
1801 let request_area = u64::from(request_w).saturating_mul(u64::from(request_h));
1802 let area_budget = request_area.saturating_mul(PRESSURE_EVICTION_AREA_BUDGET);
1803
1804 layers.clear();
1805 for &index in order {
1806 let Some(rect) = rects.get(index) else {
1807 continue;
1808 };
1809 if !layers.contains(&rect.layer) {
1810 layers.push(rect.layer);
1811 }
1812 }
1813
1814 for &layer in layers.iter() {
1815 let mut layer_freed_area = 0_u64;
1816 let mut step = 0_usize;
1817
1818 for &index in order {
1819 let on_this_layer = rects.get(index).is_some_and(|rect| rect.layer == layer);
1820 if !on_this_layer {
1821 continue;
1822 }
1823 if step >= MAX_PRESSURE_EVICTION_CANDIDATES || layer_freed_area > area_budget {
1824 break;
1825 }
1826 step += 1;
1827
1828 let area = {
1829 let Some(rect) = rects.get_mut(index) else {
1830 continue;
1831 };
1832 rect.freed = true;
1833 rect.area
1834 };
1835 layer_freed_area = layer_freed_area.saturating_add(area);
1836
1837 let Some(free) = free_area.get_mut(layer as usize) else {
1838 continue;
1839 };
1840 *free = free.saturating_add(area);
1841 // Area first because it is exact — it comes off the packer's own
1842 // accounting, so it counts the glyph pages `layer_admits` cannot see —
1843 // and because it is the cheaper of the two questions.
1844 if *free >= request_area && layer_admits(rects, layer, request_w, request_h, atlas_size)
1845 {
1846 return Some(layer);
1847 }
1848 }
1849 }
1850
1851 None
1852}
1853
1854/// Whether a `width` x `height` rectangle could still be placed in `layer` once
1855/// every rectangle the plan has marked freed is out of the way.
1856///
1857/// A *necessary* condition rather than an exact packing answer. Any placement
1858/// has to avoid each occupant whole, so it must lie entirely to one side of it:
1859/// if some occupant leaves less than the requested extent on all four sides,
1860/// there is nowhere for the rectangle to go and the plan cannot succeed. A plan
1861/// this refuses is therefore certainly hopeless; one it admits may still be
1862/// refused by the packer, which is the direction that costs a bounded number of
1863/// evictions rather than a wrong picture (see
1864/// [`ImageResidency::allocate_under_pressure`]).
1865///
1866/// `rects` is grouped by layer, so this reads one layer's slice rather than the
1867/// whole residency.
1868fn layer_admits(
1869 rects: &[PressureRect],
1870 layer: u32,
1871 width: u32,
1872 height: u32,
1873 atlas_size: (u32, u32),
1874) -> bool {
1875 if width > atlas_size.0 || height > atlas_size.1 {
1876 return false;
1877 }
1878
1879 let start = rects.partition_point(|rect| rect.layer < layer);
1880 let end = rects.partition_point(|rect| rect.layer <= layer);
1881 rects[start..end]
1882 .iter()
1883 .filter(|rect| !rect.freed)
1884 .all(|rect| {
1885 width <= rect.x0
1886 || width <= atlas_size.0.saturating_sub(rect.x1)
1887 || height <= rect.y0
1888 || height <= atlas_size.1.saturating_sub(rect.y1)
1889 })
1890}
1891
1892/// The extent `natural` is stored at inside an `atlas`-sized layer: itself when
1893/// it already fits, and otherwise the largest rectangle of the same aspect ratio
1894/// that does.
1895///
1896/// The scale is taken as the smaller of the two axis ratios and applied to both,
1897/// so the two axes of the paint transform stay in step — a per-axis fit would
1898/// stretch the image. Each axis is floored (never rounding *up* past the layer)
1899/// and then held at one texel: a source that scales below a whole texel still
1900/// has to have somewhere to be, and a zero-extent allocation is not a rectangle
1901/// the packer or the shader can address.
1902#[must_use]
1903pub fn fit_extent(natural: [u32; 2], atlas: (u32, u32)) -> (u32, u32) {
1904 let (width, height) = (natural[0], natural[1]);
1905 if width <= atlas.0 && height <= atlas.1 {
1906 return (width, height);
1907 }
1908
1909 let scale = (f64::from(atlas.0) / f64::from(width.max(1)))
1910 .min(f64::from(atlas.1) / f64::from(height.max(1)));
1911 let axis = |value: u32, ceiling: u32| {
1912 let scaled = (f64::from(value) * scale).floor();
1913 // `as` on a non-finite or out-of-range float saturates in Rust, so the
1914 // clamps below are the whole check rather than a second line of defence.
1915 (scaled as u32).clamp(1, ceiling.max(1))
1916 };
1917
1918 (axis(width, atlas.0), axis(height, atlas.1))
1919}
1920
1921/// `source` box-filtered down to `width` x `height`.
1922///
1923/// Every output texel is the unweighted mean of the half-open source rectangle
1924/// it covers, computed on the premultiplied bytes the atlas stores — which is
1925/// the space the samples are composited in, so averaging there is what keeps a
1926/// partially transparent source from bleeding its colour outward.
1927///
1928/// Minification only: a request at or above the source's own extent copies the
1929/// texels straight across rather than inventing any, since a box filter has no
1930/// magnification arm and the caller never asks for one ([`fit_extent`] only ever
1931/// shrinks).
1932#[must_use]
1933pub fn minify(source: &Pixmap, width: u32, height: u32) -> Pixmap {
1934 let source_w = u32::from(source.width());
1935 let source_h = u32::from(source.height());
1936 let width = width.clamp(1, source_w.max(1));
1937 let height = height.clamp(1, source_h.max(1));
1938
1939 let texels = source.data();
1940 let mut out = Vec::with_capacity((width as usize).saturating_mul(height as usize));
1941 let mut may_have_transparency = false;
1942
1943 for y in 0..height {
1944 // Half-open source rows, derived from the output row rather than
1945 // accumulated, so rounding never leaves a gap or an overlap between
1946 // consecutive rows.
1947 let y0 = (u64::from(y) * u64::from(source_h) / u64::from(height)) as u32;
1948 let y1 = ((u64::from(y) + 1) * u64::from(source_h) / u64::from(height)) as u32;
1949 let y1 = y1.max(y0.saturating_add(1)).min(source_h);
1950
1951 for x in 0..width {
1952 let x0 = (u64::from(x) * u64::from(source_w) / u64::from(width)) as u32;
1953 let x1 = ((u64::from(x) + 1) * u64::from(source_w) / u64::from(width)) as u32;
1954 let x1 = x1.max(x0.saturating_add(1)).min(source_w);
1955
1956 let mut sum = [0_u64; 4];
1957 let mut count = 0_u64;
1958 for row in y0..y1 {
1959 let base = (row as usize).saturating_mul(source_w as usize);
1960 for column in x0..x1 {
1961 let Some(texel) = texels.get(base.saturating_add(column as usize)) else {
1962 continue;
1963 };
1964 sum[0] += u64::from(texel.r);
1965 sum[1] += u64::from(texel.g);
1966 sum[2] += u64::from(texel.b);
1967 sum[3] += u64::from(texel.a);
1968 count += 1;
1969 }
1970 }
1971
1972 // A count of zero is unreachable — both ranges are non-empty by
1973 // construction — and flooring it at one rather than branching keeps
1974 // the divide-by-zero panic off the frame path (E17) while answering
1975 // a transparent texel if it ever were reachable, since the sums
1976 // would then be zero too.
1977 let divisor = count.max(1);
1978 // Round to nearest rather than truncating: a uniform source has to
1979 // come back bit-identical, which truncating an exact integer mean
1980 // already gives, but a near-uniform one must not drift a level
1981 // darker.
1982 let mean = |channel: usize| {
1983 ((sum[channel] + divisor / 2) / divisor).min(u64::from(u8::MAX)) as u8
1984 };
1985
1986 let texel = PremulRgba8 {
1987 r: mean(0),
1988 g: mean(1),
1989 b: mean(2),
1990 a: mean(3),
1991 };
1992 may_have_transparency |= texel.a != u8::MAX;
1993 out.push(texel);
1994 }
1995 }
1996
1997 // Both extents were clamped into `1..=u16::MAX` above (a source pixmap's own
1998 // extents are `u16`), so neither conversion can lose information.
1999 Pixmap::from_parts_with_opacity(
2000 out,
2001 width.min(u32::from(u16::MAX)) as u16,
2002 height.min(u32::from(u16::MAX)) as u16,
2003 may_have_transparency,
2004 )
2005}
2006
2007/// `axis` held at or below `ceiling`, and never below the one texel an
2008/// addressable rectangle needs.
2009fn fit_axis(axis: u32, ceiling: u64) -> u32 {
2010 axis.min(u32::try_from(ceiling).unwrap_or(u32::MAX)).max(1)
2011}
2012
2013/// `region` grown by `padding` on every side, clamped at the layer origin.
2014fn padded(region: AtlasRegion, padding: u16) -> AtlasRegion {
2015 let padding = u32::from(padding);
2016 let pad_x = padding.min(region.offset[0]);
2017 let pad_y = padding.min(region.offset[1]);
2018
2019 AtlasRegion {
2020 layer: region.layer,
2021 offset: [region.offset[0] - pad_x, region.offset[1] - pad_y],
2022 size: [
2023 region.size[0].saturating_add(pad_x + padding),
2024 region.size[1].saturating_add(pad_y + padding),
2025 ],
2026 }
2027}
2028
2029/// Refuse an image `vello_common`'s own conversion would assert on.
2030///
2031/// The three conditions are exactly the three assertions downstream: the
2032/// format `unimplemented!()`, the `u16::MAX` dimension check, and
2033/// `Pixmap::from_parts_with_opacity`'s length equality. Checking them here is
2034/// what keeps the panic off the frame path.
2035fn check_supported(data: &ImageData) -> Result<(), ImageSkip> {
2036 match data.format {
2037 ImageFormat::Rgba8 | ImageFormat::Bgra8 => {}
2038 format => return Err(ImageSkip::UnsupportedFormat(format)),
2039 }
2040
2041 if data.width == 0 || data.height == 0 {
2042 return Err(ImageSkip::DegenerateExtent {
2043 width: data.width,
2044 height: data.height,
2045 });
2046 }
2047
2048 if data.width > MAX_IMAGE_DIMENSION || data.height > MAX_IMAGE_DIMENSION {
2049 return Err(ImageSkip::TooLarge {
2050 width: data.width,
2051 height: data.height,
2052 max: MAX_IMAGE_DIMENSION,
2053 });
2054 }
2055
2056 let actual = data.data.data().len();
2057 let expected = data
2058 .format
2059 .size_in_bytes(data.width, data.height)
2060 .unwrap_or(usize::MAX);
2061 if actual != expected {
2062 return Err(ImageSkip::MalformedPixels {
2063 width: data.width,
2064 height: data.height,
2065 expected,
2066 actual,
2067 });
2068 }
2069
2070 Ok(())
2071}
2072
2073#[cfg(test)]
2074mod tests {
2075 use super::*;
2076 use peniko::{Blob, ImageAlphaType};
2077
2078 fn image(width: u32, height: u32) -> ImageData {
2079 let len = (width as usize) * (height as usize) * 4;
2080 ImageData {
2081 data: Blob::new(Arc::new(vec![255_u8; len])),
2082 format: ImageFormat::Rgba8,
2083 alpha_type: ImageAlphaType::Alpha,
2084 width,
2085 height,
2086 }
2087 }
2088
2089 fn residency() -> ImageResidency {
2090 ImageResidency::new(AtlasBudget {
2091 atlas_size: (64, 64),
2092 max_atlases: 2,
2093 })
2094 }
2095
2096 #[test]
2097 fn a_mobile_budget_is_never_the_vello_default() {
2098 let default = AtlasConfig::default();
2099 for budget in [AtlasBudget::MOBILE, AtlasBudget::DESKTOP] {
2100 assert_ne!(budget.atlas_size, default.atlas_size);
2101 assert!(budget.total_bytes() < 4096 * 4096 * ATLAS_FORMAT_BYTES * 8);
2102 }
2103 assert_eq!(AtlasBudget::MOBILE.total_bytes(), 16 << 20);
2104 assert_eq!(AtlasBudget::DESKTOP.total_bytes(), 128 << 20);
2105 }
2106
2107 #[test]
2108 fn a_downlevel_adapter_takes_the_mobile_budget() {
2109 let webgl2 = TierCaps::fake(DownlevelProfile::WebGl2);
2110 assert!(is_mobile_tier(&webgl2));
2111
2112 let mut tiler = TierCaps::fake(DownlevelProfile::Full);
2113 assert!(!is_mobile_tier(&tiler));
2114 tiler.transient_saves_memory = true;
2115 assert!(is_mobile_tier(&tiler));
2116 }
2117
2118 fn named(name: &str) -> TierCaps {
2119 let mut caps = TierCaps::fake(DownlevelProfile::Full);
2120 caps.adapter_name = name.to_string();
2121 caps
2122 }
2123
2124 #[test]
2125 fn the_adapter_table_names_the_mobile_and_desktop_families() {
2126 for name in [
2127 "Adreno (TM) 618",
2128 "Adreno (TM) 660",
2129 "Mali-G78 MP14",
2130 "Immortalis-G715",
2131 "PowerVR B-Series BXM-8-256",
2132 "Samsung Xclipse 920",
2133 "Maleoon 910",
2134 "Apple A15 GPU",
2135 "NVIDIA Tegra X1",
2136 ] {
2137 assert_eq!(adapter_family(name), Some(AdapterFamily::Mobile), "{name}");
2138 }
2139 for name in [
2140 "NVIDIA T400",
2141 "NVIDIA GeForce RTX 4090",
2142 "Quadro P1000",
2143 "AMD Radeon RX 7900 XTX",
2144 "Intel(R) UHD Graphics 770",
2145 ] {
2146 assert_eq!(adapter_family(name), Some(AdapterFamily::Desktop), "{name}");
2147 }
2148 for name in ["llvmpipe (LLVM 19.1.7, 256 bits)", "fake-full", ""] {
2149 assert_eq!(adapter_family(name), None, "{name}");
2150 }
2151 }
2152
2153 #[test]
2154 fn the_tier_table_overrides_a_mobile_driver_that_answers_like_a_desktop() {
2155 const GIB: u64 = 1 << 30;
2156 // Adreno 618/620 answer full profile and no transient savings — the
2157 // desktop answers — and used to take the desktop budget on them.
2158 let adreno = named("Adreno (TM) 618");
2159 assert!(!is_mobile_tier(&adreno));
2160
2161 let small = choose_tier(&adreno, Some(3 * GIB));
2162 assert_eq!(
2163 (small.tier, small.rule),
2164 (AtlasTier::Mobile, TierRule::Table)
2165 );
2166 assert_eq!(AtlasBudget::for_choice(small, &adreno), AtlasBudget::MOBILE);
2167
2168 let unknown = choose_tier(&adreno, None);
2169 assert_eq!(
2170 unknown.tier,
2171 AtlasTier::Mobile,
2172 "unknown memory earns nothing"
2173 );
2174
2175 let large = choose_tier(&adreno, Some(6 * GIB));
2176 assert_eq!(
2177 (large.tier, large.rule),
2178 (AtlasTier::MobileLarge, TierRule::Table)
2179 );
2180 assert_eq!(
2181 AtlasBudget::for_choice(large, &adreno),
2182 AtlasBudget::MOBILE_LARGE_WIDE
2183 );
2184 assert_eq!(
2185 AtlasBudget::for_choice(choose_tier(&adreno, Some(MOBILE_LARGE_MIN_MEMORY)), &adreno),
2186 AtlasBudget::MOBILE_LARGE_WIDE,
2187 "the threshold is inclusive"
2188 );
2189 assert_eq!(AtlasBudget::MOBILE_LARGE_WIDE.total_bytes(), 64 << 20);
2190 assert_eq!(AtlasBudget::MOBILE_LARGE_DEEP.total_bytes(), 64 << 20);
2191
2192 // A desktop family stays on the desktop budget whatever it answers.
2193 let mut nvidia = named("NVIDIA T400");
2194 nvidia.transient_saves_memory = true;
2195 let desktop = choose_tier(&nvidia, Some(GIB));
2196 assert_eq!(
2197 (desktop.tier, desktop.rule),
2198 (AtlasTier::Desktop, TierRule::Table)
2199 );
2200 }
2201
2202 #[test]
2203 fn the_large_mobile_shape_is_the_one_the_adapter_can_create() {
2204 let mut caps = named("Mali-G78 MP14");
2205 assert_eq!(
2206 AtlasBudget::mobile_large(&caps),
2207 Some(AtlasBudget::MOBILE_LARGE_WIDE)
2208 );
2209
2210 // An edge short of 2048 takes the deep shape instead.
2211 caps.max_texture_dimension_2d = 1024;
2212 assert_eq!(
2213 AtlasBudget::mobile_large(&caps),
2214 Some(AtlasBudget::MOBILE_LARGE_DEEP)
2215 );
2216
2217 // Neither shape fits: the tier is the plain mobile one, never a large
2218 // budget clamped below it.
2219 caps.max_texture_array_layers = 8;
2220 assert_eq!(AtlasBudget::mobile_large(&caps), None);
2221 let choice = choose_tier(&caps, Some(16 << 30));
2222 assert_eq!(choice.tier, AtlasTier::Mobile);
2223 assert_eq!(AtlasBudget::for_choice(choice, &caps), AtlasBudget::MOBILE);
2224 }
2225
2226 #[test]
2227 fn an_unnamed_adapter_keeps_the_capability_read_and_downlevel_wins_over_the_table() {
2228 let unnamed = TierCaps::fake(DownlevelProfile::Full);
2229 let choice = choose_tier(&unnamed, Some(64 << 30));
2230 assert_eq!(
2231 (choice.tier, choice.rule),
2232 (AtlasTier::Desktop, TierRule::Heuristic)
2233 );
2234
2235 let mut tiler = unnamed.clone();
2236 tiler.transient_saves_memory = true;
2237 let choice = choose_tier(&tiler, Some(64 << 30));
2238 assert_eq!(
2239 (choice.tier, choice.rule),
2240 (AtlasTier::Mobile, TierRule::Heuristic),
2241 "the heuristic never reaches the large tier"
2242 );
2243
2244 let mut gl_nvidia = TierCaps::fake(DownlevelProfile::WebGl2);
2245 gl_nvidia.adapter_name = "ANGLE (NVIDIA, NVIDIA T400)".to_string();
2246 let choice = choose_tier(&gl_nvidia, Some(64 << 30));
2247 assert_eq!(
2248 (choice.tier, choice.rule),
2249 (AtlasTier::Mobile, TierRule::Downlevel)
2250 );
2251 }
2252
2253 #[test]
2254 fn mem_total_is_read_in_bytes_from_meminfo() {
2255 let meminfo = "MemTotal: 5768520 kB\nMemFree: 123456 kB\n";
2256 assert_eq!(parse_mem_total(meminfo), Some(5_768_520 * 1024));
2257 assert_eq!(parse_mem_total("MemFree: 1 kB\n"), None);
2258 assert_eq!(parse_mem_total("MemTotal: lots\n"), None);
2259 assert_eq!(parse_mem_total("MemTotal: 1024 MB\n"), None);
2260 }
2261
2262 #[test]
2263 fn a_budget_is_clamped_to_the_adapters_own_ceilings() {
2264 let mut caps = TierCaps::fake(DownlevelProfile::Full);
2265 caps.max_texture_dimension_2d = 1024;
2266 caps.max_texture_array_layers = 2;
2267
2268 let budget = AtlasBudget::DESKTOP.clamped(&caps);
2269 assert_eq!(budget.atlas_size, (1024, 1024));
2270 assert_eq!(budget.max_atlases, 2);
2271
2272 // The eight-bit `atlas_index` field is a ceiling of its own.
2273 caps.max_texture_array_layers = 4096;
2274 let budget = AtlasBudget {
2275 atlas_size: (256, 256),
2276 max_atlases: 4096,
2277 }
2278 .clamped(&caps);
2279 assert_eq!(budget.max_atlases, MAX_ATLAS_LAYERS);
2280 }
2281
2282 #[test]
2283 fn the_first_layer_is_created_lazily() {
2284 let residency = residency();
2285 assert_eq!(residency.layers(), 0);
2286 assert_eq!(residency.entry_count(), 0);
2287 }
2288
2289 #[test]
2290 fn one_image_over_many_frames_uploads_exactly_once() {
2291 let mut residency = residency();
2292 let data = image(8, 8);
2293 let mut uploads = 0;
2294
2295 for _ in 0..60 {
2296 residency.begin_frame();
2297 residency.resolve(&data).expect("8x8 fits a 64x64 atlas");
2298 uploads += residency.uploads().len();
2299 // Every frame here is a frame that reached the atlas.
2300 residency.acknowledge_plan();
2301 }
2302
2303 assert_eq!(uploads, 1, "residency survives every frame that draws it");
2304 assert_eq!(residency.entry_count(), 1);
2305 assert_eq!(residency.layers(), 1);
2306 }
2307
2308 #[test]
2309 fn an_unseen_image_is_reaped_and_its_padded_region_cleared() {
2310 let mut residency = residency();
2311 let data = image(8, 8);
2312
2313 residency.begin_frame();
2314 let resident = residency.resolve(&data).expect("fits");
2315 residency.acknowledge_plan();
2316
2317 for _ in 0..MAX_UNSEEN_FRAMES {
2318 residency.begin_frame();
2319 assert!(residency.evictions().is_empty(), "still inside the window");
2320 }
2321
2322 residency.begin_frame();
2323 assert_eq!(residency.entry_count(), 0);
2324 assert_eq!(
2325 residency.evictions(),
2326 &[padded(resident.region, ATLAS_PADDING)]
2327 );
2328
2329 // The freed rectangle is available again, and the reallocation
2330 // schedules a fresh upload.
2331 residency.resolve(&data).expect("fits");
2332 assert_eq!(residency.uploads().len(), 1);
2333 }
2334
2335 #[test]
2336 fn an_image_larger_than_the_atlas_is_minified_to_fit_rather_than_skipped() {
2337 let mut residency = residency();
2338 residency.begin_frame();
2339
2340 // Twice the layer's width at a 16:1 aspect ratio: the width sets the
2341 // scale, and the height follows it rather than being fitted on its own.
2342 let resident = residency.resolve(&image(128, 8)).expect("minified to fit");
2343
2344 assert_eq!(resident.region.size, [64, 4]);
2345 assert_eq!(resident.natural, [128, 8]);
2346 assert_eq!(resident.minify_scale(), Some((0.5, 0.5)));
2347 assert_eq!(residency.minified(), 1);
2348 assert_eq!(residency.skipped(), 0, "a big image is not a skipped one");
2349 assert_eq!(residency.uploads().len(), 1);
2350 let upload = &residency.uploads()[0];
2351 assert_eq!(
2352 (upload.pixels.width(), upload.pixels.height()),
2353 (64, 4),
2354 "the upload carries exactly the region's texels"
2355 );
2356 }
2357
2358 #[test]
2359 fn an_atlas_with_no_room_left_is_still_a_skip() {
2360 // Minification fits an image to a LAYER, not to the space left in one:
2361 // a budget already full refuses the next allocation as it always did.
2362 let mut residency = ImageResidency::new(AtlasBudget {
2363 atlas_size: (64, 64),
2364 max_atlases: 1,
2365 });
2366 residency.begin_frame();
2367 residency
2368 .resolve(&image(64, 64))
2369 .expect("fills the one layer");
2370
2371 let skip = residency
2372 .resolve(&image(32, 32))
2373 .expect_err("nothing is left to allocate from");
2374 assert!(matches!(skip, ImageSkip::NoAtlasSpace { .. }));
2375 assert_eq!(residency.skipped(), 1);
2376 assert_eq!(residency.minified(), 0);
2377 }
2378
2379 #[test]
2380 fn a_minified_image_stays_resident_across_frames_at_its_declared_extent() {
2381 // Residency is keyed on the SOURCE extent, not the resident one: an
2382 // entry matched against its own minified rectangle would miss every
2383 // frame and re-upload the image on each of them.
2384 let mut residency = residency();
2385 let data = image(128, 8);
2386
2387 for _ in 0..8 {
2388 residency.begin_frame();
2389 let resident = residency.resolve(&data).expect("minified to fit");
2390 assert_eq!(resident.natural, [128, 8]);
2391 residency.acknowledge_plan();
2392 }
2393
2394 assert_eq!(residency.entry_count(), 1);
2395 assert_eq!(
2396 residency.minified(),
2397 1,
2398 "downsampled once, not once a frame"
2399 );
2400 assert!(
2401 residency.uploads().is_empty(),
2402 "no re-upload after the first"
2403 );
2404 }
2405
2406 #[test]
2407 fn a_fit_extent_preserves_the_aspect_ratio_and_never_grows() {
2408 let atlas = (64, 64);
2409 // Already inside the layer: unchanged, both axes.
2410 assert_eq!(fit_extent([64, 64], atlas), (64, 64));
2411 assert_eq!(fit_extent([8, 4], atlas), (8, 4));
2412
2413 // The `adv-huge-image` shape, at this budget's scale.
2414 assert_eq!(fit_extent([5000, 5000], (2048, 2048)), (2048, 2048));
2415
2416 // The tighter axis sets the scale for both.
2417 assert_eq!(fit_extent([128, 8], atlas), (64, 4));
2418 assert_eq!(fit_extent([8, 128], atlas), (4, 64));
2419 assert_eq!(fit_extent([1000, 10], atlas), (64, 1));
2420
2421 // A non-square layer is fitted per axis and still uniformly scaled.
2422 assert_eq!(fit_extent([400, 400], (100, 50)), (50, 50));
2423 }
2424
2425 #[test]
2426 fn an_extreme_aspect_ratio_still_fits_at_one_texel_rather_than_none() {
2427 // 10000:1 into a 64-square layer scales the short axis to 0.0064 texels.
2428 // A zero extent is not a rectangle the packer or the shader can
2429 // address, so it is held at one.
2430 let fitted = fit_extent([10_000, 1], (64, 64));
2431 assert_eq!(fitted, (64, 1));
2432
2433 let mut residency = residency();
2434 residency.begin_frame();
2435 let resident = residency
2436 .resolve(&image(10_000, 1))
2437 .expect("a sliver still becomes a rectangle");
2438 assert_eq!(resident.region.size, [64, 1]);
2439 }
2440
2441 #[test]
2442 fn a_uniform_source_minifies_to_exactly_its_own_colour() {
2443 // What `adv-huge-image` rests on: a uniform source comes back bit
2444 // identical whatever the scale, so the case's `Exact` probe is a
2445 // property of the filter rather than a tolerance.
2446 let texel = PremulRgba8 {
2447 r: 255,
2448 g: 0,
2449 b: 255,
2450 a: 255,
2451 };
2452 let source = Pixmap::from_parts_with_opacity(vec![texel; 100 * 100], 100, 100, false);
2453
2454 let small = minify(&source, 7, 3);
2455 assert_eq!((small.width(), small.height()), (7, 3));
2456 assert!(small.data().iter().all(|out| *out == texel));
2457 assert!(
2458 !small.may_have_transparency(),
2459 "an opaque source stays opaque"
2460 );
2461 }
2462
2463 #[test]
2464 fn a_box_filter_averages_the_source_texels_each_output_covers() {
2465 // A 2x1 source of black and white halves down to one texel, which must
2466 // be their mean rather than either end.
2467 let black = PremulRgba8 {
2468 r: 0,
2469 g: 0,
2470 b: 0,
2471 a: 255,
2472 };
2473 let white = PremulRgba8 {
2474 r: 255,
2475 g: 255,
2476 b: 255,
2477 a: 255,
2478 };
2479 let source = Pixmap::from_parts_with_opacity(vec![black, white], 2, 1, false);
2480
2481 let small = minify(&source, 1, 1);
2482 assert_eq!((small.width(), small.height()), (1, 1));
2483 // 255 / 2 rounded to nearest.
2484 assert_eq!(small.data()[0].r, 128);
2485 assert_eq!(small.data()[0].a, 255);
2486
2487 // Transparency is recomputed from the result, not inherited: averaging
2488 // an opaque and a fully transparent texel produces a partial one.
2489 let clear = PremulRgba8 {
2490 r: 0,
2491 g: 0,
2492 b: 0,
2493 a: 0,
2494 };
2495 let mixed = Pixmap::from_parts_with_opacity(vec![white, clear], 2, 1, true);
2496 let small = minify(&mixed, 1, 1);
2497 assert_eq!(small.data()[0].a, 128);
2498 assert!(small.may_have_transparency());
2499 }
2500
2501 #[test]
2502 fn minify_never_magnifies() {
2503 let texel = PremulRgba8 {
2504 r: 1,
2505 g: 2,
2506 b: 3,
2507 a: 255,
2508 };
2509 let source = Pixmap::from_parts_with_opacity(vec![texel; 4], 2, 2, false);
2510
2511 // A request above the source's own extent is clamped to it rather than
2512 // inventing texels the box filter has no arm for.
2513 let same = minify(&source, 8, 8);
2514 assert_eq!((same.width(), same.height()), (2, 2));
2515 assert!(same.data().iter().all(|out| *out == texel));
2516 }
2517
2518 #[test]
2519 fn a_malformed_or_degenerate_image_is_refused_before_the_conversion() {
2520 let mut short = image(4, 4);
2521 short.data = Blob::new(Arc::new(vec![0_u8; 8]));
2522 assert!(matches!(
2523 check_supported(&short),
2524 Err(ImageSkip::MalformedPixels { .. })
2525 ));
2526
2527 assert!(matches!(
2528 check_supported(&image(0, 4)),
2529 Err(ImageSkip::DegenerateExtent { .. })
2530 ));
2531
2532 let mut huge = image(1, 1);
2533 huge.width = MAX_IMAGE_DIMENSION + 1;
2534 assert!(matches!(
2535 check_supported(&huge),
2536 Err(ImageSkip::TooLarge { .. })
2537 ));
2538 }
2539
2540 #[test]
2541 fn two_images_share_a_layer_and_hold_distinct_rectangles() {
2542 let mut residency = residency();
2543 residency.begin_frame();
2544
2545 let first = residency.resolve(&image(8, 8)).expect("fits");
2546 let second = residency.resolve(&image(16, 16)).expect("fits");
2547
2548 assert_ne!(first.id, second.id);
2549 assert_eq!(first.region.layer, second.region.layer);
2550 assert_ne!(first.region.offset, second.region.offset);
2551 assert_eq!(residency.uploads().len(), 2);
2552 assert_eq!(residency.layers(), 1);
2553 }
2554
2555 #[test]
2556 fn a_second_layer_is_created_when_the_first_is_full() {
2557 let mut residency = residency();
2558 residency.begin_frame();
2559
2560 // Two 64x48 images cannot share one 64x64 layer.
2561 let first = residency.resolve(&image(64, 48)).expect("fits a layer");
2562 let second = residency.resolve(&image(64, 48)).expect("fits the next");
2563
2564 assert_eq!(first.region.layer, 0);
2565 assert_eq!(second.region.layer, 1);
2566 assert_eq!(residency.layers(), 2);
2567 }
2568
2569 #[test]
2570 fn a_slot_taken_through_the_shared_allocator_is_a_distinct_id_on_the_same_budget() {
2571 // The allocator half of the glyph/image seam, stated without the glyph
2572 // policy: whatever else packs into this cache draws from the same id
2573 // space and the same layer allowance, and its pages are visible in the
2574 // depth the array is built to.
2575 let mut residency = residency();
2576 residency.begin_frame();
2577 let resident = residency.resolve(&image(64, 48)).expect("fills a layer");
2578
2579 // Big enough that it cannot share the layer the image took, so it must
2580 // create the second one rather than report the first.
2581 let borrowed = residency
2582 .allocator_mut()
2583 .allocate(64, 48, ATLAS_PADDING)
2584 .expect("the second layer is free");
2585
2586 assert_ne!(
2587 resident.id, borrowed,
2588 "one cache never hands the same slot index to two live occupants"
2589 );
2590 assert_eq!(
2591 residency.layers(),
2592 2,
2593 "a layer created by the other class still has to be in the array"
2594 );
2595 assert_eq!(
2596 residency
2597 .allocator()
2598 .get(resident.id)
2599 .map(|resource| resource.size()),
2600 Some(resident.region.size),
2601 "and neither allocation disturbed the other's rectangle"
2602 );
2603 }
2604
2605 #[test]
2606 fn a_disabled_residency_refuses_every_image_and_touches_no_atlas() {
2607 let mut residency = ImageResidency::disabled(AtlasBudget::MOBILE);
2608 assert!(residency.is_disabled());
2609
2610 residency.begin_frame();
2611 let skip = residency
2612 .resolve(&image(8, 8))
2613 .expect_err("the kill switch refuses every image");
2614
2615 assert_eq!(skip, ImageSkip::AtlasDisabled);
2616 assert_eq!(residency.entry_count(), 0);
2617 assert_eq!(residency.layers(), 0, "no atlas layer is ever created");
2618 assert!(residency.uploads().is_empty());
2619 assert_eq!(residency.skipped(), 1);
2620 }
2621
2622 #[test]
2623 fn an_enabled_residency_is_what_an_unset_kill_switch_produces() {
2624 // The default test environment leaves `FRUST_ENGINE_NO_ATLAS` unset, so
2625 // this pins that `new` consults the switch rather than hardcoding
2626 // either answer; the disabled half is pinned above through the
2627 // constructor the switch selects.
2628 assert!(!ImageResidency::new(AtlasBudget::MOBILE).is_disabled());
2629 }
2630
2631 #[test]
2632 fn reading_the_plan_leaves_it_pending_and_acknowledging_it_clears_it() {
2633 let mut residency = residency();
2634 residency.begin_frame();
2635 residency.resolve(&image(8, 8)).expect("fits");
2636
2637 let (evictions, uploads) = residency.plan();
2638 assert!(evictions.is_empty());
2639 assert_eq!(uploads.len(), 1);
2640 assert!(
2641 residency.has_pending_plan(),
2642 "reading the plan is not servicing it"
2643 );
2644
2645 residency.acknowledge_plan();
2646 assert!(!residency.has_pending_plan());
2647 assert!(residency.uploads().is_empty());
2648 assert!(residency.evictions().is_empty());
2649 }
2650
2651 #[test]
2652 fn a_clear_is_dropped_only_for_a_rectangle_an_upload_covers_exactly() {
2653 let region = |layer, x, y, w, h| AtlasRegion {
2654 layer,
2655 offset: [x, y],
2656 size: [w, h],
2657 };
2658 let upload = |region| ImageUpload {
2659 id: ImageId::new(0),
2660 region,
2661 natural: region.size,
2662 pixels: Arc::new(Pixmap::new(
2663 u16::try_from(region.size[0]).expect("small"),
2664 u16::try_from(region.size[1]).expect("small"),
2665 )),
2666 may_have_transparency: false,
2667 };
2668
2669 let exact = region(0, 0, 0, 16, 16);
2670 let other_layer = region(1, 0, 0, 16, 16);
2671 let partial = region(0, 16, 0, 16, 16);
2672 let evictions = [exact, other_layer, partial];
2673 let uploads = [upload(exact), upload(region(0, 16, 0, 16, 8))];
2674
2675 assert_eq!(
2676 uncovered_clears(&evictions, &uploads),
2677 vec![other_layer, partial],
2678 "only the exactly covered clear goes, the rest keep their order"
2679 );
2680 assert_eq!(uncovered_clears(&evictions, &[]), evictions.to_vec());
2681 assert!(uncovered_clears(&[], &uploads).is_empty());
2682 }
2683
2684 #[test]
2685 fn a_pressure_eviction_into_the_same_rectangle_plans_no_clear() {
2686 // One slot: the second image can only take the first one's rectangle.
2687 let mut residency = ImageResidency::new(AtlasBudget {
2688 atlas_size: (16, 16),
2689 max_atlases: 1,
2690 });
2691 residency.begin_frame();
2692 let first = residency.resolve(&image(16, 16)).expect("fits");
2693 residency.acknowledge_plan();
2694
2695 residency.begin_frame();
2696 let second = residency
2697 .resolve(&image(16, 16))
2698 .expect("displaces the first");
2699 assert_eq!(second.region, first.region);
2700 assert_eq!(
2701 residency.evictions(),
2702 &[first.region],
2703 "the eviction itself is still pending"
2704 );
2705
2706 let (clears, uploads) = residency.plan();
2707 assert!(clears.is_empty(), "but the plan issues no clear for it");
2708 assert_eq!(uploads.len(), 1);
2709
2710 residency.acknowledge_plan();
2711 assert!(!residency.has_pending_plan());
2712 }
2713
2714 #[test]
2715 fn an_unacknowledged_upload_is_re_emitted_until_it_is_serviced() {
2716 // The invariant the whole pending plan exists for: a frame refused
2717 // after compiling never wrote these texels, so the entry map claiming
2718 // the image is resident has to stay answerable by a later frame's plan.
2719 let mut residency = residency();
2720 let data = image(8, 8);
2721
2722 residency.begin_frame();
2723 let first = residency.resolve(&data).expect("fits");
2724 let region = residency.uploads()[0].region;
2725
2726 for _ in 0..4 {
2727 residency.begin_frame();
2728 let again = residency.resolve(&data).expect("still resident");
2729 assert_eq!(again.region, first.region, "residency does not move");
2730 assert_eq!(
2731 residency.uploads().len(),
2732 1,
2733 "the same upload is re-offered, never duplicated"
2734 );
2735 assert_eq!(residency.uploads()[0].region, region);
2736 }
2737
2738 residency.acknowledge_plan();
2739 residency.begin_frame();
2740 residency.resolve(&data).expect("still resident");
2741 assert!(
2742 residency.uploads().is_empty(),
2743 "a serviced upload is never offered again"
2744 );
2745 }
2746
2747 #[test]
2748 fn reaping_an_unacknowledged_entry_withdraws_its_upload_with_it() {
2749 // Otherwise the plan would clear the rectangle and then write the
2750 // evicted image straight back into it, over whatever the packer handed
2751 // that space to next.
2752 let mut residency = residency();
2753 let data = image(8, 8);
2754
2755 residency.begin_frame();
2756 let resident = residency.resolve(&data).expect("fits");
2757 assert_eq!(residency.uploads().len(), 1);
2758
2759 for _ in 0..=MAX_UNSEEN_FRAMES {
2760 residency.begin_frame();
2761 }
2762
2763 assert_eq!(residency.entry_count(), 0);
2764 assert!(
2765 residency.uploads().is_empty(),
2766 "the reaped entry's unserviced upload goes with it"
2767 );
2768 assert_eq!(
2769 residency.evictions(),
2770 &[padded(resident.region, ATLAS_PADDING)]
2771 );
2772 }
2773
2774 #[test]
2775 fn every_pending_upload_lies_inside_the_atlas_its_layer_count_asks_for() {
2776 // What keeps a consumer from recording where an image lives and then
2777 // sampling a rectangle the array refused to write: the plan can only
2778 // ever name regions the budget and the reported depth already admit.
2779 let mut residency = residency();
2780 residency.begin_frame();
2781 residency.resolve(&image(64, 48)).expect("fills a layer");
2782 residency.resolve(&image(64, 48)).expect("takes the next");
2783
2784 let budget = residency.budget();
2785 let layers = residency.layers();
2786 assert_eq!(layers, 2);
2787 for upload in residency.uploads() {
2788 assert!(
2789 budget.contains(upload.region, layers),
2790 "{:?} is outside a {layers}-layer {budget:?}",
2791 upload.region
2792 );
2793 }
2794 }
2795
2796 /// A budget holding exactly two 64-square images: one per layer, with no
2797 /// room to pack a third anywhere, so "the atlas is full" is reachable in
2798 /// two allocations and needs no fragmentation reasoning.
2799 fn two_slot_residency() -> ImageResidency {
2800 ImageResidency::new(AtlasBudget {
2801 atlas_size: (64, 64),
2802 max_atlases: 2,
2803 })
2804 }
2805
2806 #[test]
2807 fn a_working_set_larger_than_the_atlas_re_uploads_rather_than_skipping() {
2808 // The scrolling-list shape: more distinct images pass the viewport
2809 // inside one age window than the atlas has rectangles for. Every draw
2810 // must still resolve — the price is a re-upload, never a hole.
2811 let mut residency = two_slot_residency();
2812 let images: Vec<ImageData> = (0..12).map(|_| image(64, 64)).collect();
2813
2814 for data in &images {
2815 residency.begin_frame();
2816 residency
2817 .resolve(data)
2818 .expect("a full atlas gives a rectangle back rather than refusing");
2819 residency.acknowledge_plan();
2820 }
2821
2822 assert_eq!(residency.skipped(), 0, "no draw was ever dropped");
2823 assert_eq!(
2824 residency.pressure_evictions(),
2825 10,
2826 "the ten images past the two slots each displaced one"
2827 );
2828 assert_eq!(residency.entry_count(), 2, "and residency stays bounded");
2829 }
2830
2831 #[test]
2832 fn an_image_resolved_this_frame_is_never_the_one_evicted() {
2833 // The ordering contract's own boundary: the plan clears before it
2834 // uploads, so a rectangle this frame already resolved cannot be handed
2835 // back inside it. A frame whose own working set outgrows the atlas is
2836 // still a skip, and nothing is scheduled for clearing.
2837 let mut residency = two_slot_residency();
2838 residency.begin_frame();
2839 residency.resolve(&image(64, 64)).expect("takes layer 0");
2840 residency.resolve(&image(64, 64)).expect("takes layer 1");
2841
2842 let skip = residency
2843 .resolve(&image(64, 64))
2844 .expect_err("nothing this frame drew is evictable");
2845
2846 assert!(matches!(skip, ImageSkip::NoAtlasSpace { .. }));
2847 assert_eq!(residency.skipped(), 1);
2848 assert_eq!(residency.pressure_evictions(), 0);
2849 assert_eq!(residency.entry_count(), 2, "both occupants stayed put");
2850 assert!(
2851 residency.evictions().is_empty(),
2852 "no region a frame is about to sample was scheduled for clearing"
2853 );
2854 }
2855
2856 #[test]
2857 fn the_least_recently_seen_image_is_the_one_displaced() {
2858 let mut residency = two_slot_residency();
2859 let old = image(64, 64);
2860 let refreshed = image(64, 64);
2861
2862 residency.begin_frame();
2863 let refreshed_region = residency.resolve(&refreshed).expect("takes a layer").region;
2864 residency.begin_frame();
2865 let old_region = residency.resolve(&old).expect("takes the other").region;
2866 residency.acknowledge_plan();
2867
2868 // The older entry is drawn again, which makes the *other* one the least
2869 // recently seen despite having been made resident second.
2870 residency.begin_frame();
2871 residency.resolve(&refreshed).expect("still resident");
2872 let arrival = residency.resolve(&image(64, 64)).expect("displaces one");
2873
2874 assert_eq!(residency.pressure_evictions(), 1);
2875 assert_eq!(
2876 arrival.region, old_region,
2877 "the rectangle taken is the least recently seen one"
2878 );
2879 assert_eq!(
2880 residency.resolve(&refreshed).map(|again| again.region),
2881 Ok(refreshed_region),
2882 "the image drawn this frame kept its own"
2883 );
2884 }
2885
2886 #[test]
2887 fn a_pressure_eviction_reports_its_rectangle_once_and_withdraws_its_upload() {
2888 let mut residency = two_slot_residency();
2889 let displaced = image(64, 64);
2890
2891 residency.begin_frame();
2892 let displaced_region = residency.resolve(&displaced).expect("takes a layer").region;
2893 residency.resolve(&image(64, 64)).expect("takes the other");
2894 // Deliberately NOT acknowledged: the displaced image still has an
2895 // upload pending, which must go with its entry rather than be written
2896 // into a rectangle its new occupant now owns.
2897 assert_eq!(residency.uploads().len(), 2);
2898
2899 residency.begin_frame();
2900 let arrival = residency
2901 .resolve(&image(64, 64))
2902 .expect("displaces the oldest");
2903
2904 assert_eq!(
2905 residency.evictions(),
2906 &[padded(displaced_region, ATLAS_PADDING)],
2907 "exactly one rectangle, reported exactly once"
2908 );
2909 assert_eq!(
2910 arrival.region, displaced_region,
2911 "and the freed rectangle is what the new occupant took"
2912 );
2913 assert_eq!(
2914 residency.uploads().len(),
2915 2,
2916 "the displaced image's unserviced upload went with its entry — \
2917 writing it would put an evicted image back over its successor"
2918 );
2919 }
2920
2921 #[test]
2922 fn an_age_reap_and_a_pressure_eviction_are_counted_separately() {
2923 // The two bounds answer different questions: one says a screen moved
2924 // on, the other says the atlas is too small for what is on it.
2925 let mut residency = two_slot_residency();
2926 let data = image(64, 64);
2927
2928 residency.begin_frame();
2929 residency.resolve(&data).expect("fits");
2930 for _ in 0..=MAX_UNSEEN_FRAMES {
2931 residency.begin_frame();
2932 }
2933
2934 assert_eq!(residency.entry_count(), 0, "the age reap ran");
2935 assert_eq!(
2936 residency.pressure_evictions(),
2937 0,
2938 "an age reap is not a pressure eviction"
2939 );
2940 assert_eq!(residency.frame_pressure_evictions(), 0);
2941 }
2942
2943 #[test]
2944 fn the_per_frame_eviction_count_is_this_frames_alone() {
2945 let mut residency = two_slot_residency();
2946 residency.begin_frame();
2947 residency.resolve(&image(64, 64)).expect("takes a layer");
2948 residency.resolve(&image(64, 64)).expect("takes the other");
2949
2950 residency.begin_frame();
2951 residency.resolve(&image(64, 64)).expect("displaces one");
2952 assert_eq!(residency.frame_pressure_evictions(), 1);
2953 assert_eq!(residency.pressure_evictions(), 1);
2954
2955 residency.begin_frame();
2956 assert_eq!(
2957 residency.frame_pressure_evictions(),
2958 0,
2959 "a new frame starts from nothing"
2960 );
2961 assert_eq!(
2962 residency.pressure_evictions(),
2963 1,
2964 "while the lifetime total keeps counting"
2965 );
2966 }
2967
2968 /// A budget of exactly four 32-square cells in one layer, so "the atlas is
2969 /// full" needs no reasoning about how a packer splits free space and a
2970 /// request that fits *no* arrangement of the holes is one rectangle.
2971 fn four_slot_residency() -> ImageResidency {
2972 ImageResidency::new(AtlasBudget {
2973 atlas_size: (64, 64),
2974 max_atlases: 1,
2975 })
2976 }
2977
2978 #[test]
2979 fn a_request_no_candidate_set_can_place_is_refused_before_anything_is_freed() {
2980 // The storm, at the cache seam. One 32-square is this frame's and three
2981 // are last frame's; a 48-square fits none of the holes those three
2982 // would leave while the fourth is resident. Freeing them buys nothing
2983 // — the draw is skipped either way — and costs three clears plus three
2984 // re-uploads, every frame the scene repeats. So the refusal has to come
2985 // before the first rectangle is handed back.
2986 let mut residency = four_slot_residency();
2987 let resident: Vec<ImageData> = (0..4).map(|_| image(32, 32)).collect();
2988 residency.begin_frame();
2989 for data in &resident {
2990 residency
2991 .resolve(data)
2992 .expect("the four cells take four images");
2993 }
2994 residency.acknowledge_plan();
2995
2996 residency.begin_frame();
2997 residency.resolve(&resident[0]).expect("still resident");
2998
2999 let skip = residency
3000 .resolve(&image(48, 48))
3001 .expect_err("no arrangement of the three candidates admits a 48-square");
3002
3003 assert!(matches!(skip, ImageSkip::NoAtlasSpace { .. }));
3004 assert_eq!(
3005 residency.pressure_evictions(),
3006 0,
3007 "a request that could not have been placed displaced nothing"
3008 );
3009 assert_eq!(residency.entry_count(), 4, "the residency is untouched");
3010 assert!(
3011 residency.evictions().is_empty(),
3012 "and nothing was scheduled for clearing on the way to failing"
3013 );
3014 assert!(residency.uploads().is_empty());
3015 }
3016
3017 #[test]
3018 fn a_request_larger_than_every_candidate_together_is_refused_on_area_alone() {
3019 // The cheaper half of the same precheck: freeing all three candidates
3020 // leaves three quarters of one layer, and the request wants all of it.
3021 // The area comes off the packer's own accounting, so it counts what the
3022 // glyph half of the allocator holds as well as this residency's own.
3023 let mut residency = four_slot_residency();
3024 let resident: Vec<ImageData> = (0..4).map(|_| image(32, 32)).collect();
3025 residency.begin_frame();
3026 for data in &resident {
3027 residency
3028 .resolve(data)
3029 .expect("the four cells take four images");
3030 }
3031 residency.acknowledge_plan();
3032
3033 residency.begin_frame();
3034 residency.resolve(&resident[0]).expect("still resident");
3035 residency
3036 .resolve(&image(64, 64))
3037 .expect_err("a whole layer is more than the three candidates hold");
3038
3039 assert_eq!(residency.pressure_evictions(), 0);
3040 assert_eq!(residency.entry_count(), 4);
3041 assert!(residency.evictions().is_empty());
3042 }
3043
3044 #[test]
3045 fn a_satisfiable_request_under_pressure_still_evicts_the_minimum() {
3046 // The other side of the precheck: it must not turn a working eviction
3047 // into a skip. One 32-square arriving against four resident ones is
3048 // satisfiable by freeing exactly one, and that is what it costs.
3049 let mut residency = four_slot_residency();
3050 let resident: Vec<ImageData> = (0..4).map(|_| image(32, 32)).collect();
3051 residency.begin_frame();
3052 for data in &resident {
3053 residency
3054 .resolve(data)
3055 .expect("the four cells take four images");
3056 }
3057 residency.acknowledge_plan();
3058
3059 residency.begin_frame();
3060 residency.resolve(&image(32, 32)).expect("displaces one");
3061
3062 assert_eq!(
3063 residency.pressure_evictions(),
3064 1,
3065 "the minimum, not the lot"
3066 );
3067 assert_eq!(residency.entry_count(), 4);
3068 assert_eq!(residency.evictions().len(), 1);
3069 }
3070
3071 #[test]
3072 fn a_blob_redrawn_at_a_new_extent_never_frees_what_this_frame_resolved() {
3073 // The extent-mismatch release under the pressure path's own rule. The
3074 // plan clears before it uploads, so giving the rectangle back on a
3075 // frame whose first draw already resolved it would zero texels that
3076 // draw is about to sample.
3077 let square = image(16, 8);
3078 let transposed = ImageData {
3079 data: square.data.clone(),
3080 format: square.format,
3081 alpha_type: square.alpha_type,
3082 width: 8,
3083 height: 16,
3084 };
3085
3086 let mut residency = residency();
3087 residency.begin_frame();
3088 let first = residency.resolve(&square).expect("takes a rectangle");
3089 let skip = residency
3090 .resolve(&transposed)
3091 .expect_err("the second extent cannot take the first one's rectangle");
3092
3093 assert!(
3094 matches!(skip, ImageSkip::SameFrameExtentConflict { .. }),
3095 "a same-frame ordering conflict, not an atlas-capacity refusal: {skip:?}"
3096 );
3097 assert!(
3098 residency.evictions().is_empty(),
3099 "no clear over a rectangle this frame already sampled"
3100 );
3101 assert_eq!(residency.entry_count(), 1);
3102 assert_eq!(residency.uploads().len(), 1);
3103
3104 // On a later frame nothing has sampled it yet, so the release is
3105 // ordinary again and the new extent takes its place.
3106 residency.acknowledge_plan();
3107 residency.begin_frame();
3108 let second = residency.resolve(&transposed).expect("the new extent fits");
3109 assert_eq!(second.natural, [8, 16]);
3110 assert_eq!(
3111 residency.evictions(),
3112 &[padded(first.region, ATLAS_PADDING)],
3113 "the rectangle the old extent held is reported for clearing"
3114 );
3115 }
3116
3117 #[test]
3118 fn a_layer_admits_only_what_every_occupant_leaves_room_for() {
3119 // The planner's own necessary condition, in isolation. A placement has
3120 // to lie entirely to one side of each occupant, so a 32-square in one
3121 // corner of a 64-square layer leaves nothing a 48-square can occupy —
3122 // while a 32-square still has three cells to choose from.
3123 let corner = PressureRect {
3124 key: 1,
3125 layer: 0,
3126 x0: 0,
3127 y0: 0,
3128 x1: 32,
3129 y1: 32,
3130 area: 1024,
3131 last_seen: 0,
3132 slot: 0,
3133 freed: false,
3134 };
3135 let rects = [corner];
3136
3137 assert!(!layer_admits(&rects, 0, 48, 48, (64, 64)));
3138 assert!(layer_admits(&rects, 0, 32, 32, (64, 64)));
3139 assert!(layer_admits(&rects, 0, 32, 64, (64, 64)));
3140 // A freed rectangle is not an obstacle, and a layer nothing occupies
3141 // admits anything the layer itself can hold.
3142 assert!(layer_admits(
3143 &[PressureRect {
3144 freed: true,
3145 ..corner
3146 }],
3147 0,
3148 64,
3149 64,
3150 (64, 64)
3151 ));
3152 assert!(!layer_admits(&[], 0, 65, 64, (64, 64)));
3153 // An occupant on another layer says nothing about this one.
3154 assert!(layer_admits(
3155 &[PressureRect { layer: 1, ..corner }],
3156 0,
3157 64,
3158 64,
3159 (64, 64)
3160 ));
3161 }
3162
3163 #[test]
3164 fn a_plan_that_would_outrun_its_budget_frees_nothing() {
3165 // The bound is on the *search*, not only on the damage: a walk that
3166 // reaches the budget without finding a fit answers `None`, and the
3167 // caller has released nothing by then.
3168 let mut rects: Vec<PressureRect> = (0..MAX_PRESSURE_EVICTION_CANDIDATES + 8)
3169 .map(|index| {
3170 let index = u32::try_from(index).expect("fits");
3171 PressureRect {
3172 key: u64::from(index),
3173 layer: 0,
3174 x0: index,
3175 y0: 0,
3176 x1: index + 1,
3177 y1: 1,
3178 area: 1,
3179 last_seen: 0,
3180 slot: index,
3181 freed: false,
3182 }
3183 })
3184 .collect();
3185 let order: Vec<usize> = (0..rects.len()).collect();
3186 let mut free_area = vec![0_u64];
3187 let mut layers = Vec::new();
3188
3189 assert_eq!(
3190 plan_pressure_eviction(
3191 64,
3192 64,
3193 (64, 64),
3194 &mut rects,
3195 &order,
3196 &mut free_area,
3197 &mut layers
3198 ),
3199 None,
3200 "a walk that cannot fit the request inside its budget plans nothing"
3201 );
3202 assert!(
3203 rects.iter().filter(|rect| rect.freed).count() <= MAX_PRESSURE_EVICTION_CANDIDATES + 1,
3204 "and it stopped walking rather than marking the whole residency"
3205 );
3206 }
3207
3208 #[test]
3209 fn a_satisfiable_layer_is_not_starved_by_a_hopeless_ones_candidates() {
3210 // The multi-layer shape the per-layer budget exists for. Layer 0 is
3211 // "this frame's" 64x40 anchor plus more than
3212 // `MAX_PRESSURE_EVICTION_CANDIDATES` one-pixel stale fillers packed
3213 // into the 64x24 strip left over — every one of them stale (older
3214 // than the incoming request's frame) and none of them, individually
3215 // or together, ever able to free a 32-tall rectangle, since the strip
3216 // they sit in is only 24 pixels tall. Layer 1 is four 32-square
3217 // quadrants exactly tiling the layer, three "this frame's" and one
3218 // stale. Layers 2 and 3 are each one "this frame's" 64-square image,
3219 // filling them outright so the incoming request cannot simply grow
3220 // into an unused layer.
3221 //
3222 // A single pooled budget spends its whole allowance walking layer 0's
3223 // fillers — none of which can ever satisfy the request — and never
3224 // reaches layer 1's one satisfiable candidate at all. A per-layer
3225 // budget exhausts layer 0's own allowance the same way, then gives
3226 // layer 1 a fresh one and finds the fit immediately.
3227 let mut residency = ImageResidency::new(AtlasBudget {
3228 atlas_size: (64, 64),
3229 max_atlases: 4,
3230 });
3231
3232 let anchor = image(64, 40);
3233 let fillers: Vec<ImageData> = (0..MAX_PRESSURE_EVICTION_CANDIDATES + 6)
3234 .map(|_| image(1, 1))
3235 .collect();
3236 let quadrants: Vec<ImageData> = (0..4).map(|_| image(32, 32)).collect();
3237 let layer2_filler = image(64, 64);
3238 let layer3_filler = image(64, 64);
3239
3240 residency.begin_frame();
3241 residency
3242 .resolve(&anchor)
3243 .expect("the anchor takes layer 0's top");
3244 for filler in &fillers {
3245 residency
3246 .resolve(filler)
3247 .expect("a one-pixel filler always finds room in the leftover strip");
3248 }
3249 for quadrant in &quadrants {
3250 residency
3251 .resolve(quadrant)
3252 .expect("a 32-square quadrant packs layer 1's empty corners");
3253 }
3254 residency
3255 .resolve(&layer2_filler)
3256 .expect("a whole-layer image grows into a fresh layer 2");
3257 residency
3258 .resolve(&layer3_filler)
3259 .expect("a whole-layer image grows into a fresh layer 3");
3260 residency.acknowledge_plan();
3261 assert_eq!(
3262 residency.layers(),
3263 4,
3264 "fixture precondition: all four layers exist"
3265 );
3266
3267 // Refresh everything except the fillers and the first quadrant, so
3268 // only they are stale candidates when the request arrives — the
3269 // fillers sort ahead of the stale quadrant because they were
3270 // resolved first and share the same `last_seen`.
3271 residency.begin_frame();
3272 residency.resolve(&anchor).expect("still resident");
3273 residency.resolve(&quadrants[1]).expect("still resident");
3274 residency.resolve(&quadrants[2]).expect("still resident");
3275 residency.resolve(&quadrants[3]).expect("still resident");
3276 residency.resolve(&layer2_filler).expect("still resident");
3277 residency.resolve(&layer3_filler).expect("still resident");
3278
3279 let arrival = residency
3280 .resolve(&image(32, 32))
3281 .expect("a per-layer budget reaches layer 1's satisfiable candidate");
3282
3283 assert_eq!(
3284 residency.pressure_evictions(),
3285 1,
3286 "exactly the one quadrant needed, not layer 0's hopeless fillers"
3287 );
3288 assert_eq!(residency.evictions().len(), 1);
3289 assert_eq!(
3290 residency.evictions()[0].layer,
3291 arrival.region.layer,
3292 "the eviction and the arrival land on the same, single layer"
3293 );
3294 assert_ne!(
3295 arrival.region.layer, 0,
3296 "the winning layer is not the one full of hopeless fillers"
3297 );
3298 }
3299
3300 /// The tier line's own text, which only a `perf-trace` build has.
3301 #[cfg(feature = "perf-trace")]
3302 #[test]
3303 fn the_resolved_tier_line_names_the_tier_and_is_written_once() {
3304 let mobile = TierCaps::fake(DownlevelProfile::WebGl2);
3305 let choice = choose_tier(&mobile, None);
3306 assert_eq!(
3307 atlas_tier_line(&mobile, choice, AtlasBudget::MOBILE),
3308 "frust-perf atlas tier=mobile rule=downlevel budget=1024x1024x4 downlevel=WebGl2 \
3309 transient_saves_memory=false memory_mib=unknown adapter=fake-webgl2"
3310 );
3311
3312 let mut adreno = TierCaps::fake(DownlevelProfile::Full);
3313 adreno.adapter_name = "Adreno (TM) 660".to_string();
3314 let choice = choose_tier(&adreno, Some(8 << 30));
3315 assert_eq!(
3316 atlas_tier_line(&adreno, choice, AtlasBudget::MOBILE_LARGE_WIDE),
3317 "frust-perf atlas tier=mobile_large rule=table budget=2048x2048x4 downlevel=Full \
3318 transient_saves_memory=false memory_mib=8192 adapter=Adreno (TM) 660"
3319 );
3320
3321 // An adapter the table does not name says which capability answer
3322 // decided it.
3323 let mut tiler = TierCaps::fake(DownlevelProfile::Full);
3324 tiler.transient_saves_memory = true;
3325 let line = atlas_tier_line(&tiler, choose_tier(&tiler, None), AtlasBudget::MOBILE);
3326 assert!(line.contains("tier=mobile rule=heuristic"));
3327 assert!(line.contains("transient_saves_memory=true"));
3328
3329 let latch = TierLogOnce::new();
3330 let choice = choose_tier(&mobile, None);
3331 assert!(
3332 latch.emit(&mobile, choice, AtlasBudget::MOBILE),
3333 "the first call writes the line"
3334 );
3335 assert!(
3336 !latch.emit(&mobile, choice, AtlasBudget::MOBILE),
3337 "and no later one repeats it"
3338 );
3339 }
3340
3341 #[test]
3342 fn an_override_redistributes_a_tiers_memory_but_never_exceeds_it() {
3343 let tier = AtlasBudget::DESKTOP;
3344
3345 // The pathological override the adapter alone would admit: a
3346 // 16384-square layer is a gibibyte on its own, so clamping the layer
3347 // count could not have brought it inside the tier's 128 MiB.
3348 let huge = AtlasBudget {
3349 atlas_size: (16_384, 16_384),
3350 max_atlases: tier.max_atlases,
3351 }
3352 .within_total_bytes(tier.total_bytes());
3353 assert!(huge.total_bytes() <= tier.total_bytes());
3354 assert_eq!(huge.atlas_size.0, huge.atlas_size.1, "aspect preserved");
3355 assert!(huge.max_atlases >= 1);
3356
3357 // A modest override is left exactly as asked for.
3358 let modest = AtlasBudget {
3359 atlas_size: (1024, 1024),
3360 max_atlases: tier.max_atlases,
3361 }
3362 .within_total_bytes(tier.total_bytes());
3363 assert_eq!(modest.atlas_size, (1024, 1024));
3364 assert_eq!(modest.max_atlases, tier.max_atlases);
3365
3366 // An extreme aspect ratio floors one axis to nothing; it is raised back
3367 // to a texel and the other axis gives the room up, so the pair still
3368 // fits and neither axis is zero.
3369 let sliver = AtlasBudget {
3370 atlas_size: (65_535, 4),
3371 max_atlases: 1,
3372 }
3373 .within_total_bytes(64);
3374 assert!(sliver.atlas_size.0 >= 1 && sliver.atlas_size.1 >= 1);
3375 assert!(sliver.total_bytes() <= 64);
3376 }
3377}