Skip to main content

mirage_engine/assets/
mod.rs

1//! Loaded sources, and the names a game's builds read from them.
2
3use core::mem::discriminant;
4use std::collections::{HashMap, HashSet};
5use std::sync::Arc;
6
7use crate::Error;
8use crate::mesh::{Clip, MeshData, NoClips, Part};
9use crate::skybox::SkyboxData;
10use crate::sound::{Encoded, SoundData};
11
12pub(crate) use model::{NamedAnimation, NamedModel, Readable};
13pub(crate) use named::{NamedMesh, NamedSlot};
14pub(crate) use ogg::Stream;
15pub(crate) use texture::Textures;
16pub use texture::{ReliefData, ShadingData, TextureData};
17pub(crate) use unresolved::{Missing, QUALIFIER, Unresolved};
18
19/// The example's asset source, loaded for every test that needs data.
20#[cfg(test)]
21pub(crate) const BEACON: &[u8] = include_bytes!("../../examples/assets/hello.glb");
22
23/// A two-second tone, the sound every test that needs one loads.
24#[cfg(test)]
25pub(crate) const SWEEP: &[u8] = include_bytes!("../../tests/assets/sweep.ogg");
26
27/// The models the tests read: a skinned rig of three joints under two clips,
28/// the same rig under a root node scaled and turned, a keyed cube with
29/// another under it no clip moves, one action written on two tracks, two
30/// animations of one name, and a model two skins and a set of shapes cover.
31#[cfg(test)]
32pub(crate) const RIG: &[u8] = include_bytes!("../../tests/assets/a_rig.glb");
33#[cfg(test)]
34pub(crate) const SCALED: &[u8] = include_bytes!("../../tests/assets/a_rig_scaled.glb");
35#[cfg(test)]
36pub(crate) const PROP: &[u8] = include_bytes!("../../tests/assets/b_prop.glb");
37#[cfg(test)]
38pub(crate) const TRACKED: &[u8] = include_bytes!("../../tests/assets/two_tracks_same_action.glb");
39#[cfg(test)]
40pub(crate) const MERGED: &[u8] = include_bytes!("../../tests/assets/d_merge.glb");
41#[cfg(test)]
42pub(crate) const ROBOT: &[u8] = include_bytes!("../../tests/assets/robot-expressive.glb");
43
44/// The two models the examples draw, which the tests read as well: one of
45/// sixteen joints under four clips, and one whose two meshes share a skin.
46#[cfg(test)]
47pub(crate) const CORGI: &[u8] = include_bytes!("../../examples/assets/corgi.glb");
48#[cfg(test)]
49pub(crate) const CHEST: &[u8] = include_bytes!("../../examples/assets/chest.glb");
50
51/// Four pixels, the texture source every test that needs one loads.
52#[cfg(test)]
53pub(crate) const IMP: &[u8] = include_bytes!("../../tests/assets/imp.png");
54
55/// The example's font, the source every test that needs one loads.
56#[cfg(test)]
57pub(crate) const OPERATOR: &[u8] = include_bytes!("../../examples/assets/pixel-operator.ttf");
58
59/// One source as [`Assets::load`] takes it: the file name it came in, and
60/// its bytes.
61#[cfg(test)]
62pub(crate) fn file(source: &str, bytes: &[u8]) -> (String, Vec<u8>) {
63    (source.to_owned(), bytes.to_vec())
64}
65
66/// Everything [`Config::with_assets`](crate::Config::with_assets) loaded, by
67/// name across all sources.
68///
69/// A name only one source used is read bare, `"Ship"`; a name more than
70/// one shares needs its source, `"props#Ship"`.
71#[derive(Default)]
72pub struct Assets {
73    /// Each name against every source that used it, in load order.
74    items: HashMap<String, Vec<(Arc<str>, Item)>>,
75    stems: HashSet<Arc<str>>,
76}
77
78impl Assets {
79    /// The mesh a source loaded under `name`, its material names resolved
80    /// to the parts `P`.
81    ///
82    /// A part names exactly one material; one that resolves to no part is
83    /// drawn as authored unless a draw repaints every slot. A missing or
84    /// ambiguous name, a part no material resolves to, and a part two
85    /// materials resolve to each return empty data, which carries the name
86    /// into the startup error.
87    pub fn mesh<P: Part>(&self, name: &str) -> MeshData<P, NoClips> {
88        match self.find(name, Item::mesh) {
89            Ok(mesh) => mesh.resolved(name),
90            Err(unaddressable) => MeshData::missing(Unresolved::of(unaddressable)),
91        }
92    }
93
94    /// The model a source loaded under `name`: the mesh of one of its root
95    /// nodes, the joints that pose it, and one animation per clip of `C`.
96    ///
97    /// Material names resolve to the parts `P` as [`mesh`](Assets::mesh)
98    /// resolves them, and each clip resolves the one animation of the source
99    /// whose name it matches. An animation no clip matches is left alone; a
100    /// clip that matches none, a clip two animations match, and a clip whose
101    /// animation moves no joint of this model each return empty data, which
102    /// carries the name into the startup error.
103    pub fn model<P: Part, C: Clip>(&self, name: &str) -> MeshData<P, C> {
104        match self.find(name, Item::model) {
105            Ok(model) => model.resolved(name),
106            Err(unaddressable) => MeshData::missing(Unresolved::of(unaddressable)),
107        }
108    }
109
110    /// The texture a source loaded under `name`.
111    ///
112    /// A missing name returns empty pixels carrying the name into the
113    /// startup error.
114    pub fn texture(&self, name: &str) -> TextureData {
115        match self.find(name, Item::texture) {
116            Ok(texture) => texture.clone(),
117            Err(unaddressable) => TextureData::missing(Unresolved::of(unaddressable)),
118        }
119    }
120
121    /// The texture a source loaded under `name`, read as a relief holding a
122    /// normal and a depth per texel.
123    ///
124    /// A missing name returns empty pixels carrying the name into the
125    /// startup error.
126    pub fn relief(&self, name: &str) -> ReliefData {
127        ReliefData::loaded(self.texture(name))
128    }
129
130    /// The same pixels read as the whole sky: the whole way around across
131    /// the image, and zenith to nadir down it.
132    ///
133    /// A missing name returns a black sky carrying that name into the
134    /// startup error, and so does an image that is no sky at all, under the
135    /// name of the skybox it was built for.
136    pub fn skybox(&self, name: &str) -> SkyboxData {
137        let mut image = self.texture(name);
138
139        match image.drawn() {
140            true => SkyboxData::equirect(image),
141            false => SkyboxData::missing(image.take_unresolved()),
142        }
143    }
144
145    /// The sound a source loaded under `name`.
146    ///
147    /// A missing name returns silence, which carries the name into the
148    /// startup error.
149    pub fn sound(&self, name: &str) -> SoundData {
150        match self.find(name, Item::sound) {
151            Ok(clip) => SoundData::loaded(Arc::clone(clip)),
152            Err(unaddressable) => SoundData::missing(Unresolved::of(unaddressable)),
153        }
154    }
155
156    /// The font a source loaded under `name`.
157    ///
158    /// A missing or ambiguous name is the error it returns, not a record it
159    /// carries: a font is read once startup has already written its error,
160    /// so a record it carries would reach nothing.
161    #[cfg(feature = "ui")]
162    pub(crate) fn font(&self, name: &str) -> Result<Arc<[u8]>, Error> {
163        match self.find(name, Item::font) {
164            Ok(bytes) => Ok(Arc::clone(bytes)),
165            Err(unaddressable) => Err(Error::msg(unaddressable.to_string())),
166        }
167    }
168
169    /// Builds a store by decoding `files` in load order. Each item is a file
170    /// name and its bytes.
171    ///
172    /// Fails with one error naming every file the engine cannot read, every
173    /// file whose name cannot qualify an item name, every file that names one
174    /// item of a kind more than once, and every file name two files share. A
175    /// name two files share is not an error: it is only read qualified.
176    pub(crate) fn load(files: impl IntoIterator<Item = (String, Vec<u8>)>) -> Result<Self, Error> {
177        let decoded = files
178            .into_iter()
179            .map(|(source, bytes)| DecodedFile::decode(&source, &bytes));
180
181        let mut loaded = Self::default();
182        let mut failures = Vec::new();
183        for file in decoded {
184            if let Err(error) = file.and_then(|file| loaded.place(file)) {
185                failures.push(error.to_string());
186            }
187        }
188
189        match failures.is_empty() {
190            true => Ok(loaded),
191            false => Err(Error::msg(format!(
192                "the game's asset sources did not load: {}",
193                failures.join("; ")
194            ))),
195        }
196    }
197
198    /// What `name` resolves to among the items `of` reads, or what to record
199    /// if it resolves to nothing.
200    ///
201    /// A name an item holds whole is matched before the name is split at
202    /// its `#`. One name reaches one item of every kind, so a source that
203    /// holds a mesh and a model of one name is read either way.
204    fn find<'a, T>(&'a self, name: &str, of: impl Fn(&'a Item) -> Option<T>) -> Result<T, Missing> {
205        let held = |bare: &str| -> Vec<(&'a Arc<str>, T)> {
206            self.items
207                .get(bare)
208                .into_iter()
209                .flatten()
210                .filter_map(|(stem, item)| of(item).map(|held| (stem, held)))
211                .collect()
212        };
213
214        let mut shared = held(name);
215        match shared.len() {
216            1 => return Ok(shared.remove(0).1),
217            0 => {}
218            _ => {
219                return Err(Missing::Ambiguous {
220                    name: name.to_owned(),
221                    sources: shared
222                        .into_iter()
223                        .map(|(stem, _)| stem.to_string())
224                        .collect(),
225                });
226            }
227        }
228
229        let Some((stem, bare)) = name.split_once(QUALIFIER) else {
230            return Err(Missing::absent(name));
231        };
232        held(bare)
233            .into_iter()
234            .find(|(other, _)| other.as_ref() == stem)
235            .map(|(_, item)| item)
236            .ok_or_else(|| Missing::absent(name))
237    }
238
239    /// Takes in the items `file` decoded, under the file name it read them
240    /// by.
241    ///
242    /// Fails where a file of that name is in the store already, which nothing
243    /// could then tell apart by name; the store is left as it was.
244    fn place(&mut self, file: DecodedFile) -> Result<(), Error> {
245        let DecodedFile { stem, items } = file;
246        if !self.stems.insert(Arc::clone(&stem)) {
247            return Err(shared_stem(&stem));
248        }
249
250        for (name, item) in items {
251            self.items
252                .entry(name)
253                .or_default()
254                .push((Arc::clone(&stem), item));
255        }
256        Ok(())
257    }
258}
259
260/// One file decoded on its own: the file name its items are read by, and the
261/// items it holds.
262struct DecodedFile {
263    stem: Arc<str>,
264    items: Vec<(String, Item)>,
265}
266
267impl DecodedFile {
268    /// Decodes the file `source` names out of `bytes`, against nothing else.
269    ///
270    /// Fails where that file name could qualify no item name, where the
271    /// engine cannot read the bytes, and where they name one item of a kind
272    /// more than once.
273    fn decode(source: &str, bytes: &[u8]) -> Result<Self, Error> {
274        let stem: Arc<str> = Arc::from(stem(source)?);
275        let named = |error| Error::msg(format!("the asset source `{source}` {error}"));
276        let items = match Kind::of(source) {
277            Kind::Model => glb::decode(&stem, bytes).map_err(named)?,
278            // A texture or a sound is one item, which its own file name is
279            // the name of.
280            Kind::Texture => vec![(
281                stem.to_string(),
282                Item::Texture(texture::decode(bytes).map_err(named)?),
283            )],
284            Kind::Sound => vec![(
285                stem.to_string(),
286                Item::Sound(Arc::new(ogg::decode(bytes).map_err(named)?)),
287            )],
288            Kind::Font => font_items(&stem, bytes).map_err(named)?,
289        };
290
291        let mut names = HashSet::with_capacity(items.len());
292        for (name, item) in &items {
293            if !names.insert((name.as_str(), discriminant(item))) {
294                return Err(Error::msg(format!(
295                    "the asset source `{source}` calls two things `{name}`"
296                )));
297            }
298        }
299
300        Ok(Self { stem, items })
301    }
302}
303
304/// What a font source adds to the store under `stem`: its bytes, for the UI
305/// to draw with.
306#[cfg(feature = "ui")]
307fn font_items(stem: &str, bytes: &[u8]) -> Result<Vec<(String, Item)>, Error> {
308    let font = font::decode(bytes)?;
309    Ok(vec![(stem.to_owned(), Item::Font(font))])
310}
311
312/// The same, without the UI: the source decodes the same way, and no item
313/// keeps a font nothing can draw with.
314#[cfg(not(feature = "ui"))]
315fn font_items(_stem: &str, bytes: &[u8]) -> Result<Vec<(String, Item)>, Error> {
316    font::decode(bytes)?;
317    Ok(Vec::new())
318}
319
320/// The error for two sources one file name could mean either of.
321fn shared_stem(stem: &str) -> Error {
322    Error::msg(format!(
323        "two asset sources are both called `{stem}`, and a shared item name is reached by \
324         file name, so rename one of them"
325    ))
326}
327
328/// One decoded item a source named.
329enum Item {
330    Mesh(NamedMesh),
331    Model(NamedModel),
332    Texture(TextureData),
333    Sound(Arc<Encoded>),
334    #[cfg(feature = "ui")]
335    Font(Arc<[u8]>),
336}
337
338impl Item {
339    fn mesh(&self) -> Option<&NamedMesh> {
340        match self {
341            Self::Mesh(mesh) => Some(mesh),
342            _ => None,
343        }
344    }
345
346    fn model(&self) -> Option<&NamedModel> {
347        match self {
348            Self::Model(model) => Some(model),
349            _ => None,
350        }
351    }
352
353    fn texture(&self) -> Option<&TextureData> {
354        match self {
355            Self::Texture(texture) => Some(texture),
356            _ => None,
357        }
358    }
359
360    fn sound(&self) -> Option<&Arc<Encoded>> {
361        match self {
362            Self::Sound(clip) => Some(clip),
363            _ => None,
364        }
365    }
366
367    #[cfg(feature = "ui")]
368    fn font(&self) -> Option<&Arc<[u8]>> {
369        match self {
370            Self::Font(bytes) => Some(bytes),
371            _ => None,
372        }
373    }
374}
375
376/// Kind of thing a source holds, from its file name.
377enum Kind {
378    Model,
379    Texture,
380    Sound,
381    Font,
382}
383
384impl Kind {
385    /// Kind a source holds, from the end of its file name.
386    fn of(source: &str) -> Self {
387        match source.rsplit_once('.') {
388            Some((_, kind)) if kind.eq_ignore_ascii_case("ogg") => Self::Sound,
389            Some((_, kind)) if kind.eq_ignore_ascii_case("png") => Self::Texture,
390            Some((_, kind))
391                if kind.eq_ignore_ascii_case("ttf") || kind.eq_ignore_ascii_case("otf") =>
392            {
393                Self::Font
394            }
395            _ => Self::Model,
396        }
397    }
398}
399
400/// The name a source qualifies its items by, without its file extension.
401///
402/// Fails where that name holds the mark a qualified name is read through,
403/// which nothing could then read a shared name of the source by.
404fn stem(source: &str) -> Result<&str, Error> {
405    let file = source.rsplit(['/', '\\']).next().unwrap_or(source);
406    let stem = file.rsplit_once('.').map_or(file, |(stem, _)| stem);
407    if stem.contains(QUALIFIER) {
408        return Err(Error::msg(format!(
409            "the asset source `{stem}` holds a `{QUALIFIER}` in its file name, which is the \
410             mark a shared item name is read through, so rename it"
411        )));
412    }
413    Ok(stem)
414}
415
416pub(crate) mod font;
417mod glb;
418mod model;
419mod named;
420pub(crate) mod ogg;
421#[cfg(test)]
422mod testing;
423mod texture;
424mod unresolved;
425
426#[cfg(test)]
427mod tests {
428    use core::time::Duration;
429
430    use super::*;
431    use crate::assets::testing::unresolved;
432    use crate::math::UVec2;
433    use crate::mesh::NoParts;
434
435    fn loaded(sources: &[&str]) -> Assets {
436        Assets::load(sources.iter().map(|source| file(source, BEACON)))
437            .expect("the example's model decodes")
438    }
439
440    #[test]
441    fn the_store_is_shared_across_threads() {
442        fn assert_send_sync<T: Send + Sync>() {}
443        assert_send_sync::<Assets>();
444    }
445
446    #[test]
447    fn a_miss_returns_empty_data_carrying_the_name_into_one_error() {
448        let assets = Assets::default();
449        let hull = assets.mesh::<NoParts>("hull");
450        let mut paint = assets.texture("paint");
451
452        assert_eq!(hull.slots().len(), 0, "a miss draws nothing");
453        assert_eq!(paint.size(), UVec2::ZERO, "and reads no pixels");
454
455        let mut carried = unresolved(hull);
456        carried.record(unresolved(assets.mesh::<NoParts>("hull")));
457        carried.record(paint.take_unresolved());
458        assert_eq!(
459            carried
460                .error()
461                .expect("both names came back on the data the pulls returned")
462                .to_string(),
463            "the game's assets did not resolve: no asset is named `hull`; \
464             no asset is named `paint`",
465            "one line each, however many builds pulled the name"
466        );
467    }
468
469    #[test]
470    fn a_name_loaded_as_one_kind_is_a_miss_as_the_other() {
471        let assets = loaded(&["hello.glb"]);
472        let mut beacon = assets.texture("beacon");
473
474        assert!(beacon.pixels().is_empty());
475        assert!(beacon.take_unresolved().error().is_some());
476    }
477
478    #[test]
479    fn a_name_two_sources_share_is_reachable_only_by_their_file_names() {
480        let assets = loaded(&["art/props.glb", "art/scene.glb"]);
481        let shared = assets.mesh::<NoParts>("beacon");
482
483        assert_eq!(shared.slots().len(), 0, "bare, it reaches neither of them");
484        assert_eq!(
485            unresolved(shared)
486                .error()
487                .expect("the ambiguity came back on the empty data")
488                .to_string(),
489            "the game's assets did not resolve: several sources call something `beacon`; \
490             ask for `props#beacon` or `scene#beacon`"
491        );
492
493        let qualified = assets.mesh::<NoParts>("props#beacon");
494        let mut panels = assets.texture("scene#beacon_panels");
495        assert_eq!(qualified.slots().len(), 2);
496        assert_eq!(panels.size(), UVec2::splat(16));
497        assert!(
498            unresolved(qualified).is_empty() && panels.take_unresolved().is_empty(),
499            "qualified, both resolve"
500        );
501    }
502
503    #[test]
504    fn a_name_only_one_source_uses_stays_bare() {
505        let assets = loaded(&["props.glb"]);
506        let bare = assets.mesh::<NoParts>("beacon");
507        let qualified = assets.mesh::<NoParts>("props#beacon");
508
509        assert_eq!(bare.slots().len(), 2);
510        assert_eq!(qualified.slots().len(), 2);
511        assert!(
512            unresolved(bare).is_empty() && unresolved(qualified).is_empty(),
513            "either way of pulling it works"
514        );
515    }
516
517    #[test]
518    fn a_sound_source_is_named_by_its_own_file_name() {
519        let assets = Assets::load([file("audio/theme.ogg", SWEEP)]).expect("the fixture decodes");
520        let theme = assets.sound("theme");
521        let as_a_mesh = assets.mesh::<NoParts>("theme");
522
523        assert_eq!(
524            theme.duration(),
525            Duration::from_secs(2),
526            "one source, one sound, named by the file it came in"
527        );
528        assert!(as_a_mesh.indices().is_empty());
529        assert_eq!(
530            unresolved(as_a_mesh)
531                .error()
532                .expect("pulling it as a mesh reaches nothing")
533                .to_string(),
534            "the game's assets did not resolve: no asset is named `theme`"
535        );
536    }
537
538    #[test]
539    fn a_texture_source_is_named_by_its_own_file_name() {
540        let assets = Assets::load([file("art/imp.png", IMP)]).expect("the fixture decodes");
541
542        let mut imp = assets.texture("imp");
543        assert_eq!(imp.size(), UVec2::splat(2), "one source, one texture");
544        assert_eq!(
545            imp.pixels()[..4],
546            [u8::MAX, 0, 0, u8::MAX],
547            "read row by row from the top left"
548        );
549        assert_eq!(assets.texture("imp#imp").size(), UVec2::splat(2));
550        assert!(imp.take_unresolved().is_empty());
551
552        let mut goblin = assets.texture("goblin");
553        assert_eq!(goblin.size(), UVec2::ZERO);
554        assert_eq!(
555            goblin
556                .take_unresolved()
557                .error()
558                .expect("only the second pull missed")
559                .to_string(),
560            "the game's assets did not resolve: no asset is named `goblin`"
561        );
562    }
563
564    #[cfg(feature = "ui")]
565    #[test]
566    fn a_font_source_is_named_by_its_own_file_name() {
567        let assets =
568            Assets::load([file("art/pixel-operator.ttf", OPERATOR)]).expect("the fixture decodes");
569
570        assert_eq!(
571            assets
572                .font("pixel-operator")
573                .expect("one source, one font, named by the file it came in")
574                .len(),
575            OPERATOR.len(),
576            "the bytes the source held"
577        );
578        assert_eq!(
579            assets
580                .font("nothing")
581                .expect_err("no source holds that name")
582                .to_string(),
583            "no asset is named `nothing`",
584            "a font that missed is the error it returned, and carries nothing"
585        );
586
587        let assets = Assets::load([
588            file("art/pixel-operator.ttf", OPERATOR),
589            file("art/OTHER.OTF", OPERATOR),
590        ])
591        .expect("either extension, in either case");
592        assert!(assets.font("OTHER").is_ok());
593    }
594
595    #[test]
596    fn a_font_source_that_does_not_decode_cannot_start() {
597        let Err(error) = Assets::load([file("junk.ttf", b"not a font at all")]) else {
598            panic!("nothing decodes that");
599        };
600
601        assert!(
602            error.to_string().starts_with(
603                "the game's asset sources did not load: the asset source `junk.ttf` did not decode"
604            ),
605            "got {error}"
606        );
607    }
608
609    #[test]
610    fn a_texture_source_that_does_not_decode_cannot_start() {
611        let Err(error) = Assets::load([file("junk.png", b"not a texture at all")]) else {
612            panic!("nothing decodes that");
613        };
614
615        assert!(
616            error.to_string().starts_with(
617                "the game's asset sources did not load: the asset source `junk.png` did not decode"
618            ),
619            "got {error}"
620        );
621    }
622
623    #[test]
624    fn a_source_whose_file_name_holds_the_qualifier_cannot_start() {
625        let Err(error) = Assets::load([file("art/art#hello.glb", BEACON)]) else {
626            panic!("nothing could reach a name it shared");
627        };
628
629        assert_eq!(
630            error.to_string(),
631            "the game's asset sources did not load: the asset source `art#hello` holds a `#` in \
632             its file name, which is the mark a shared item name is read through, so rename it"
633        );
634    }
635
636    #[test]
637    fn two_sources_with_one_file_name_cannot_start() {
638        let Err(error) = Assets::load([
639            file("art/hello.glb", BEACON),
640            file("other/hello.glb", BEACON),
641        ]) else {
642            panic!("nothing could tell the two apart");
643        };
644
645        assert_eq!(
646            error.to_string(),
647            "the game's asset sources did not load: two asset sources are both called `hello`, \
648             and a shared item name is reached by file name, so rename one of them"
649        );
650    }
651
652    #[test]
653    fn every_source_that_did_not_load_is_named_in_one_error() {
654        let Err(error) = Assets::load([
655            file("art/hello.glb", BEACON),
656            file("other/hello.glb", BEACON),
657            file("art#props.glb", BEACON),
658        ]) else {
659            panic!("two file names are one, and the third holds the qualifier");
660        };
661
662        assert_eq!(
663            error.to_string(),
664            "the game's asset sources did not load: two asset sources are both called `hello`, \
665             and a shared item name is reached by file name, so rename one of them; the asset \
666             source `art#props` holds a `#` in its file name, which is the mark a shared item \
667             name is read through, so rename it",
668            "one error, in load order, naming both"
669        );
670
671        let Err(error) = Assets::load([
672            file("junk/beacon.glb", b"not a container at all"),
673            file("art/beacon.glb", BEACON),
674        ]) else {
675            panic!("the first of these two does not decode");
676        };
677
678        let text = error.to_string();
679        assert!(
680            text.starts_with(
681                "the game's asset sources did not load: the asset source `junk/beacon.glb` did \
682                 not decode"
683            ),
684            "got {text}"
685        );
686        assert!(
687            !text.contains("both called"),
688            "and the file that did not load claims no file name: {text}"
689        );
690    }
691}