Skip to main content

khora_data/ecs/components/
material_ref.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//! Authored, serialized reference to a material.
16//!
17//! `MaterialRef` is the *only* serialized material form. It is an explicit
18//! discriminator the developer/editor sets:
19//!
20//! - [`MaterialRef::Inline`] embeds an ad-hoc material value (editor- or
21//!   code-created) directly in the scene.
22//! - [`MaterialRef::Asset`] references a `.kmat` file in the VFS by its stable
23//!   `AssetUUID`.
24//!
25//! A registered resolver `DataSystem` (in `khora-io`) turns a `MaterialRef`
26//! into the runtime-only [`MaterialHandle`] (a `HandleComponent<Box<dyn
27//! Material>>`), which the GPU projection consumes. `MaterialRef` itself never
28//! touches the GPU and never carries a resolved handle.
29
30use bincode::config;
31use khora_core::asset::{AssetUUID, Material, StandardMaterial};
32
33use crate::ecs::components::{
34    deserialize_material_component, material_from_json, material_to_json,
35    serialize_material_component,
36};
37use crate::ecs::HandleComponent;
38
39/// Runtime-only resolved material: a shared handle to the type-erased material
40/// data plus its identifying `AssetUUID`. Produced by the resolver, consumed by
41/// the GPU material projection. A readable alias for call sites.
42pub type MaterialHandle = HandleComponent<Box<dyn Material>>;
43
44/// Authored reference to a material — the only serialized material form.
45///
46/// The enum *is* the discriminator: `Inline` embeds the material value,
47/// `Asset` carries the stable UUID of a `.kmat` in the VFS.
48///
49/// Both arms carry an *identity UUID* — the content-derived UUID for `Inline`,
50/// the stable asset UUID for `Asset`. The resolver compares this identity
51/// against the resolved handle's UUID every tick: a mismatch (or a missing
52/// handle) means the authored reference changed and must be re-resolved. The
53/// `Inline` UUID is never serialized; it is recomputed from the material
54/// content on deserialize via [`MaterialRef::inline`] so it stays purely
55/// content-derived. Construct `Inline` through [`MaterialRef::inline`] so the
56/// UUID and the material value can never disagree.
57pub enum MaterialRef {
58    /// Ad-hoc material data created in code or the editor, embedded inline.
59    /// The `uuid` is content-derived; build via [`MaterialRef::inline`].
60    Inline {
61        /// The embedded material value.
62        material: Box<dyn Material>,
63        /// Content-derived identity UUID (the same content hash the GPU
64        /// projection keys on). Not serialized — recomputed on load.
65        uuid: AssetUUID,
66    },
67    /// Reference to a `.kmat` asset in the VFS, resolved by UUID.
68    Asset(AssetUUID),
69}
70
71impl MaterialRef {
72    /// Builds an `Inline` reference from a material value, deriving its identity
73    /// UUID from the material content. Two inline references holding equal
74    /// material content get the same UUID, so they dedup to one resolved handle
75    /// (and one `GpuMaterial`). On a serialization failure the UUID falls back
76    /// to a fresh random value (logged) rather than panicking.
77    pub fn inline(material: Box<dyn Material>) -> Self {
78        let uuid = match serialize_material_component(material.base_color(), &*material) {
79            Some(bytes) => AssetUUID::new_v5(&blake3::hash(&bytes).to_hex()),
80            None => {
81                log::error!(
82                    "MaterialRef::inline: failed to serialize material for identity UUID; \
83                     using a random UUID (this inline material will not dedup)."
84                );
85                AssetUUID::new()
86            }
87        };
88        Self::Inline { material, uuid }
89    }
90
91    /// Returns the identity UUID: the content-derived UUID for `Inline`, the
92    /// stable asset UUID for `Asset`. The resolver compares this against the
93    /// resolved handle's UUID to detect an authored change.
94    pub fn uuid(&self) -> AssetUUID {
95        match self {
96            Self::Inline { uuid, .. } => *uuid,
97            Self::Asset(uuid) => *uuid,
98        }
99    }
100}
101
102impl Clone for MaterialRef {
103    fn clone(&self) -> Self {
104        match self {
105            Self::Inline { material, uuid } => Self::Inline {
106                material: material.clone_box(),
107                uuid: *uuid,
108            },
109            Self::Asset(uuid) => Self::Asset(*uuid),
110        }
111    }
112}
113
114impl crate::ecs::Component for MaterialRef {}
115
116/// On-disk form of a [`MaterialRef`]. The bincode discriminant distinguishes
117/// the two arms; `Inline` holds the opaque type-tagged material bytes produced
118/// by [`serialize_material_component`], `Asset` holds the raw UUID (preserved
119/// verbatim, never regenerated).
120#[derive(bincode::Encode, bincode::Decode)]
121enum SerializableMaterialRef {
122    /// Type-tagged material payload (`SerializableMaterialData` bytes).
123    Inline(Vec<u8>),
124    /// VFS asset UUID, round-tripped unchanged.
125    Asset(AssetUUID),
126}
127
128/// Serializes the entity's `MaterialRef` into the recipe byte stream.
129fn serialize_material_ref(
130    world: &crate::ecs::World,
131    entity: khora_core::ecs::entity::EntityId,
132) -> Option<Vec<u8>> {
133    let mref = world.get::<MaterialRef>(entity)?;
134    let on_disk = match mref {
135        MaterialRef::Inline { material, .. } => {
136            let bytes = serialize_material_component(material.base_color(), &**material)?;
137            SerializableMaterialRef::Inline(bytes)
138        }
139        MaterialRef::Asset(uuid) => SerializableMaterialRef::Asset(*uuid),
140    };
141    bincode::encode_to_vec(&on_disk, config::standard()).ok()
142}
143
144/// Reconstructs a `MaterialRef` from recipe bytes and attaches it to `entity`.
145fn deserialize_material_ref(
146    world: &mut crate::ecs::World,
147    entity: khora_core::ecs::entity::EntityId,
148    data: &[u8],
149) -> Result<(), String> {
150    let (on_disk, _): (SerializableMaterialRef, _) =
151        bincode::decode_from_slice(data, config::standard()).map_err(|e| e.to_string())?;
152    let mref = match on_disk {
153        SerializableMaterialRef::Inline(bytes) => {
154            let (handle, _uuid) = deserialize_material_component(&bytes)?;
155            MaterialRef::inline(handle.clone_box())
156        }
157        SerializableMaterialRef::Asset(uuid) => MaterialRef::Asset(uuid),
158    };
159    world
160        .add_component(entity, mref)
161        .map_err(|e| format!("{e:?}"))?;
162    Ok(())
163}
164
165/// Editor JSON form: `Inline` reuses `material_to_json`; `Asset` emits
166/// `{ "asset": "<uuid>" }`.
167fn material_ref_to_json(
168    world: &crate::ecs::World,
169    entity: khora_core::ecs::entity::EntityId,
170) -> Option<serde_json::Value> {
171    let mref = world.get::<MaterialRef>(entity)?;
172    match mref {
173        MaterialRef::Inline { material, .. } => material_to_json(&**material),
174        MaterialRef::Asset(uuid) => serde_json::to_value(uuid)
175            .ok()
176            .map(|uuid_json| serde_json::json!({ "asset": uuid_json })),
177    }
178}
179
180/// Parses the editor JSON form back into a `MaterialRef` and applies it.
181fn material_ref_from_json(
182    world: &mut crate::ecs::World,
183    entity: khora_core::ecs::entity::EntityId,
184    value: &serde_json::Value,
185) -> Result<(), String> {
186    let mref = if let Some(asset) = value.get("asset") {
187        let uuid: AssetUUID = serde_json::from_value(asset.clone()).map_err(|e| e.to_string())?;
188        MaterialRef::Asset(uuid)
189    } else {
190        let (handle, _uuid) = material_from_json(value)?;
191        MaterialRef::inline(handle.clone_box())
192    };
193    if !world.set_component(entity, mref.clone()) {
194        world
195            .add_component(entity, mref)
196            .map_err(|e| format!("{e:?}"))?;
197    }
198    Ok(())
199}
200
201inventory::submit! {
202    crate::scene::ComponentRegistration {
203        type_id: std::any::TypeId::of::<MaterialRef>(),
204        type_name: "MaterialRef",
205        provenance: crate::ecs::ComponentProvenance::Authored,
206        serialize_recipe: serialize_material_ref,
207        deserialize_recipe: deserialize_material_ref,
208        create_default: |world, entity| {
209            world
210                .add_component(
211                    entity,
212                    MaterialRef::inline(Box::new(StandardMaterial::default())),
213                )
214                .map_err(|e| format!("{e:?}"))?;
215            Ok(())
216        },
217        to_json: material_ref_to_json,
218        from_json: material_ref_from_json,
219        remove: |world, entity| {
220            match world.remove_component::<MaterialRef>(entity) {
221                Ok(_) => Ok(()),
222                Err(e) => Err(format!("{e:?}")),
223            }
224        },
225    }
226}