Domain Deep-Dive
AdvancedAsset 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.binThe 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