Skip to main content

frust_engine/cache/
gradients.rs

1//! Gradient colour-ramp (LUT) cache.
2//!
3//! A gradient is drawn by sampling a pre-baked colour ramp. Baking a ramp is
4//! expensive relative to a frame, and the same gradient typically recurs across
5//! frames (a themed button, a scrim), so ramps are cached by the colour-affecting
6//! properties of the gradient — its stops, interpolation colour space and hue
7//! direction — which is exactly what [`GradientCacheKey`] captures. Geometry
8//! (start/end points, radii, angles) does not affect the ramp and is therefore
9//! deliberately absent from the key: two gradients that differ only in placement
10//! share one ramp.
11//!
12//! Every cached ramp lives in one packed `Rgba8Unorm` byte buffer that is uploaded
13//! to a single texture as a flat texel stream. A cached ramp is addressed by a
14//! texel offset plus a width, so the buffer must stay contiguous: eviction
15//! therefore compacts the buffer and rewrites the offsets of the survivors.
16//!
17//! Residency is bounded by [`GradientCache::capacity`] and reclaimed LRU by frame
18//! epoch. The epoch advances on every lookup, so each live entry carries a
19//! distinct last-used value and the eviction threshold is unambiguous.
20
21use std::collections::HashMap;
22
23use vello_common::encode::{EncodedGradient, GradientCacheKey, MAX_GRADIENT_LUT_SIZE};
24use vello_common::fearless_simd::{Level, Simd, dispatch};
25use vello_common::peniko::color::cache_key::CacheKey;
26
27/// Bytes per texel of the gradient LUT texture (`Rgba8Unorm`).
28///
29/// Converts between the byte offsets the packed buffer is indexed by and the
30/// texel offsets a shader samples with.
31pub const BYTES_PER_TEXEL: u32 = 4;
32
33/// Geometry of the single texture the packed gradient LUTs are uploaded into.
34///
35/// The texture holds ramps as a flat texel stream that wraps at `width`; a ramp is
36/// free to straddle a row boundary, so `width` constrains nothing but the upload
37/// footprint.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct GradientTextureLayout {
40    /// Texture width in texels.
41    pub width: u32,
42    /// Texture height in texels.
43    pub height: u32,
44}
45
46impl GradientTextureLayout {
47    /// The texture format gradient LUTs are packed for.
48    pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
49
50    /// Create a layout for a square texture of `dim` texels a side.
51    pub fn square(dim: u32) -> Self {
52        Self {
53            width: dim,
54            height: dim,
55        }
56    }
57
58    /// Row stride in bytes, as required by `wgpu`'s texel copy layout.
59    pub fn bytes_per_row(self) -> u32 {
60        self.width << 2
61    }
62
63    /// Total byte footprint of the texture — the length an upload must be padded to.
64    pub fn byte_capacity(self) -> usize {
65        self.width as usize * self.height as usize * BYTES_PER_TEXEL as usize
66    }
67
68    /// The number of cache entries this texture can hold in the worst case, where
69    /// every ramp is baked at the maximum LUT size.
70    pub fn worst_case_entry_capacity(self) -> u32 {
71        let texels = self.width.saturating_mul(self.height);
72        texels / MAX_GRADIENT_LUT_SIZE as u32
73    }
74}
75
76/// Where one cached gradient ramp lives in the packed LUT buffer.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct CachedRamp {
79    /// Texel offset at which this ramp starts.
80    pub lut_start: u32,
81    /// Width of this ramp in texels.
82    pub width: u32,
83}
84
85/// One cache entry: a ramp plus the epoch it was last used at.
86#[derive(Debug, Clone, Copy)]
87struct CacheEntry {
88    ramp: CachedRamp,
89    last_used: u64,
90}
91
92/// Reusable working memory for eviction, kept so that a frame that evicts does not
93/// also allocate.
94#[derive(Debug, Default)]
95struct ScratchSpace {
96    /// Last-used epochs of every live entry, used to select the eviction threshold.
97    epochs: Vec<u64>,
98    /// Ramps removed by the current eviction, sorted by `lut_start` for compaction.
99    removed: Vec<CachedRamp>,
100    /// Prefix sums of removed widths, used to rewrite surviving offsets.
101    prefix_sum: Vec<u32>,
102}
103
104/// An LRU cache of baked gradient colour ramps packed into one upload buffer.
105#[derive(Debug)]
106pub struct GradientCache {
107    /// Monotonic counter advanced on every lookup; supplies LRU ordering.
108    epoch: u64,
109    /// Ramps by colour-affecting gradient identity.
110    entries: HashMap<CacheKey<GradientCacheKey>, CacheEntry>,
111    /// All live ramps, packed contiguously as `Rgba8Unorm` texels.
112    luts: Vec<u8>,
113    /// Whether `luts` has changed since the last [`GradientCache::mark_synced`].
114    has_changed: bool,
115    /// Maximum number of entries retained across a [`GradientCache::maintain`].
116    capacity: u32,
117    /// SIMD level used to bake ramps.
118    level: Level,
119    scratch: ScratchSpace,
120}
121
122impl GradientCache {
123    /// Create a cache retaining at most `capacity` ramps.
124    pub fn new(capacity: u32, level: Level) -> Self {
125        Self {
126            epoch: 0,
127            entries: HashMap::new(),
128            luts: Vec::new(),
129            has_changed: false,
130            capacity,
131            level,
132            scratch: ScratchSpace::default(),
133        }
134    }
135
136    /// Create a cache sized for the texture the ramps will be uploaded into.
137    ///
138    /// Capacity is the worst-case entry count for that texture — every ramp baked
139    /// at [`MAX_GRADIENT_LUT_SIZE`] — so the packed buffer cannot outgrow the
140    /// texture regardless of how complex the cached gradients turn out to be.
141    pub fn for_texture(layout: GradientTextureLayout, level: Level) -> Self {
142        Self::new(layout.worst_case_entry_capacity(), level)
143    }
144
145    /// The maximum number of entries retained across a [`GradientCache::maintain`].
146    pub fn capacity(&self) -> u32 {
147        self.capacity
148    }
149
150    /// Number of ramps currently resident.
151    pub fn entry_count(&self) -> usize {
152        self.entries.len()
153    }
154
155    /// Size of the packed LUT buffer in bytes.
156    pub fn luts_size(&self) -> usize {
157        self.luts.len()
158    }
159
160    /// The packed LUT bytes, ready to be uploaded as `Rgba8Unorm` texels.
161    pub fn luts(&self) -> &[u8] {
162        &self.luts
163    }
164
165    /// Whether no ramp bytes are packed.
166    pub fn is_empty(&self) -> bool {
167        self.luts.is_empty()
168    }
169
170    /// Whether the packed bytes have changed since the last upload.
171    pub fn has_changed(&self) -> bool {
172        self.has_changed
173    }
174
175    /// Record that the packed bytes have been uploaded.
176    pub fn mark_synced(&mut self) {
177        self.has_changed = false;
178    }
179
180    /// Look a gradient up without baking it or disturbing LRU order.
181    pub fn lookup(&self, gradient: &EncodedGradient) -> Option<CachedRamp> {
182        self.entries
183            .get(&gradient.cache_key)
184            .map(|entry| entry.ramp)
185    }
186
187    /// Return the cached ramp for `gradient`, baking and packing it on a miss.
188    ///
189    /// Offsets returned within one frame stay valid for that frame: baking only
190    /// appends, and the compaction that rewrites offsets happens in
191    /// [`GradientCache::maintain`] at the frame boundary.
192    pub fn get_or_create_ramp(&mut self, gradient: &EncodedGradient) -> CachedRamp {
193        self.epoch += 1;
194
195        if let Some(entry) = self.entries.get_mut(&gradient.cache_key) {
196            entry.last_used = self.epoch;
197            return entry.ramp;
198        }
199
200        let lut_start = u32::try_from(self.luts.len()).unwrap_or(u32::MAX) / BYTES_PER_TEXEL;
201        let width = dispatch!(self.level, simd => bake_ramp(simd, gradient, &mut self.luts));
202        let ramp = CachedRamp {
203            lut_start,
204            width: u32::try_from(width).unwrap_or(u32::MAX),
205        };
206
207        self.has_changed = true;
208        self.entries.insert(
209            gradient.cache_key.clone(),
210            CacheEntry {
211                ramp,
212                last_used: self.epoch,
213            },
214        );
215
216        ramp
217    }
218
219    /// Evict least-recently-used ramps down to [`GradientCache::capacity`].
220    ///
221    /// Call once per frame, after the frame's paints have been encoded — never
222    /// mid-frame, since compaction invalidates previously returned offsets.
223    pub fn maintain(&mut self) {
224        let excess = self.entries.len().saturating_sub(self.capacity as usize);
225        self.evict(excess);
226    }
227
228    /// Take the packed bytes, leaving the cache's buffer empty.
229    ///
230    /// Paired with [`GradientCache::restore_luts`] so an upload can pad the buffer
231    /// to the texture footprint and hand it back without copying the ramp bytes.
232    /// The restored buffer must hold the same logical content.
233    pub fn take_luts(&mut self) -> Vec<u8> {
234        std::mem::take(&mut self.luts)
235    }
236
237    /// Give back a buffer taken by [`GradientCache::take_luts`].
238    pub fn restore_luts(&mut self, luts: Vec<u8>) {
239        self.luts = luts;
240    }
241
242    /// Borrow the packed bytes padded out to `layout`'s full byte footprint,
243    /// ready to hand to a texel copy.
244    ///
245    /// Returns `None` when no ramps are packed, and when `layout` is too small
246    /// to hold them. The second case is a *refusal*, logged rather than
247    /// silently served: padding to a footprint below the packed length would
248    /// truncate the buffer instead of extending it, uploading a prefix of the
249    /// ramps under offsets computed for all of them — every gradient past the
250    /// cut would sample whatever the texture already held. Skipping the upload
251    /// leaves the previous frame's texels in place, which is stale but
252    /// coherent, and leaves `has_changed` set so the next upload against a
253    /// large enough layout still happens.
254    ///
255    /// The padding is applied to the cache's own buffer and undone when the
256    /// returned [`LutUpload`] drops, so a served upload costs one resize rather
257    /// than a copy of every ramp.
258    pub fn begin_upload(&mut self, layout: GradientTextureLayout) -> Option<LutUpload<'_>> {
259        if self.luts.is_empty() {
260            return None;
261        }
262
263        let logical_len = self.luts.len();
264        if layout.byte_capacity() < logical_len {
265            log::warn!(
266                "gradient LUT upload skipped: {logical_len} packed bytes do not fit a \
267                 {}x{} texture ({} bytes)",
268                layout.width,
269                layout.height,
270                layout.byte_capacity(),
271            );
272            return None;
273        }
274
275        let mut bytes = self.take_luts();
276        bytes.resize(layout.byte_capacity(), 0);
277
278        Some(LutUpload {
279            cache: self,
280            bytes,
281            logical_len,
282            layout,
283        })
284    }
285
286    /// Remove `count` least-recently-used entries and compact the packed buffer.
287    fn evict(&mut self, count: usize) {
288        if count == 0 || self.entries.is_empty() {
289            return;
290        }
291
292        let mut epochs = std::mem::take(&mut self.scratch.epochs);
293        epochs.clear();
294        epochs.extend(self.entries.values().map(|entry| entry.last_used));
295
296        // The epoch advances on every lookup, so no two live entries share a
297        // last-used value and everything at or below the threshold is exactly the
298        // `count` oldest.
299        let (_, &mut threshold, _) = epochs.select_nth_unstable(count - 1);
300        self.scratch.epochs = epochs;
301
302        let mut removed = std::mem::take(&mut self.scratch.removed);
303        removed.clear();
304        self.entries.retain(|_, entry| {
305            if entry.last_used <= threshold {
306                removed.push(entry.ramp);
307                false
308            } else {
309                true
310            }
311        });
312
313        removed.sort_unstable_by_key(|ramp| ramp.lut_start);
314        let mut prefix_sum = std::mem::take(&mut self.scratch.prefix_sum);
315        self.compact_luts(&removed, &mut prefix_sum);
316
317        self.scratch.removed = removed;
318        self.scratch.prefix_sum = prefix_sum;
319        self.has_changed = true;
320    }
321
322    /// Close the gaps left by `removed` in the packed buffer and rewrite the
323    /// offsets of the entries that survived.
324    ///
325    /// `removed` must be sorted by `lut_start`.
326    fn compact_luts(&mut self, removed: &[CachedRamp], prefix_sum: &mut Vec<u32>) {
327        if removed.is_empty() {
328            return;
329        }
330
331        // `prefix_sum[i]` is the total texel width removed before `removed[i]`, so
332        // a survivor's offset shrinks by the entry matching its position in
333        // `removed`. The leading zero makes the partition point below index it
334        // directly.
335        prefix_sum.clear();
336        prefix_sum.push(0);
337
338        let mut write_pos = 0;
339        let mut read_pos = 0;
340
341        for ramp in removed {
342            let remove_start = (ramp.lut_start * BYTES_PER_TEXEL) as usize;
343            let remove_end = remove_start + (ramp.width * BYTES_PER_TEXEL) as usize;
344
345            if read_pos < remove_start {
346                self.luts.copy_within(read_pos..remove_start, write_pos);
347                write_pos += remove_start - read_pos;
348            }
349
350            read_pos = remove_end;
351            prefix_sum.push(prefix_sum.last().copied().unwrap_or(0) + ramp.width);
352        }
353
354        let luts_len = self.luts.len();
355        if read_pos < luts_len {
356            self.luts.copy_within(read_pos..luts_len, write_pos);
357            write_pos += luts_len - read_pos;
358        }
359        self.luts.truncate(write_pos);
360
361        for entry in self.entries.values_mut() {
362            let pos = removed.partition_point(|ramp| ramp.lut_start < entry.ramp.lut_start);
363            entry.ramp.lut_start -= prefix_sum[pos];
364        }
365    }
366}
367
368/// The packed LUT bytes padded to a texture's footprint for one upload.
369///
370/// Dereferences to the bytes a texel copy consumes. Dropping it trims the padding
371/// and hands the buffer back to the cache, so the cache is only ever borrowed for
372/// the duration of the upload.
373#[derive(Debug)]
374pub struct LutUpload<'a> {
375    cache: &'a mut GradientCache,
376    bytes: Vec<u8>,
377    logical_len: usize,
378    layout: GradientTextureLayout,
379}
380
381impl LutUpload<'_> {
382    /// The texture geometry these bytes were padded for.
383    pub fn layout(&self) -> GradientTextureLayout {
384        self.layout
385    }
386
387    /// Row stride in bytes for the texel copy.
388    pub fn bytes_per_row(&self) -> u32 {
389        self.layout.bytes_per_row()
390    }
391
392    /// Number of bytes that hold actual ramp data; the rest is zero padding.
393    pub fn logical_len(&self) -> usize {
394        self.logical_len
395    }
396}
397
398impl std::ops::Deref for LutUpload<'_> {
399    type Target = [u8];
400
401    fn deref(&self) -> &Self::Target {
402        &self.bytes
403    }
404}
405
406impl Drop for LutUpload<'_> {
407    fn drop(&mut self) {
408        let mut bytes = std::mem::take(&mut self.bytes);
409        bytes.truncate(self.logical_len);
410        self.cache.restore_luts(bytes);
411    }
412}
413
414/// Bake `gradient`'s colour ramp, append it to `output`, and return its texel width.
415#[inline(always)]
416fn bake_ramp<S: Simd>(simd: S, gradient: &EncodedGradient, output: &mut Vec<u8>) -> usize {
417    let lut = gradient.u8_lut(simd);
418    let bytes: &[u8] = bytemuck::cast_slice(lut.lut());
419    output.extend_from_slice(bytes);
420    lut.width()
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    /// A cache holding `len` bytes of packed ramps, with no baking involved:
428    /// the refusal is a decision over the buffer's length against the
429    /// texture's footprint, and needs no real gradient to exercise.
430    fn packed(len: usize) -> GradientCache {
431        let mut cache = GradientCache::new(8, Level::baseline());
432        cache.restore_luts(vec![0xAB; len]);
433        cache
434    }
435
436    #[test]
437    fn an_upload_into_a_texture_smaller_than_the_packed_ramps_is_refused() {
438        let layout = GradientTextureLayout::square(4);
439        let mut cache = packed(layout.byte_capacity() + 4);
440
441        assert!(
442            cache.begin_upload(layout).is_none(),
443            "a too-small layout must refuse rather than truncate"
444        );
445        assert_eq!(
446            cache.luts_size(),
447            layout.byte_capacity() + 4,
448            "the refusal leaves every packed ramp byte in place"
449        );
450    }
451
452    #[test]
453    fn an_upload_exactly_filling_the_texture_is_served() {
454        let layout = GradientTextureLayout::square(4);
455        let mut cache = packed(layout.byte_capacity());
456
457        let upload = cache.begin_upload(layout).expect("an exact fit is served");
458        assert_eq!(upload.len(), layout.byte_capacity());
459        assert_eq!(upload.logical_len(), layout.byte_capacity());
460    }
461}