1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! SVG texture atlas for batched GPU rendering
//!
//! Packs all rasterized SVG icons into a single shared RGBA texture using
//! shelf-packing (skyline algorithm). Eliminates per-icon GPU textures and
//! enables single-draw-call rendering for all SVG instances in a frame.
use std::collections::{HashMap, HashSet};
/// Region allocated in the SVG atlas
#[derive(Debug, Clone, Copy)]
pub struct SvgAtlasRegion {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
impl SvgAtlasRegion {
/// Returns [u_min, v_min, u_max, v_max] matching GpuImageInstance.src_uv format
pub fn uv_bounds(&self, atlas_w: u32, atlas_h: u32) -> [f32; 4] {
let u_min = self.x as f32 / atlas_w as f32;
let v_min = self.y as f32 / atlas_h as f32;
let u_max = (self.x + self.width) as f32 / atlas_w as f32;
let v_max = (self.y + self.height) as f32 / atlas_h as f32;
[u_min, v_min, u_max, v_max]
}
}
/// A shelf in the skyline packing algorithm
#[derive(Debug)]
struct Shelf {
y: u32,
height: u32,
x: u32,
}
const INITIAL_SIZE: u32 = 1024;
const MAX_SIZE: u32 = 4096;
const PADDING: u32 = 2;
/// SVG texture atlas — packs rasterized SVGs into a single GPU texture
pub struct SvgAtlas {
width: u32,
height: u32,
pixels: Vec<u8>,
shelves: Vec<Shelf>,
entries: HashMap<u64, SvgAtlasRegion>,
texture: wgpu::Texture,
view: wgpu::TextureView,
dirty: bool,
/// Cache keys accessed since the last `end_frame()` call.
/// Used by the mark-and-sweep eviction: when the atlas is full,
/// `evict_unused()` clears entries that weren't accessed this
/// frame and repacks the atlas from scratch. Without this,
/// animated SVGs (each frame producing unique cache keys) fill
/// the atlas permanently and all subsequent insertions fail.
used_this_frame: HashSet<u64>,
}
impl SvgAtlas {
pub fn new(device: &wgpu::Device) -> Self {
let (texture, view) = create_atlas_texture(device, INITIAL_SIZE, INITIAL_SIZE);
Self {
width: INITIAL_SIZE,
height: INITIAL_SIZE,
pixels: vec![0u8; (INITIAL_SIZE * INITIAL_SIZE * 4) as usize],
shelves: Vec::new(),
entries: HashMap::new(),
texture,
view,
dirty: false,
used_this_frame: HashSet::new(),
}
}
pub fn view(&self) -> &wgpu::TextureView {
&self.view
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
pub fn entry_count(&self) -> usize {
self.entries.len()
}
/// Look up an existing entry by cache key
pub fn get(&self, cache_key: u64) -> Option<&SvgAtlasRegion> {
self.entries.get(&cache_key)
}
/// Mark a cache key as "used this frame" for eviction tracking.
/// Call this for every SVG that actually gets rendered.
pub fn mark_used(&mut self, cache_key: u64) {
self.used_this_frame.insert(cache_key);
}
/// Allocate space, write pixels, and insert a cache entry. Returns the region.
///
/// Returns `None` if the atlas is full at the maximum size — callers
/// should either fall back to a per-icon texture or simply skip the
/// element for this frame. We deliberately do *not* `clear()` and
/// retry on overflow at MAX_SIZE: that turns the atlas into a
/// thrashing cache where every overflowing frame wipes valid entries
/// and re-rasterizes them on the next frame, churning the GPU and
/// keeping the texture pinned at the maximum 4096×4096 footprint
/// (~67 MB CPU + ~67 MB GPU) forever. If repeated overflow happens,
/// the warning surfaces it instead of silently degrading.
pub fn insert(
&mut self,
cache_key: u64,
width: u32,
height: u32,
rgba_pixels: &[u8],
device: &wgpu::Device,
) -> Option<SvgAtlasRegion> {
// Try to allocate — grow if needed, but never repack mid-frame.
// Eviction runs in `begin_frame()` at the top of the render
// pass so UV coordinates stay stable for the entire loop.
let region = match self.allocate(width, height) {
Some(r) => r,
None => {
if self.grow(device) {
self.allocate(width, height)?
} else {
tracing::warn!(
"SVG atlas at max size {}x{} could not fit {}x{} ({} entries, {:.1}% used) — skipping insertion",
self.width,
self.height,
width,
height,
self.entries.len(),
self.utilization() * 100.0,
);
return None;
}
}
};
self.used_this_frame.insert(cache_key);
self.write_pixels(®ion, rgba_pixels);
self.entries.insert(cache_key, region);
Some(region)
}
/// Upload dirty pixels to the GPU texture
pub fn upload(&mut self, queue: &wgpu::Queue) {
if !self.dirty {
return;
}
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &self.texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&self.pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(self.width * 4),
rows_per_image: Some(self.height),
},
wgpu::Extent3d {
width: self.width,
height: self.height,
depth_or_array_layers: 1,
},
);
self.dirty = false;
}
/// Clear all entries and shelves (full eviction)
pub fn clear(&mut self) {
self.entries.clear();
self.shelves.clear();
self.pixels.fill(0);
self.used_this_frame.clear();
self.dirty = true;
}
/// Call at the **start** of each SVG render pass, before any
/// `get()` / `insert()` calls. Evicts entries from the previous
/// frame that won't be needed, then resets the tracking set for
/// the new frame.
///
/// Eviction runs here — not reactively inside `insert()` — so
/// that all UV coordinates computed during the frame stay stable.
/// A mid-loop repack would move surviving entries to new shelf
/// positions, invalidating UVs already pushed into the instance
/// buffer and producing a visible blink.
pub fn begin_frame(&mut self, device: &wgpu::Device) {
// Only evict if the atlas is ≥75% full and there are entries
// from the previous frame that weren't used.
if self.utilization() >= 0.75 {
let stale_count = self
.entries
.keys()
.filter(|k| !self.used_this_frame.contains(k))
.count();
if stale_count > 0 {
self.evict_unused(device);
}
}
self.used_this_frame.clear();
}
/// Evict entries that weren't accessed this frame, then reset the
/// allocator so new insertions can reuse the freed space. Returns
/// `true` if any entries were evicted.
///
/// This is the escape hatch for animated SVGs that produce unique
/// cache keys every frame (e.g. rotating icons with per-frame
/// tint changes). Without eviction the atlas fills up permanently
/// and all subsequent insertions fail.
fn evict_unused(&mut self, device: &wgpu::Device) -> bool {
let before = self.entries.len();
// Keep only entries that were accessed this frame
self.entries
.retain(|key, _| self.used_this_frame.contains(key));
let evicted = before - self.entries.len();
if evicted == 0 {
return false;
}
tracing::info!(
"SVG atlas: evicted {} stale entries ({} remain), repacking",
evicted,
self.entries.len(),
);
// Repack: save the pixel data for surviving entries, clear
// the atlas, then re-insert them. This reclaims fragmented
// space from the shelf packer.
let surviving: Vec<(u64, Vec<u8>, u32, u32)> = self
.entries
.iter()
.map(|(&key, region)| {
let row_bytes = region.width as usize * 4;
let mut px = vec![0u8; row_bytes * region.height as usize];
for y in 0..region.height {
let src =
((region.y + y) as usize * self.width as usize + region.x as usize) * 4;
let dst = y as usize * row_bytes;
if src + row_bytes <= self.pixels.len() {
px[dst..dst + row_bytes]
.copy_from_slice(&self.pixels[src..src + row_bytes]);
}
}
(key, px, region.width, region.height)
})
.collect();
// Full reset of the packing state
self.shelves.clear();
self.entries.clear();
self.pixels.fill(0);
// Re-insert surviving entries
for (key, px, w, h) in surviving {
if let Some(region) = self.allocate(w, h) {
self.write_pixels(®ion, &px);
self.entries.insert(key, region);
} else {
// Atlas shrunk below surviving set — try growing
if self.grow(device) {
if let Some(region) = self.allocate(w, h) {
self.write_pixels(®ion, &px);
self.entries.insert(key, region);
}
}
}
}
self.dirty = true;
true
}
/// Calculate atlas utilization (0.0 to 1.0)
pub fn utilization(&self) -> f32 {
let used_height = self.shelves.last().map(|s| s.y + s.height).unwrap_or(0);
used_height as f32 / self.height as f32
}
/// Allocate a region using shelf packing
fn allocate(&mut self, width: u32, height: u32) -> Option<SvgAtlasRegion> {
let padded_w = width + PADDING;
let padded_h = height + PADDING;
// Find best shelf (smallest height that fits, lowest Y)
let mut best_shelf: Option<usize> = None;
let mut best_y = u32::MAX;
for (i, shelf) in self.shelves.iter().enumerate() {
if shelf.height >= padded_h && shelf.x + padded_w <= self.width && shelf.y < best_y {
best_y = shelf.y;
best_shelf = Some(i);
}
}
if let Some(idx) = best_shelf {
let shelf = &mut self.shelves[idx];
let region = SvgAtlasRegion {
x: shelf.x,
y: shelf.y,
width,
height,
};
shelf.x += padded_w;
return Some(region);
}
// Create new shelf
let new_y = self.shelves.last().map(|s| s.y + s.height).unwrap_or(0);
if new_y + padded_h > self.height {
return None;
}
let region = SvgAtlasRegion {
x: 0,
y: new_y,
width,
height,
};
self.shelves.push(Shelf {
y: new_y,
height: padded_h,
x: padded_w,
});
Some(region)
}
/// Blit RGBA pixel data into the atlas at the given region
fn write_pixels(&mut self, region: &SvgAtlasRegion, rgba: &[u8]) {
let row_bytes = region.width as usize * 4;
for y in 0..region.height {
let src_offset = y as usize * row_bytes;
let dst_offset =
((region.y + y) as usize * self.width as usize + region.x as usize) * 4;
if src_offset + row_bytes <= rgba.len() && dst_offset + row_bytes <= self.pixels.len() {
self.pixels[dst_offset..dst_offset + row_bytes]
.copy_from_slice(&rgba[src_offset..src_offset + row_bytes]);
}
}
self.dirty = true;
}
/// Double atlas dimensions, copy old pixels into top-left quadrant.
/// Creates a new GPU texture. Returns false if already at max size.
fn grow(&mut self, device: &wgpu::Device) -> bool {
let new_w = (self.width * 2).min(MAX_SIZE);
let new_h = (self.height * 2).min(MAX_SIZE);
if new_w == self.width && new_h == self.height {
return false;
}
let mut new_pixels = vec![0u8; (new_w * new_h * 4) as usize];
for y in 0..self.height {
let src_start = (y * self.width * 4) as usize;
let src_end = src_start + (self.width * 4) as usize;
let dst_start = (y * new_w * 4) as usize;
let dst_end = dst_start + (self.width * 4) as usize;
new_pixels[dst_start..dst_end].copy_from_slice(&self.pixels[src_start..src_end]);
}
self.pixels = new_pixels;
self.width = new_w;
self.height = new_h;
let (texture, view) = create_atlas_texture(device, new_w, new_h);
self.texture = texture;
self.view = view;
self.dirty = true;
tracing::info!(
"SVG atlas grew to {}x{} ({:.1} MB)",
new_w,
new_h,
(new_w as f64 * new_h as f64 * 4.0) / (1024.0 * 1024.0)
);
true
}
}
fn create_atlas_texture(
device: &wgpu::Device,
width: u32,
height: u32,
) -> (wgpu::Texture, wgpu::TextureView) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("SVG Atlas"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
(texture, view)
}