Hot reloading architecture
TL;DR: I can now modify my sinscape.js
script file and when I do the sine-wave based voxel landscape will automatically refresh in the engine without a restart.
I wanted to add "hot reloading" to the engine so that changes to data files are automatically reflected in the running engine. This is one of those small developer-ergonomics changes that, over time, I believe has huge benefits to productivity.
The primary challenge was to architect this such that the engine internals remain clean: i.e.
- Avoid scattering with knowledge of source file to asset mappings throughout the engine
- Avoid introducing complex inter-object references within the engine (makes for a Rust lifetime manageable headache)
- Minimal runtime impact in a release build
- Keep file watching code isolated and independent as it's a development feature, not an engine feature
I expect to have to revist this as the engine functionality increases and as I learn more about how to use Rust more effectively. ๐
This article does not go into full depth on some of changes discussed. If you'd like more detail added to any section, let me know! I wanted to be sure there was an audience for this before going into any more depth.
Architectureโ
- Build a list of files -> asset ids during loading
- Add a dev-only Actor that watches for file change
- Trigger a reload for any assets that have been marked dirty
- Do the reload
Build the dependency graph during scene loading (DependencyList)โ
Record the dependenciesโ
As the loader opens files, it maintains a mapping of each file to the list of asset ids that file impacted. Building the "graph" is simple as long as two rules are followed:
- Record direct dependencies: whenever a file is opened, ensure any assets created by that file add any entry mapping that
file -> asset id
- Record transitive dependnecies: whenever an asset relies on data from another asset, copy all the dependencies from the existing asset to the newly created asset.
Example: when loading a .vox
file, we simply add that file name as a dependency on the model that's going to use that vox data.
dependency_list.add_model_entry(vox_file.to_str().unwrap(), &desc.header.id);
let vox_data: vox_format::VoxData = vox_format::from_file(vox_file).unwrap();
We record the dependencies as IDs rather than object references as it's far cleaner for managing lifetimes.
For a simple scene, we end up with a list like the following
1.4 INFO --- Dependency list ---
[Model] mmm-house3
data/dist/base/models/mmm-house3/mmm-house3.yaml
data/dist/base/models/mmm-house3/obj_house3.vox
[Model] sinscape
data/dist/base/generators/sinscape.js
data/dist/base/models/sinscape.yaml
[Model] unit_cube
data/dist/base/models/unit_cube.yaml
[Scene] main
data/dist/base/scenes/main.yaml
[Instance] house-000
data/dist/base/models/mmm-house3/mmm-house3.yaml
data/dist/base/models/mmm-house3/obj_house3.vox
data/dist/base/scenes/main.yaml
[Scene] main-001
data/dist/base/scenes/main-001.yaml
Intrusive trackingโ
This is an "intrusive" approach: the bookkeeping of dependency tracking must be inlined directly into the loading logic and cannot be plugged in as an optional feature. This however feels fine as a design choice since the cost of building a mapping table is relatively low and it is conceptually simple.
The loading code expects each asset load to have 1 or more calls to methods such as the below. Thus, we want an interface that makes recording dependencies simple, hard-to-get-wrong, and ideally self-descriptive one-liners.
impl DependencyList {
// ...
// Direct dependencies
pub fn add_scene_entry(&mut self, file_path: &str, id: &str) { ... }
pub fn add_model_entry(&mut self, file_path: &str, id: &str) { ... }
pub fn add_instance_entry(&mut self, file_path: &str, id: &str) { ... }
// Transitive dependencies
pub fn copy_entries(&mut self,
src_type: EntityType, src_id: &str,
dst_type: EntityType, dst_id: &str) { ... }
// ...
Design choice: a list not a graphโ
Transitive dependencies copy dependencies which flattens the dependency graph. This makes it a dependency list. This is done for simplicity's sake, though has a small trade-off (continue reading for more on this).
The alternative would be to record asset -> asset
dependencies as well file -> asset
dependencies. This would add only a little more complexity as the flattening would happen at use, not build, time for the list -- but per the below this didn't seem worth doing at this stage. ๐คท
Design choice: an immutable list after initializationโ
The architecture builds this list at initial load only. It is treated effectively an immutable/static list after startup.
โ The benefit is this is very simple to reason about. The dependency list requires no dynamic update logic.