Skip to main content

khora_data/gpu/
eviction.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! GPU asset-cache eviction — budgeted reclamation of orphaned GPU resources.
16//!
17//! The CPU→GPU projection ([`ProjectionRegistry`](crate::gpu::ProjectionRegistry))
18//! is insert-only: it uploads a `GpuMesh` / `GpuMaterial` the first time an
19//! entity needs one, tags the entity, and never revisits it. Despawning an
20//! entity, or editing a material inline (which mints a fresh asset UUID),
21//! therefore leaves the old cache entry — and the wgpu buffers, textures,
22//! views and bind group it owns — alive forever. `AssetHandle` is a plain
23//! `Arc`: dropping the last clone frees the *struct*, but its fields are
24//! `Copy` id handles into backend slotmaps that only an explicit `destroy_*`
25//! releases. So the cache grew without bound.
26//!
27//! [`AssetEviction`] is the Data-layer self-maintenance that closes the leak,
28//! the GPU-asset twin of [`EcsMaintenance`](crate::ecs::EcsMaintenance). Each
29//! frame, in [`TickPhase::Maintenance`](crate::ecs::TickPhase::Maintenance), it
30//! diffs the cached UUID set against the set still referenced by live entities
31//! and, under a per-frame budget, removes the orphans and destroys their GPU
32//! resources. It only ever removes entries no live entity references, so it
33//! changes representation, never game semantics (adapt the HOW, not the WHAT).
34//!
35//! Maintenance runs at the end of the tick, after the render descent has read
36//! the caches for this frame, so freeing an orphan is safe: nothing projects a
37//! despawned entity next frame, and the ECS query already skips orphan rows so
38//! a not-yet-compacted despawned row never keeps a UUID artificially live.
39
40use std::collections::HashSet;
41
42use khora_core::asset::{AssetHandle, AssetUUID};
43use khora_core::renderer::api::scene::{GpuMaterial, GpuMesh};
44use khora_core::renderer::GraphicsDevice;
45
46use crate::ecs::{HandleComponent, World};
47use crate::gpu::AssetStore;
48
49/// Default number of orphaned GPU assets reclaimed per frame, shared across
50/// meshes and materials. Bounded like [`EcsMaintenance`] so a burst of
51/// despawns spreads its teardown cost over several frames.
52const DEFAULT_MAX_PER_FRAME: usize = 16;
53
54/// Direct GPU asset-cache maintenance service.
55///
56/// Evicts up to `max_per_frame` orphaned GPU asset entries each frame,
57/// destroying their backing wgpu resources. Mirrors
58/// [`EcsMaintenance`](crate::ecs::EcsMaintenance) for the GPU asset store:
59/// budgeted, idempotent, and Data-owned.
60pub struct AssetEviction {
61    max_per_frame: usize,
62    last_evicted_count: usize,
63}
64
65impl AssetEviction {
66    /// Creates a new eviction service with the default per-frame budget.
67    pub fn new() -> Self {
68        Self {
69            max_per_frame: DEFAULT_MAX_PER_FRAME,
70            last_evicted_count: 0,
71        }
72    }
73
74    /// Creates a new eviction service with a custom per-frame budget.
75    pub fn with_budget(max_per_frame: usize) -> Self {
76        Self {
77            max_per_frame,
78            last_evicted_count: 0,
79        }
80    }
81
82    /// Runs one frame of eviction, sharing `max_per_frame` across materials
83    /// then meshes. Orphans beyond the budget stay cached for the next frame —
84    /// harmless, since an orphaned entry is simply not yet reclaimed.
85    pub fn tick(&mut self, store: &AssetStore, world: &World, device: &dyn GraphicsDevice) {
86        self.last_evicted_count = 0;
87        let mut remaining = self.max_per_frame;
88        if remaining == 0 {
89            return;
90        }
91
92        let materials = take_orphans::<GpuMaterial>(store, world, remaining);
93        for handle in &materials {
94            destroy_gpu_material(handle, device);
95        }
96        remaining -= materials.len();
97        self.last_evicted_count += materials.len();
98
99        if remaining > 0 {
100            let meshes = take_orphans::<GpuMesh>(store, world, remaining);
101            for handle in &meshes {
102                destroy_gpu_mesh(handle, device);
103            }
104            self.last_evicted_count += meshes.len();
105        }
106
107        if self.last_evicted_count > 0 {
108            log::trace!(
109                "AssetEviction: reclaimed {} orphaned GPU asset(s)",
110                self.last_evicted_count
111            );
112        }
113    }
114
115    /// Number of GPU assets evicted in the last [`tick`](Self::tick).
116    pub fn last_evicted_count(&self) -> usize {
117        self.last_evicted_count
118    }
119
120    /// The maximum number of GPU assets evicted per frame.
121    pub fn max_per_frame(&self) -> usize {
122        self.max_per_frame
123    }
124}
125
126impl Default for AssetEviction {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132/// The set of asset UUIDs still referenced by a live entity's
133/// `HandleComponent<A>`. The ECS query skips orphan rows, so a despawned
134/// entity whose row has not yet been compacted does not keep its UUID live.
135fn live_handles<A: khora_core::asset::Asset>(world: &World) -> HashSet<AssetUUID> {
136    let mut live = HashSet::new();
137    for (handle,) in world.query::<(&HandleComponent<A>,)>() {
138        live.insert(handle.uuid);
139    }
140    live
141}
142
143/// Removes up to `budget` orphaned entries of type `A` from the store and
144/// returns their handles for teardown. Device-free — the removal *is* the leak
145/// fix; the caller destroys the returned resources. An entry is orphaned when
146/// no live entity references its UUID.
147fn take_orphans<A: khora_core::asset::Asset>(
148    store: &AssetStore,
149    world: &World,
150    budget: usize,
151) -> Vec<AssetHandle<A>> {
152    let live = live_handles::<A>(world);
153    let cache = store.store::<A>();
154
155    // Snapshot the orphaned keys under a read lock, then drop it before taking
156    // the write lock — no lock is held across the device `destroy_*` calls.
157    let orphans: Vec<AssetUUID> = {
158        let guard = cache.read().unwrap_or_else(|e| e.into_inner());
159        guard
160            .keys()
161            .filter(|uuid| !live.contains(uuid))
162            .take(budget)
163            .collect()
164    };
165    if orphans.is_empty() {
166        return Vec::new();
167    }
168
169    let mut guard = cache.write().unwrap_or_else(|e| e.into_inner());
170    orphans
171        .iter()
172        .filter_map(|uuid| guard.remove(uuid))
173        .collect()
174}
175
176/// Frees the wgpu resources a [`GpuMaterial`] exclusively owns: its bind group,
177/// uniform buffer, and each declared texture with its view. The `sampler` is
178/// the engine-shared filtering sampler and is deliberately left intact.
179/// Teardown errors are logged, never fatal (a stale id is harmless).
180fn destroy_gpu_material(material: &GpuMaterial, device: &dyn GraphicsDevice) {
181    if let Err(e) = device.destroy_bind_group(material.bind_group) {
182        log::warn!("AssetEviction: destroy_bind_group failed: {e:?}");
183    }
184    if let Err(e) = device.destroy_buffer(material.uniform_buffer) {
185        log::warn!("AssetEviction: destroy_buffer (material uniform) failed: {e:?}");
186    }
187    for view in [
188        material.base_color_view,
189        material.metallic_roughness_view,
190        material.normal_view,
191        material.emissive_view,
192        material.occlusion_view,
193    ]
194    .into_iter()
195    .flatten()
196    {
197        if let Err(e) = device.destroy_texture_view(view) {
198            log::warn!("AssetEviction: destroy_texture_view failed: {e:?}");
199        }
200    }
201    for texture in [
202        material.base_color_texture,
203        material.metallic_roughness_texture,
204        material.normal_texture,
205        material.emissive_texture,
206        material.occlusion_texture,
207    ]
208    .into_iter()
209    .flatten()
210    {
211        if let Err(e) = device.destroy_texture(texture) {
212            log::warn!("AssetEviction: destroy_texture failed: {e:?}");
213        }
214    }
215}
216
217/// Frees the wgpu resources a [`GpuMesh`] owns: its vertex and index buffers.
218/// A non-indexed mesh still holds a real (zero-sized) index buffer, so both are
219/// always destroyed. Teardown errors are logged, never fatal.
220fn destroy_gpu_mesh(mesh: &GpuMesh, device: &dyn GraphicsDevice) {
221    if let Err(e) = device.destroy_buffer(mesh.vertex_buffer) {
222        log::warn!("AssetEviction: destroy_buffer (mesh vertex) failed: {e:?}");
223    }
224    if let Err(e) = device.destroy_buffer(mesh.index_buffer) {
225        log::warn!("AssetEviction: destroy_buffer (mesh index) failed: {e:?}");
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use khora_core::renderer::api::command::BindGroupId;
233    use khora_core::renderer::api::pipeline::{PrimitiveTopology, ShaderVariantKey};
234    use khora_core::renderer::api::resource::{BufferId, SamplerId};
235    use khora_core::renderer::api::util::IndexFormat;
236
237    /// A cache-only `GpuMaterial` stub (no real GPU resources) — enough to
238    /// exercise the device-free selection/removal logic.
239    fn material_stub() -> GpuMaterial {
240        GpuMaterial {
241            uniform_buffer: BufferId(0),
242            base_color_view: None,
243            metallic_roughness_view: None,
244            normal_view: None,
245            emissive_view: None,
246            occlusion_view: None,
247            base_color_texture: None,
248            metallic_roughness_texture: None,
249            normal_texture: None,
250            emissive_texture: None,
251            occlusion_texture: None,
252            sampler: SamplerId(0),
253            bind_group: BindGroupId(0),
254            variant: ShaderVariantKey::empty(),
255            double_sided: false,
256            blend: false,
257        }
258    }
259
260    fn mesh_stub() -> GpuMesh {
261        GpuMesh {
262            vertex_buffer: BufferId(0),
263            index_buffer: BufferId(0),
264            index_count: 0,
265            index_format: IndexFormat::Uint32,
266            primitive_topology: PrimitiveTopology::TriangleList,
267        }
268    }
269
270    /// Inserts a material into the store under `uuid` and returns nothing;
271    /// the entity (if any) is spawned separately so we control liveness.
272    fn cache_material(store: &AssetStore, uuid: AssetUUID) {
273        store
274            .store::<GpuMaterial>()
275            .write()
276            .unwrap()
277            .insert(uuid, AssetHandle::new(material_stub()));
278    }
279
280    fn cache_mesh(store: &AssetStore, uuid: AssetUUID) {
281        store
282            .store::<GpuMesh>()
283            .write()
284            .unwrap()
285            .insert(uuid, AssetHandle::new(mesh_stub()));
286    }
287
288    fn material_count(store: &AssetStore) -> usize {
289        store.store::<GpuMaterial>().read().unwrap().len()
290    }
291
292    fn mesh_count(store: &AssetStore) -> usize {
293        store.store::<GpuMesh>().read().unwrap().len()
294    }
295
296    /// Spawns an entity tagged with `HandleComponent<GpuMaterial>` for `uuid`,
297    /// making `uuid` a live reference.
298    fn spawn_material_ref(world: &mut World, uuid: AssetUUID) {
299        world.spawn(HandleComponent {
300            handle: AssetHandle::new(material_stub()),
301            uuid,
302        });
303    }
304
305    fn spawn_mesh_ref(world: &mut World, uuid: AssetUUID) {
306        world.spawn(HandleComponent {
307            handle: AssetHandle::new(mesh_stub()),
308            uuid,
309        });
310    }
311
312    #[test]
313    fn keeps_referenced_material_evicts_orphan() {
314        let store = AssetStore::new();
315        let mut world = World::new();
316
317        let live = AssetUUID::new();
318        let orphan = AssetUUID::new();
319        cache_material(&store, live);
320        cache_material(&store, orphan);
321        spawn_material_ref(&mut world, live);
322
323        let removed = take_orphans::<GpuMaterial>(&store, &world, 16);
324        assert_eq!(removed.len(), 1, "exactly the orphan is removed");
325        assert_eq!(material_count(&store), 1, "cache no longer grows unbounded");
326        assert!(
327            store.store::<GpuMaterial>().read().unwrap().contains(&live),
328            "the referenced material stays cached"
329        );
330    }
331
332    #[test]
333    fn nothing_evicted_when_all_referenced() {
334        let store = AssetStore::new();
335        let mut world = World::new();
336        let a = AssetUUID::new();
337        let b = AssetUUID::new();
338        cache_material(&store, a);
339        cache_material(&store, b);
340        spawn_material_ref(&mut world, a);
341        spawn_material_ref(&mut world, b);
342
343        let removed = take_orphans::<GpuMaterial>(&store, &world, 16);
344        assert!(removed.is_empty());
345        assert_eq!(material_count(&store), 2);
346    }
347
348    #[test]
349    fn despawn_makes_material_evictable() {
350        let store = AssetStore::new();
351        let mut world = World::new();
352        let uuid = AssetUUID::new();
353        cache_material(&store, uuid);
354
355        // Spawn then despawn: the query skips the orphaned row, so the UUID is
356        // no longer live and becomes evictable.
357        let entity = world.spawn(HandleComponent {
358            handle: AssetHandle::new(material_stub()),
359            uuid,
360        });
361        assert_eq!(take_orphans::<GpuMaterial>(&store, &world, 16).len(), 0);
362
363        world.despawn(entity);
364        let removed = take_orphans::<GpuMaterial>(&store, &world, 16);
365        assert_eq!(removed.len(), 1);
366        assert_eq!(material_count(&store), 0);
367    }
368
369    #[test]
370    fn budget_bounds_evictions_per_call() {
371        let store = AssetStore::new();
372        let world = World::new();
373        for _ in 0..5 {
374            cache_material(&store, AssetUUID::new());
375        }
376        // No live refs → all 5 are orphans, but the budget caps the batch.
377        let removed = take_orphans::<GpuMaterial>(&store, &world, 2);
378        assert_eq!(removed.len(), 2);
379        assert_eq!(
380            material_count(&store),
381            3,
382            "leftover orphans stay for next frame"
383        );
384    }
385
386    #[test]
387    fn evicts_orphaned_mesh_and_respects_liveness() {
388        let store = AssetStore::new();
389        let mut world = World::new();
390        let live = AssetUUID::new();
391        let orphan = AssetUUID::new();
392        cache_mesh(&store, live);
393        cache_mesh(&store, orphan);
394        spawn_mesh_ref(&mut world, live);
395
396        let removed = take_orphans::<GpuMesh>(&store, &world, 16);
397        assert_eq!(removed.len(), 1);
398        assert_eq!(mesh_count(&store), 1);
399        assert!(store.store::<GpuMesh>().read().unwrap().contains(&live));
400    }
401}