Tutorial 45: ContentManager and Asset Pipeline
What you’ll learn
- The generic
ContentManager::Load<T>and which asset types it accepts. RootDirectoryand how asset paths resolve.- Freeing assets with
Unload()on a state change. - The JSON descriptor formats CNA uses.
Before you start — Tutorial 08: Loading and Drawing Textures and Tutorial 09: Drawing Text with SpriteFont — this is the reference pass over the loader those two used informally.
CNA's ContentManager provides the same Load<T> interface as XNA 4.0, and it reads two kinds of content: compiled .xnb binaries, and open file formats such as PNG, glTF, WAV, OGG and .cnj descriptors. When both exist for an asset, the .xnb wins.
CNA is an XNB loader, not a content pipeline. It reads .xnb files that XNA, MonoGame or FNA produced — 50 built-in type readers (49 without FFmpeg, which drops the video reader), a real LZX decompressor, and two-pass shared-resource resolution. What it does not have, and deliberately never will, is the other direction: there is no ContentImporter, no ContentProcessor and no ContentCompiler, so nothing here builds .xnb files. Authoring stays with the original XNA tooling or with CNA's own loose-file formats.
The readers are not registered automatically. The registry starts deliberately empty, so a fresh ContentManager cannot read any .xnb at all until you call CNA::Internal::Xnb::RegisterAllBuiltInXnbReaders() once at startup. It lives in an Internal header, which is a known ergonomics gap — but you do have to call it. A second gap: there is no ReflectiveReader, so custom or user-defined types cannot be loaded from XNB and must use the loose-file path instead. See the XNB Content Pipeline reference.
ContentManager::Load<T> generic
#include "Microsoft/Xna/Framework/Content/ContentManager.hpp"
using namespace Microsoft::Xna::Framework::Content;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Audio;
// Inside Game::LoadContent():
auto& content = getContentProperty(); // returns ContentManager&
content.setRootDirectoryProperty("Content"); // relative to the executable
// Load<T> returns T by value — not a pointer. A failed load throws
// ContentLoadException; there is no null to check.
// Texture2D — loads PNG/JPG/BMP from Content/textures/player.png
Texture2D tex = content.Load<Texture2D>("textures/player");
// Model — resolves Content/models/ship.xnb, then .cnj, then .gltf/.glb
Model model = content.Load<Model>("models/ship");
// SoundEffect — loads from Content/audio/explosion.wav
auto sfx = content.Load<SoundEffect>("audio/explosion");
// Song (streaming) — loads from Content/music/theme.ogg
auto song = content.Load<Song>("music/theme");
// SpriteFont — loads from Content/fonts/arial20.cnj
SpriteFont font = content.Load<SpriteFont>("fonts/arial20");
Supported asset types
| Type | File extension(s) | Notes |
|---|---|---|
Texture2D | .png .jpg .jpeg .bmp .gif .tga .tif .tiff .qoi | The image file is loaded directly. There is no descriptor file. |
Model | .xnb .cnj .gltf .glb | Tried in that order. .cnj is a JSON descriptor plus binary vertex/index sidecars, produced by the gltf_to_cnj tool. There is no OBJ loader. |
SoundEffect | .wav | The WAV file is loaded directly into memory. There is no descriptor file. Use for short clips. |
Song | .mp3 .ogg .wav .flac .opus .aac .wma | Streaming; only one Song plays at a time. Note that .aac and .m4a resolve but will not actually play — SDL3_mixer has no AAC decoder. |
SpriteFont | .cnj | A .cnj descriptor naming a glyph-atlas texture, which is itself loaded through ContentManager. |
Effect | .xnb, .cnj | XNB EffectReader loads XNA/FNA D3D9 Effect Framework bytecode when the active renderer supports compiled effects. A .cnj descriptor can instead name renderer-native sources for ShaderEffect. HLSL .fx source, DXBC, and MGFX are not interchangeable with the supported Effect Framework binary. |
TextureCube | .dds | Compressed cube maps are limited to DXT1, DXT3 and DXT5. |
Texture3D | .cnj | — |
SkinnedModel | .skinnedmodel.json | — |
Video | .mp4 .ogv .webm .mkv .avi .mov | Linux and macOS only. The video translation units are excluded from Windows, Emscripten and Android builds, so Video and VideoPlayer are missing symbols there. The headers still exist, which means such code compiles and then fails to link. |
ContentManager::RootDirectory
// Default is empty string (alongside the executable)
content.setRootDirectoryProperty("Content");
// Nested managers per state (one content manager per game screen)
ContentManager levelContent(&getServicesProperty(), "Content/Level1");
ContentManager::Unload()
Unload() disposes every asset loaded through that ContentManager instance. Call it when transitioning between game states to reclaim GPU and audio memory.
// On screen exit:
levelContent.Unload();
// All Texture2D, Model, SoundEffect, etc. loaded via levelContent are now freed.
// Do NOT use any pointers obtained from levelContent after this point.
JSON descriptor formats
Only some content types have a descriptor. Texture2D and SoundEffect do not — the image or WAV file is read directly, and everything you might expect a descriptor to configure is set on the object in C++ instead. The types that do use one all share CNA's single .cnj document format, which carries a cnjVersion and a type that must match the C++ type you ask for.
Texture2D — no descriptor
There is no .texture.json. Load<Texture2D> resolves the asset name against .png, .jpg, .jpeg, .bmp, .gif, .tga, .tif, .tiff and .qoi, and decodes whichever it finds. Sampling and wrapping are not asset properties in XNA at all — they are device state you set at draw time:
auto* tex = content.Load<Texture2D>("textures/terrain");
// Wrapping and filtering are sampler state, not file metadata.
gd.getSamplerStatesProperty()[0] = SamplerState::LinearWrap;
SoundEffect — no descriptor
There is no .sound.json. Load<SoundEffect> reads a .wav file and nothing else. Volume, pitch and pan are per-playback values, so they are arguments to Play or properties of a SoundEffectInstance:
auto* sfx = content.Load<SoundEffect>("audio/explosion");
// volume, pitch, pan — set at the call site, not in a file.
sfx->Play(0.8f, 0.0f, 0.0f);
.cnj (Model)
Geometry lives in the binary sidecars the descriptor names; the JSON just wires them together. Generate it with gltf_to_cnj rather than writing it by hand — see Tutorial 35.
// Content/models/ship.cnj
{
"cnjVersion": 1,
"type": "Model",
"meshes": [
{ "name": "Hull", "vertices": "ship_body_verts.bin", "indices": "ship_body_idx.bin", "vertexStride": 32, "effect": "BasicEffect" },
{ "name": "Engine", "vertices": "ship_engine_verts.bin", "indices": "ship_engine_idx.bin", "vertexStride": 32, "effect": "BasicEffect" }
]
}
.cnj (SpriteFont)
texture names the glyph atlas and is required — a descriptor without it raises a ContentLoadException. That name goes back through ContentManager, so the atlas is resolved and cached like any other texture. Each glyph carries source (its rectangle in the atlas), crop (the offset and size used when drawing), and kerning as XNA's three floats: left bearing, advance width, right bearing.
// Content/fonts/arial20.cnj
{
"cnjVersion": 1,
"type": "SpriteFont",
"texture": "fonts/arial20_atlas",
"lineSpacing": 24,
"spacing": 0.0,
"defaultCharacter": "?",
"glyphs": [
{ "char": 32, "source": [0, 0, 6, 20], "crop": [0, 0, 6, 20], "kerning": [0.0, 6.0, 0.0] },
{ "char": 65, "source": [10, 0, 14, 20], "crop": [0, 0, 14, 20], "kerning": [0.0, 14.0, 0.0] }
]
}
.cnj (Effect)
The Effect descriptor has exactly two shader fields, vertex and fragment, each naming a GLSL source file relative to the descriptor. Missing either raises a ContentLoadException. Load it as Effect — the type the reader is registered for — then downcast to ShaderEffect. See Tutorial 52.
// Content/effects/wave.cnj
{
"cnjVersion": 1,
"type": "Effect",
"vertex": "wave.vert",
"fragment": "wave.frag"
}
Code example: load multiple assets, unload on state change
class MultiAssetGame final : public Game {
public:
MultiAssetGame() : graphics_(this) {}
protected:
void Initialize() override {
Game::Initialize();
state_ = GameState::Menu;
}
void LoadContent() override {
// Menu assets use the global ContentManager
auto& shared = getContentProperty();
shared.setRootDirectoryProperty("Content");
menuFont_ = shared.Load<SpriteFont>("fonts/title");
menuBg_ = shared.Load<Texture2D>("ui/menu_bg");
// Game-level assets in a separate manager so they can be unloaded
gameContent_ = std::make_unique<ContentManager>(
&getServicesProperty(), "Content/Game");
playerTex_ = gameContent_->Load<Texture2D>("player");
enemyTex_ = gameContent_->Load<Texture2D>("enemy");
shootSfx_ = gameContent_->Load<SoundEffect>("shoot");
levelModel_ = gameContent_->Load<Model>("level01");
}
void Update(GameTime& gameTime) override {
auto kb = Keyboard::GetState();
if (state_ == GameState::Menu && kb.IsKeyDown(Keys::Enter)) {
state_ = GameState::Playing;
}
if (state_ == GameState::Playing && kb.IsKeyDown(Keys::Escape)) {
// Free all in-game assets; menu assets remain loaded
gameContent_->Unload();
playerTex_ = {};
enemyTex_ = {};
shootSfx_.reset();
levelModel_ = {};
state_ = GameState::Menu;
}
}
void Draw(const GameTime&) override {
auto& gd = getGraphicsDeviceProperty();
gd.Clear(Color::Black);
// ... draw based on state_ ...
gd.Present();
}
private:
enum class GameState { Menu, Playing };
GameState state_ = GameState::Menu;
GraphicsDeviceManager graphics_;
std::unique_ptr<ContentManager> gameContent_;
Texture2D menuBg_;
Texture2D playerTex_;
Texture2D enemyTex_;
Model levelModel_;
std::optional<SpriteFont> menuFont_; // no default ctor
std::optional<SoundEffect> shootSfx_; // no default ctor
};
Load<T> returns the asset by value, so your member is your copy — there is no shared handle to keep alive. How you declare that member depends on the type: Texture2D and Model are default-constructible, so a plain value member works and is what CNA's own examples use. SpriteFont, Song and SoundEffect are not, so wrap those in std::optional<T> and take the address with &*font_ when an API wants a pointer.
After Unload() the manager's cached copy is gone; reset your holders as shown above so the next state change reloads cleanly.