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}