1use std::sync::{Arc, Mutex};
64
65use khora_core::asset::{AssetHandle, AssetUUID, Material};
66use khora_core::ecs::entity::EntityId;
67use khora_core::lane::OutputDeck;
68use khora_core::renderer::api::scene::{GpuMaterial, GpuMesh, Mesh};
69use khora_core::Runtime;
70use khora_data::ecs::{
71 reconstruct_procedural_mesh, DataSystemRegistration, HandleComponent, MaterialRef, MeshRef,
72 TickPhase, World,
73};
74use khora_data::AssetStore;
75
76use crate::asset::AssetService;
77
78struct ResolvedMaterial {
84 entity: EntityId,
85 uuid: AssetUUID,
86 handle: AssetHandle<Box<dyn Material>>,
87 had_stale_handle: bool,
88}
89
90struct ResolvedMesh {
94 entity: EntityId,
95 uuid: AssetUUID,
96 handle: AssetHandle<Mesh>,
97 had_stale_handle: bool,
98}
99
100fn asset_resolver_system(world: &mut World, runtime: &Runtime, _deck: &mut OutputDeck) {
101 resolve_materials(world, runtime);
102 resolve_meshes(world, runtime);
103}
104
105fn resolve_materials(world: &mut World, runtime: &Runtime) {
106 let asset_store = runtime.resources.get::<AssetStore>();
107 let asset_service = runtime.services.get::<Arc<Mutex<AssetService>>>().cloned();
108
109 let mut pending: Vec<ResolvedMaterial> = Vec::new();
115 {
116 let query = world.query::<(
117 EntityId,
118 &MaterialRef,
119 Option<&HandleComponent<Box<dyn Material>>>,
120 )>();
121
122 for (entity, material_ref, current) in query {
123 let expected = material_ref.uuid();
124 if current.map(|h| h.uuid) == Some(expected) {
127 continue;
128 }
129 let had_stale_handle = current.is_some();
133
134 match material_ref {
135 MaterialRef::Inline { material, .. } => {
136 let handle = AssetHandle::new(material.clone_box());
137 pending.push(ResolvedMaterial {
138 entity,
139 uuid: expected,
140 handle,
141 had_stale_handle,
142 });
143 }
144 MaterialRef::Asset(uuid) => {
145 let Some(service) = &asset_service else {
146 log::error!(
147 "asset_resolver: AssetService missing; cannot resolve material \
148 asset {uuid:?} on entity {entity:?}."
149 );
150 continue;
151 };
152 let loaded = match service.lock() {
153 Ok(mut svc) => svc.load::<Box<dyn Material>>(uuid),
154 Err(_) => {
155 log::error!("asset_resolver: AssetService mutex poisoned.");
156 continue;
157 }
158 };
159 match loaded {
160 Ok(handle) => pending.push(ResolvedMaterial {
161 entity,
162 uuid: *uuid,
163 handle,
164 had_stale_handle,
165 }),
166 Err(e) => {
167 log::error!(
170 "asset_resolver: failed to load material asset {uuid:?} for \
171 entity {entity:?}: {e:#}"
172 );
173 }
174 }
175 }
176 }
177 }
178 }
179
180 if pending.is_empty() {
181 return;
182 }
183
184 let store = asset_store.map(|s| s.store::<Box<dyn Material>>());
191 for ResolvedMaterial {
192 entity,
193 uuid,
194 handle,
195 had_stale_handle,
196 } in pending
197 {
198 if let Some(store) = &store {
199 store
200 .write()
201 .unwrap_or_else(|e| e.into_inner())
202 .insert(uuid, handle.clone());
203 }
204 let component = HandleComponent { handle, uuid };
205 if had_stale_handle {
206 world.set_component(entity, component);
207 if let Err(e) = world.remove_component::<HandleComponent<GpuMaterial>>(entity) {
208 log::trace!(
209 "asset_resolver: dropping stale GpuMaterial handle on {entity:?} skipped: {e:?}"
210 );
211 }
212 } else if let Err(e) = world.add_component(entity, component) {
213 log::warn!("asset_resolver: attaching material handle on {entity:?} failed: {e:?}");
214 }
215 }
216}
217
218fn resolve_meshes(world: &mut World, runtime: &Runtime) {
219 let asset_store = runtime.resources.get::<AssetStore>();
220 let asset_service = runtime.services.get::<Arc<Mutex<AssetService>>>().cloned();
221
222 let mut pending: Vec<ResolvedMesh> = Vec::new();
227 {
228 let query = world.query::<(EntityId, &MeshRef, Option<&HandleComponent<Mesh>>)>();
229
230 for (entity, mesh_ref, current) in query {
231 let expected = mesh_ref.uuid();
232 if current.map(|h| h.uuid) == Some(expected) {
235 continue;
236 }
237 let had_stale_handle = current.is_some();
238
239 match mesh_ref {
240 MeshRef::Procedural { kind, params, .. } => {
241 let mesh = reconstruct_procedural_mesh(*kind, *params);
242 pending.push(ResolvedMesh {
243 entity,
244 uuid: expected,
245 handle: AssetHandle::new(mesh),
246 had_stale_handle,
247 });
248 }
249 MeshRef::Asset(uuid) => {
250 let Some(service) = &asset_service else {
251 log::error!(
252 "asset_resolver: AssetService missing; cannot resolve mesh \
253 asset {uuid:?} on entity {entity:?}."
254 );
255 continue;
256 };
257 let loaded = match service.lock() {
258 Ok(mut svc) => svc.load::<Mesh>(uuid),
259 Err(_) => {
260 log::error!("asset_resolver: AssetService mutex poisoned.");
261 continue;
262 }
263 };
264 match loaded {
265 Ok(handle) => pending.push(ResolvedMesh {
266 entity,
267 uuid: *uuid,
268 handle,
269 had_stale_handle,
270 }),
271 Err(e) => {
272 log::error!(
275 "asset_resolver: failed to load mesh asset {uuid:?} for \
276 entity {entity:?}: {e:#}"
277 );
278 }
279 }
280 }
281 }
282 }
283 }
284
285 if pending.is_empty() {
286 return;
287 }
288
289 let store = asset_store.map(|s| s.store::<Mesh>());
295 for ResolvedMesh {
296 entity,
297 uuid,
298 handle,
299 had_stale_handle,
300 } in pending
301 {
302 if let Some(store) = &store {
303 store
304 .write()
305 .unwrap_or_else(|e| e.into_inner())
306 .insert(uuid, handle.clone());
307 }
308 let component = HandleComponent { handle, uuid };
309 if had_stale_handle {
310 world.set_component(entity, component);
311 if let Err(e) = world.remove_component::<HandleComponent<GpuMesh>>(entity) {
312 log::trace!(
313 "asset_resolver: dropping stale GpuMesh handle on {entity:?} skipped: {e:?}"
314 );
315 }
316 } else if let Err(e) = world.add_component(entity, component) {
317 log::warn!("asset_resolver: attaching mesh handle on {entity:?} failed: {e:?}");
318 }
319 }
320}
321
322inventory::submit! {
323 DataSystemRegistration {
324 name: "asset_resolver",
325 phase: TickPhase::PreExtract,
326 run: asset_resolver_system,
327 order_hint: -5,
331 runs_after: &[],
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use khora_core::asset::StandardMaterial;
339 use khora_core::math::LinearRgba;
340 use khora_core::renderer::api::command::BindGroupId;
341 use khora_core::renderer::api::pipeline::{PrimitiveTopology, ShaderVariantKey};
342 use khora_core::renderer::api::resource::{BufferId, SamplerId};
343 use khora_core::renderer::api::util::IndexFormat;
344
345 fn gpu_material_stub(uuid: AssetUUID) -> HandleComponent<GpuMaterial> {
349 HandleComponent {
350 handle: AssetHandle::new(GpuMaterial {
351 uniform_buffer: BufferId(0),
352 base_color_view: None,
353 metallic_roughness_view: None,
354 normal_view: None,
355 emissive_view: None,
356 occlusion_view: None,
357 base_color_texture: None,
358 metallic_roughness_texture: None,
359 normal_texture: None,
360 emissive_texture: None,
361 occlusion_texture: None,
362 sampler: SamplerId(0),
363 bind_group: BindGroupId(0),
364 variant: ShaderVariantKey::empty(),
365 double_sided: false,
366 blend: false,
367 }),
368 uuid,
369 }
370 }
371
372 fn gpu_mesh_stub(uuid: AssetUUID) -> HandleComponent<GpuMesh> {
374 HandleComponent {
375 handle: AssetHandle::new(GpuMesh {
376 vertex_buffer: BufferId(0),
377 index_buffer: BufferId(0),
378 index_count: 0,
379 index_format: IndexFormat::Uint32,
380 primitive_topology: PrimitiveTopology::TriangleList,
381 }),
382 uuid,
383 }
384 }
385
386 #[test]
387 fn resolves_inline_material_into_handle_and_store() {
388 let mut world = World::new();
389 let store = AssetStore::new();
390
391 let mut runtime = Runtime::default();
392 runtime.resources.insert(store.clone());
393
394 let distinctive = StandardMaterial {
395 base_color: LinearRgba::new(0.2, 0.4, 0.6, 1.0),
396 roughness: 0.33,
397 metallic: 0.7,
398 ..Default::default()
399 };
400 let entity = world.spawn(MaterialRef::inline(Box::new(distinctive.clone())));
401
402 let mut deck = OutputDeck::default();
403 asset_resolver_system(&mut world, &runtime, &mut deck);
404
405 let resolved = world
406 .get::<HandleComponent<Box<dyn Material>>>(entity)
407 .expect("inline material should resolve to a handle");
408 let material: &dyn Material = &**resolved.handle;
409 let standard = material
410 .as_any()
411 .downcast_ref::<StandardMaterial>()
412 .expect("resolved material should downcast to StandardMaterial");
413 assert_eq!(standard.base_color, distinctive.base_color);
414 assert_eq!(standard.roughness, distinctive.roughness);
415 assert_eq!(standard.metallic, distinctive.metallic);
416
417 let sub = store.store::<Box<dyn Material>>();
419 let guard = sub.read().unwrap();
420 assert!(
421 guard.get(&resolved.uuid).is_some(),
422 "resolved material should be inserted into the shared AssetStore"
423 );
424 }
425
426 #[test]
427 fn inline_resolution_is_idempotent() {
428 let mut world = World::new();
429 let runtime = Runtime::default();
430 let entity = world.spawn(MaterialRef::inline(Box::new(StandardMaterial::default())));
431
432 let mut deck = OutputDeck::default();
433 asset_resolver_system(&mut world, &runtime, &mut deck);
434 let first = world
435 .get::<HandleComponent<Box<dyn Material>>>(entity)
436 .expect("first pass resolves")
437 .uuid;
438
439 asset_resolver_system(&mut world, &runtime, &mut deck);
441 let second = world
442 .get::<HandleComponent<Box<dyn Material>>>(entity)
443 .expect("handle persists")
444 .uuid;
445 assert_eq!(first, second);
446 }
447
448 #[test]
452 fn changing_inline_material_reconciles_handle_and_drops_gpu() {
453 let mut world = World::new();
454 let store = AssetStore::new();
455 let mut runtime = Runtime::default();
456 runtime.resources.insert(store.clone());
457
458 let mat_a = StandardMaterial {
459 base_color: LinearRgba::new(0.1, 0.2, 0.3, 1.0),
460 ..Default::default()
461 };
462 let entity = world.spawn(MaterialRef::inline(Box::new(mat_a)));
463
464 let mut deck = OutputDeck::default();
465 asset_resolver_system(&mut world, &runtime, &mut deck);
466 let uuid_a = world
467 .get::<HandleComponent<Box<dyn Material>>>(entity)
468 .expect("material A resolves")
469 .uuid;
470
471 world
473 .add_component(entity, gpu_material_stub(uuid_a))
474 .unwrap();
475
476 let mat_b = StandardMaterial {
478 base_color: LinearRgba::new(0.9, 0.8, 0.7, 1.0),
479 roughness: 0.5,
480 ..Default::default()
481 };
482 assert!(
483 world.set_component(entity, MaterialRef::inline(Box::new(mat_b))),
484 "replacing the MaterialRef must succeed"
485 );
486
487 asset_resolver_system(&mut world, &runtime, &mut deck);
488
489 let uuid_b = world
490 .get::<HandleComponent<Box<dyn Material>>>(entity)
491 .expect("material B resolves")
492 .uuid;
493 assert_ne!(
494 uuid_a, uuid_b,
495 "different material content => different uuid"
496 );
497 assert!(
498 world.get::<HandleComponent<GpuMaterial>>(entity).is_none(),
499 "the stale GpuMaterial handle must be removed so the projection rebuilds"
500 );
501 }
502
503 #[test]
506 fn steady_state_material_resolution_does_not_remove_gpu() {
507 let mut world = World::new();
508 let store = AssetStore::new();
509 let mut runtime = Runtime::default();
510 runtime.resources.insert(store.clone());
511
512 let entity = world.spawn(MaterialRef::inline(Box::new(StandardMaterial::default())));
513
514 let mut deck = OutputDeck::default();
515 asset_resolver_system(&mut world, &runtime, &mut deck);
516 let uuid = world
517 .get::<HandleComponent<Box<dyn Material>>>(entity)
518 .expect("resolves")
519 .uuid;
520
521 world
522 .add_component(entity, gpu_material_stub(uuid))
523 .unwrap();
524
525 asset_resolver_system(&mut world, &runtime, &mut deck);
527
528 let after = world
529 .get::<HandleComponent<Box<dyn Material>>>(entity)
530 .expect("handle persists")
531 .uuid;
532 assert_eq!(uuid, after, "steady state keeps the same CPU handle uuid");
533 assert!(
534 world.get::<HandleComponent<GpuMaterial>>(entity).is_some(),
535 "steady state must not remove the still-valid GpuMaterial handle"
536 );
537 }
538
539 #[test]
543 fn changing_procedural_mesh_reconciles_handle_and_drops_gpu() {
544 use khora_data::ecs::ProceduralMeshKind;
545
546 let mut world = World::new();
547 let store = AssetStore::new();
548 let mut runtime = Runtime::default();
549 runtime.resources.insert(store.clone());
550
551 let entity = world.spawn(MeshRef::procedural(
552 ProceduralMeshKind::Cube,
553 [1.0, 0.0, 0.0, 0.0],
554 ));
555
556 let mut deck = OutputDeck::default();
557 asset_resolver_system(&mut world, &runtime, &mut deck);
558 let uuid_a = world
559 .get::<HandleComponent<Mesh>>(entity)
560 .expect("mesh A resolves")
561 .uuid;
562
563 world.add_component(entity, gpu_mesh_stub(uuid_a)).unwrap();
564
565 assert!(
567 world.set_component(
568 entity,
569 MeshRef::procedural(ProceduralMeshKind::Cube, [4.0, 0.0, 0.0, 0.0]),
570 ),
571 "replacing the MeshRef must succeed"
572 );
573
574 asset_resolver_system(&mut world, &runtime, &mut deck);
575
576 let uuid_b = world
577 .get::<HandleComponent<Mesh>>(entity)
578 .expect("mesh B resolves")
579 .uuid;
580 assert_ne!(uuid_a, uuid_b, "different params => different uuid");
581 assert!(
582 world.get::<HandleComponent<GpuMesh>>(entity).is_none(),
583 "the stale GpuMesh handle must be removed so the projection rebuilds"
584 );
585 }
586
587 #[test]
588 fn resolves_asset_material_from_kmat_via_service() {
589 use crate::asset::{FileLoader, IndexBuilder};
590 use khora_core::asset::StandardMaterial;
591 use khora_telemetry::MetricsRegistry;
592 use std::fs;
593 use tempfile::tempdir;
594
595 let dir = tempdir().unwrap();
598 let assets_root = dir.path();
599 fs::create_dir_all(assets_root.join("materials")).unwrap();
600 let rel_path = "materials/brass.kmat";
601 let kmat = r#"{
602 "type_name": "StandardMaterial",
603 "material": {
604 "base_color": { "r": 0.71, "g": 0.65, "b": 0.26, "a": 1.0 },
605 "base_color_texture": (),
606 "metallic": 1.0,
607 "roughness": 0.18,
608 "metallic_roughness_texture": (),
609 "normal_map": (),
610 "occlusion_map": (),
611 "emissive": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 },
612 "emissive_texture": (),
613 "alpha_mode": "Opaque",
614 "alpha_cutoff": 0.5,
615 "double_sided": false,
616 },
617 }"#;
618 fs::write(assets_root.join(rel_path), kmat).unwrap();
619
620 let index_bytes = IndexBuilder::new(assets_root).build_index_bytes().unwrap();
624 let uuid = AssetUUID::new_v5(rel_path);
625
626 let metrics = Arc::new(MetricsRegistry::new());
627 let mut service = AssetService::new(
628 &index_bytes,
629 Box::new(FileLoader::new(assets_root)),
630 metrics,
631 None,
632 )
633 .unwrap();
634 service.register_inventory_decoders();
636
637 let store = AssetStore::new();
638 let mut runtime = Runtime::default();
639 runtime.resources.insert(store.clone());
640 runtime
641 .services
642 .insert(Arc::new(Mutex::new(service)) as Arc<Mutex<AssetService>>);
643
644 let mut world = World::new();
645 let entity = world.spawn(MaterialRef::Asset(uuid));
646
647 let mut deck = OutputDeck::default();
648 asset_resolver_system(&mut world, &runtime, &mut deck);
649
650 let resolved = world
651 .get::<HandleComponent<Box<dyn Material>>>(entity)
652 .expect("asset material should resolve to a handle");
653 assert_eq!(resolved.uuid, uuid);
655
656 let material: &dyn Material = &**resolved.handle;
657 let standard = material
658 .as_any()
659 .downcast_ref::<StandardMaterial>()
660 .expect("resolved .kmat should decode to StandardMaterial");
661 assert_eq!(standard.metallic, 1.0);
662 assert_eq!(standard.roughness, 0.18);
663 assert_eq!(standard.base_color.r, 0.71);
664
665 let sub = store.store::<Box<dyn Material>>();
668 assert!(
669 sub.read().unwrap().get(&uuid).is_some(),
670 "resolved asset material should be published to the shared AssetStore"
671 );
672 }
673
674 #[test]
675 fn resolves_procedural_mesh_into_handle_with_cube_counts() {
676 use khora_data::ecs::ProceduralMeshKind;
677
678 let mut world = World::new();
679 let store = AssetStore::new();
680 let mut runtime = Runtime::default();
681 runtime.resources.insert(store.clone());
682
683 let entity = world.spawn(MeshRef::procedural(
684 ProceduralMeshKind::Cube,
685 [2.0, 0.0, 0.0, 0.0],
686 ));
687
688 let mut deck = OutputDeck::default();
689 asset_resolver_system(&mut world, &runtime, &mut deck);
690
691 let resolved = world
692 .get::<HandleComponent<Mesh>>(entity)
693 .expect("procedural mesh should resolve to a handle");
694 let mesh: &Mesh = &resolved.handle;
695 assert_eq!(mesh.positions.len(), 24, "cube has 24 vertices");
696 assert_eq!(
697 mesh.indices.as_ref().map_or(0, |i| i.len()),
698 36,
699 "cube has 36 indices"
700 );
701
702 let sub = store.store::<Mesh>();
704 assert!(
705 sub.read().unwrap().get(&resolved.uuid).is_some(),
706 "resolved procedural mesh should be published to the shared AssetStore"
707 );
708 }
709
710 #[test]
712 fn identical_procedural_meshes_share_one_uuid() {
713 use khora_data::ecs::ProceduralMeshKind;
714
715 let mut world = World::new();
716 let runtime = Runtime::default();
717
718 let make = || MeshRef::procedural(ProceduralMeshKind::Sphere, [0.5, 16.0, 16.0, 0.0]);
719 let a = world.spawn(make());
720 let b = world.spawn(make());
721
722 let mut deck = OutputDeck::default();
723 asset_resolver_system(&mut world, &runtime, &mut deck);
724
725 let ua = world.get::<HandleComponent<Mesh>>(a).unwrap().uuid;
726 let ub = world.get::<HandleComponent<Mesh>>(b).unwrap().uuid;
727 assert_eq!(
728 ua, ub,
729 "identical procedural meshes must share a content uuid"
730 );
731 }
732
733 #[test]
737 fn resolves_asset_mesh_from_obj_via_service() {
738 use crate::asset::{FileLoader, IndexBuilder, MeshDispatcher};
739 use khora_telemetry::MetricsRegistry;
740 use std::fs;
741 use tempfile::tempdir;
742
743 let dir = tempdir().unwrap();
744 let assets_root = dir.path();
745 fs::create_dir_all(assets_root.join("meshes")).unwrap();
746 let rel_path = "meshes/tri.obj";
747 let obj = "v 0.0 0.0 0.0\nv 1.0 0.0 0.0\nv 0.0 1.0 0.0\nf 1 2 3\n";
749 fs::write(assets_root.join(rel_path), obj).unwrap();
750
751 let index_bytes = IndexBuilder::new(assets_root).build_index_bytes().unwrap();
752 let uuid = AssetUUID::new_v5(rel_path);
753
754 let metrics = Arc::new(MetricsRegistry::new());
755 let mut service = AssetService::new(
756 &index_bytes,
757 Box::new(FileLoader::new(assets_root)),
758 metrics,
759 None,
760 )
761 .unwrap();
762 service.register_decoder::<Mesh>("mesh", MeshDispatcher::default());
765
766 let store = AssetStore::new();
767 let mut runtime = Runtime::default();
768 runtime.resources.insert(store.clone());
769 runtime
770 .services
771 .insert(Arc::new(Mutex::new(service)) as Arc<Mutex<AssetService>>);
772
773 let mut world = World::new();
774 let entity = world.spawn(MeshRef::Asset(uuid));
775
776 let mut deck = OutputDeck::default();
777 asset_resolver_system(&mut world, &runtime, &mut deck);
778
779 let resolved = world
780 .get::<HandleComponent<Mesh>>(entity)
781 .expect("asset mesh should resolve to a handle");
782 assert_eq!(resolved.uuid, uuid);
784 let mesh: &Mesh = &resolved.handle;
785 assert_eq!(mesh.positions.len(), 3, "the triangle has 3 vertices");
786
787 let sub = store.store::<Mesh>();
788 assert!(
789 sub.read().unwrap().get(&uuid).is_some(),
790 "resolved asset mesh should be published to the shared AssetStore"
791 );
792 }
793}