Tutorial 35: Loading 3D Models

3D Rendering  ·  Intermediate

What you’ll learn

  • What the Model class holds, and how a model gets into your project.
  • Converting glTF to .cnj offline, and loading glTF directly.
  • Model::Draw(), walking meshes and mesh parts, and the bone hierarchy.
  • Where CNA's Model API deliberately differs from XNA's.

Before you startTutorial 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 ModelMeshCollection of logical geometry groupings, reached with getMeshesProperty().
  • Bones — a ModelBoneCollection of named transforms, reached with getBonesProperty(). The root is getRootProperty().
  • Effects — each ModelMeshPart carries its own Effect*, typically a BasicEffect, 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.

ExtensionWhat it isProduced by
.xnbAn authentic compiled XNA asset. Checked first, ahead of everything else — if one is present it wins.The original XNA Content Pipeline.
.cnjCNA'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 / .glbA 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 unitScale equivalent 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 pointersModelMesh* 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 referencegetContentProperty().Load<Model>(name)Model by value
model.Meshesmodel.getMeshesProperty(), yielding ModelMesh*
model.Bonesmodel.getBonesProperty(), sized by getCountProperty()
mesh.MeshPartsmesh->getMeshPartsProperty(), yielding ModelMeshPart*
mesh.ParentBone.Indexmesh->getParentBoneProperty()->getIndexProperty() (may be null)
part.Effectpart->getEffectProperty()Effect*
mesh.Draw()mesh->Draw() — same, and still no arguments
effect.DiffuseColor = ... (Vector3)fx->setDiffuseColorProperty(Vector3)

Next steps