Files
Oxide/docs/assets.md
T
Homer Simpson 9eead719b0 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>
2026-07-05 20:41:02 +02:00

9.3 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();                                      // discover files in fonts/ models/ …
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)

AssetKind::classify(path) infers the kind: the leading folder wins, with the file extension as a fallback for files dropped directly in assets/.

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 just dropping the file into the matching typed folder (fonts/, textures/, …) — no separate import step. The Project panel is an asset browser listing each typed folder's assets from the database, and an asset-reference field in the inspector renders as a picker populated from it, 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.