Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though

This commit is contained in:
Ryan Ward
2026-05-07 11:29:34 -05:00
parent f4e2475c60
commit a41cbd8cc7
2433 changed files with 14208 additions and 9409 deletions
@@ -0,0 +1,500 @@
use anyhow::anyhow;
use anyhow::{Error, Result};
use async_channel::{self, Receiver, Sender};
use bytes::Bytes;
use derivative::Derivative;
use futures::FutureExt as _;
use futures::{future::BoxFuture, Future};
use std::any::{Any, TypeId};
use std::pin::Pin;
use std::{cell::RefCell, collections::HashMap, hash::Hash, rc::Rc, sync::Arc};
use crate::image_cache::ImageCache;
use crate::{r#async::executor, Entity, ModelContext, SingletonEntity};
use super::AssetProvider;
pub trait FetchAsset: crate::r#async::Spawnable + Future<Output = Result<Bytes>> {}
impl<T: crate::r#async::Spawnable + Future<Output = Result<Bytes>> + ?Sized> FetchAsset for T {}
/// Marker trait for async asset ID namespaces.
///
/// Each distinct kind of async asset source defines its own zero-sized marker
/// type that implements this trait. The marker's [`TypeId`] is stored inside
/// [`AsyncAssetId`] so that IDs from different sources can never collide, even
/// if they happen to share the same key string.
pub trait AsyncAssetType: 'static {}
/// A namespaced identifier for an [`AssetSource::Async`] entry.
///
/// The namespace is stored as a [`TypeId`] derived from a marker type that
/// implements [`AsyncAssetType`]. This guarantees that two different async
/// sources cannot accidentally produce colliding cache keys.
#[derive(Clone, Hash, PartialEq, Eq)]
pub struct AsyncAssetId {
namespace: TypeId,
key: String,
}
impl AsyncAssetId {
/// Creates a new ID in the namespace defined by `N`.
pub fn new<N: AsyncAssetType>(key: impl Into<String>) -> Self {
Self {
namespace: TypeId::of::<N>(),
key: key.into(),
}
}
/// Returns the key portion of this ID.
pub fn key(&self) -> &str {
&self.key
}
}
impl std::fmt::Debug for AsyncAssetId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// TypeId's Debug output is opaque, so just print the key.
f.debug_struct("AsyncAssetId")
.field("key", &self.key)
.finish()
}
}
/// A "URI" for some data file. In other words, the location of an asset.
#[derive(Derivative)]
#[derivative(Clone, Hash, PartialEq, Eq, Debug)]
pub enum AssetSource {
/// Loaded from an arbitrary asynchronous source (e.g. a URL fetch).
Async {
/// A namespaced identifier used as the cache key.
id: AsyncAssetId,
/// A factory that produces the future to fetch the asset bytes.
/// Called at most once per unique `id` — only when the asset is
/// not already loaded or loading.
#[derivative(Hash = "ignore", PartialEq = "ignore", Debug = "ignore")]
fetch: Arc<dyn Fn() -> Pin<Box<dyn FetchAsset>> + Send + Sync>,
},
/// Included in the app bundle.
Bundled {
// Assets that are statically included in the bundle can be statically
// referenced, hence using a `&'static str` here and not a `String`.
path: &'static str,
},
/// Accessible in the user's local filesystem at the provided path.
LocalFile { path: String },
/// Image loaded directly with bytes
Raw { id: String },
}
/// The public representation of an asset's current state (i.e., in-memory availability).
pub enum AssetState<T> {
Loading { handle: AssetHandle },
Loaded { data: Rc<T> },
Evicted,
FailedToLoad(Rc<Error>),
}
/// An external type so views can refer to the asset they requested.
/// Transforms into a future that resolves once the asset is finished loading, allowing
/// work to be scheduled at the time of load completion.
#[derive(Clone, Hash, PartialEq, Eq, Debug)]
pub struct AssetHandle {
source: AssetSource,
asset_type: TypeId,
}
impl AssetHandle {
/// Creates a future that resolves whenever the asset is finished loading.
pub fn when_loaded(&self, asset_cache: &AssetCache) -> Option<BoxFuture<'static, ()>> {
asset_cache.create_future_for_loading_asset(self)
}
}
/// An internal representation of an asset's state, as it's tracked and updated by the
/// AssetCache. An implementation.
enum AssetStateInternal {
Loading {
channel: (Sender<()>, Receiver<()>),
},
Loaded {
data: Rc<dyn Any>,
timestamp: u64,
size_in_bytes: usize,
},
Evicted,
Error(Rc<Error>),
}
impl AssetStateInternal {
fn loading() -> Self {
// Whenever we add an asset in a loading state, we create a channel that
// can be alerted once the asset load completes (i.e., becomes available
// or encounters an error). The channel must support the ability to clone one
// side of the channel.
let channel = async_channel::bounded(1);
AssetStateInternal::Loading { channel }
}
fn to_external_type<T: Asset>(&self, source: AssetSource) -> AssetState<T> {
match self {
AssetStateInternal::Loading { .. } => AssetState::Loading {
handle: AssetHandle {
source,
asset_type: TypeId::of::<T>(),
},
},
AssetStateInternal::Loaded { data, .. } => AssetState::Loaded {
data: data
.clone()
.downcast::<T>()
.expect("should not fail to downcast"),
},
AssetStateInternal::Evicted => AssetState::Evicted,
AssetStateInternal::Error(err) => AssetState::FailedToLoad(err.clone()),
}
}
}
/// A general-purpose data cache for managing assets. Generalized to any file type.
/// Internally handles networking and persistence caching.
pub struct AssetCache {
// Note: interior mutability allows us to update the state of an asset
// without requiring a mutable reference to the AssetCache.
inner: Rc<RefCell<HashMap<AssetHandle, AssetStateInternal>>>,
bundled_asset_provider: Box<dyn AssetProvider>,
foreground_executor: Rc<executor::Foreground>,
background_executor: Arc<executor::Background>,
}
pub trait Asset: Any {
fn try_from_bytes(data: &[u8]) -> anyhow::Result<Self>
where
Self: Sized;
fn size_in_bytes(&self) -> usize;
}
impl Asset for String {
fn try_from_bytes(data: &[u8]) -> anyhow::Result<Self>
where
Self: Sized,
{
std::str::from_utf8(data)
.map(|s| s.to_string())
.map_err(|e| e.into())
}
fn size_in_bytes(&self) -> usize {
self.len()
}
}
impl AssetCache {
const MAX_RAW_ASSET_SIZE: usize = 320 * 1000 * 1000; // 320MB
pub fn new(
bundled_asset_provider: Box<dyn AssetProvider>,
foreground_executor: Rc<executor::Foreground>,
background_executor: Arc<executor::Background>,
) -> Self {
Self {
inner: Rc::new(RefCell::new(HashMap::new())),
bundled_asset_provider,
foreground_executor,
background_executor,
}
}
/// Tracks the current total size of raw assets in memory.
pub fn get_total_raw_asset_size(&self) -> usize {
self.inner
.borrow()
.iter()
.filter_map(|(handle, state)| {
if let AssetStateInternal::Loaded { size_in_bytes, .. } = state {
if matches!(handle.source, AssetSource::Raw { .. }) {
return Some(*size_in_bytes);
}
}
None
})
.sum()
}
/// Removes the least recently added raw assets until the total size is within the limit.
fn evict_raw_assets_if_needed(&self, ctx: &ModelContext<Self>) -> Vec<u32> {
let mut total_size = self.get_total_raw_asset_size();
let mut assets = self.inner.borrow_mut();
if total_size <= Self::MAX_RAW_ASSET_SIZE {
return vec![];
}
// Collect all raw assets with their timestamps
let mut raw_assets: Vec<_> = assets
.iter()
.filter_map(|(handle, state)| {
if matches!(handle.source, AssetSource::Raw { .. }) {
if let AssetStateInternal::Loaded {
timestamp,
size_in_bytes,
..
} = state
{
return Some((handle.clone(), *timestamp, *size_in_bytes));
}
}
None
})
.collect();
// Sort by timestamp (oldest first)
raw_assets.sort_by_key(|&(_, timestamp, _)| timestamp);
let mut evicted_image_ids = vec![];
// Evict until within the limit
for (handle, _, size_in_bytes) in raw_assets {
if total_size <= Self::MAX_RAW_ASSET_SIZE {
break;
}
if let AssetSource::Raw { id } = &handle.source {
if assets.remove(&handle).is_some() {
assets.insert(handle.clone(), AssetStateInternal::Evicted);
ImageCache::as_ref(ctx).evict_image(&handle.source);
total_size -= size_in_bytes;
if let Ok(id) = id.parse::<u32>() {
evicted_image_ids.push(id);
}
}
}
}
evicted_image_ids
}
/// The main API of the asset cache. Given the location of an asset, returns an indicator of the
/// in-memory availability of the asset. If the asset is not already loaded or loading, a background
/// task is spawned to perform the retrieval.
///
/// Note: this is an idempotent operation. It can be called as many times as needed on a given
/// asset and won't duplicate work.
pub fn load_asset<T: Asset>(&self, source: AssetSource) -> AssetState<T> {
let mut assets = self.inner.borrow_mut();
// If we've already seen this asset source, we can simply return the current state of it. Otherwise,
// begin the load.
let key = AssetHandle {
source: source.clone(),
asset_type: TypeId::of::<T>(),
};
if !assets.contains_key(&key) {
match source.clone() {
AssetSource::Async { fetch, .. } => {
assets.insert(key.clone(), AssetStateInternal::loading());
let future = (fetch)();
self.load_asynchronously::<T>(source.clone(), future);
}
AssetSource::Bundled { path } => {
let asset_state = match self
.bundled_asset_provider
.get(path)
.and_then(|bytes| T::try_from_bytes(&bytes))
{
Ok(asset) => {
let timestamp = instant::now() as u64;
let size_in_bytes = asset.size_in_bytes();
AssetStateInternal::Loaded {
data: Rc::new(asset) as Rc<dyn Any>,
timestamp,
size_in_bytes,
}
}
Err(err) => AssetStateInternal::Error(Rc::new(err)),
};
assets.insert(key.clone(), asset_state);
}
AssetSource::LocalFile { path } => {
assets.insert(key.clone(), AssetStateInternal::loading());
self.load_asynchronously::<T>(
source.clone(),
Box::pin(async move {
let buffer = async_fs::read(path).await?;
Ok(buffer.into())
}),
);
}
AssetSource::Raw { id } => {
assets.insert(
key.clone(),
AssetStateInternal::Error(Rc::new(anyhow!(
"Raw image with ID {:?} did not exist",
id
))),
);
}
};
}
assets[&key].to_external_type(source)
}
pub fn insert_raw_asset_bytes<T: Asset>(
&self,
id: String,
bytes: &[u8],
ctx: &mut ModelContext<Self>,
) {
let mut assets = self.inner.borrow_mut();
let source = AssetSource::Raw { id: id.clone() };
let key = AssetHandle {
source: source.clone(),
asset_type: TypeId::of::<T>(),
};
match T::try_from_bytes(bytes) {
Ok(asset) => {
let timestamp = instant::now() as u64;
let size_in_bytes = asset.size_in_bytes();
assets.insert(
key.clone(),
AssetStateInternal::Loaded {
data: Rc::new(asset) as Rc<dyn Any>,
timestamp,
size_in_bytes,
},
);
}
Err(err) => {
log::warn!("Raw asset conversion failed (ID: {id}): {err:#}");
assets.insert(key.clone(), AssetStateInternal::Error(Rc::new(err)));
}
};
ImageCache::as_ref(ctx).evict_image(&source);
drop(assets);
let image_ids = self.evict_raw_assets_if_needed(ctx);
if !image_ids.is_empty() {
ctx.emit(AssetCacheEvent::ImagesEvicted { image_ids });
}
}
// Creates a future that resolves when an asset is loaded into moemory.
fn create_future_for_loading_asset(
&self,
asset_handle: &AssetHandle,
) -> Option<BoxFuture<'static, ()>> {
let assets = self.inner.borrow_mut();
assets.get(asset_handle).map(|asset_state| {
match asset_state {
AssetStateInternal::Loading { channel } => {
// Internally, the future works by cloning a new receiver on the channel that's assigned
// to this asset. Inside the future, we simply wait on the receiving end of the channel.
// Note that the channel is held by the AssetStateInternal::Loading variant, so when the asset
// is promoted to the Loaded or FailedToLoad variants, the channel is dropped. This returns a
// RecvError to any receivers, serving as our notification that the asset is no longer loading.
let rx = channel.1.clone();
async move {
let _ = rx.recv().await;
}
.boxed()
}
// If the asset isn't currently loading, it is either already loaded or it's in an error state. Either
// way, we should return a future that resolves immediately since there's no more pending updates
// for this asset.
_ => futures::future::ready(()).boxed(),
}
})
}
// Helper method to spawn the futures that perform an asset load and place the results into the asset cache.
fn load_asynchronously<T: Asset>(
&self,
asset_source: AssetSource,
future: Pin<Box<dyn FetchAsset>>,
) {
let (tx, rx) = futures::channel::oneshot::channel();
// Spawn the work on the background executor.
self.background_executor
.spawn(async move {
let result = future.await;
// When the fetch finished, send the results to the future running on the foreground executor.
if tx.send(result).is_err() {
log::error!("Error sending background task result to main thread");
}
})
.detach();
// Spawn a receiver on the foreground executor.
let assets = Rc::downgrade(&self.inner);
self.foreground_executor
.spawn_boxed(Box::pin(async move {
let result = match rx.await {
Ok(result) => result,
Err(_) => {
let msg = "sender unexpectedly dropped before receiver";
log::error!("{msg}");
Err(anyhow!(msg))
}
};
let Some(assets) = assets.upgrade() else {
return;
};
let mut assets = assets.borrow_mut();
// Populate the asset cache with the result.
let handle = AssetHandle {
source: asset_source.clone(),
asset_type: TypeId::of::<T>(),
};
match result {
Ok(bytes) => match T::try_from_bytes(&bytes) {
Ok(asset) => {
log::debug!("Asset fetch succeeded: {asset_source:?}");
let timestamp = instant::now() as u64;
let size_in_bytes = asset.size_in_bytes();
assets.insert(
handle,
AssetStateInternal::Loaded {
data: Rc::new(asset) as Rc<dyn Any>,
timestamp,
size_in_bytes,
},
);
}
Err(err) => {
log::warn!("Asset conversion failed ({asset_source:?}): {err:#}");
assets.insert(handle, AssetStateInternal::Error(Rc::new(err)));
}
},
Err(err) => {
log::warn!("Asset fetch failed ({asset_source:?}): {err:#}");
assets.insert(handle, AssetStateInternal::Error(Rc::new(err)));
}
}
}))
.detach();
}
}
#[derive(Debug, Clone)]
pub enum AssetCacheEvent {
ImagesEvicted { image_ids: Vec<u32> },
}
impl Entity for AssetCache {
type Event = AssetCacheEvent;
}
impl SingletonEntity for AssetCache {}
+16
View File
@@ -0,0 +1,16 @@
use anyhow::{anyhow, Result};
use std::borrow::Cow;
pub mod asset_cache;
impl AssetProvider for () {
fn get(&self, path: &str) -> Result<Cow<'_, [u8]>> {
Err(anyhow!(
"get called on empty asset provider with \"{}\"",
path
))
}
}
pub trait AssetProvider: 'static {
fn get(&self, path: &str) -> Result<Cow<'_, [u8]>>;
}