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}