1use 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
49const DEFAULT_MAX_PER_FRAME: usize = 16;
53
54pub struct AssetEviction {
61 max_per_frame: usize,
62 last_evicted_count: usize,
63}
64
65impl AssetEviction {
66 pub fn new() -> Self {
68 Self {
69 max_per_frame: DEFAULT_MAX_PER_FRAME,
70 last_evicted_count: 0,
71 }
72 }
73
74 pub fn with_budget(max_per_frame: usize) -> Self {
76 Self {
77 max_per_frame,
78 last_evicted_count: 0,
79 }
80 }
81
82 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 pub fn last_evicted_count(&self) -> usize {
117 self.last_evicted_count
118 }
119
120 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
132fn 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
143fn 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 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
176fn 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
217fn 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 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 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 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 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 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}