Skip to main content

concinnity_core/
resource.rs

1//! Runtime resource tables: per-kind, handle-indexed views of a compiled blob's
2//! resource stream. A resource (an audio clip, a texture, a mesh, a material, ...)
3//! is compiled by cook and addressed at runtime by its dense per-kind handle. The
4//! owning system reads the table by that handle instead of querying an ECS column
5//! or scanning names, so a resource lives in a table it owns rather than as a
6//! component. Renderer-free (the tables are plain handle-indexed data), so they
7//! live here where the physics / audio subsystem crates can reach them; the
8//! client re-exports them under `crate::resource::*`, alongside the engine-side
9//! `install_resource_tables` that inserts them as World resources.
10
11mod handles;
12mod install;
13mod runtime;
14
15pub use handles::{MeshBlock, ResourceHandles};
16pub use install::{append_environment_map, append_material, append_mesh};
17pub use runtime::RuntimeMeshPayloads;
18
19use alloc::collections::BTreeSet;
20use alloc::vec;
21use alloc::vec::Vec;
22
23use crate::ecs::{PayloadLocator, ResourceKind, ResourceRecord, World};
24
25/// One loaded resource's runtime form. A payload resource (audio clip, and later
26/// meshes / textures) carries a `PayloadLocator` into the blob payload section; a
27/// data resource (a baked Material) carries its runtime bytes in `data_bytes`.
28#[derive(Debug, Clone, Default)]
29pub struct ResourceEntry {
30    /// Where the compiled payload lives, for a payload resource.
31    pub payload: Option<PayloadLocator>,
32    /// The runtime bytes, for a data resource.
33    pub data_bytes: Vec<u8>,
34}
35
36impl ResourceEntry {
37    /// A payload resource the world baked for itself at start, holding its
38    /// bytes directly because no blob backs them.
39    pub fn baked(payload_bytes: Vec<u8>) -> Self {
40        Self {
41            payload: None,
42            data_bytes: payload_bytes,
43        }
44    }
45
46    /// The payload bytes this entry holds directly, for one baked at start.
47    /// `None` for a build-compiled resource, whose bytes are read through its
48    /// [`PayloadLocator`] instead.
49    pub fn baked_bytes(&self) -> Option<&[u8]> {
50        (self.payload.is_none() && !self.data_bytes.is_empty()).then_some(&self.data_bytes[..])
51    }
52}
53
54// Build a dense per-kind table from the blob's resource stream, indexed by
55// handle: `table[handle]` is that resource's entry. Records of other kinds are
56// ignored; a missing handle yields a default entry so indexing by any handle in
57// range never panics. Each record's data bytes are MOVED into its entry (a
58// record belongs to exactly one kind, so the per-kind builders never contend);
59// the records are spent scaffolding once every table is built.
60pub(crate) fn resource_table(
61    records: &mut [ResourceRecord],
62    kind: ResourceKind,
63) -> Vec<ResourceEntry> {
64    let tag = kind as u8;
65    let Some(max_handle) = records
66        .iter()
67        .filter(|r| r.resource_kind == tag)
68        .map(|r| r.handle)
69        .max()
70    else {
71        return Vec::new();
72    };
73    let mut table = vec![ResourceEntry::default(); max_handle as usize + 1];
74    for record in records.iter_mut().filter(|r| r.resource_kind == tag) {
75        table[record.handle as usize] = ResourceEntry {
76            payload: record.payload.clone(),
77            data_bytes: core::mem::take(&mut record.data_bytes),
78        };
79    }
80    table
81}
82
83// Declare the per-kind tables. Every table is the same handle-indexed newtype
84// over `ResourceEntry` with the same accessors, so they are generated from one
85// list rather than hand-copied; a table whose kind carries no blob payload
86// simply reports no locators. Bespoke accessors live in a plain `impl` below.
87macro_rules! resource_tables {
88    ($($name:ident => $kind:ident),* $(,)?) => {
89        $(
90            #[derive(Debug, Clone, Default)]
91            /// A handle-indexed table of one resource kind.
92            pub struct $name(pub Vec<ResourceEntry>);
93
94            impl $name {
95                /// Build the table from the blob's resource stream.
96                pub fn from_records(records: &mut [ResourceRecord]) -> Self {
97                    Self(resource_table(records, ResourceKind::$kind))
98                }
99
100                /// Number of resources of this kind; a handle is in range when
101                /// its index is below this.
102                pub fn len(&self) -> usize {
103                    self.0.len()
104                }
105
106                /// Whether the table holds no resources.
107                pub fn is_empty(&self) -> bool {
108                    self.0.is_empty()
109                }
110
111                /// Append a resource the running world baked for itself and
112                /// return the handle it landed on. The table index is the
113                /// handle, so an append extends the space past every handle
114                /// the build assigned and moves none of them.
115                pub fn append(&mut self, entry: ResourceEntry) -> u32 {
116                    let handle = self.0.len() as u32;
117                    self.0.push(entry);
118                    handle
119                }
120
121                /// The payload locator for a handle, if the handle is in range
122                /// and the resource has a compiled payload.
123                pub fn locator(&self, handle: usize) -> Option<PayloadLocator> {
124                    self.0.get(handle).and_then(|e| e.payload.clone())
125                }
126
127                /// Every locator in handle order (index == the resource's
128                /// handle), skipping entries with no payload.
129                pub fn locators(&self) -> impl Iterator<Item = (usize, PayloadLocator)> + '_ {
130                    self.0
131                        .iter()
132                        .enumerate()
133                        .filter_map(|(i, e)| e.payload.clone().map(|l| (i, l)))
134                }
135
136                /// Blob indices holding a payload of this kind. The graphics
137                /// systems consult this to keep those blobs resident for the
138                /// system that inits after them.
139                pub fn blob_indices(&self) -> BTreeSet<u32> {
140                    self.0
141                        .iter()
142                        .filter_map(|e| e.payload.as_ref().map(|l| l.blob_index))
143                        .collect()
144                }
145            }
146        )*
147    };
148}
149
150resource_tables! {
151    // Audio clips, indexed by `AudioClipHandle`. `AudioSystem` reads this at init.
152    AudioClipTable => AudioClip,
153    // Textures, indexed by `TextureHandle`. The renderer reads this at init to
154    // build its shared texture pool. Every texture (file or procedural) has a
155    // compiled payload, so an entry's `payload` is normally `Some`.
156    TextureTable => Texture,
157    // Color-grading LUTs, indexed by `ColorLutHandle`. The renderer uses only
158    // the first (handle 0) and warns when a world declares more.
159    ColorLutTable => ColorLut,
160    // IBL environment maps, indexed by `EnvironmentMapHandle`. The renderer uses
161    // only the first (handle 0); a world declares at most one.
162    EnvironmentMapTable => EnvironmentMap,
163    // Fonts, indexed by `FontHandle`. The renderer reads this at init to build
164    // its glyph atlases + metrics; every font has a compiled SDF-atlas payload.
165    FontTable => Font,
166    // Static meshes, indexed by `MeshHandle`. Mesh shares its handle space with
167    // the still-component geometry producers (ProceduralMesh, VoxelChunk,
168    // mesh-kind File): the Mesh block leads that space, so this table covers
169    // handles `0..len` and the runtime appends the component-produced geometry
170    // after it in the same block order cook assigned.
171    MeshTable => Mesh,
172    // Skinned meshes, indexed by `SkinnedMeshHandle`. A hybrid entry: `payload`
173    // locates the compiled geometry (vertices + indices + skeleton) while
174    // `data_bytes` carries the baked runtime fields (placement, material/texture
175    // handles, capsule, spawn reserve) as a `(name_id, SkinnedMesh)` postcard
176    // tuple -- `asset_id` is serde-skipped on the schema struct, so the interned
177    // name travels beside it for the runtime's spawn-by-name registration.
178    SkinnedMeshTable => SkinnedMesh,
179    // Materials, indexed by `MaterialHandle`. Unlike the payload-backed tables,
180    // a Material is a DATA resource: cook bakes its validated args into the
181    // record's `data_bytes` (no blob payload), so `data_bytes(handle)` returns
182    // the serialized `Material` the renderer deserializes to build its map.
183    MaterialTable => Material,
184}
185
186impl SkinnedMeshTable {
187    /// Whether any skinned mesh declares a character capsule; gates whether the
188    /// world needs a PhysicsSystem.
189    pub fn has_capsule(&self) -> bool {
190        self.0.iter().any(|e| {
191            crate::blob::decode_exact::<(u32, crate::components::SkinnedMesh)>(&e.data_bytes)
192                .is_ok_and(|(_, sm)| sm.capsule.is_some())
193        })
194    }
195}
196
197impl MaterialTable {
198    /// The baked material bytes for a handle, if the handle is in range.
199    pub fn data_bytes(&self, handle: usize) -> Option<&[u8]> {
200        self.0.get(handle).map(|e| e.data_bytes.as_slice())
201    }
202}
203
204/// Install every per-kind table built from a compiled world's resource stream
205/// into `world`. The single place the table set is enumerated: the shipped
206/// runtime's blob load, the in-memory cook build, and the [`bake`](crate::bake)
207/// builder all reach it, so a kind that migrates into the stream gets wired
208/// into every host by adding one line here. Each builder MOVES its kind's data
209/// bytes out of the records, so the caller's record slice is spent scaffolding
210/// afterwards.
211pub fn install_tables(world: &mut World, records: &mut [ResourceRecord]) {
212    world.insert_resource(AudioClipTable::from_records(records));
213    world.insert_resource(TextureTable::from_records(records));
214    world.insert_resource(ColorLutTable::from_records(records));
215    world.insert_resource(EnvironmentMapTable::from_records(records));
216    world.insert_resource(FontTable::from_records(records));
217    world.insert_resource(MaterialTable::from_records(records));
218    world.insert_resource(MeshTable::from_records(records));
219    world.insert_resource(SkinnedMeshTable::from_records(records));
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn rec(kind: ResourceKind, handle: u32, blob_index: u32) -> ResourceRecord {
227        ResourceRecord {
228            resource_kind: kind as u8,
229            handle,
230            payload: Some(PayloadLocator {
231                blob_index,
232                offset: 0,
233                len: 1,
234            }),
235            data_bytes: Vec::new(),
236        }
237    }
238
239    #[test]
240    fn table_is_dense_by_handle_and_ignores_other_kinds() {
241        // Handles out of order, interleaved with another kind; the table places
242        // each clip at its handle index and drops the mesh record.
243        let mut records = vec![
244            rec(ResourceKind::AudioClip, 1, 0),
245            rec(ResourceKind::Mesh, 0, 5),
246            rec(ResourceKind::AudioClip, 0, 0),
247        ];
248        let table = AudioClipTable::from_records(&mut records);
249        assert_eq!(table.0.len(), 2);
250        assert!(table.locator(0).is_some());
251        assert!(table.locator(1).is_some());
252        // A handle past the end resolves to None rather than panicking.
253        assert!(table.locator(2).is_none());
254    }
255
256    #[test]
257    fn empty_stream_yields_an_empty_table() {
258        let table = AudioClipTable::from_records(&mut []);
259        assert!(table.0.is_empty());
260        assert!(table.blob_indices().is_empty());
261        assert_eq!(table.locators().count(), 0);
262    }
263
264    #[test]
265    fn blob_indices_collects_every_payload_blob() {
266        let mut records = vec![
267            rec(ResourceKind::AudioClip, 0, 0),
268            rec(ResourceKind::AudioClip, 1, 3),
269        ];
270        let table = AudioClipTable::from_records(&mut records);
271        let mut indices: Vec<u32> = table.blob_indices().into_iter().collect();
272        indices.sort_unstable();
273        assert_eq!(indices, vec![0, 3]);
274    }
275
276    #[test]
277    fn texture_table_is_dense_by_handle_and_ignores_other_kinds() {
278        // A texture record and an audio record interleaved; the texture table
279        // keeps only the textures, placed at their handle index.
280        let mut records = vec![
281            rec(ResourceKind::Texture, 1, 2),
282            rec(ResourceKind::AudioClip, 0, 9),
283            rec(ResourceKind::Texture, 0, 1),
284        ];
285        let table = TextureTable::from_records(&mut records);
286        assert_eq!(table.len(), 2);
287        assert!(table.locator(0).is_some());
288        assert!(table.locator(1).is_some());
289        assert!(table.locator(2).is_none());
290        let mut indices: Vec<u32> = table.blob_indices().into_iter().collect();
291        indices.sort_unstable();
292        assert_eq!(indices, vec![1, 2]);
293        assert!(TextureTable::from_records(&mut []).is_empty());
294    }
295
296    #[test]
297    fn every_table_reads_only_its_own_kind() {
298        // One record per kind in one stream; each table sees exactly its own.
299        let mut records = vec![
300            rec(ResourceKind::AudioClip, 0, 0),
301            rec(ResourceKind::Texture, 0, 1),
302            rec(ResourceKind::ColorLut, 0, 2),
303            rec(ResourceKind::EnvironmentMap, 0, 3),
304            rec(ResourceKind::Font, 0, 4),
305            rec(ResourceKind::Mesh, 0, 5),
306            rec(ResourceKind::SkinnedMesh, 0, 6),
307            rec(ResourceKind::Material, 0, 7),
308        ];
309        assert_eq!(AudioClipTable::from_records(&mut records).len(), 1);
310        assert_eq!(TextureTable::from_records(&mut records).len(), 1);
311        assert_eq!(ColorLutTable::from_records(&mut records).len(), 1);
312        assert_eq!(EnvironmentMapTable::from_records(&mut records).len(), 1);
313        assert_eq!(FontTable::from_records(&mut records).len(), 1);
314        assert_eq!(MeshTable::from_records(&mut records).len(), 1);
315        assert_eq!(SkinnedMeshTable::from_records(&mut records).len(), 1);
316        assert_eq!(MaterialTable::from_records(&mut records).len(), 1);
317    }
318}