Skip to content

Instantly share code, notes, and snippets.

@viridia
Created March 21, 2024 09:45
Show Gist options
  • Save viridia/926098ad68edfea46ca601243c1c6067 to your computer and use it in GitHub Desktop.
Save viridia/926098ad68edfea46ca601243c1c6067 to your computer and use it in GitHub Desktop.

Bevy Inline Assets

An "Inline Asset" is an asset whose data is encoded entirely within the asset path itself, without the need to load data from a filesystem or anywhere else. This is similar in concept to a "data URL", which allows a image or resource to be encoded directly within the URL itself.

There are a couple of reasons why you might want to use an inline asset:

  • The asset data is very small, and encoding the data within the path eliminates the overhead of loading a file.
  • You want to use Bevy's asset system as a cache of algorithmically-constructed objects.

For example, let's say you have a game that has a lot of procedurally-generated materials, using an algorithm that depends on the game state. Or perhaps you have a complex scene asset which contains serialized descriptions of various materials. In either case, you'd want to avoid creating multiple copies of the same material - that is, if two materials have the same parameters, it would be nice to have both handles point to the same material instance instead of two separate instances.

This "de-duping" could be managed by maintaing a resource containing a HashMap which is keyed by the material parameters. However, this approach has a number of complexities: you'll need to come up with a way to know when a material is no longer being used, so it can be removed from the table. And you'll need to register the materials as assets so that you can get a handle, which is what most Bevy APIs expect. Also, the materials may not all be the same Rust type, so you'll need to come up with a strategy for handling multiple types.

But there's a simpler approach: why go through all the effort of maintaining an index of materials, when Bevy's asset server is already an index? The only challenge is that Bevy assets are keyed by path, in other words, the keys are strings. So we just need a way to convert the parameters into a string.

The solution has two parts: first, there's a special "inline asset source" (with the prefix "inline://") which is essentially a null reader: unlike a normal filesystem reader, it doesn't do any i/o, and simply returns a zero-length byte array as the result:

/// An asset reader that doesn't actually read anything, merely passes through the asset path
/// as the asset data.
pub struct InlineAssetReader;

/// An AsyncReader that always returns EOF.
struct NullReader;

impl AsyncRead for NullReader {
    fn poll_read(
        self: std::pin::Pin<&mut Self>,
        _cx: &mut std::task::Context<'_>,
        _buf: &mut [u8],
    ) -> std::task::Poll<std::io::Result<usize>> {
        std::task::Poll::Ready(Ok(0))
    }
}

impl AssetReader for InlineAssetReader {
    async fn read<'a>(
        &'a self,
        _path: &'a std::path::Path,
    ) -> Result<Box<bevy::asset::io::Reader<'a>>, bevy::asset::io::AssetReaderError> {
        Ok(Box::new(NullReader))
    }

    async fn read_meta<'a>(
        &'a self,
        path: &'a std::path::Path,
    ) -> Result<Box<bevy::asset::io::Reader<'a>>, bevy::asset::io::AssetReaderError> {
        Err(bevy::asset::io::AssetReaderError::NotFound(path.to_owned()))
    }

    async fn read_directory<'a>(
        &'a self,
        _path: &'a std::path::Path,
    ) -> Result<Box<bevy::asset::io::PathStream>, bevy::asset::io::AssetReaderError> {
        unreachable!("Reading directories is not supported by ComputedAssetReader.")
    }

    async fn is_directory<'a>(
        &'a self,
        _path: &'a std::path::Path,
    ) -> Result<bool, bevy::asset::io::AssetReaderError> {
        Ok(false)
    }
}

The asset reader will need to be registered with .register_asset_source():

app.register_asset_source(
    "inline",
    AssetSource::build().with_reader(|| Box::new(InlineAssetReader)),
)

The second part is a custom AssetLoader that decodes the path into a parameter object, and uses that to construct the asset. To do this we want to serialize the parameters into a string. We need to choose a serialization format that has the following properties:

  • The serialization should be deterministic and order-insensitive, that is, a given set of parameters should always be serialized in the same order. Otherwise the same set of parameters might produce different serialized outputs, which would mean that they were different keys.
  • The output should not have any characters such as ':' or '#' which would confuse Bevy assets.
  • The output should be relatively compact.

There are various formats that can be used, but an easy solution is to use MessagePack followed by Base64. This means that the asset's parameters can be serialized using serde to produce a base64-encoded string. Here's an example of a trait which can be implemented to support both encoding and decoding of a parameter object:

#[non_exhaustive]
#[derive(Debug, Error)]
pub enum InlineAssetError {
    #[error("Could not decode inline asset: {0}")]
    DecodeMsgpack(#[from] rmp_serde::decode::Error),
    #[error("Could not decode base64: {0}")]
    DecodeBase64(#[from] DecodeError),
    #[error("Could not encode inline asset: {0}")]
    EncodeMsgpack(#[from] rmp_serde::encode::Error),
}

pub trait InlineAssetParams
where
    Self: Sized + Serialize + DeserializeOwned,
{
    fn encode(&self) -> Result<String, InlineAssetError> {
        let bytes = rmp_serde::to_vec(self)?;
        Ok(URL_SAFE_NO_PAD.encode(bytes))
    }

    fn decode(encoded: &str) -> Result<Self, InlineAssetError> {
        let bytes = URL_SAFE_NO_PAD.decode(encoded)?;
        Ok(rmp_serde::from_read(Cursor::new(bytes))?)
    }
}

You can then implement this trait on a parameter struct:

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct MyCustomMaterialParams {
    pub texture: Option<String>,
    pub color: Option<LinearRgba>,
    pub roughness: f32,
    pub unlit: bool,
}

impl InlineAssetParams for MyCustomMaterialParams {}

To load the material, construct an asset path that uses the inline asset source:

let material = load_context.load(format!("inline://{}.custom", params.encode().unwrap()));

Because asset loaders are identified by file extension, you'll need to invent a custom file extension for the inline asset to select the appropriate loader.

Finally, you'll need an asset loader to decode the path:

impl AssetLoader for MyCustomMaterialLoader {
    type Asset = MyCustomMaterial;
    type Settings = ();

    type Error = InlineAssetError;

    async fn load<'a>(
        &'a self,
        _reader: &'a mut bevy::asset::io::Reader<'_>,
        _settings: &'a Self::Settings,
        load_context: &'a mut bevy::asset::LoadContext<'_>,
    ) -> Result<Self::Asset, Self::Error> {
        let path = load_context.path().file_stem().unwrap().to_str().unwrap();
        let params = MyCustomMaterialParams::decode(path)?;
        let mut material = MyCustomMaterial {
            perceptual_roughness: params.roughness,
            unlit: params.unlit,
            // etc.
            ..default()
        };
        Ok(material)
    }
    
    fn extensions(&self) -> &[&str] {
        &["custom"]
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment