Skip to content
C++
Domain Deep-Dive
Advanced

Asset Pipeline and Hot-Reload in C++

Building a C++ game asset pipeline: offline processing, runtime loading, asset IDs, dependency tracking, and hot-reload for fast iteration.

Asset pipeline overview

cpp
Source assets              Processed assets         Runtime
(PSD, FBX, WAV)     →    (DXT5, GLTF, OGG)   →   (GPU texture,
     │                          │                    mesh buffer,
     │  offline cooker          │  streaming          audio PCM)
     │  (build step)            │  loader
     ▼                          ▼
 asset_db.json            assets/packed.bin

The key insight: separate the import step (slow, done offline) from the load step (fast, done at runtime).


Asset identification

Use content-hashed IDs or stable string IDs, never raw file paths at runtime:

cpp
// Option 1: Compile-time FNV-1a hash
constexpr uint64_t HashStr(std::string_view s) noexcept {
    uint64_t h = 14695981039346656037ULL;
    for (char c : s) h = (h ^ static_cast<uint8_t>(c)) * 1099511628211ULL;
    return h;
}

struct AssetID {
    uint64_t value;
    constexpr bool operator==(AssetID o) const { return value == o.value; }
};

constexpr AssetID ASSET(std::string_view path) { return { HashStr(path) }; }

// Usage — no string allocation at runtime
static constexpr AssetID PLAYER_MESH = ASSET("characters/player.mesh");
static constexpr AssetID FIRE_SFX    = ASSET("sfx/fire_loop.wav");

// Specialize std::hash for AssetID
template<> struct std::hash<AssetID> {
    size_t operator()(AssetID id) const noexcept { return id.value; }
};

Asset manager — ref-counted loading

cpp
class AssetManager {
    struct Entry {
        std::shared_ptr<void> data;
        std::type_index type;
        uint32_t ref_count = 0;
    };

    std::unordered_map<AssetID, Entry> m_cache;
    std::mutex m_mutex;

public:
    template<typename T>
    std::shared_ptr<T> Load(AssetID id) {
        std::lock_guard lock(m_mutex);

        auto it = m_cache.find(id);
        if (it != m_cache.end()) {
            return std::static_pointer_cast<T>(it->second.data);
        }

        // Load from disk
        auto asset = std::make_shared<T>(LoadFromDisk<T>(id));
        m_cache[id] = { asset, typeid(T) };
        return asset;
    }

    // Evict assets with no live references
    void GC() {
        std::lock_guard lock(m_mutex);
        std::erase_if(m_cache, [](auto& kv) {
            return kv.second.data.use_count() == 1; // only in cache
        });
    }
};

Binary asset format

Avoid parsing overhead at load time — use flat, directly-mappable formats:

cpp
// Mesh file layout (binary, little-endian)
struct MeshFileHeader {
    uint32_t magic;        // 'MESH'
    uint32_t version;      // 1
    uint32_t vertex_count;
    uint32_t index_count;
    uint32_t submesh_count;
    // immediately followed by: vertices[], indices[], submeshes[]
};

// Load by memory-mapping — zero copy
class MeshAsset {
    MappedFile m_file;  // mmap() on Linux, MapViewOfFile on Windows
public:
    explicit MeshAsset(AssetID id) {
        m_file = MappedFile(AssetPath(id));
        auto* header = reinterpret_cast<const MeshFileHeader*>(m_file.data());
        assert(header->magic == 'MESH');
        m_vertices = { header + 1, header->vertex_count };
        // ...
    }
    std::span<const Vertex> vertices() const { return m_vertices; }
};

Hot-reload

File watching for fast iteration without restarting:

cpp
#include <sys/inotify.h>  // Linux; use ReadDirectoryChangesW on Windows

class HotReloader {
    int m_inotify_fd;
    std::unordered_map<int, std::string> m_watch_map; // wd → path
    AssetManager& m_assets;

public:
    HotReloader(AssetManager& assets) : m_assets(assets) {
        m_inotify_fd = inotify_init1(IN_NONBLOCK);
    }

    void Watch(const std::string& dir) {
        int wd = inotify_add_watch(m_inotify_fd, dir.c_str(),
            IN_CLOSE_WRITE | IN_MOVED_TO);
        m_watch_map[wd] = dir;
    }

    // Call once per frame from main thread
    void Poll() {
        alignas(struct inotify_event) char buf[4096];
        ssize_t n = read(m_inotify_fd, buf, sizeof(buf));
        if (n <= 0) return;

        for (char* p = buf; p < buf + n; ) {
            auto* ev = reinterpret_cast<inotify_event*>(p);
            if (ev->len > 0) {
                std::string changed = m_watch_map[ev->wd] + "/" + ev->name;
                OnFileChanged(changed);
            }
            p += sizeof(inotify_event) + ev->len;
        }
    }

    void OnFileChanged(const std::string& path) {
        AssetID id = ASSET(path);
        m_assets.Invalidate(id);  // evict and reload on next access
        LogInfo("Hot-reloaded: {}", path);
    }
};

What's safe to hot-reload:

  • Textures: yes (swap GPU resource)
  • Shaders: yes (recompile and swap pipeline)
  • Meshes: yes (re-upload to GPU)
  • Audio: yes (re-decode and swap buffer)
  • Game code (scripts/Lua): yes
  • Core engine DLLs: complex but possible with DLL hot-swap

Streaming large worlds

For open-world games, stream chunks based on player position:

cpp
struct Chunk { glm::ivec2 coord; };

class WorldStreamer {
    std::unordered_set<Chunk, ChunkHash> m_loaded;
    int m_stream_radius = 3; // load 7×7 grid of chunks

public:
    void Update(glm::vec3 player_pos) {
        glm::ivec2 center = WorldToChunk(player_pos);

        // Load needed chunks
        for (int dx = -m_stream_radius; dx <= m_stream_radius; ++dx)
        for (int dz = -m_stream_radius; dz <= m_stream_radius; ++dz) {
            Chunk c { center + glm::ivec2(dx, dz) };
            if (!m_loaded.count(c)) {
                LoadChunkAsync(c);
                m_loaded.insert(c);
            }
        }

        // Unload distant chunks
        std::erase_if(m_loaded, [&](Chunk c) {
            if (glm::length(glm::vec2(c.coord - center)) > m_stream_radius + 1) {
                UnloadChunk(c);
                return true;
            }
            return false;
        });
    }
};
Edit on GitHubUpdated 2026-05-24T00:00:00.000Z