Logo
Published on

Lantern v0.1: reference-counted asset scoping for a small raylib framework

Authors
  • avatar
    Name
    Ryan Flores
    Twitter

Context

Spooky Run was a weekend game jam made in raylib (C++, web build on itch.io). I extracted the reusable parts into Lantern so a second game can start from a working base. That base now includes a game/window/audio lifetime wrapper, a scene manager, an asset store, and a tool that generates asset-path constants from the assets/ folder.

The problem

Assets in the jam game were loaded and unloaded per scene, through a static asset manager and later per-scene stores. Two things bothered me:

  • Which store holds which asset was something a developer had to remember.
  • Moving between scenes produced an audible pause in the music (about half a second). My hypothesis was that unloading and reloading assets caused it.

Design

Ownership at the top

Game owns the window, the audio device, one AssetStore, and the SceneManager. Members are destroyed in reverse declaration order, so the scene manager (and the scenes' asset handles) go first, and the audio device and window close last. Anything that calls raylib is released while they are still open. Copy and move are deleted on the classes that own a resource.

private:
    Window       window;
    Audio        audio;
    AssetStore   assets;
    SceneManager scene_manager;

One reference-counted store

AssetStore::Get<T>(path) returns a shared_ptr<T>. The store keeps only weak_ptrs, so it never keeps an asset alive. If the cached entry has expired, Get loads the asset again. Loading and unloading per type live in a traits class, AssetTraits<T>, specialized for Texture2D, Sound, Music and Font; the store keeps one map per type in a tuple.

template <>
struct AssetTraits<Texture2D> {
    static constexpr const char* Name = "Texture2D";
    static Texture2D Load(const std::string& path) { return LoadTexture(path.c_str()); }
    static bool      Valid(const Texture2D& asset) { return IsTextureValid(asset); }
    static void      Unload(const Texture2D& asset) { UnloadTexture(asset); }
};

The deleter attached to each shared_ptr does two jobs, in order: unload the GPU/audio resource, then free the heap memory that held the raylib struct. A failed load throws with the type and path in the message. Because the store owns nothing, it needs no destructor (rule of zero which I learned going through this project too :)).

template <typename T>
std::shared_ptr<T> AssetStore::Get(const std::string_view file_path) {
    auto&             map = std::get<std::unordered_map<std::string, std::weak_ptr<T>>>(maps);
    const std::string path(file_path);

    if (const auto it = map.find(path); it != map.end()) {
        if (std::shared_ptr<T> ptr = it->second.lock()) return ptr;
    }

    const T loaded = AssetTraits<T>::Load(path);
    if (!AssetTraits<T>::Valid(loaded)) {
        AssetError(std::string("AssetStore: failed to load ") + AssetTraits<T>::Name + " '" + path + "'");
    }

    std::shared_ptr<T> asset(new T(loaded), [](T* a) {
        AssetTraits<T>::Unload(*a);
        delete a;
    });

    map[path] = asset;
    return asset;
}

Scoped handles

Each scene owns an AssetScope, which is a wrapper for a map from path to a type-erased shared_ptr<void> plus the std::type_index of what was stored. A scene loads everything it needs in its constructor and reads it back by reference in Draw. The void-erased pointer still destroys the real type, because the deleter lives in the control block. Asking for a path that was never loaded, or for the wrong type, fails with the path in the message.

template <typename T>
T& Get(const std::string_view asset_path) const {
    const auto it = assets.find(std::string(asset_path));
    if (it == assets.end()) {
        AssetError(std::string("AssetScope::Get: ") + AssetTraits<T>::Name + " '" + std::string(asset_path) +
                   "' was not loaded in this scope. Load it (or call the helper's Preload) first.");
    }
    // ...
    return *std::static_pointer_cast<T>(it->second.ptr);
}

template <typename T>
void Load(const std::string_view asset_path) {
    assets.insert_or_assign(std::string(asset_path), Entry{asset_store.Get<T>(asset_path), std::type_index(typeid(T))});
}

Swap order

In SceneManager::Run(), pending() builds the new scene (which acquires its handles in its constructor) before the assignment releases the old one, so an asset used by both scenes is never down to zero owners.

if (pending && (!transitioning || transition_progress >= swap_time)) {
    current = pending();
    pending = nullptr;
    current->Enter();
    current->scene_manager = this;
}

Path constants

