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}