Files
Oxide/docs/assets.md
T
Homer c47efa876f Editor: Unity-style file explorer in the Project panel
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>
2026-07-10 21:01:05 +02:00

12 KiB
Raw Blame History

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.