Asset System

#include <safi/render/assets.h>

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

typedef struct { uint32_t id; } SafiModelHandle;
typedef struct { uint32_t id; } SafiTextureHandle;
typedef struct { uint32_t id; } SafiMeshHandle;
typedef struct { uint32_t id; } SafiMaterialHandle;
typedef struct { uint32_t id; } SafiShaderHandle;

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:

SafiApp app;
safi_app_init(&app, &(SafiAppDesc){
    .title        = "My Game",
    .project_root = "/path/to/assets",   // or NULL → CWD at init
    ...
});

Both absolute and relative paths work everywhere:

// All three resolve to the same slot — path cache keys on the absolute form.
safi_assets_load_model_lit("models/player.glb", shader_dir);
safi_assets_load_model_lit("/abs/path/to/assets/models/player.glb", shader_dir);

Helpers in the header:

void        safi_assets_set_project_root(const char *abs);
const char *safi_assets_project_root(void);

/* Strip the project-root prefix if `abs` is inside it. Returns false
 * (passes `abs` through verbatim) when the path lives elsewhere. */
bool safi_assets_path_to_relative(const char *abs, char *out, size_t cap);

/* Absolute passthrough; join relative paths onto the project root. */
void safi_assets_path_resolve(const char *in, char *out, size_t cap);

Shader root

Shaders live in the CMake build tree (not the source asset tree), so they get their own root:

void        safi_assets_set_shader_root(const char *abs);
const char *safi_assets_shader_root(void);

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:

typedef struct SafiAssetEntry {
    char     relative[256];   // project-root-relative (or abs if outside root)
    bool     is_dir;
    uint64_t size_bytes;
    int64_t  mtime;
} SafiAssetEntry;

SafiAssetEntry rows[64];
int n = safi_assets_list("models", "glb,gltf", rows, 64);
// n == 2: BoxTextured.glb + player.glb
  • filter is a comma-separated extension list, case-insensitive, with or without leading dots. NULL or "" disables filtering.
  • Subdirectories are always returned regardless of filter so callers can recurse.
  • Order is whatever SDL's SDL_EnumerateDirectory gives 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.

// Load a lit-shaded glTF. Returns a handle with refcount = 1.
SafiModelHandle h = safi_assets_load_model_lit(path, shader_dir);

// Second load of the same path → cache hit, refcount = 2, no GPU work.
SafiModelHandle h2 = safi_assets_load_model_lit(path, shader_dir);

// Resolve to the underlying SafiModel* for draw calls.
SafiModel *m = safi_assets_resolve_model(h);

// Get the path that was used to load this model.
const char *p = safi_assets_model_path(h);   // "" for code-owned models

Textures work the same way:

SafiTextureHandle th = safi_assets_load_texture("textures/brick.png");
SDL_GPUTexture *tex  = safi_assets_resolve_texture(th);

Code-owned kinds: Mesh, Material

For assets built at runtime (procedural meshes, one-off materials), use register_*:

SafiMesh mesh;
safi_mesh_create(&r, &mesh, verts, nv, idx, ni);
SafiMeshHandle mh = safi_assets_register_mesh(mesh);
// ownership transfers — don't call safi_mesh_destroy manually

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 to NULL.
  • 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.

// Toggled via SafiAppDesc; defaults on when enable_debug_ui is true.
SafiAppDesc desc = {
    ...
    .enable_debug_ui  = true,
    .enable_hot_reload = true,   // optional override for release builds
};

// Manual reload entry points still exist (editor "Reload" buttons, tests):
safi_assets_reload(model_handle);          // re-decode from .path
safi_assets_reload_texture(texture_handle);

// Subscribe to reload events — fires after the swap. Up to 4 subscribers.
safi_assets_on_reload(my_callback, my_ctx);

// The tick is cheap (stat() per active slot) and runs ~4×/second.
safi_assets_watch_tick();

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:

typedef struct SafiMeshRenderer {
    SafiModelHandle model;   // 0 = nothing to draw
    bool            visible;
} SafiMeshRenderer;

The engine's render system resolves the handle each frame:

const SafiModel *m = safi_assets_resolve_model(mr.model);
if (!m) continue;
safi_model_draw_lit(r, m, &vs, &cam, &lights);

Full public API

FunctionDescription
safi_assets_init(r)Initialize the registry. Called by safi_app_init.
safi_assets_shutdown()Drain all slots, warn on leaks.
safi_assets_set_project_root(abs)Set the root for relative path resolution.
safi_assets_project_root()Retrieve the root currently in effect.
safi_assets_set_shader_root(abs)Register the shader build-tree directory.
safi_assets_shader_root()Retrieve the shader root in effect.
safi_assets_path_resolve(in, out, cap)Absolute passthrough; join relatives onto the root.
safi_assets_path_to_relative(abs, out, cap)Strip the project-root prefix (for scene save).
safi_assets_list(dir, filter, out, cap)Enumerate children of dir; filter by extension.
safi_assets_watch_tick()Poll mtimes, reload changed slots (ticked by app).
safi_assets_load_model(path, shader_dir)Load a glTF with unlit pipeline.
safi_assets_load_model_lit(path, shader_dir)Load a glTF with lit pipeline.
safi_assets_register_model(model)Register a code-built SafiModel.
safi_assets_resolve_model(h)Resolve handle → SafiModel* (NULL if stale).
safi_assets_model_path(h)Path string or "".
safi_assets_acquire_model(h) / release_model(h)Refcount management.
safi_assets_load_texture(path)Load + decode an image.
safi_assets_register_texture_rgba8(px, w, h)Upload a raw RGBA8 buffer.
safi_assets_resolve_texture(h)Resolve → SDL_GPUTexture*.
safi_assets_texture_path(h)Path string or "".
safi_assets_acquire_texture(h) / release_texture(h)Refcount management.
safi_assets_register_mesh(mesh)Register a code-built SafiMesh.
safi_assets_register_material(mat)Register a code-built SafiMaterial.
safi_assets_resolve_mesh(h) / resolve_material(h)Resolve by handle.
safi_assets_reload(h)Re-decode model from path.
safi_assets_reload_texture(h)Re-decode texture from path.
safi_assets_on_reload(cb, ctx)Subscribe to reload events.
WIP — Async loading

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