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

266 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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