Tutorial 35: Loading 3D Models
What you’ll learn
- What the
Modelclass holds, and how a model gets into your project. - Converting glTF to
.cnjoffline, and loading glTF directly. Model::Draw(), walking meshes and mesh parts, and the bone hierarchy.- Where CNA's
ModelAPI deliberately differs from XNA's.
Before you start — Tutorial 32: BasicEffect and 3D Lighting (models are drawn with BasicEffect) and Tutorial 08: Loading and Drawing Textures (models load through ContentManager). Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the 2D-only renderers (SDL_RENDERER, DIRECT2D, CANVAS, HTML_DOM, SKIA, BLEND2D, FREEDIRECT, DIRECTX1, GDI, SVG_DOM, OPENVG, NANOVG, PIXIJS) throw on 3D calls.
CNA's Model class mirrors XNA's Microsoft.Xna.Framework.Graphics.Model. It bundles geometry, materials, and a bone hierarchy. You load it through ContentManager and draw it with a single call, or walk its meshes to apply your own effects.
The Model class
A Model holds three things:
- Meshes — a
ModelMeshCollectionof logical geometry groupings, reached withgetMeshesProperty(). - Bones — a
ModelBoneCollectionof named transforms, reached withgetBonesProperty(). The root isgetRootProperty(). - Effects — each
ModelMeshPartcarries its ownEffect*, typically aBasicEffect, built by the content reader.
Every property is a getXxxProperty() accessor. CNA does not expose XNA's C# properties as public fields. There is no model.Meshes, no mesh.ParentBone, no part.Effect — they are getMeshesProperty(), getParentBoneProperty(), and getEffectProperty(). This applies to every CNA type, not just Model.
Content formats: how a model gets in
CNA has no runtime FBX or .x importer, and it does not run the XNA Content Pipeline. There are three real paths into Load<Model>, and ContentManager tries them in a fixed order.
| Extension | What it is | Produced by |
|---|---|---|
.xnb | An authentic compiled XNA asset. Checked first, ahead of everything else — if one is present it wins. | The original XNA Content Pipeline. |
.cnj | CNA's own content format: a small JSON descriptor plus binary vertex/index sidecars. The normal path. | The gltf_to_cnj tool, or written by hand. |
.gltf / .glb | A glTF file parsed directly at load time, with no conversion step and no sidecars. | Any 3D authoring tool. |
You give Load<Model> a logical name with no extension, and ContentManager resolves it against that list. A .cnj sidecar always wins over a same-named .gltf/.glb, which is what lets you convert once and keep the source file next to it.
glTF to .cnj: the offline converter
CNA ships an offline converter at tools/gltf_to_cnj/. Its CMake target is cna_tool_gltf_to_cnj and it is built unconditionally, so a normal CNA build produces it. It is built on the vendored cgltf library and takes a real .gltf or .glb file:
gltf_to_cnj <input.gltf|input.glb> <outputDir> <baseName> [unitScale]
The optional fourth argument scales all positions, bone bind poses, and animated translation keyframes. glTF mandates meters, so a file authored in centimetres needs 0.01. For a file called hero.glb:
./cna_tool_gltf_to_cnj assets/hero.glb Content/models hero
That writes Content/models/hero.cnj, plus one hero_mesh<N>_verts.bin and hero_mesh<N>_idx.bin per mesh, any extracted textures, a hero.skeleton.bin if the file is skinned, and one standalone .cnj AnimationClip document per animation clip. The tool handles skeletons, skinning, and animation; it resamples glTF's per-component animation channels into CNA's own per-keyframe shape and supports LINEAR, STEP, and CUBICSPLINE interpolation.
A file containing more than one skin produces one Model .cnj per skin, named <baseName>_<skinName>.cnj.
The converter and runtime reader share the same alpha.1 import core. Both carry skeletal and rigid animation, morph targets, PBR maps/factors/state, vertex-coloured PBR, topology and structured diagnostics. Draco decoding is build-dependent: when the decoder is absent, a required Draco extension is refused rather than misread. Only nodes reachable from the selected scene become model content.
What a Model .cnj looks like
The descriptor is small. The geometry lives in the binary sidecars it names; the JSON just wires them together:
{
"cnjVersion": 2,
"type": "Model",
"meshes": [
{
"name": "Quad",
"vertices": "quad_verts.bin",
"indices": "quad_idx.bin",
"vertexStride": 32,
"effect": "BasicEffect"
}
]
}
A Model descriptor accepts the legacy version-1 shape and the version-2 hierarchy emitted by the alpha.1 converter; version 2 adds the bones graph and per-mesh parentBone. The type must match the C++ type you ask for. PBR layouts extend the older packed records: current model strides include 16, 20, 24, 32, 48, 52, 56, 60, 68, 76 and 80 bytes. Treat the recorded vertexStride as a format contract, not as sizeof() a C++ struct.
Loading directly from glTF
This is the shortest path into CNA and the one most people should start with. Drop a .gltf or .glb into your content root, point Load<Model> at its name, and CNA parses it at load time. There is no tooling step, no conversion, and no CMake option to switch on — the loader is built on a vendored cgltf 1.15 and is always compiled in.
// Content/models/hero.glb on disk, nothing else needed
Model hero = getContentProperty().Load<Model>("models/hero");
The runtime importer handles skeletal and rigid animation, morph targets, all three glTF interpolation modes (LINEAR, STEP, CUBICSPLINE), build-dependent Draco decoding, PBR material extensions, lights, texture transforms and other cases tracked by its source-owned extension registry.
The runtime path combines every mesh group into one Model; independent skins are exposed through getSkinsEXTProperty(). Two workflow needs can still send you to the offline converter:
- You want each mesh group emitted as a separate asset rather than combined in one runtime model.
- There is no
unitScaleequivalent at runtime, so a file not authored in metres needs the tool too.
Alpha.1 glTF boundaries are explicit. Rigid animation on an otherwise unskinned model is retained through ModelAnimationsEXT; factor-only and vertex-coloured PBR materials stay PBR; strip, fan and loop topologies are converted; and unsupported required extensions are rejected. A mixed file whose Tag is already occupied by SkinningData cannot also expose extra rigid clips there, so those tracks are omitted with a named diagnostic. Inspect Model::getGltfImportReportEXTProperty() after loading for this and other deliberate reductions.
ContentManager::Load<Model>
Note the shape of this call — it is not XNA's. Content is a property accessor, and Load<T> returns T by value, not a pointer:
// In LoadContent():
getContentProperty().setRootDirectoryProperty("Content");
Model model = getContentProperty().Load<Model>("models/hero");
Because Model comes back by value there is no pointer to check against nullptr and nothing to delete. The GPU resources behind it — vertex buffers, index buffers, effects, textures — are owned by a shared handle carried inside the Model, so copies of it stay valid. A failed load throws ContentLoadException; it does not return a null.
Model::Draw()
The convenience overload draws the whole model with its existing effect settings:
// In Draw():
model_.Draw(world, view, projection);
This is the only three-argument Draw in the model API. It walks every ModelMesh and every ModelMeshPart, pushes the three matrices onto each part's effect, and issues the draw calls.
ModelMesh::Draw() takes no arguments. It draws the mesh's parts using whatever their effects are already configured with. If you want a per-mesh world matrix, set it on the effects first, then call Draw(). There is no mesh.Draw(world, view, projection).
Walking meshes and parts
The collections are iterable, and they yield pointers — ModelMesh* from ModelMeshCollection, ModelMeshPart* from ModelMeshPartCollection. They also support integer and by-name indexing, and report their size through getCountProperty().
for (ModelMesh* mesh : model_.getMeshesProperty()) {
for (ModelMeshPart* part : mesh->getMeshPartsProperty()) {
auto* fx = dynamic_cast<BasicEffect*>(part->getEffectProperty());
if (fx != nullptr) {
fx->setWorldProperty(world);
fx->setViewProperty(view);
fx->setProjectionProperty(projection);
// setDiffuseColorProperty takes a Vector3 (RGB, 0..1) -- NOT a Color.
fx->setDiffuseColorProperty(Vector3(1.0f, 0.0f, 0.0f));
}
}
mesh->Draw(); // no arguments
}
Diffuse colour is a Vector3, not a Color. BasicEffect::setDiffuseColorProperty(const Vector3&) matches XNA, where BasicEffect.DiffuseColor is a Vector3 of linear 0..1 components with no alpha. Passing Color::Red will not compile. Alpha is a separate property.
The bone hierarchy
getBonesProperty() returns the model's bones; each ModelBone exposes getNameProperty(), getIndexProperty(), getTransformProperty(), getParentProperty(), and getChildrenProperty(). The transform stored on a bone is local to its parent. To flatten the hierarchy into world-relative matrices, use CopyAbsoluteBoneTransformsTo:
std::vector<Matrix> boneTransforms(
static_cast<std::size_t>(model_.getBonesProperty().getCountProperty()));
model_.CopyAbsoluteBoneTransformsTo(boneTransforms);
for (ModelMesh* mesh : model_.getMeshesProperty()) {
const ModelBone* parent = mesh->getParentBoneProperty();
const Matrix meshWorld = (parent != nullptr)
? boneTransforms[parent->getIndexProperty()] * overallWorld
: overallWorld;
for (ModelMeshPart* part : mesh->getMeshPartsProperty()) {
if (auto* fx = dynamic_cast<BasicEffect*>(part->getEffectProperty())) {
fx->setWorldProperty(meshWorld);
fx->setViewProperty(view);
fx->setProjectionProperty(projection);
}
}
mesh->Draw();
}
getParentBoneProperty() returns a ModelBone* that can be null — a model built without per-mesh parent bones leaves it that way — so check it before dereferencing. For a static model with a single root bone this whole loop collapses to using overallWorld directly.
The companion methods are CopyBoneTransformsTo (root-relative rather than absolute) and CopyBoneTransformsFrom, which writes a vector of matrices back into the model's bones — the hook for driving an animated skeleton.
Full example
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/BasicEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/Model.hpp"
#include "Microsoft/Xna/Framework/Graphics/ModelMesh.hpp"
#include "Microsoft/Xna/Framework/Graphics/ModelMeshPart.hpp"
#include "Microsoft/Xna/Framework/Graphics/ModelBone.hpp"
#include "Microsoft/Xna/Framework/MathHelper.hpp"
#include <vector>
using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
class ModelLoadGame final : public Game
{
public:
ModelLoadGame() : graphics_(this)
{
graphics_.setPreferredBackBufferWidthProperty(800);
graphics_.setPreferredBackBufferHeightProperty(600);
}
protected:
void LoadContent() override
{
getContentProperty().setRootDirectoryProperty("Content");
// Resolves models/hero.xnb, then models/hero.cnj, then models/hero.gltf/.glb.
// Throws ContentLoadException if none of them is there.
model_ = getContentProperty().Load<Model>("models/hero");
boneTransforms_.resize(
static_cast<std::size_t>(model_.getBonesProperty().getCountProperty()));
model_.CopyAbsoluteBoneTransformsTo(boneTransforms_);
for (ModelMesh* mesh : model_.getMeshesProperty()) {
for (ModelMeshPart* part : mesh->getMeshPartsProperty()) {
if (auto* fx = dynamic_cast<BasicEffect*>(part->getEffectProperty())) {
fx->EnableDefaultLighting();
}
}
}
}
void Update(GameTime& gameTime) override
{
// getTotalSecondsProperty() returns double.
const double dt = gameTime.getElapsedGameTimeProperty().getTotalSecondsProperty();
angle_ += static_cast<float>(dt) * 0.8f;
}
void Draw(const GameTime&) override
{
auto& gd = getGraphicsDeviceProperty();
gd.Clear(Color(40, 40, 60));
const Matrix world = Matrix::CreateRotationY(angle_);
const Matrix view = Matrix::CreateLookAt(
Vector3(0.0f, 3.0f, 8.0f), Vector3(0.0f, 1.0f, 0.0f), Vector3::Up);
const Matrix proj = Matrix::CreatePerspectiveFieldOfView(
MathHelper::PiOver4, 800.0f / 600.0f, 0.1f, 500.0f);
for (ModelMesh* mesh : model_.getMeshesProperty()) {
const ModelBone* parent = mesh->getParentBoneProperty();
const Matrix meshWorld = (parent != nullptr)
? boneTransforms_[parent->getIndexProperty()] * world
: world;
for (ModelMeshPart* part : mesh->getMeshPartsProperty()) {
if (auto* fx = dynamic_cast<BasicEffect*>(part->getEffectProperty())) {
fx->setWorldProperty(meshWorld);
fx->setViewProperty(view);
fx->setProjectionProperty(proj);
}
}
mesh->Draw();
}
gd.Present();
}
private:
GraphicsDeviceManager graphics_;
Model model_;
std::vector<Matrix> boneTransforms_;
float angle_ = 0.0f;
};
int main() { ModelLoadGame game; game.Run(); }
The whole Draw() body above is what model_.Draw(world, view, proj) does internally. Write it out only when you need per-mesh or per-part control.
API differences from XNA, in one place
| XNA (C#) | CNA (C++) |
|---|---|
getContentProperty().Load<Model>(name) → Model reference | getContentProperty().Load<Model>(name) → Model by value |
model.Meshes | model.getMeshesProperty(), yielding ModelMesh* |
model.Bones | model.getBonesProperty(), sized by getCountProperty() |
mesh.MeshParts | mesh->getMeshPartsProperty(), yielding ModelMeshPart* |
mesh.ParentBone.Index | mesh->getParentBoneProperty()->getIndexProperty() (may be null) |
part.Effect | part->getEffectProperty() → Effect* |
mesh.Draw() | mesh->Draw() — same, and still no arguments |
effect.DiffuseColor = ... (Vector3) | fx->setDiffuseColorProperty(Vector3) |
Next steps
- Tutorial 36: Texturing 3D Models — apply textures to a loaded mesh.
- Tutorial 34: 3D Camera — give the player control over the view.
- Tutorial 45: ContentManager — how asset resolution and caching work.