The last item of the Stage-10 editor-UX batch. The Project panel's fixed typed-folder listing becomes a real file explorer over assets/: - breadcrumbs + double-click folder navigation, "➕ New Folder", inline rename rows, context menus (Open / Rename / Delete), drag a row onto a folder (or "..") to move it, drag files in from the OS to import into the current folder, double-click to open (scripts via the external-editor flow, others via xdg-open). - All behavior lives egui-free in editor/src/explorer.rs (listing, breadcrumbs, name validation/uniquing, create/rename/move/delete/ import) and is unit-tested; the shell only renders it. Renames and moves ride the uid-preserving AssetDatabase ops so saved AssetRefs keep resolving; unregistered files fall back to fs::rename. Folders delete only when empty — no recursive asset deletion. - Engine: AssetDatabase::scan now walks the WHOLE assets/ tree instead of just the typed folders, so assets organised into custom folders register and survive rescans (covered by updated unit tests). File operations act immediately and bypass the undo stack, like the hierarchy's structural edits. GUI piece — needs an eye-check before promotion to main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
12 KiB
Asset Server & Handles
oxide_engine::asset is the engine's central way to load and own external data
(meshes, and later textures, audio, fonts, …). It lands in Stage 5 and underpins
three later stages: live reload (Stage 10), open-world streaming (Stage 21), and
export packing (Stage 16). Putting it in early means later loaders plug into one
system instead of each inventing its own.
Handles own assets
The unit of ownership is a Handle<T>: a typed, reference-counted reference to
a loaded asset. It is cheap to clone (an Arc bump), and the asset behind it
lives exactly as long as at least one handle does. The AssetServer keeps only
a Weak reference in its dedup cache, so it never keeps an otherwise-unused
asset alive — drop the last handle and the asset is freed.
use oxide_engine::asset::{AssetServer, GltfModel, Handle};
let assets = AssetServer::new(); // built-in loaders (glTF) registered
let model: Handle<GltfModel> = assets.load("assets/models/cube.gltf");
if let Some(model) = model.get() { // Option<Arc<GltfModel>>, None until loaded
println!("{} meshes", model.meshes.len());
}
Key handle methods:
| Method | Meaning |
|---|---|
get() |
Some(Arc<T>) once loaded, else None (cheap clone of the value Arc) |
state() / is_loaded() |
lifecycle: Loading / Loaded / Failed |
wait() |
block until ready; Some(value) or None on failure |
error() |
the failure message, if any |
source() |
the path it was loaded from (in-memory assets have none) |
ref_count() |
number of live handles (the server holds none) |
Deduplication and freeing
Loading the same path+type twice returns handles to one shared asset — the loader runs once:
# use oxide_engine::asset::{AssetServer, GltfModel, Handle};
# let assets = AssetServer::new();
let a: Handle<GltfModel> = assets.load("model.gltf");
let b: Handle<GltfModel> = assets.load("model.gltf");
assert_eq!(a.id(), b.id()); // same underlying asset
assert_eq!(assets.live_asset_count(), 1);
When the last handle drops, the weak cache entry dies and the asset is collected;
live_asset_count() prunes such entries as it counts.
Synchronous vs background loading
load blocks until the asset is ready. load_async returns immediately with a
handle in the Loading state and fills it on a background thread:
# use oxide_engine::asset::{AssetServer, GltfModel, Handle};
# let assets = AssetServer::new();
let handle: Handle<GltfModel> = assets.load_async("big.gltf");
// ... do other work while it loads ...
let model = handle.wait(); // or poll handle.state()
add(value) stores an already-constructed, in-memory asset (no path, not
deduplicated) and returns a handle to it — useful for procedurally generated or
test data.
Writing a loader
Formats are pluggable via the AssetLoader trait: declare the output type and
the extensions, and implement load. Register it with
server.register_loader(...); the server dispatches by extension and verifies
the loader's output type matches the requested T.
use std::path::Path;
use oxide_engine::asset::{AssetError, AssetLoader};
struct TextLoader;
impl AssetLoader for TextLoader {
type Asset = String;
fn extensions(&self) -> &'static [&'static str] { &["txt"] }
fn load(&self, path: &Path) -> Result<String, AssetError> {
std::fs::read_to_string(path).map_err(|e| AssetError::Load {
path: path.to_path_buf(),
message: e.to_string(),
})
}
}
let assets = oxide_engine::asset::AssetServer::empty(); // no built-ins
assets.register_loader(TextLoader);
The built-in GltfLoader is exactly this pattern wrapping the Stage-4
load_gltf importer; AssetServer::new() registers it for .gltf/.glb.
Errors are specific — NoExtension, NoLoader, TypeMismatch, and Load — so
callers can tell "no loader" from "the file is broken".
Live reload foundation
reload(path) re-runs the loader and updates the existing asset in place, so
every live handle observes the new contents on its next get():
# use oxide_engine::asset::{AssetServer, GltfModel, Handle};
# let assets = AssetServer::new();
let model: Handle<GltfModel> = assets.load("model.gltf");
// ... the file changes on disk ...
let same = assets.reload::<GltfModel>("model.gltf");
assert_eq!(same.id(), model.id()); // same asset, new data
Stage 10 wires this to the file watcher to hot-reload changed assets while the editor runs.
Asset database — stable, project-relative references
The AssetServer loads by path, but a scene or UI document must not bake an
absolute system path into its saved data — that breaks the moment the
project moves to another directory or machine, and it is the chief obstacle to a
clean game export (Stage 16). The AssetDatabase is the bridge.
Every imported asset lives under a typed subfolder of the project's
assets/ directory and gets a stable AssetUid. The database records, per
project, the mapping AssetUid ↔ assets-relative path (e.g.
"fonts/Inter-Regular.ttf") in a manifest at the project root
(assets.manifest). Saved documents reference assets by AssetUid; resolving a
uid yields the relative path, which combined with the current project root
gives an absolute path the server loads and deduplicates.
use oxide_engine::asset::{AssetDatabase, AssetServer, GltfModel};
let mut db = AssetDatabase::open(project_root); // reads assets.manifest if present
db.scan(); // walk assets/ — register new files, prune missing
db.save().unwrap(); // persist any newly-assigned uids
let uid = db.uid_of("models/cube.glb").unwrap();
let server = AssetServer::new();
let model = db.load::<GltfModel>(&server, uid); // Option<Handle<GltfModel>>
Because the stored mapping is purely relative, a reference resolves to the same
handle across save/load and after the whole project directory moves — open
the database from the new location (or call set_root) and every uid keeps
resolving. The uid layer (rather than referencing by relative path directly)
also lets an asset be renamed or moved within the project later without
breaking references, since the uid travels with the file in the manifest.
Typed folders
AssetKind fixes each asset's subfolder and the extensions that belong to it:
| Kind | Folder | Extensions |
|---|---|---|
Font |
fonts/ |
ttf, otf |
Texture |
textures/ |
png, jpg, jpeg, tga, bmp, dds, ktx2 |
Model |
models/ |
gltf, glb, obj |
Audio |
audio/ |
wav, ogg, mp3, flac |
Ui |
ui/ |
(recognised by folder — shares .ron with scenes) |
Script |
scripts/ |
rhai |
AssetKind::classify(path) infers the kind: the leading folder wins, with the
file extension as a fallback for files dropped directly in assets/.
File operations — rename, move, delete
The database also performs file reorganisation, so the editor's file explorer (and any tool) can rename or move assets without breaking saved references — the disk operation and the uid map are updated together:
# use oxide_engine::asset::{AssetDatabase, AssetUid};
# fn demo(db: &mut AssetDatabase, uid: AssetUid) -> Result<(), oxide_engine::asset::AssetDbError> {
db.move_asset(uid, "textures/env/brick.png")?; // rename/move; uid unchanged
db.move_folder("textures/env", "textures/world")?; // every entry under it follows
db.delete_asset(uid)?; // file + entry; uid never reused
db.save()?; // persist the new paths
# Ok(())
# }
All three refuse to touch anything outside assets/ (.. is rejected) and
refuse to overwrite an existing destination (AssetDbError has a variant per
failure). Kinds are re-classified from the new path, since a move can change
the typed folder. Deleting drops the entry but never reuses its uid — a
dangling reference stays dangling instead of silently pointing at a new file.
Asset-reference fields in the inspector
A component points at an asset with an AssetRef<T> field — not a live
Handle<T>. A handle is process-local and not serializable, so persisting one
would be wrong; an AssetRef<T> is a thin, serializable wrapper over
Option<AssetUid> that resolves to a handle on demand:
use oxide_engine::asset::{AssetRef, AssetServer, AssetDatabase};
use oxide_engine::ui::Font;
#[derive(serde::Serialize, serde::Deserialize)]
struct Label { font: AssetRef<Font> } // serializes as just the uid
# fn demo(label: &Label, db: &AssetDatabase, server: &AssetServer) {
let handle = label.font.resolve(db, server); // Option<Handle<Font>>
# }
Because AssetRef<T> round-trips through reflection's RON path, the field is
editable in the inspector with no per-type code. Its
type_name is the syntactic spelling
"AssetRef < Font >"; asset_ref_target unwraps that to the target type name
("Font"), and AssetKind::for_handle_target maps it to the kind the editor's
asset picker filters by — so selecting a UI element and picking a font lists only
the assets under fonts/. A bare Handle<T> field (e.g. on a non-persisted
type) is recognised the same way.
In the editor
EditorState holds an asset_db whenever a project is open: the editor opens
and scans it on project open/create and rescans when the file watcher reports
changes under assets/, so importing an asset is as simple as dropping the
file anywhere under assets/ — no separate import step. An asset-reference
field in the inspector renders as a picker populated from the database,
filtered to the field's target kind. New projects are seeded with a bundled
default UI font (Inter, SIL OFL) under fonts/, referenced by its
project-relative path like any other asset.
The Project panel hosts a Unity-style file explorer over assets/
(oxide_editor::explorer holds the egui-free behavior layer; the shell only
renders it):
- breadcrumbs + folder navigation (double-click a folder, click a crumb);
- ➕ New Folder, and per-row context menus with Rename and Delete (folders delete only when empty — recursive asset deletion is deliberately not offered);
- drag a row onto a folder (or the
..row) to move it; - drag files in from the OS to import them into the current folder (copied in under a collision-free name, registered, manifest saved);
- double-click a file to open it — scripts via the external-editor flow
(see scripting.md), everything else via
xdg-open.
Renames and moves go through the database's uid-preserving file ops, so saved
AssetRefs keep resolving after any reorganisation; unregistered files
(licenses, notes) fall back to plain filesystem operations. File operations
act immediately and bypass the undo stack, like the hierarchy's structural
edits.