9eead719b0
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>
222 lines
9.3 KiB
Markdown
222 lines
9.3 KiB
Markdown
# 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.
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
# 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:
|
|
|
|
```rust
|
|
# 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`.
|
|
|
|
```rust
|
|
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()`:
|
|
|
|
```rust
|
|
# 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.
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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`](../engine/src/reflect.rs) 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.
|
|
|
|
[`AssetDatabase`]: ../engine/src/asset/database.rs
|
|
[`AssetKind`]: ../engine/src/asset/database.rs
|
|
[`AssetUid`]: ../engine/src/asset/database.rs
|
|
[`AssetRef<T>`]: ../engine/src/asset/database.rs
|
|
[`asset_ref_target`]: ../engine/src/asset/database.rs
|
|
[`AssetKind::for_handle_target`]: ../engine/src/asset/database.rs
|
|
[`Handle<T>`]: ../engine/src/asset/handle.rs
|
|
[`AssetServer`]: ../engine/src/asset/server.rs
|
|
[`AssetLoader`]: ../engine/src/asset/server.rs
|
|
[`GltfLoader`]: ../engine/src/asset/gltf.rs
|
|
[`load_gltf`]: ../engine/src/asset/gltf.rs
|