Asset System
Every render asset in SafiEngine is addressed by a stable 32-bit handle instead of a raw pointer. Handles survive reloads, are safe to serialize into scene files, and let the registry deduplicate loads by path.
Handle types
id == 0 is always invalid ("no handle"). Internally, id = (slot_index + 1) | (generation << 16) — the same packing scheme used by SafiSoundHandle in the audio subsystem.
Project root + relative paths
Scene files store asset locations relative to the project root so the same JSON works across machines. The root is set once in safi_app_init:
Both absolute and relative paths work everywhere:
Helpers in the header:
Shader root
Shaders live in the CMake build tree (not the source asset tree), so they get their own root:
safi_shader_load (and callers like safi_assets_load_model_lit) accept a NULL shader_dir argument and fall back to this root. Set it once from SafiAppDesc.shader_root and never pass it again. Scene files that stored model paths no longer have to know where compiled shaders live on the install machine.
Directory enumeration
The M3 asset browser lists files on disk via safi_assets_list — shallow, filtered by extension, results already anchored to the project root:
filteris a comma-separated extension list, case-insensitive, with or without leading dots.NULLor""disables filtering.- Subdirectories are always returned regardless of filter so callers can recurse.
- Order is whatever SDL's
SDL_EnumerateDirectorygives back; sort on the result if you need a consistent UI order.
Path-cached kinds: Model & Texture
Models and textures are loaded from disk and deduplicated by path: if two entities load models/player.glb, only one decode + upload happens.
Textures work the same way:
Code-owned kinds: Mesh, Material
For assets built at runtime (procedural meshes, one-off materials), use register_*:
Refcounting
Every slot has an int refcount.
load_*/register_*create a slot with refcount = 1.acquire_*bumps the refcount.release_*decrements. At zero, GPU resources are freed and the slot's generation is bumped — any handle still pointing at the old generation resolves toNULL.safi_assets_shutdown()drains every slot regardless of refcount, logging a warning per leak.
Components that carry handles (e.g. SafiMeshRenderer.model) should acquire on attachment and release on removal. The engine's primitive_system demonstrates this pattern via its EcsOnRemove observer.
Hot-reload
A poll-based watcher walks every loaded model/texture slot, stat()s the underlying file, and calls the reload entry point when mtime advances. Handle ids stay stable across reloads — only the backing GPU resource is swapped.
The engine ticks safi_assets_watch_tick() from inside safi_app_tick with a 250 ms throttle, so "save in editor → see in game" feels immediate without stat-spamming the filesystem.
safi_assets_reload_texture swaps the SDL_GPUTexture* in its slot in place — callers that cache the raw pointer (e.g. SafiModel.base_colors[]) need to resolve through safi_assets_resolve_texture again after a reload. primitive_system does this automatically: it subscribes to the reload callback at init, and on any texture reload invalidates the _hash of every SafiPrimitive referencing that texture handle so the next frame rebuilds and picks up the fresh pointer. Editor code that renders thumbnails into GPU textures should subscribe the same way.
SafiMeshRenderer integration
SafiMeshRenderer.model is now a SafiModelHandle:
The engine's render system resolves the handle each frame:
Full public API
Async / staged loading (background decode + GPU upload in a later frame) is not yet implemented. All loads are synchronous and block the main thread.
See also
SafiMesh— GPU buffer pairSafiMaterial— pipeline + base-color textureSafiModel— multi-primitive glTF container- Stock Components —
SafiMeshRendererusesSafiModelHandle