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>
This commit is contained in:
Homer Simpson
2026-07-05 20:41:02 +02:00
parent 2afb56b329
commit 9eead719b0
157 changed files with 47270 additions and 2 deletions
+221
View File
@@ -0,0 +1,221 @@
# 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