c47efa876f
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>
266 lines
12 KiB
Markdown
266 lines
12 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(); // 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:
|
||
|
||
```rust
|
||
# 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:
|
||
|
||
```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 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](scripting.md)), everything else via `xdg-open`.
|
||
|
||
Renames and moves go through the database's uid-preserving file ops, so saved
|
||
`AssetRef`s 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.
|
||
|
||
[`AssetDatabase`]: ../engine/src/asset/database.rs
|
||
[`AssetDbError`]: ../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
|