Import Oxide engine (Stages 0–10) under MIT license

Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Homer Simpson
2026-07-05 20:41:02 +02:00
parent 2afb56b329
commit 9eead719b0
157 changed files with 47270 additions and 2 deletions
+508
View File
@@ -0,0 +1,508 @@
//! [`AssetServer`]: the central registry that loads, deduplicates, and hands out
//! [`Handle`]s, plus the [`AssetLoader`] trait that makes it extensible.
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock, Weak};
use super::handle::{AssetCell, AssetId, Handle};
/// Errors produced while loading assets.
#[derive(Debug, thiserror::Error)]
pub enum AssetError {
/// The path had no file extension to pick a loader by.
#[error("path has no file extension: {0}")]
NoExtension(PathBuf),
/// No loader was registered for the file's extension.
#[error("no loader registered for extension '.{0}'")]
NoLoader(String),
/// A loader exists for the extension, but it produces a different asset
/// type than the one requested at the call site.
#[error("loader for '.{ext}' produces a different asset type than requested")]
TypeMismatch {
/// The extension whose loader was selected.
ext: String,
},
/// The loader itself failed (I/O, parse, etc.).
#[error("failed to load {path}: {message}")]
Load {
/// The asset path.
path: PathBuf,
/// The loader's error message.
message: String,
},
}
/// A pluggable importer that turns a file into an asset of one concrete type.
///
/// Implement this for each asset format and register it with
/// [`AssetServer::register_loader`]. The server dispatches by file extension and
/// checks that the loader's [`Asset`](Self::Asset) type matches what the caller
/// asked to load.
pub trait AssetLoader: Send + Sync + 'static {
/// The type this loader produces.
type Asset: Send + Sync + 'static;
/// The lower-or-mixed-case extensions (without the dot) this loader handles,
/// e.g. `&["gltf", "glb"]`.
fn extensions(&self) -> &'static [&'static str];
/// Loads and parses the asset at `path`.
fn load(&self, path: &Path) -> Result<Self::Asset, AssetError>;
}
/// Type-erased view of an [`AssetLoader`] so loaders of different output types
/// can share one registry.
trait ErasedLoader: Send + Sync {
fn output_type(&self) -> TypeId;
fn load(&self, path: &Path) -> Result<Box<dyn Any + Send + Sync>, AssetError>;
}
impl<L: AssetLoader> ErasedLoader for L {
fn output_type(&self) -> TypeId {
TypeId::of::<L::Asset>()
}
fn load(&self, path: &Path) -> Result<Box<dyn Any + Send + Sync>, AssetError> {
Ok(Box::new(<L as AssetLoader>::load(self, path)?))
}
}
type CacheKey = (TypeId, PathBuf);
/// One entry in the dedup cache. Carries a weak reference to the asset cell so
/// dropped assets are pruned, plus a function pointer that knows how to rerun
/// the loader for the cell's concrete type. Storing the reload-by-type as a
/// per-entry `fn` is what lets [`AssetServer::reload_path`] reload an asset
/// without knowing its `T` at the call site — the original `insert_cache::<T>`
/// captures `T` into the function pointer.
#[derive(Clone)]
struct CacheEntry {
weak: Weak<dyn Any + Send + Sync>,
reload_in_place: fn(&AssetServer, &Path),
}
struct Inner {
loaders: RwLock<HashMap<String, Arc<dyn ErasedLoader>>>,
/// Dedup cache: weak references, so a cached asset with no live handles is
/// collected and reloaded fresh next time.
cache: Mutex<HashMap<CacheKey, CacheEntry>>,
next_id: AtomicU64,
}
/// The central asset registry.
///
/// Cloning an `AssetServer` is cheap (it shares one inner state via `Arc`) so it
/// can be handed to background load threads and stored across systems. Loading
/// the same path+type twice returns handles to **one** shared asset; when the
/// last handle is dropped the asset is freed.
///
/// ```no_run
/// use oxide_engine::asset::AssetServer;
/// use oxide_engine::asset::GltfModel;
///
/// let assets = AssetServer::new(); // glTF loader registered by default
/// let model = assets.load::<GltfModel>("assets/models/cube.gltf");
/// if let Some(model) = model.get() {
/// println!("{} meshes", model.meshes.len());
/// }
/// ```
#[derive(Clone)]
pub struct AssetServer {
inner: Arc<Inner>,
}
impl AssetServer {
/// A server with the engine's built-in loaders registered (currently glTF).
pub fn new() -> Self {
let server = Self::empty();
super::register_default_loaders(&server);
server
}
/// A server with **no** loaders registered. Use [`register_loader`] to add
/// them; handy for tests or fully custom asset pipelines.
///
/// [`register_loader`]: Self::register_loader
pub fn empty() -> Self {
Self {
inner: Arc::new(Inner {
loaders: RwLock::new(HashMap::new()),
cache: Mutex::new(HashMap::new()),
next_id: AtomicU64::new(1),
}),
}
}
/// Registers `loader`, mapping each of its extensions to it.
pub fn register_loader<L: AssetLoader>(&self, loader: L) {
let exts: Vec<String> = loader
.extensions()
.iter()
.map(|e| e.to_lowercase())
.collect();
let erased: Arc<dyn ErasedLoader> = Arc::new(loader);
let mut loaders = self.inner.loaders.write().unwrap();
for ext in exts {
loaders.insert(ext, erased.clone());
}
}
/// Removes the loader registered for `extension` (without the dot). Returns
/// whether one was present. Used when a module that added a loader is removed.
pub fn unregister_loader(&self, extension: &str) -> bool {
self.inner
.loaders
.write()
.unwrap()
.remove(&extension.to_lowercase())
.is_some()
}
/// Loads the asset at `path` as type `T`, blocking until it is ready.
///
/// Returns a handle to a cached asset if one of the same path+type is
/// already live. On failure the returned handle is in the
/// [`Failed`](super::LoadState::Failed) state (inspect [`Handle::error`]).
pub fn load<T: Send + Sync + 'static>(&self, path: impl AsRef<Path>) -> Handle<T> {
let path = path.as_ref().to_path_buf();
let key = (TypeId::of::<T>(), path.clone());
if let Some(handle) = self.cached::<T>(&key) {
return handle;
}
match self.run_loader::<T>(&path) {
Ok(value) => {
let cell = AssetCell::new_loaded(self.next_id(), Some(path), value);
self.insert_cache(key, &cell);
Handle::from_cell(cell)
}
// Failures are not cached, so a later load retries from scratch.
Err(err) => Handle::from_cell(AssetCell::new_failed(
self.next_id(),
Some(path),
err.to_string(),
)),
}
}
/// Loads the asset at `path` as type `T` on a background thread, returning a
/// handle immediately in the [`Loading`](super::LoadState::Loading) state.
///
/// Poll [`Handle::state`]/[`Handle::get`], or block with [`Handle::wait`].
pub fn load_async<T: Send + Sync + 'static>(&self, path: impl AsRef<Path>) -> Handle<T> {
let path = path.as_ref().to_path_buf();
let key = (TypeId::of::<T>(), path.clone());
if let Some(handle) = self.cached::<T>(&key) {
return handle;
}
// Insert the loading cell up front so concurrent requests dedup onto it.
let cell = AssetCell::<T>::new_loading(self.next_id(), Some(path.clone()));
self.insert_cache(key.clone(), &cell);
let server = self.clone();
let worker_cell = cell.clone();
std::thread::spawn(move || match server.run_loader::<T>(&path) {
Ok(value) => worker_cell.set_loaded(value),
Err(err) => {
worker_cell.set_failed(err.to_string());
// Don't leave a failed slot cached.
server.inner.cache.lock().unwrap().remove(&key);
}
});
Handle::from_cell(cell)
}
/// Adds an already-constructed, in-memory asset and returns a handle to it.
/// In-memory assets have no source path and are not cached for dedup.
pub fn add<T: Send + Sync + 'static>(&self, value: T) -> Handle<T> {
Handle::from_cell(AssetCell::new_loaded(self.next_id(), None, value))
}
/// Returns a handle to an already-loaded asset of this path+type, if one is
/// still live, without triggering a load.
pub fn get<T: Send + Sync + 'static>(&self, path: impl AsRef<Path>) -> Option<Handle<T>> {
let key = (TypeId::of::<T>(), path.as_ref().to_path_buf());
self.cached::<T>(&key)
}
/// Re-runs the loader for `path` and updates the existing asset in place, so
/// every live handle observes the new contents. If no handle is currently
/// live, behaves like [`load`](Self::load). This is the foundation the
/// live-reload stage builds on.
pub fn reload<T: Send + Sync + 'static>(&self, path: impl AsRef<Path>) -> Handle<T> {
let path = path.as_ref().to_path_buf();
let key = (TypeId::of::<T>(), path.clone());
let existing = self.cached::<T>(&key);
match self.run_loader::<T>(&path) {
Ok(value) => match existing {
Some(handle) => {
handle.set_loaded(value);
handle
}
None => {
let cell = AssetCell::new_loaded(self.next_id(), Some(path), value);
self.insert_cache(key, &cell);
Handle::from_cell(cell)
}
},
Err(err) => match existing {
Some(handle) => {
handle.set_failed(err.to_string());
handle
}
None => Handle::from_cell(AssetCell::new_failed(
self.next_id(),
Some(path),
err.to_string(),
)),
},
}
}
/// The number of distinct assets still alive (have at least one live
/// handle). Prunes collected entries as a side effect.
pub fn live_asset_count(&self) -> usize {
let mut cache = self.inner.cache.lock().unwrap();
cache.retain(|_, entry| entry.weak.strong_count() > 0);
cache.len()
}
/// Reruns the loader for every cached asset whose source path is `path`,
/// updating each existing handle in place. Returns the number of assets
/// reloaded.
///
/// Unlike [`reload`](Self::reload) this does **not** need `T` at the call
/// site — it dispatches on what types are actually cached for `path`. The
/// file-watcher uses this to react to disk changes without knowing every
/// asset type at compile time. Paths that are not currently cached return
/// `0`; they will be loaded fresh by the next [`load`](Self::load) call.
pub fn reload_path(&self, path: &Path) -> usize {
// Snapshot the set of typed reload fns to call so we don't hold the
// cache lock while re-running loaders (which would deadlock — `reload`
// takes the lock too).
let reloaders: Vec<fn(&AssetServer, &Path)> = {
let cache = self.inner.cache.lock().unwrap();
cache
.iter()
.filter_map(|(key, entry)| {
if key.1 == path && entry.weak.strong_count() > 0 {
Some(entry.reload_in_place)
} else {
None
}
})
.collect()
};
let n = reloaders.len();
for f in reloaders {
f(self, path);
}
n
}
// --- internals ---------------------------------------------------------
fn next_id(&self) -> AssetId {
AssetId(self.inner.next_id.fetch_add(1, Ordering::Relaxed))
}
fn cached<T: Send + Sync + 'static>(&self, key: &CacheKey) -> Option<Handle<T>> {
let cache = self.inner.cache.lock().unwrap();
let arc = cache.get(key)?.weak.upgrade()?;
let cell = arc.downcast::<AssetCell<T>>().ok()?;
Some(Handle::from_cell(cell))
}
fn insert_cache<T: Send + Sync + 'static>(&self, key: CacheKey, cell: &Arc<AssetCell<T>>) {
let erased: Arc<dyn Any + Send + Sync> = cell.clone();
// `reload_in_place` keeps the concrete `T` in its signature, so the
// path-keyed `reload_path` can rebuild the typed handle without
// knowing `T` at the call site.
let entry = CacheEntry {
weak: Arc::downgrade(&erased),
reload_in_place: |server, path| {
server.reload::<T>(path);
},
};
self.inner.cache.lock().unwrap().insert(key, entry);
}
fn run_loader<T: Send + Sync + 'static>(&self, path: &Path) -> Result<T, AssetError> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.ok_or_else(|| AssetError::NoExtension(path.to_path_buf()))?
.to_lowercase();
let loader = self
.inner
.loaders
.read()
.unwrap()
.get(&ext)
.cloned()
.ok_or_else(|| AssetError::NoLoader(ext.clone()))?;
if loader.output_type() != TypeId::of::<T>() {
return Err(AssetError::TypeMismatch { ext });
}
let boxed = loader.load(path)?;
Ok(*boxed
.downcast::<T>()
.expect("loader output_type matched the request but downcast failed"))
}
}
impl Default for AssetServer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::asset::LoadState;
use std::sync::atomic::{AtomicU32, Ordering};
// A trivial asset + loader: each "load" reads a file's text and counts how
// many times the loader actually ran, so dedup can be observed.
struct Counter(Arc<AtomicU32>);
#[derive(Debug, PartialEq, Eq)]
struct TextAsset(String);
struct TextLoader(Arc<AtomicU32>);
impl AssetLoader for TextLoader {
type Asset = TextAsset;
fn extensions(&self) -> &'static [&'static str] {
&["txt"]
}
fn load(&self, path: &Path) -> Result<TextAsset, AssetError> {
self.0.fetch_add(1, Ordering::SeqCst);
let text = std::fs::read_to_string(path).map_err(|e| AssetError::Load {
path: path.to_path_buf(),
message: e.to_string(),
})?;
Ok(TextAsset(text.trim().to_string()))
}
}
fn temp_file(name: &str, contents: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"oxide_asset_test_{}_{name}.txt",
std::process::id()
));
std::fs::write(&path, contents).unwrap();
path
}
fn server() -> (AssetServer, Counter) {
let counter = Arc::new(AtomicU32::new(0));
let server = AssetServer::empty();
server.register_loader(TextLoader(counter.clone()));
(server, Counter(counter))
}
#[test]
fn loads_and_reads_an_asset() {
let (server, _c) = server();
let path = temp_file("hello", " hello world ");
let handle = server.load::<TextAsset>(&path);
assert_eq!(handle.state(), LoadState::Loaded);
assert_eq!(handle.get().unwrap().0, "hello world");
assert_eq!(handle.source(), Some(path.as_path()));
std::fs::remove_file(path).ok();
}
#[test]
fn loading_twice_yields_one_resource() {
let (server, c) = server();
let path = temp_file("dedup", "data");
let a = server.load::<TextAsset>(&path);
let b = server.load::<TextAsset>(&path);
// Same allocation: loader ran once, ids match, two handles share it.
assert_eq!(c.0.load(Ordering::SeqCst), 1);
assert_eq!(a.id(), b.id());
assert_eq!(a.ref_count(), 2);
assert_eq!(server.live_asset_count(), 1);
std::fs::remove_file(path).ok();
}
#[test]
fn dropping_all_handles_frees_the_asset() {
let (server, _c) = server();
let path = temp_file("free", "data");
let handle = server.load::<TextAsset>(&path);
assert_eq!(server.live_asset_count(), 1);
drop(handle);
// With no live handles, the weak cache entry is dead and pruned.
assert_eq!(server.live_asset_count(), 0);
assert!(server.get::<TextAsset>(&path).is_none());
std::fs::remove_file(path).ok();
}
#[test]
fn missing_loader_and_type_mismatch_are_distinct_errors() {
let (server, _c) = server();
let path = temp_file("x", "data");
// No loader for ".dat".
let bad_ext = path.with_extension("dat");
std::fs::write(&bad_ext, "data").unwrap();
let h = server.load::<TextAsset>(&bad_ext);
assert_eq!(h.state(), LoadState::Failed);
assert!(h.error().unwrap().contains("no loader"));
// A ".txt" loader exists but produces TextAsset, not String.
let renamed = path.with_extension("txt");
std::fs::write(&renamed, "data").unwrap();
let h2 = server.load::<String>(&renamed);
assert!(h2.error().unwrap().contains("different asset type"));
std::fs::remove_file(path).ok();
std::fs::remove_file(bad_ext).ok();
std::fs::remove_file(renamed).ok();
}
#[test]
fn async_load_completes_and_dedups() {
let (server, c) = server();
let path = temp_file("async", "background");
let handle = server.load_async::<TextAsset>(&path);
let value = handle.wait().expect("async load should succeed");
assert_eq!(value.0, "background");
// A second request dedups onto the same now-loaded asset.
let again = server.load::<TextAsset>(&path);
assert_eq!(again.id(), handle.id());
assert_eq!(c.0.load(Ordering::SeqCst), 1);
std::fs::remove_file(path).ok();
}
#[test]
fn reload_updates_in_place_for_existing_handles() {
let (server, _c) = server();
let path = temp_file("reload", "before");
let handle = server.load::<TextAsset>(&path);
assert_eq!(handle.get().unwrap().0, "before");
// Change the file on disk and reload: the SAME handle sees new contents.
std::fs::write(&path, "after").unwrap();
let reloaded = server.reload::<TextAsset>(&path);
assert_eq!(reloaded.id(), handle.id());
assert_eq!(handle.get().unwrap().0, "after");
std::fs::remove_file(path).ok();
}
#[test]
fn add_stores_in_memory_assets() {
let (server, _c) = server();
let handle = server.add(TextAsset("in-memory".to_string()));
assert_eq!(handle.get().unwrap().0, "in-memory");
assert!(handle.source().is_none());
}
}