1use crate::{
37 ecs::{HandleComponent, MaterialRef, Without, World},
38 gpu::AssetStore,
39};
40use khora_core::{
41 asset::{AlphaMode, AssetHandle, AssetUUID, Material},
42 ecs::entity::EntityId,
43 math::{LinearRgba, Origin3D},
44 renderer::{
45 api::{
46 command::BindGroupDescriptor,
47 material::{bindings::flag, fill_material_bind_group_entries, MaterialGpuBindings},
48 pipeline::{LayoutKey, ShaderVariantKey},
49 resource::{
50 AddressMode, BufferDescriptor, BufferUsage, CpuTexture, FilterMode, ImageAspect,
51 MipmapFilterMode, SamplerDescriptor, SamplerId, TextureDescriptor,
52 TextureDimension, TextureId, TextureUsage, TextureViewDescriptor, TextureViewId,
53 },
54 scene::{GpuMaterial, GpuMesh, MaterialUniforms, Mesh},
55 util::{IndexFormat, SampleCount, TextureColorSpace},
56 },
57 traits::PipelineSystem,
58 GraphicsDevice,
59 },
60};
61use std::borrow::Cow;
62use std::collections::HashMap;
63use std::sync::{Arc, OnceLock};
64
65#[derive(Clone)]
71pub struct ProjectionRegistry {
72 store: AssetStore,
75 sampler: Arc<OnceLock<SamplerId>>,
78}
79
80impl ProjectionRegistry {
81 pub fn new(store: AssetStore) -> Self {
83 Self {
84 store,
85 sampler: Arc::new(OnceLock::new()),
86 }
87 }
88
89 pub fn sync_all(&self, world: &mut World, device: &dyn GraphicsDevice) {
100 let cache = self.store.store::<GpuMesh>();
101 let mut pending: HashMap<EntityId, HandleComponent<GpuMesh>> = HashMap::new();
103
104 {
105 let query = world.query::<(
106 EntityId,
107 &HandleComponent<Mesh>,
108 Without<HandleComponent<GpuMesh>>,
109 )>();
110
111 for (entity_id, mesh_handle_comp, _) in query {
112 let uuid = mesh_handle_comp.uuid;
113
114 if !cache
116 .read()
117 .unwrap_or_else(|e| e.into_inner())
118 .contains(&uuid)
119 {
120 let gpu_mesh = Self::upload_mesh(mesh_handle_comp, device);
121 cache
122 .write()
123 .unwrap_or_else(|e| e.into_inner())
124 .insert(uuid, AssetHandle::new(gpu_mesh));
125 }
126
127 if let Some(handle) = cache.read().unwrap_or_else(|e| e.into_inner()).get(&uuid) {
129 pending.insert(
130 entity_id,
131 HandleComponent {
132 handle: handle.clone(),
133 uuid,
134 },
135 );
136 }
137 }
138 }
139
140 for (entity_id, component) in pending {
142 if let Err(e) = world.add_component(entity_id, component) {
145 log::warn!("projection: attaching GPU handle to {entity_id:?} failed: {e:?}");
146 }
147 }
148 }
149
150 fn upload_mesh(mesh: &Mesh, device: &dyn GraphicsDevice) -> GpuMesh {
152 let vertex_data = mesh.create_vertex_buffer();
154 let vb_desc = BufferDescriptor {
155 label: Some("Mesh Vertex Buffer".into()),
156 size: vertex_data.len() as u64,
157 usage: BufferUsage::VERTEX | BufferUsage::COPY_DST,
158 mapped_at_creation: false,
159 };
160 let vertex_buffer = device
161 .create_buffer_with_data(&vb_desc, &vertex_data)
162 .expect("Failed to create vertex buffer");
163
164 let (index_buffer, index_count) = if let Some(indices) = &mesh.indices {
166 let index_data = bytemuck::cast_slice(indices);
167 let ib_desc = BufferDescriptor {
168 label: Some("Mesh Index Buffer".into()),
169 size: index_data.len() as u64,
170 usage: BufferUsage::INDEX | BufferUsage::COPY_DST,
171 mapped_at_creation: false,
172 };
173 let buffer = device
174 .create_buffer_with_data(&ib_desc, index_data)
175 .expect("Failed to create index buffer");
176 (buffer, indices.len() as u32)
177 } else {
178 let dummy_desc = BufferDescriptor {
179 label: Some("Empty Index Buffer".into()),
180 size: 0,
181 usage: BufferUsage::INDEX,
182 mapped_at_creation: false,
183 };
184 let buffer = device
185 .create_buffer(&dummy_desc)
186 .expect("Failed to create empty index buffer");
187 (buffer, 0)
188 };
189
190 GpuMesh {
191 vertex_buffer,
192 index_buffer,
193 index_count,
194 index_format: IndexFormat::Uint32,
195 primitive_topology: mesh.primitive_type,
196 }
197 }
198
199 pub fn sync_materials(
220 &self,
221 world: &mut World,
222 device: &dyn GraphicsDevice,
223 pipeline_system: &dyn PipelineSystem,
224 ) {
225 let projector = MaterialProjector {
226 device,
227 pipeline_system,
228 sampler: self.sampler(device),
229 };
230 let material_cache = self.store.store::<GpuMaterial>();
231 let cpu_textures = self.store.store::<CpuTexture>();
232
233 let mut pending: HashMap<EntityId, HandleComponent<GpuMaterial>> = HashMap::new();
234
235 {
236 let query = world.query::<(
237 EntityId,
238 &HandleComponent<Box<dyn Material>>,
239 Without<HandleComponent<GpuMaterial>>,
240 )>();
241 let textures = cpu_textures.read().unwrap_or_else(|e| e.into_inner());
242 for (entity_id, material_handle, _) in query {
243 let uuid = material_handle.uuid;
244 let material: &dyn Material = &**material_handle.handle;
245
246 if !material_cache
247 .read()
248 .unwrap_or_else(|e| e.into_inner())
249 .contains(&uuid)
250 {
251 if !material_textures_ready(material, &textures) {
253 continue;
254 }
255 let Some(gpu_material) = projector.build_gpu_material(material, &textures)
256 else {
257 continue;
261 };
262 material_cache
263 .write()
264 .unwrap_or_else(|e| e.into_inner())
265 .insert(uuid, AssetHandle::new(gpu_material));
266 }
267
268 if let Some(handle) = material_cache
269 .read()
270 .unwrap_or_else(|e| e.into_inner())
271 .get(&uuid)
272 {
273 pending.insert(
274 entity_id,
275 HandleComponent {
276 handle: handle.clone(),
277 uuid,
278 },
279 );
280 }
281 }
282 }
283
284 {
291 let query = world.query::<(
292 EntityId,
293 &HandleComponent<GpuMesh>,
294 Without<MaterialRef>,
295 Without<HandleComponent<Box<dyn Material>>>,
296 Without<HandleComponent<GpuMaterial>>,
297 )>();
298 for (entity_id, _, _, _, _) in query {
299 log::warn!(
300 "ProjectionRegistry: entity {entity_id:?} has a mesh but no material reference; \
301 skipping (no default material)."
302 );
303 }
304 }
305
306 for (entity_id, component) in pending {
307 if let Err(e) = world.add_component(entity_id, component) {
310 log::warn!("projection: attaching GPU handle to {entity_id:?} failed: {e:?}");
311 }
312 }
313 }
314
315 fn sampler(&self, device: &dyn GraphicsDevice) -> SamplerId {
317 if let Some(s) = self.sampler.get() {
318 return *s;
319 }
320 let sampler = device
321 .create_sampler(&SamplerDescriptor {
322 label: Some(Cow::Borrowed("khora_material_sampler")),
323 address_mode_u: AddressMode::Repeat,
324 address_mode_v: AddressMode::Repeat,
325 address_mode_w: AddressMode::Repeat,
326 mag_filter: FilterMode::Linear,
327 min_filter: FilterMode::Linear,
328 mipmap_filter: MipmapFilterMode::Linear,
329 lod_min_clamp: 0.0,
330 lod_max_clamp: 100.0,
331 compare: None,
332 anisotropy_clamp: 1,
333 border_color: None,
334 })
335 .expect("Failed to create material sampler");
336 let _ = self.sampler.set(sampler);
337 *self.sampler.get().unwrap_or(&sampler)
338 }
339}
340
341fn alpha_mask_cutoff(mode: AlphaMode) -> f32 {
346 match mode {
347 AlphaMode::Mask(cutoff) => cutoff,
348 AlphaMode::Opaque | AlphaMode::Blend => 0.0,
349 }
350}
351
352fn material_textures_ready(
355 material: &dyn Material,
356 cpu_textures: &crate::assets::Assets<CpuTexture>,
357) -> bool {
358 [
359 material.base_color_texture(),
360 material.metallic_roughness_texture(),
361 material.normal_map(),
362 material.emissive_texture(),
363 material.occlusion_map(),
364 ]
365 .into_iter()
366 .all(|slot| slot.is_none_or(|uuid| cpu_textures.contains(&uuid)))
367}
368
369fn material_variant(material: &dyn Material) -> ShaderVariantKey {
374 let mut variant = ShaderVariantKey::empty();
375 if material.base_color_texture().is_some() {
376 variant = variant.flag(flag::HAS_BASE_COLOR_TEXTURE);
377 }
378 if material.metallic_roughness_texture().is_some() {
379 variant = variant.flag(flag::HAS_METALLIC_ROUGHNESS_TEXTURE);
380 }
381 if material.normal_map().is_some() {
382 variant = variant.flag(flag::HAS_NORMAL_MAP);
383 }
384 if material.emissive_texture().is_some() {
385 variant = variant.flag(flag::HAS_EMISSIVE_TEXTURE);
386 }
387 if material.occlusion_map().is_some() {
388 variant = variant.flag(flag::HAS_OCCLUSION_MAP);
389 }
390 variant
391}
392
393struct MaterialProjector<'a> {
396 device: &'a dyn GraphicsDevice,
397 pipeline_system: &'a dyn PipelineSystem,
398 sampler: SamplerId,
399}
400
401impl MaterialProjector<'_> {
402 fn build_gpu_material(
408 &self,
409 material: &dyn Material,
410 cpu_textures: &crate::assets::Assets<CpuTexture>,
411 ) -> Option<GpuMaterial> {
412 let variant = material_variant(material);
413
414 let emissive = material.emissive_color();
415 let uniforms = MaterialUniforms {
416 base_color: material.base_color(),
417 emissive: LinearRgba::new(emissive.r, emissive.g, emissive.b, 1.0),
418 ambient: material.ambient_color(),
419 pbr_factors: [
420 material.metallic(),
421 material.roughness(),
422 alpha_mask_cutoff(material.alpha_mode()),
423 0.0,
424 ],
425 };
426
427 use TextureColorSpace::{Linear, Srgb};
433 let base_color = self.resolve_texture(material.base_color_texture(), Srgb, cpu_textures)?;
434 let metallic_roughness =
435 self.resolve_texture(material.metallic_roughness_texture(), Linear, cpu_textures)?;
436 let normal = self.resolve_texture(material.normal_map(), Linear, cpu_textures)?;
437 let emissive = self.resolve_texture(material.emissive_texture(), Srgb, cpu_textures)?;
438 let occlusion = self.resolve_texture(material.occlusion_map(), Linear, cpu_textures)?;
440
441 let uniform_buffer = self
442 .device
443 .create_buffer_with_data(
444 &BufferDescriptor {
445 label: Some("Material Uniform Buffer".into()),
446 size: std::mem::size_of::<MaterialUniforms>() as u64,
447 usage: BufferUsage::UNIFORM | BufferUsage::COPY_DST,
448 mapped_at_creation: false,
449 },
450 bytemuck::bytes_of(&uniforms),
451 )
452 .map_err(|e| log::error!("material uniform buffer creation failed: {e:?}"))
453 .ok()?;
454
455 let bindings = MaterialGpuBindings {
456 uniform_buffer,
457 base_color: base_color.map(|(_, view)| view),
458 metallic_roughness: metallic_roughness.map(|(_, view)| view),
459 normal: normal.map(|(_, view)| view),
460 emissive: emissive.map(|(_, view)| view),
461 occlusion: occlusion.map(|(_, view)| view),
462 sampler: self.sampler,
463 };
464
465 let layout = self
469 .pipeline_system
470 .layout(self.device, LayoutKey::Material, &variant)
471 .map_err(|e| log::error!("material group-2 layout resolution failed: {e:?}"))
472 .ok()?;
473
474 let mut entries = Vec::with_capacity(7);
475 fill_material_bind_group_entries(&bindings, &mut entries);
476 let bind_group = self
477 .device
478 .create_bind_group(&BindGroupDescriptor {
479 label: Some("Material Bind Group"),
480 layout,
481 entries: &entries,
482 })
483 .map_err(|e| log::error!("material bind group creation failed: {e:?}"))
484 .ok()?;
485
486 Some(GpuMaterial {
487 uniform_buffer,
488 base_color_view: base_color.map(|(_, view)| view),
489 metallic_roughness_view: metallic_roughness.map(|(_, view)| view),
490 normal_view: normal.map(|(_, view)| view),
491 emissive_view: emissive.map(|(_, view)| view),
492 occlusion_view: occlusion.map(|(_, view)| view),
493 base_color_texture: base_color.map(|(texture, _)| texture),
494 metallic_roughness_texture: metallic_roughness.map(|(texture, _)| texture),
495 normal_texture: normal.map(|(texture, _)| texture),
496 emissive_texture: emissive.map(|(texture, _)| texture),
497 occlusion_texture: occlusion.map(|(texture, _)| texture),
498 sampler: self.sampler,
499 bind_group,
500 variant,
501 double_sided: material.double_sided(),
502 blend: matches!(material.alpha_mode(), AlphaMode::Blend),
505 })
506 }
507
508 fn resolve_texture(
520 &self,
521 slot: Option<AssetUUID>,
522 color_space: TextureColorSpace,
523 cpu_textures: &crate::assets::Assets<CpuTexture>,
524 ) -> Option<Option<(TextureId, TextureViewId)>> {
525 let Some(uuid) = slot else {
526 return Some(None);
527 };
528 let Some(cpu) = cpu_textures.get(&uuid) else {
529 log::error!(
530 "ProjectionRegistry: material references texture {uuid:?} not present in \
531 Assets<CpuTexture>; cannot build material."
532 );
533 return None;
534 };
535 let pair = self.upload_texture(cpu, color_space)?;
536 Some(Some(pair))
537 }
538
539 fn upload_texture(
552 &self,
553 cpu: &CpuTexture,
554 color_space: TextureColorSpace,
555 ) -> Option<(TextureId, TextureViewId)> {
556 let format = cpu.format.with_color_space(color_space);
557 let texture: TextureId = self
558 .device
559 .create_texture(&TextureDescriptor {
560 label: Some(Cow::Borrowed("material_texture")),
561 size: cpu.size,
562 mip_level_count: 1,
563 sample_count: SampleCount::X1,
564 dimension: TextureDimension::D2,
565 format,
566 usage: TextureUsage::TEXTURE_BINDING | TextureUsage::COPY_DST,
567 view_formats: Cow::Borrowed(&[]),
568 })
569 .map_err(|e| log::error!("material texture upload failed: {e:?}"))
570 .ok()?;
571 self.device
572 .write_texture(
573 texture,
574 &cpu.pixels,
575 Some(format.bytes_per_pixel() * cpu.size.width),
576 Origin3D::default(),
577 cpu.size,
578 )
579 .map_err(|e| log::error!("material texture write failed: {e:?}"))
580 .ok()?;
581 let view = self
582 .device
583 .create_texture_view(
584 texture,
585 &TextureViewDescriptor {
586 label: Some(Cow::Borrowed("material_texture_view")),
587 format: None,
588 dimension: None,
589 aspect: ImageAspect::All,
590 base_mip_level: 0,
591 mip_level_count: None,
592 base_array_layer: 0,
593 array_layer_count: None,
594 },
595 )
596 .map_err(|e| log::error!("material texture view failed: {e:?}"))
597 .ok()?;
598 Some((texture, view))
599 }
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605 use khora_core::asset::{AssetUUID, StandardMaterial};
606 use khora_core::renderer::api::material::bindings::flag;
607
608 #[test]
609 fn alpha_mask_cutoff_only_masks_for_mask_mode() {
610 assert_eq!(alpha_mask_cutoff(AlphaMode::Opaque), 0.0);
611 assert_eq!(alpha_mask_cutoff(AlphaMode::Blend), 0.0);
612 assert_eq!(alpha_mask_cutoff(AlphaMode::Mask(0.3)), 0.3);
613 }
614
615 #[test]
616 fn material_variant_sets_occlusion_flag_when_declared() {
617 let plain = StandardMaterial::default();
618 assert!(!material_variant(&plain).has_flag(flag::HAS_OCCLUSION_MAP));
619
620 let with_ao = StandardMaterial {
621 occlusion_map: Some(AssetUUID::new_v5("textures/ao.png")),
622 ..Default::default()
623 };
624 assert!(material_variant(&with_ao).has_flag(flag::HAS_OCCLUSION_MAP));
625 }
626}