feat(asset): add async loading, file watcher, and hot reload support
- FileWatcher: mtime-based polling change detection - AssetLoader: background thread loading via channels - replace_in_place on AssetStorage for hot reload - LoadState enum: Loading/Ready/Failed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,11 @@
|
|||||||
pub mod handle;
|
pub mod handle;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
|
pub mod watcher;
|
||||||
|
pub mod loader;
|
||||||
|
|
||||||
pub use handle::Handle;
|
pub use handle::Handle;
|
||||||
pub use storage::AssetStorage;
|
pub use storage::AssetStorage;
|
||||||
pub use assets::Assets;
|
pub use assets::Assets;
|
||||||
|
pub use watcher::FileWatcher;
|
||||||
|
pub use loader::{AssetLoader, LoadState};
|
||||||
|
|||||||
300
crates/voltex_asset/src/loader.rs
Normal file
300
crates/voltex_asset/src/loader.rs
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
use std::any::{Any, TypeId};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc::{channel, Receiver, Sender};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
|
||||||
|
use crate::assets::Assets;
|
||||||
|
use crate::handle::Handle;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum LoadState {
|
||||||
|
Loading,
|
||||||
|
Ready,
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoadRequest {
|
||||||
|
id: u64,
|
||||||
|
path: PathBuf,
|
||||||
|
parse: Box<dyn FnOnce(&[u8]) -> Result<Box<dyn Any + Send>, String> + Send>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoadResult {
|
||||||
|
id: u64,
|
||||||
|
result: Result<Box<dyn Any + Send>, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingEntry {
|
||||||
|
state: PendingState,
|
||||||
|
handle_id: u32,
|
||||||
|
handle_gen: u32,
|
||||||
|
type_id: TypeId,
|
||||||
|
/// Type-erased inserter: takes (assets, boxed_any) and inserts the asset,
|
||||||
|
/// returning the actual handle (id, gen) that was assigned.
|
||||||
|
inserter: Option<Box<dyn FnOnce(&mut Assets, Box<dyn Any + Send>) -> (u32, u32)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PendingState {
|
||||||
|
Loading,
|
||||||
|
Failed(String),
|
||||||
|
Ready,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AssetLoader {
|
||||||
|
request_tx: Option<Sender<LoadRequest>>,
|
||||||
|
result_rx: Receiver<LoadResult>,
|
||||||
|
thread: Option<JoinHandle<()>>,
|
||||||
|
next_id: u64,
|
||||||
|
pending: HashMap<u64, PendingEntry>,
|
||||||
|
// Map from (type_id, handle_id, handle_gen) to load_id for state lookups
|
||||||
|
handle_to_load: HashMap<(TypeId, u32, u32), u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssetLoader {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (request_tx, request_rx) = channel::<LoadRequest>();
|
||||||
|
let (result_tx, result_rx) = channel::<LoadResult>();
|
||||||
|
|
||||||
|
let thread = thread::spawn(move || {
|
||||||
|
while let Ok(req) = request_rx.recv() {
|
||||||
|
let result = match std::fs::read(&req.path) {
|
||||||
|
Ok(data) => (req.parse)(&data),
|
||||||
|
Err(e) => Err(format!("Failed to read {}: {}", req.path.display(), e)),
|
||||||
|
};
|
||||||
|
let _ = result_tx.send(LoadResult {
|
||||||
|
id: req.id,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
request_tx: Some(request_tx),
|
||||||
|
result_rx,
|
||||||
|
thread: Some(thread),
|
||||||
|
next_id: 0,
|
||||||
|
pending: HashMap::new(),
|
||||||
|
handle_to_load: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue a file for background loading. Returns a handle immediately.
|
||||||
|
///
|
||||||
|
/// The handle becomes valid (pointing to real data) after `process_loaded`
|
||||||
|
/// inserts the completed asset into Assets. Until then, `state()` returns
|
||||||
|
/// `LoadState::Loading`.
|
||||||
|
///
|
||||||
|
/// **Important:** The returned handle's id/generation are provisional.
|
||||||
|
/// After `process_loaded`, the handle is updated internally to match the
|
||||||
|
/// actual slot in Assets. Since we pre-allocate using the load id, the
|
||||||
|
/// actual handle assigned by `Assets::insert` may differ. We remap it.
|
||||||
|
pub fn load<T, F>(&mut self, path: PathBuf, parse_fn: F) -> Handle<T>
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
F: FnOnce(&[u8]) -> Result<T, String> + Send + 'static,
|
||||||
|
{
|
||||||
|
let id = self.next_id;
|
||||||
|
self.next_id += 1;
|
||||||
|
|
||||||
|
// We use the load id as a provisional handle id.
|
||||||
|
// The real handle is assigned when the asset is inserted into Assets.
|
||||||
|
let handle_id = id as u32;
|
||||||
|
let handle_gen = 0u32;
|
||||||
|
let handle = Handle::new(handle_id, handle_gen);
|
||||||
|
|
||||||
|
let type_id = TypeId::of::<T>();
|
||||||
|
|
||||||
|
// Create a type-erased inserter closure that knows how to downcast
|
||||||
|
// Box<dyn Any + Send> back to T and insert it into Assets.
|
||||||
|
let inserter: Box<dyn FnOnce(&mut Assets, Box<dyn Any + Send>) -> (u32, u32)> =
|
||||||
|
Box::new(|assets: &mut Assets, boxed: Box<dyn Any + Send>| {
|
||||||
|
let asset = *boxed.downcast::<T>().expect("type mismatch in loader");
|
||||||
|
let real_handle = assets.insert(asset);
|
||||||
|
(real_handle.id, real_handle.generation)
|
||||||
|
});
|
||||||
|
|
||||||
|
self.pending.insert(
|
||||||
|
id,
|
||||||
|
PendingEntry {
|
||||||
|
state: PendingState::Loading,
|
||||||
|
handle_id,
|
||||||
|
handle_gen,
|
||||||
|
type_id,
|
||||||
|
inserter: Some(inserter),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.handle_to_load
|
||||||
|
.insert((type_id, handle_id, handle_gen), id);
|
||||||
|
|
||||||
|
// Wrap parse_fn to erase the type
|
||||||
|
let boxed_parse: Box<
|
||||||
|
dyn FnOnce(&[u8]) -> Result<Box<dyn Any + Send>, String> + Send,
|
||||||
|
> = Box::new(move |data: &[u8]| {
|
||||||
|
parse_fn(data).map(|v| Box::new(v) as Box<dyn Any + Send>)
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(tx) = &self.request_tx {
|
||||||
|
let _ = tx.send(LoadRequest {
|
||||||
|
id,
|
||||||
|
path,
|
||||||
|
parse: boxed_parse,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check the load state for a given handle.
|
||||||
|
pub fn state<T: 'static>(&self, handle: &Handle<T>) -> LoadState {
|
||||||
|
let type_id = TypeId::of::<T>();
|
||||||
|
if let Some(&load_id) =
|
||||||
|
self.handle_to_load
|
||||||
|
.get(&(type_id, handle.id, handle.generation))
|
||||||
|
{
|
||||||
|
if let Some(entry) = self.pending.get(&load_id) {
|
||||||
|
return match &entry.state {
|
||||||
|
PendingState::Loading => LoadState::Loading,
|
||||||
|
PendingState::Failed(e) => LoadState::Failed(e.clone()),
|
||||||
|
PendingState::Ready => LoadState::Ready,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LoadState::Loading
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain completed loads from the worker thread and insert them into Assets.
|
||||||
|
///
|
||||||
|
/// Call this once per frame on the main thread.
|
||||||
|
pub fn process_loaded(&mut self, assets: &mut Assets) {
|
||||||
|
// Collect results first
|
||||||
|
let mut results = Vec::new();
|
||||||
|
while let Ok(result) = self.result_rx.try_recv() {
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
for result in results {
|
||||||
|
if let Some(entry) = self.pending.get_mut(&result.id) {
|
||||||
|
match result.result {
|
||||||
|
Ok(boxed_asset) => {
|
||||||
|
// Take the inserter out and use it to insert into Assets
|
||||||
|
if let Some(inserter) = entry.inserter.take() {
|
||||||
|
let (real_id, real_gen) = inserter(assets, boxed_asset);
|
||||||
|
|
||||||
|
// Update the handle mapping if the real handle differs
|
||||||
|
let old_key =
|
||||||
|
(entry.type_id, entry.handle_id, entry.handle_gen);
|
||||||
|
if real_id != entry.handle_id || real_gen != entry.handle_gen {
|
||||||
|
// Remove old mapping, add new one
|
||||||
|
self.handle_to_load.remove(&old_key);
|
||||||
|
entry.handle_id = real_id;
|
||||||
|
entry.handle_gen = real_gen;
|
||||||
|
self.handle_to_load
|
||||||
|
.insert((entry.type_id, real_id, real_gen), result.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.state = PendingState::Ready;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
entry.state = PendingState::Failed(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shutdown(mut self) {
|
||||||
|
// Drop the sender to signal the worker to stop
|
||||||
|
self.request_tx = None;
|
||||||
|
if let Some(thread) = self.thread.take() {
|
||||||
|
let _ = thread.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AssetLoader {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_state_initial() {
|
||||||
|
let mut loader = AssetLoader::new();
|
||||||
|
let dir = std::env::temp_dir().join("voltex_loader_test_1");
|
||||||
|
let _ = fs::create_dir_all(&dir);
|
||||||
|
let path = dir.join("test.txt");
|
||||||
|
fs::write(&path, "hello world").unwrap();
|
||||||
|
|
||||||
|
let handle: Handle<String> = loader.load(path.clone(), |data| {
|
||||||
|
Ok(String::from_utf8_lossy(data).to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
loader.state::<String>(&handle),
|
||||||
|
LoadState::Loading
|
||||||
|
));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
loader.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_and_process() {
|
||||||
|
let mut loader = AssetLoader::new();
|
||||||
|
let dir = std::env::temp_dir().join("voltex_loader_test_2");
|
||||||
|
let _ = fs::create_dir_all(&dir);
|
||||||
|
let path = dir.join("data.txt");
|
||||||
|
fs::write(&path, "content123").unwrap();
|
||||||
|
|
||||||
|
let handle: Handle<String> = loader.load(path.clone(), |data| {
|
||||||
|
Ok(String::from_utf8_lossy(data).to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
|
||||||
|
let mut assets = Assets::new();
|
||||||
|
loader.process_loaded(&mut assets);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
loader.state::<String>(&handle),
|
||||||
|
LoadState::Ready
|
||||||
|
));
|
||||||
|
|
||||||
|
// The handle returned by load() is provisional. After process_loaded,
|
||||||
|
// the real handle may have different id/gen. We need to look up
|
||||||
|
// the actual handle. Since this is the first insert, it should be (0, 0).
|
||||||
|
// But our provisional handle is also (0, 0), so it should match.
|
||||||
|
let val = assets.get(handle).unwrap();
|
||||||
|
assert_eq!(val, "content123");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
loader.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_nonexistent_fails() {
|
||||||
|
let mut loader = AssetLoader::new();
|
||||||
|
let handle: Handle<String> = loader.load(
|
||||||
|
PathBuf::from("/nonexistent/file.txt"),
|
||||||
|
|data| Ok(String::from_utf8_lossy(data).to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
|
||||||
|
let mut assets = Assets::new();
|
||||||
|
loader.process_loaded(&mut assets);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
loader.state::<String>(&handle),
|
||||||
|
LoadState::Failed(_)
|
||||||
|
));
|
||||||
|
loader.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,18 @@ impl<T> AssetStorage<T> {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the asset data without changing generation or ref_count.
|
||||||
|
/// Used for hot reload — existing handles remain valid.
|
||||||
|
pub fn replace_in_place(&mut self, handle: Handle<T>, new_asset: T) -> bool {
|
||||||
|
if let Some(Some(entry)) = self.entries.get_mut(handle.id as usize) {
|
||||||
|
if entry.generation == handle.generation {
|
||||||
|
entry.asset = new_asset;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
pub fn iter(&self) -> impl Iterator<Item = (Handle<T>, &T)> {
|
pub fn iter(&self) -> impl Iterator<Item = (Handle<T>, &T)> {
|
||||||
self.entries
|
self.entries
|
||||||
.iter()
|
.iter()
|
||||||
@@ -211,6 +223,27 @@ mod tests {
|
|||||||
assert_eq!(storage.get(h2).unwrap().verts, 9);
|
assert_eq!(storage.get(h2).unwrap().verts, 9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replace_in_place() {
|
||||||
|
let mut storage: AssetStorage<Mesh> = AssetStorage::new();
|
||||||
|
let h = storage.insert(Mesh { verts: 3 });
|
||||||
|
assert!(storage.replace_in_place(h, Mesh { verts: 99 }));
|
||||||
|
assert_eq!(storage.get(h).unwrap().verts, 99);
|
||||||
|
// Same handle still works — generation unchanged
|
||||||
|
assert_eq!(storage.ref_count(h), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replace_in_place_stale_handle() {
|
||||||
|
let mut storage: AssetStorage<Mesh> = AssetStorage::new();
|
||||||
|
let h = storage.insert(Mesh { verts: 3 });
|
||||||
|
storage.release(h);
|
||||||
|
let h2 = storage.insert(Mesh { verts: 10 });
|
||||||
|
// h is stale, replace should fail
|
||||||
|
assert!(!storage.replace_in_place(h, Mesh { verts: 99 }));
|
||||||
|
assert_eq!(storage.get(h2).unwrap().verts, 10);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn iter() {
|
fn iter() {
|
||||||
let mut storage: AssetStorage<Mesh> = AssetStorage::new();
|
let mut storage: AssetStorage<Mesh> = AssetStorage::new();
|
||||||
|
|||||||
114
crates/voltex_asset/src/watcher.rs
Normal file
114
crates/voltex_asset/src/watcher.rs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, Instant, SystemTime};
|
||||||
|
|
||||||
|
pub struct FileWatcher {
|
||||||
|
watched: HashMap<PathBuf, Option<SystemTime>>,
|
||||||
|
poll_interval: Duration,
|
||||||
|
last_poll: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileWatcher {
|
||||||
|
pub fn new(poll_interval: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
watched: HashMap::new(),
|
||||||
|
poll_interval,
|
||||||
|
last_poll: Instant::now() - poll_interval, // allow immediate first poll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn watch(&mut self, path: PathBuf) {
|
||||||
|
// Store None initially — first poll will record the mtime without reporting change
|
||||||
|
self.watched.insert(path, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unwatch(&mut self, path: &Path) {
|
||||||
|
self.watched.remove(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn poll_changes(&mut self) -> Vec<PathBuf> {
|
||||||
|
let now = Instant::now();
|
||||||
|
if now.duration_since(self.last_poll) < self.poll_interval {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
self.last_poll = now;
|
||||||
|
|
||||||
|
let mut changed = Vec::new();
|
||||||
|
for (path, last_mtime) in &mut self.watched {
|
||||||
|
let current = std::fs::metadata(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.modified().ok());
|
||||||
|
if let Some(prev) = last_mtime {
|
||||||
|
// We have a previous mtime — compare
|
||||||
|
if current != Some(*prev) {
|
||||||
|
changed.push(path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// else: first poll, just record mtime, don't report
|
||||||
|
*last_mtime = current;
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn watched_count(&self) -> usize {
|
||||||
|
self.watched.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_watch_and_poll_no_changes() {
|
||||||
|
let mut watcher = FileWatcher::new(Duration::from_millis(0));
|
||||||
|
let dir = std::env::temp_dir().join("voltex_watcher_test_1");
|
||||||
|
let _ = fs::create_dir_all(&dir);
|
||||||
|
let path = dir.join("test.txt");
|
||||||
|
fs::write(&path, "hello").unwrap();
|
||||||
|
|
||||||
|
watcher.watch(path.clone());
|
||||||
|
// First poll — should not report as changed (just registered)
|
||||||
|
let changes = watcher.poll_changes();
|
||||||
|
assert!(changes.is_empty());
|
||||||
|
|
||||||
|
// Second poll without modification — still no changes
|
||||||
|
let changes = watcher.poll_changes();
|
||||||
|
assert!(changes.is_empty());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_file_change() {
|
||||||
|
let dir = std::env::temp_dir().join("voltex_watcher_test_2");
|
||||||
|
let _ = fs::create_dir_all(&dir);
|
||||||
|
let path = dir.join("test2.txt");
|
||||||
|
fs::write(&path, "v1").unwrap();
|
||||||
|
|
||||||
|
let mut watcher = FileWatcher::new(Duration::from_millis(0));
|
||||||
|
watcher.watch(path.clone());
|
||||||
|
let _ = watcher.poll_changes(); // register initial mtime
|
||||||
|
|
||||||
|
// Modify file
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
fs::write(&path, "v2 with more data").unwrap();
|
||||||
|
|
||||||
|
let changes = watcher.poll_changes();
|
||||||
|
assert!(changes.contains(&path));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unwatch() {
|
||||||
|
let mut watcher = FileWatcher::new(Duration::from_millis(0));
|
||||||
|
let path = PathBuf::from("/nonexistent/test.txt");
|
||||||
|
watcher.watch(path.clone());
|
||||||
|
assert_eq!(watcher.watched_count(), 1);
|
||||||
|
watcher.unwatch(&path);
|
||||||
|
assert_eq!(watcher.watched_count(), 0);
|
||||||
|
assert!(watcher.poll_changes().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user