Skip to main content

khora_data/ecs/components/
material_registry.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//! Open, inventory-based registration system for serializable materials.
16//!
17//! Each concrete material type registers itself via `inventory::submit!` with
18//! a type name, a serialize function, and a deserialize function. This allows
19//! any custom material (including those from plugins) to be serializable as
20//! long as it registers itself.
21//!
22//! The `#[derive(Material)]` proc-macro auto-generates the registration.
23
24use bincode::config;
25use inventory::collect;
26use khora_core::asset::{AssetHandle, AssetUUID, Material};
27use khora_core::math::LinearRgba;
28
29/// A serializable wrapper for a material that stores the type name and binary data.
30#[derive(bincode::Encode, bincode::Decode, Debug, Clone)]
31pub struct SerializableMaterialData {
32    /// Identifier used to look up the matching `MaterialRegistration` on decode.
33    pub type_name: String,
34    /// Opaque payload produced by the registration's `serialize` function.
35    pub data: Vec<u8>,
36}
37
38/// Function pointer that decodes binary data into a boxed `dyn Material`.
39pub type MaterialDeserializeFn = fn(&[u8]) -> Result<Box<dyn Material>, String>;
40
41/// Registration entry for a serializable material type.
42///
43/// Submitted via `inventory::submit!` either manually or through `#[derive(Material)]`.
44pub struct MaterialRegistration {
45    /// Unique type name used for lookup during deserialization.
46    pub type_name: &'static str,
47    /// Serializes a `dyn Material` into binary data. Returns `None` if the material
48    /// does not match this registration's concrete type.
49    pub serialize: fn(&dyn Material) -> Option<Vec<u8>>,
50    /// Deserializes binary data back into a `Box<dyn Material>`.
51    pub deserialize: MaterialDeserializeFn,
52    /// Creates a default instance of this material type (for placeholder handles).
53    pub create_default: fn() -> Box<dyn Material>,
54    /// Serializes a `dyn Material` into a serde-JSON value for the editor
55    /// inspector. Returns `None` if the material does not match this
56    /// registration's concrete type. Mirrors `serialize` but in an
57    /// editable, human-readable encoding.
58    pub serialize_json: fn(&dyn Material) -> Option<serde_json::Value>,
59    /// Deserializes a serde-JSON value (as produced by `serialize_json`) back
60    /// into a `Box<dyn Material>`.
61    pub deserialize_json: fn(&serde_json::Value) -> Result<Box<dyn Material>, String>,
62}
63
64collect!(MaterialRegistration);
65
66/// Serializes a `dyn Material` by finding the matching `MaterialRegistration`
67/// and encoding the material data with its type name.
68pub fn serialize_material_component(
69    base_color: LinearRgba,
70    material: &dyn Material,
71) -> Option<Vec<u8>> {
72    for reg in inventory::iter::<MaterialRegistration> {
73        if let Some(data) = (reg.serialize)(material) {
74            let serializable = SerializableMaterialData {
75                type_name: reg.type_name.to_string(),
76                data,
77            };
78            return bincode::encode_to_vec(&serializable, config::standard()).ok();
79        }
80    }
81    // Fallback: no registration found, serialize just the base color as a StandardMaterial-like placeholder.
82    log::warn!(
83        "No MaterialRegistration found for material type; falling back to base-color-only serialization."
84    );
85    let serializable = SerializableMaterialData {
86        type_name: "__unknown__".to_string(),
87        data: bincode::encode_to_vec(base_color, config::standard()).ok()?,
88    };
89    bincode::encode_to_vec(&serializable, config::standard()).ok()
90}
91
92/// Deserializes a `(handle, uuid)` material pair from binary data.
93pub fn deserialize_material_component(
94    data: &[u8],
95) -> Result<(AssetHandle<Box<dyn Material>>, AssetUUID), String> {
96    let (serializable, _): (SerializableMaterialData, _) =
97        bincode::decode_from_slice(data, config::standard()).map_err(|e| e.to_string())?;
98
99    if serializable.type_name == "__unknown__" {
100        // Reconstruct a basic StandardMaterial from the fallback base color.
101        let (base_color, _): (LinearRgba, _) =
102            bincode::decode_from_slice(&serializable.data, config::standard())
103                .map_err(|e| e.to_string())?;
104        let mat = khora_core::asset::StandardMaterial {
105            base_color,
106            ..Default::default()
107        };
108        let handle = AssetHandle::new(Box::new(mat) as Box<dyn Material>);
109        return Ok((handle, AssetUUID::new()));
110    }
111
112    for reg in inventory::iter::<MaterialRegistration> {
113        if reg.type_name == serializable.type_name {
114            let material = (reg.deserialize)(&serializable.data)?;
115            let uuid = AssetUUID::new();
116            let handle = AssetHandle::new(material);
117            return Ok((handle, uuid));
118        }
119    }
120
121    Err(format!(
122        "No MaterialRegistration found for type '{}'",
123        serializable.type_name
124    ))
125}
126
127/// Serializes a material into an editable serde-JSON object of the form
128/// `{ "type_name": <name>, "material": <concrete material> }`, matching the
129/// `(type_name, data)` split that [`serialize_material_component`] uses for
130/// bincode. Returns `None` if no registration claims the material.
131pub fn material_to_json(material: &dyn Material) -> Option<serde_json::Value> {
132    for reg in inventory::iter::<MaterialRegistration> {
133        if let Some(material_json) = (reg.serialize_json)(material) {
134            return Some(serde_json::json!({
135                "type_name": reg.type_name,
136                "material": material_json,
137            }));
138        }
139    }
140    None
141}
142
143/// Reconstructs a `(handle, uuid)` pair from a JSON object produced by
144/// [`material_to_json`]. The `type_name` selects the matching registration; the
145/// `material` sub-value is decoded by that registration's `deserialize_json`.
146pub fn material_from_json(
147    value: &serde_json::Value,
148) -> Result<(AssetHandle<Box<dyn Material>>, AssetUUID), String> {
149    let type_name = value
150        .get("type_name")
151        .and_then(serde_json::Value::as_str)
152        .ok_or_else(|| "material JSON missing string 'type_name'".to_string())?;
153    let material_value = value
154        .get("material")
155        .ok_or_else(|| "material JSON missing 'material' object".to_string())?;
156
157    for reg in inventory::iter::<MaterialRegistration> {
158        if reg.type_name == type_name {
159            let material = (reg.deserialize_json)(material_value)?;
160            return Ok((AssetHandle::new(material), AssetUUID::new()));
161        }
162    }
163
164    Err(format!(
165        "No MaterialRegistration found for type '{type_name}'"
166    ))
167}
168
169// ─── Built-in material registrations ───
170
171use khora_core::asset::{EmissiveMaterial, StandardMaterial, UnlitMaterial, WireframeMaterial};
172
173// The scene + inspector `ComponentRegistration` for the authored material
174// reference lives on `MaterialRef` (see `material_ref.rs`); it reuses the
175// helpers above. The four built-in `MaterialRegistration` entries below are
176// the open type-tag registry those helpers (and the `.kmat` decoder) dispatch
177// through — keep them.
178
179inventory::submit! {
180    MaterialRegistration {
181        type_name: "StandardMaterial",
182        serialize: |mat| {
183            mat.as_any().downcast_ref::<StandardMaterial>().map(|m| {
184                bincode::encode_to_vec(m, config::standard()).unwrap_or_default()
185            })
186        },
187        deserialize: |data| {
188            let (m, _) = bincode::decode_from_slice::<StandardMaterial, _>(data, config::standard())
189                .map_err(|e| e.to_string())?;
190            Ok(Box::new(m) as Box<dyn Material>)
191        },
192        create_default: || Box::new(StandardMaterial::default()) as Box<dyn Material>,
193        serialize_json: |mat| {
194            mat.as_any()
195                .downcast_ref::<StandardMaterial>()
196                .and_then(|m| serde_json::to_value(m).ok())
197        },
198        deserialize_json: |value| {
199            let m: StandardMaterial =
200                serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
201            Ok(Box::new(m) as Box<dyn Material>)
202        },
203    }
204}
205
206inventory::submit! {
207    MaterialRegistration {
208        type_name: "UnlitMaterial",
209        serialize: |mat| {
210            mat.as_any().downcast_ref::<UnlitMaterial>().map(|m| {
211                bincode::encode_to_vec(m, config::standard()).unwrap_or_default()
212            })
213        },
214        deserialize: |data| {
215            let (m, _) = bincode::decode_from_slice::<UnlitMaterial, _>(data, config::standard())
216                .map_err(|e| e.to_string())?;
217            Ok(Box::new(m) as Box<dyn Material>)
218        },
219        create_default: || Box::new(UnlitMaterial::default()) as Box<dyn Material>,
220        serialize_json: |mat| {
221            mat.as_any()
222                .downcast_ref::<UnlitMaterial>()
223                .and_then(|m| serde_json::to_value(m).ok())
224        },
225        deserialize_json: |value| {
226            let m: UnlitMaterial =
227                serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
228            Ok(Box::new(m) as Box<dyn Material>)
229        },
230    }
231}
232
233inventory::submit! {
234    MaterialRegistration {
235        type_name: "EmissiveMaterial",
236        serialize: |mat| {
237            mat.as_any().downcast_ref::<EmissiveMaterial>().map(|m| {
238                bincode::encode_to_vec(m, config::standard()).unwrap_or_default()
239            })
240        },
241        deserialize: |data| {
242            let (m, _) = bincode::decode_from_slice::<EmissiveMaterial, _>(data, config::standard())
243                .map_err(|e| e.to_string())?;
244            Ok(Box::new(m) as Box<dyn Material>)
245        },
246        create_default: || Box::new(EmissiveMaterial::default()) as Box<dyn Material>,
247        serialize_json: |mat| {
248            mat.as_any()
249                .downcast_ref::<EmissiveMaterial>()
250                .and_then(|m| serde_json::to_value(m).ok())
251        },
252        deserialize_json: |value| {
253            let m: EmissiveMaterial =
254                serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
255            Ok(Box::new(m) as Box<dyn Material>)
256        },
257    }
258}
259
260inventory::submit! {
261    MaterialRegistration {
262        type_name: "WireframeMaterial",
263        serialize: |mat| {
264            mat.as_any().downcast_ref::<WireframeMaterial>().map(|m| {
265                bincode::encode_to_vec(m, config::standard()).unwrap_or_default()
266            })
267        },
268        deserialize: |data| {
269            let (m, _) = bincode::decode_from_slice::<WireframeMaterial, _>(data, config::standard())
270                .map_err(|e| e.to_string())?;
271            Ok(Box::new(m) as Box<dyn Material>)
272        },
273        create_default: || Box::new(WireframeMaterial::default()) as Box<dyn Material>,
274        serialize_json: |mat| {
275            mat.as_any()
276                .downcast_ref::<WireframeMaterial>()
277                .and_then(|m| serde_json::to_value(m).ok())
278        },
279        deserialize_json: |value| {
280            let m: WireframeMaterial =
281                serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
282            Ok(Box::new(m) as Box<dyn Material>)
283        },
284    }
285}