assetgen (a separate C# tool, released under its own assetgen-v... tags) turns the assets/ folder structure into nested namespaces of inline constexpr std::string_view constants, so a typo in a path is a compile error.

Decisions and what I rejected

  • A static/global asset manager: lifetime became implicit and hard to test. I usually go for static singletons because they are so easy to use and set up, but I wanted to challenge myself and create a system that didn't rely on a singleton class. I still only have 1 AssetStore, and that is because of the rule of 0, where I can delete those ctors and dtors.
  • A persistent store that is filled as the game runs, or several stores. This would devolve to "which store holds this asset?" which becomes a thing the developer has to remember (not what I want).
  • Lazy loading with a flag: loading happens mid-frame on first use, and it undoes the loud failure on a missing path.
  • Injecting the store through a setter: the store would not exist during the scene constructor, which is where handles are acquired, and a reference member cannot be reseated.
  • A hand-written wrapper class vs shared_ptr<void> for mixed types: I learned of shared_ptr<void> and that I could use static_pointer_cast<T>() on it to get the shared pointer back out and first accepted that casting to the wrong type is undefined behavior; a later commit added the type_index check so a mismatch fails loudly.

Mistakes that taught me something

  • Dereferencing a handle returned by Get inside Draw and keeping a reference. The temporary owner died at the end of the statement, the deleter ran, and the reference pointed at freed memory. Symptom: MAYHEM! Lots of crazy stuff going on there
const Texture2D& background = *assets.Get<Texture2D>(Assets::UI::BUTTON_BG); // The shared_ptr dies at the ;
  • Assigning a freshly created shared_ptr into a weak_ptr and calling lock(): the only owner was a temporary, so the asset was gone before it was returned.
map[normalizedPath] = std::make_shared<T>(Asset<T>::Load(normalizedPath), [](Asset<T> asset){Asset<T>::Unload(asset);});

(make_shared also takes no deleter, and the deleter receives a pointer, not a value.)

  • Declaring deleted copy/move operations removed the implicit default constructor.
  • My first timer wrapped only the scene constructor. The lazy loads in the first Draw (including a 96 px font atlas) happened after it, so the number did not match what I heard.

Results

What I heard

Same scene-change route, music playing.

BuildDebugRelease
Before: Lantern before-shared-ptr (ec8c745)audible pause, about 0.5 sno pause
After: Lantern lantern-v0.1.0no pauseno pause

The pause was a Debug-build cost. Release never had it, so the refactor did not fix a Release problem, however...

What the log shows

Raylib's own File loaded / Unloaded lines, same scripted route on both builds. I went through the main menu, and completed 2 levels.

Repeated asset loading before and after, same scripted route
BeforeAfterChange
Asset files loaded9342-55%
Unload events7625-67%
96 px font rasterized7 timesonce-86%
Each wall/floor texture loaded7 times2-3 times

The font atlas (95 glyphs into a 1024x1024 texture) is the most expensive single load in the log. Before, every scene rebuilt it; after, it loads once.

Why my first timer disagreed with my ears

The instrumented baseline's swap timer read 44-58 ms, but the log showed a second batch of loads (walls, floors, pieces, icons, and the font) in the new scene's first Draw, after the timer stopped.

This is not the ceiling

I think I can push it further. Looking at the new log, leaving a level unloads the piece and floor textures (the win or lost scene in between does not hold them), and the next level loads the same files again. The idea is to keep those handles alive across the intermediate scenes, for example in a longer-lived scope, and then re-run the same scripted route and add a third bar to the chart above. My initial thought is sub-scenes, where a scene can have multiple sub-scenes. Level Menu hosts the level scenes. Level Menu owns the AssetStore, and when we go to the levels, that reference doesn't die, and is re-used between each sub-scene/level. That's just right outa my noggin, and I have no idea how it will work. I'll play around with that when I want to optimize Lantern a bit more.

Limitations and next steps

  • Preload lists in each scene must match what Draw uses; helper functions that fetch their own assets hide dependencies. I really really really don't like this. This is the one thing I'm going to fix right away somehow. It's up to the developer to keep track of what they'll use, and load it in the ctor of the scene. Not a good plan.
  • Assets shared by non-adjacent scenes (level, win screen, next level) are still reloaded; see "This is not the ceiling".
  • SceneManager::Run() assigns scene_manager after Enter(), so it is not set during Enter().
  • Expired weak_ptr entries stay in the cache maps until the path is requested again.
  • No fullscreen/scaling support yet.
  • Ideas I parked on purpose: lazy loading, frame-sliced loading with a progress bar, a data-driven manifest per scene, async image decode.