Skip to main content

khora_data/gpu/
projection.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//! CPU→GPU mesh + material projection — the data-layer replacement for
16//! `MeshPreparationSystem`.
17//!
18//! [`ProjectionRegistry`] is created once in `engine.rs` bootstrap, registered
19//! into `ServiceRegistry`, and called via `sync_all()` in `tick_with_services()`
20//! **before** the scheduler dispatches agents.
21//!
22//! After `sync_all()` returns for a given frame, every entity that has a
23//! `HandleComponent<Mesh>` also has a `HandleComponent<GpuMesh>`, and the
24//! shared `GpuCache` is fully up to date.  This call is idempotent: entities
25//! already holding a `HandleComponent<GpuMesh>` are skipped via the
26//! `Without<HandleComponent<GpuMesh>>` query filter.
27//!
28//! Materials are projected per-variant with **no fallback textures**: a
29//! material's [`ShaderVariantKey`] is exactly the set of maps it declares, and
30//! the cached group-2 bind group is built against the same
31//! `(LayoutKey::Material, variant)` layout the lit pipeline binds. A mesh with
32//! no resolved material handle (and no pending `MaterialRef`), or a material
33//! referencing a texture absent from the `Assets<CpuTexture>` store, is logged
34//! and skipped rather than silently substituted.
35
36use 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/// Engine-wide CPU→GPU mesh + material upload service.
66///
67/// Registered into `ServiceRegistry` during bootstrap.
68/// `sync_all()` is called once per frame in `EngineCore::tick_with_services()`
69/// before the scheduler runs agents.
70#[derive(Clone)]
71pub struct ProjectionRegistry {
72    /// Unified store holding the `Assets<GpuMesh>` / `Assets<GpuMaterial>` /
73    /// `Assets<CpuTexture>` sub-stores.
74    store: AssetStore,
75    /// The single filtering sampler every material's maps share, created
76    /// lazily on the first material upload.
77    sampler: Arc<OnceLock<SamplerId>>,
78}
79
80impl ProjectionRegistry {
81    /// Creates a new `ProjectionRegistry` backed by the shared [`AssetStore`].
82    pub fn new(store: AssetStore) -> Self {
83        Self {
84            store,
85            sampler: Arc::new(OnceLock::new()),
86        }
87    }
88
89    /// Uploads any newly loaded CPU meshes to the GPU and tags their ECS entities.
90    ///
91    /// For each entity that has `HandleComponent<Mesh>` but not yet
92    /// `HandleComponent<GpuMesh>`:
93    /// 1. Checks whether the UUID is already in `GpuCache` (shared across agents).
94    /// 2. If not, uploads vertex + index buffers via `device`.
95    /// 3. Inserts the result into `GpuCache`.
96    /// 4. Adds `HandleComponent<GpuMesh>` to the entity so subsequent frames skip it.
97    ///
98    /// This method is idempotent and safe to call every frame.
99    pub fn sync_all(&self, world: &mut World, device: &dyn GraphicsDevice) {
100        let cache = self.store.store::<GpuMesh>();
101        // Phase 1: collect pending uploads (read-only ECS borrow).
102        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                // Cache miss: upload to GPU for the first time.
115                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                // Schedule the ECS component addition.
128                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        // Phase 2: mutate the ECS world (no longer borrowed by the query above).
141        for (entity_id, component) in pending {
142            // The ECS query skips stale orphan rows, so an entity reaches phase 2
143            // only when it genuinely lacks the handle — any failure is unexpected.
144            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    /// Uploads a single CPU [`Mesh`] to the GPU and returns the resulting [`GpuMesh`].
151    fn upload_mesh(mesh: &Mesh, device: &dyn GraphicsDevice) -> GpuMesh {
152        // Upload vertex buffer.
153        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        // Upload index buffer (or create an empty placeholder).
165        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    /// Uploads any newly-needed materials to the GPU and tags their ECS
200    /// entities with a `HandleComponent<GpuMaterial>`.
201    ///
202    /// Runs once per frame in `TickPhase::PreExtract`, after `sync_all`
203    /// (so every rendered entity already has a `HandleComponent<GpuMesh>`)
204    /// and before `RenderFlow` projects the world. Idempotent: entities
205    /// already carrying a `HandleComponent<GpuMaterial>` are skipped.
206    ///
207    /// Only entities with a resolved `HandleComponent<Box<dyn Material>>` are
208    /// projected. Each material's [`ShaderVariantKey`] is exactly the maps it
209    /// declares; the group-2 bind group is built against the matching
210    /// `pipeline_system.layout(device, LayoutKey::Material, &variant)` — the
211    /// SAME layout the lit pipeline binds for that variant. There is no
212    /// fallback: a mesh entity without any material reference is logged and
213    /// skipped, and a material referencing a texture absent from
214    /// `Assets<CpuTexture>` is deferred (a missing decode is logged once it
215    /// has clearly stalled — see [`MaterialProjector::resolve_texture`]).
216    ///
217    /// This call performs no asset loading — the `Assets<CpuTexture>` sub-store
218    /// is populated by the SDK layer that owns the `AssetService`.
219    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                    // Defer until every referenced texture has been decoded.
252                    if !material_textures_ready(material, &textures) {
253                        continue;
254                    }
255                    let Some(gpu_material) = projector.build_gpu_material(material, &textures)
256                    else {
257                        // A clear error has already been logged; skip this
258                        // material rather than deferring forever or binding a
259                        // mismatched layout.
260                        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        // A mesh entity that references no material at all has nothing to
285        // project — there is no default. An entity with an unresolved
286        // `MaterialRef` is NOT materialless (the resolver will produce its
287        // handle on a later tick), so it is excluded from the warning. Log +
288        // skip so a truly materialless entity is visibly absent rather than
289        // silently substituted (SAA: never invent game state).
290        {
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            // The ECS query skips stale orphan rows, so an entity reaches phase 2
308            // only when it genuinely lacks the handle — any failure is unexpected.
309            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    /// Returns the shared material sampler, creating it on first call.
316    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
341/// The alpha-mask cutoff written to `pbr_factors.z`: the shader discards a
342/// fragment whose output alpha is below it. Only [`AlphaMode::Mask`] masks;
343/// `Opaque` and `Blend` never discard, so the cutoff is `0.0` (an alpha in
344/// `[0, 1]` is never `< 0.0`).
345fn alpha_mask_cutoff(mode: AlphaMode) -> f32 {
346    match mode {
347        AlphaMode::Mask(cutoff) => cutoff,
348        AlphaMode::Opaque | AlphaMode::Blend => 0.0,
349    }
350}
351
352/// Returns `true` once every texture the material references has been
353/// decoded into `cpu_textures` (texture-less slots count as ready).
354fn 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
369/// Builds the [`ShaderVariantKey`] for a material: one `HAS_*` flag per
370/// declared texture slot. This is the single place the four texture flags
371/// are derived, so the layout, the WGSL `#ifdef`s, and the bind group all
372/// agree.
373fn 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
393/// Borrowed handles for one material-projection pass: the device, the
394/// per-variant layout source, and the shared sampler.
395struct MaterialProjector<'a> {
396    device: &'a dyn GraphicsDevice,
397    pipeline_system: &'a dyn PipelineSystem,
398    sampler: SamplerId,
399}
400
401impl MaterialProjector<'_> {
402    /// Builds a [`GpuMaterial`] from a concrete material: derives the variant
403    /// from its declared maps, uploads each referenced texture (role-correct
404    /// sRGB/linear), and builds the group-2 bind group against the matching
405    /// per-variant layout. Returns `None` (after logging a clear error) if a
406    /// declared texture cannot be uploaded or the layout / bind group fails.
407    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        // Color maps decode as sRGB; data maps (normal, metallic-roughness)
428        // must stay linear or lighting is wrong. A declared-but-unuploadable
429        // texture aborts the build (no silent fallback). Each slot resolves to
430        // the `(texture, view)` pair so the material owns both — the view for
431        // binding, the texture so eviction can free it.
432        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        // AO is a linear data map (red channel = occlusion), never sRGB.
439        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        // The bind group MUST be built against the same `(Material, variant)`
466        // layout the lit pipeline declares at group 2, so the two are
467        // byte-for-byte identical.
468        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            // Only `Blend` needs the transparent pipeline + sorted pass; `Mask`
503            // discards in the shader and stays opaque.
504            blend: matches!(material.alpha_mode(), AlphaMode::Blend),
505        })
506    }
507
508    /// Resolves an optional texture slot to an optional `(texture, view)` pair:
509    /// - `None` slot → `Ok(None)` (the variant omits this binding).
510    /// - `Some(uuid)` decoded + uploaded → `Some(Some((texture, view)))`.
511    /// - `Some(uuid)` absent from the store, or an upload failure → `None`
512    ///   (the whole build aborts after a clear log), never a fallback.
513    ///
514    /// Returns `Option<Option<(TextureId, TextureViewId)>>` so the caller's `?`
515    /// aborts the build on a hard failure while still distinguishing "no
516    /// texture declared" from "texture present". The `TextureId` is carried
517    /// alongside the view so the built [`GpuMaterial`] owns both and eviction
518    /// can free the underlying texture, not just its view.
519    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    /// Uploads a decoded [`CpuTexture`] to the GPU and returns the created
540    /// texture together with a sampleable view.
541    ///
542    /// The upload format combines what the decoder produced (`cpu.format` — the
543    /// pixel layout, 8-bit or float/HDR) with `color_space`, which the material
544    /// slot supplies (color maps are sRGB, data maps linear). Float layouts keep
545    /// their format regardless, since HDR values are linear by construction. The
546    /// row stride follows the resolved format rather than assuming 4 bytes per
547    /// pixel, so an HDR texture uploads correctly.
548    ///
549    /// The `TextureId` is returned so the owning material can free it on
550    /// eviction — destroying the view alone would leak the texture.
551    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}