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