Tutorial 35: Loading 3D Models

3D Rendering  ·  Intermediate

CNA's Model class mirrors XNA's Microsoft.Xna.Framework.Graphics.Model. It encapsulates geometry, materials, and a bone hierarchy. You load it through ContentManager and draw it with a single call — or iterate its mesh parts to apply custom effects.

The Model Class

Model is a high-level asset that bundles everything needed to render a 3D object:

  • Meshes — a std::vector<ModelMesh> representing logical groupings of geometry.
  • Bones — a hierarchy of named transforms used for skeletal animation or sub-object grouping.
  • Effects — each ModelMeshPart has its own Effect, typically a BasicEffect, loaded from the asset.

The Model object is owned by ContentManager — do not delete it manually.

.model.json Descriptor Format

CNA's content pipeline expects a JSON descriptor alongside the binary geometry data. A minimal descriptor looks like this:

// Content/models/crate.model.json
{
  "meshes": [
    {
      "name": "Crate",
      "parentBone": 0,
      "parts": [
        {
          "vertexBuffer": "crate.vb",
          "indexBuffer":  "crate.ib",
          "primitiveType": "TriangleList",
          "primitiveCount": 12,
          "vertexOffset": 0,
          "startIndex":   0,
          "vertexStride": 32,
          "diffuseColor":  [1.0, 1.0, 1.0, 1.0],
          "textureFile":   "textures/crate.png"
        }
      ]
    }
  ],
  "bones": [
    { "name": "Root", "parent": -1, "transform": [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1] }
  ]
}

The .vb and .ib files are binary vertex and index buffers written by the CNA asset converter. See the Model Loading reference for the full schema.

💡

Content pipeline. CNA ships a converter in tools/cna-convert that takes OBJ or glTF files and produces .model.json + binary sidecar files. Run it as part of your build or asset pipeline before calling Load<Model>.

ContentManager::Load<Model>

Loading a model is identical to loading a texture — pass the asset name (no extension) to Content.Load.

// In LoadContent():
model_ = Content.Load<Model>("models/crate");
// Loads Content/models/crate.model.json (and its sidecar files)

ContentManager caches loaded assets. Calling Load<Model> twice with the same name returns the same pointer — no double-loading.

Model::Draw()

The convenience overload draws the entire model in one call using its default effects:

// In Draw():
model_->Draw(world, view, projection);

Internally this iterates every ModelMesh, every ModelMeshPart, sets the three matrices on the part's Effect, calls Apply(), and issues DrawIndexedPrimitives. It is equivalent to the manual loop shown below.

ModelMesh and ModelMeshPart

For custom rendering — different effects per mesh, custom per-part uniforms — iterate manually:

for (auto& mesh : model_->Meshes) {
    for (auto& part : mesh.MeshParts) {
        // Cast to BasicEffect (or your custom effect)
        auto* fx = dynamic_cast<BasicEffect*>(part.Effect.get());
        if (fx) {
            fx->setWorld(world);
            fx->setView(view);
            fx->setProjection(projection);
            fx->setDiffuseColor(Color::Red); // override per-part color
        }
        for (auto& pass : part.Effect->getCurrentTechnique().Passes) {
            pass.Apply();
            gd.DrawIndexedPrimitives(
                part.PrimitiveType,
                part.BaseVertex,
                0,
                part.NumVertices,
                part.StartIndex,
                part.PrimitiveCount);
        }
    }
}

Bones Array

The Model::Bones collection maps bone names to indices and stores the local transform of each bone. To get the final world-space transform accounting for the full hierarchy, use CopyAbsoluteBoneTransformsTo:

std::vector<Matrix> boneTransforms(model_->Bones.size());
model_->CopyAbsoluteBoneTransformsTo(boneTransforms);

for (auto& mesh : model_->Meshes) {
    Matrix meshWorld = boneTransforms[mesh.ParentBone.Index] * overallWorld;
    mesh.Draw(meshWorld, view, projection);
}

For static models with a single root bone this simplifies to just using boneTransforms[0] as the world matrix.

Per-mesh Transforms

To move, rotate, or scale the whole model, multiply each bone's absolute transform by your desired world matrix:

// Helper: set World on every BasicEffect in the model
static void ApplyTransformToModel(Model& model,
                                  const std::vector<Matrix>& bones,
                                  const Matrix& world,
                                  const Matrix& view,
                                  const Matrix& proj)
{
    for (auto& mesh : model.Meshes) {
        Matrix meshWorld = bones[mesh.ParentBone.Index] * world;
        for (auto& part : mesh.MeshParts) {
            auto* fx = dynamic_cast<BasicEffect*>(part.Effect.get());
            if (fx) {
                fx->setWorld(meshWorld);
                fx->setView(view);
                fx->setProjection(proj);
            }
        }
        mesh.Draw();  // effects already configured above
    }
}

Full Model Loading Demo

#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/BasicEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/Model.hpp"
#include "Microsoft/Xna/Framework/MathHelper.hpp"
#include <stdexcept>

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;

class ModelLoadGame final : public Game {
public:
    ModelLoadGame() : graphics_(this) {
        graphics_.setPreferredBackBufferWidth(800);
        graphics_.setPreferredBackBufferHeight(600);
        Content.setRootDirectory("Content");
    }

protected:
    void LoadContent() override {
        // Try to load the model; fall back to a hard-coded placeholder
        try {
            model_ = Content.Load<Model>("models/crate");
        } catch (const std::exception& e) {
            // If the asset is missing during development, continue without it.
            // You could instead draw a debug cube using VertexBuffer directly.
            (void)e;
            model_ = nullptr;
        }

        // Bone transforms buffer
        if (model_) {
            boneTransforms_.resize(model_->Bones.size());
            model_->CopyAbsoluteBoneTransformsTo(boneTransforms_);
        }
    }

    void Update(GameTime& gameTime) override {
        float dt = (float)gameTime.getElapsedGameTime().TotalSeconds();
        angle_ += dt * 0.8f;  // slow spin
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color(40, 40, 60));

        Matrix world = Matrix::CreateRotationY(angle_);
        Matrix view  = Matrix::CreateLookAt(
            Vector3(0, 3, 8), Vector3(0, 1, 0), Vector3::Up);
        Matrix proj  = Matrix::CreatePerspectiveFieldOfView(
            MathHelper::PiOver4, 800.0f / 600.0f, 0.1f, 500.0f);

        if (model_) {
            // Override each mesh part's BasicEffect with updated matrices
            for (auto& mesh : model_->Meshes) {
                Matrix meshWorld = boneTransforms_[mesh.ParentBone.Index] * world;
                for (auto& part : mesh.MeshParts) {
                    auto* fx = dynamic_cast<BasicEffect*>(part.Effect.get());
                    if (fx) {
                        fx->setWorld(meshWorld);
                        fx->setView(view);
                        fx->setProjection(proj);
                        fx->setLightingEnabled(true);
                        fx->EnableDefaultLighting();
                    }
                }
                mesh.Draw();
            }
        }

        gd.Present();
    }

private:
    GraphicsDeviceManager          graphics_;
    Model*                         model_          = nullptr;
    std::vector<Matrix>            boneTransforms_;
    float                          angle_          = 0.0f;
};

int main() { ModelLoadGame game; game.Run(); }

Next Steps