Tutorial 77: Asset Streaming and Async Loading
The problem with loading stalls
Synchronous asset loading in LoadContent blocks the main thread. For a small game with a few textures this is fine — loading takes under a second. For a large game with gigabytes of textures, meshes, and audio, synchronous loading produces freezes that break immersion: the game hangs for several seconds between areas, or shows a frozen loading screen.
The solution is to load assets on a background thread and upload them to the GPU on the main thread when they are ready. The two-phase approach is necessary because GPU operations (creating textures, uploading vertex data) must happen on the thread that owns the OpenGL context or Vulkan queue — that is always the main thread in CNA.
std::async and std::future for background loading
C++23's standard std::async / std::future provide a simple way to run a function on a thread pool and retrieve its result later:
// Start loading a PNG from disk on a background thread.
// Returns raw pixel data — no GPU operations yet.
std::future<std::vector<uint8_t>> LoadRawPixelDataAsync(
const std::string& path) {
return std::async(std::launch::async, [path]() -> std::vector<uint8_t> {
// SDL_image or stb_image — no OpenGL/Vulkan calls here
return LoadPNGToMemory(path); // pure CPU/disk operation
});
}
// In LoadContent or wherever you start the load:
auto future = LoadRawPixelDataAsync("assets/level2/terrain.png");
// In Update — check if the load is done without blocking:
if (future.valid() &&
future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) {
auto pixels = future.get(); // retrieve result, non-blocking now
// Upload to GPU on main thread:
terrainTex_ = std::make_unique<Texture2D>(
gd, texWidth_, texHeight_,
false, SurfaceFormat::Color);
terrainTex_->SetData(pixels.data(),
static_cast<int>(pixels.size()));
}
Thread-safe resource queue
For loading many assets simultaneously, use a producer-consumer queue. Background threads push completed CPU-side data into the queue; the main thread drains the queue each frame to do the GPU upload:
// BackgroundLoader.hpp
#pragma once
#include <future>
#include <queue>
#include <mutex>
#include <functional>
#include <string>
#include <vector>
#include <atomic>
struct LoadedAsset {
std::string name;
int width = 0;
int height = 0;
std::vector<uint8_t> pixels;
std::function<void()> onComplete; // optional callback
};
class BackgroundLoader {
public:
// Enqueue a texture path for background loading.
// progressCallback is invoked from the background thread — it must be
// thread-safe (e.g. update an atomic progress counter only).
void EnqueueTexture(const std::string& name,
const std::string& path,
std::function<void()> onComplete = {}) {
++pending_;
futures_.push_back(
std::async(std::launch::async,
[this, name, path, onComplete]() {
LoadedAsset asset;
asset.name = name;
asset.pixels = LoadPNG(path,
&asset.width, &asset.height);
asset.onComplete = onComplete;
{
std::lock_guard<std::mutex> lock(mutex_);
ready_.push(std::move(asset));
}
--pending_;
}));
}
// Call from the main thread each frame.
// Returns true if all previously enqueued assets have been processed.
bool PumpUploads(GraphicsDevice& gd,
std::unordered_map<std::string,
std::unique_ptr<Texture2D>>& out,
int maxPerFrame = 2) {
std::lock_guard<std::mutex> lock(mutex_);
int uploaded = 0;
while (!ready_.empty() && uploaded < maxPerFrame) {
LoadedAsset asset = std::move(ready_.front());
ready_.pop();
auto tex = std::make_unique<Texture2D>(
gd, asset.width, asset.height,
false, SurfaceFormat::Color);
tex->SetData(asset.pixels.data(),
static_cast<int>(asset.pixels.size()));
out[asset.name] = std::move(tex);
if (asset.onComplete) asset.onComplete();
++uploaded;
}
return pending_.load() == 0 && ready_.empty();
}
float Progress() const {
// crude progress: assumes a known total
int done = totalQueued_ - static_cast<int>(pending_.load());
return totalQueued_ > 0
? static_cast<float>(done) / totalQueued_
: 1.0f;
}
int totalQueued_ = 0;
private:
std::atomic<int> pending_{0};
std::mutex mutex_;
std::queue<LoadedAsset> ready_;
std::vector<std::future<void>> futures_;
};
GPU upload on the main thread
The PumpUploads method above is called from the game's Update or Draw. It dequeues up to maxPerFrame assets to avoid uploading so much data in one frame that the game stutters. Adjust maxPerFrame based on how large each asset is — for small textures set it to 4 or 8; for large ones (4K textures) set it to 1.
Loading screen pattern
Implement a state machine in your game that switches between STATE_LOADING and STATE_PLAYING. During loading, render a progress bar using SpriteBatch (no 3D assets needed — just a solid colour quad) and call PumpUploads each frame:
void Update(const GameTime& gt) override {
if (state_ == State::Loading) {
bool done = loader_->PumpUploads(
getGraphicsDeviceProperty(), textures_, 4);
loadProgress_ = loader_->Progress();
if (done) state_ = State::Playing;
return;
}
// Normal game update...
}
void Draw(const GameTime& gt) override {
auto& gd = getGraphicsDeviceProperty();
gd.Clear(Color::Black);
if (state_ == State::Loading) {
// Draw a loading bar
int barW = static_cast<int>(640.0f * loadProgress_);
spriteBatch_->Begin();
spriteBatch_->Draw(*whitePixel_,
Rectangle(80, 350, barW, 20), Color::CornflowerBlue);
spriteBatch_->Draw(*whitePixel_,
Rectangle(80, 350, 640, 20), Color::White * 0.3f);
spriteBatch_->DrawString(*font_,
"Loading... " +
std::to_string(static_cast<int>(loadProgress_ * 100)) + "%",
Vector2(80, 310), Color::White);
spriteBatch_->End();
gd.Present();
return;
}
// Normal game draw...
}
ContentManager thread safety note
CNA's ContentManager::Load<T>() is not thread-safe. Do not call it from background threads. The BackgroundLoader pattern above bypasses ContentManager intentionally — it reads raw file data on background threads and only touches CNA GPU types on the main thread.
If you want to use ContentManager for level streaming, create a separate ContentManager per level on the main thread, but trigger the loading from a background thread using a flag / condition variable to signal when to start. The actual Load calls must happen on the main thread.