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}