Tutorial 36: Texturing 3D Models

3D Rendering  ·  Intermediate

In the previous tutorial we loaded a 3D model from a .model.json descriptor. Here we go further and apply textures to model meshes, control filtering with SamplerState, and swap textures at runtime.

Texture2D on Model Meshes

A .model.json descriptor can name texture files for each mesh part. When ContentManager::Load<Model> processes the file it automatically loads the referenced Texture2D assets and stores them on each ModelMeshPart's effect.

If the model was exported without embedded texture references — or you want to override the texture at runtime — you access the effect directly:

for (auto& mesh : model->Meshes) {
    for (auto& part : mesh.MeshParts) {
        auto* be = dynamic_cast<BasicEffect*>(part.Effect.get());
        if (be) {
            be->setTextureEnabled(true);
            be->setTexture(crateTexture);
        }
    }
}

Applying Texture via BasicEffect::Texture

Two properties must be set together:

  • setTextureEnabled(true) — tells the effect to sample from the texture stage.
  • setTexture(tex) — supplies the Texture2D to use.

The vertex data must include UV coordinates. Use VertexPositionNormalTexture instead of VertexPositionColor for textured geometry — it carries a Vector2 TextureCoordinate per vertex.

Important: VertexColorEnabled and TextureEnabled are mutually exclusive on BasicEffect. Always call setVertexColorEnabled(false) before enabling texturing, otherwise the effect may produce unexpected results.

Multiple Textures per Mesh

Each ModelMeshPart has its own Effect pointer, so different parts of the same mesh can use different textures. This is how a single car model can have separate materials for the body paint, glass, and tyres:

model->Meshes[0].MeshParts[0].Effect = bodyEffect;   // painted metal
model->Meshes[0].MeshParts[1].Effect = glassEffect;  // transparent glass
model->Meshes[0].MeshParts[2].Effect = tyreEffect;   // rubber tread

Each effect holds its own Texture2D reference and state, so they render independently on each draw call.

UV Coordinates

UV (sometimes called ST) coordinates are a 2D value stored per vertex that tells the GPU how to map the texture image onto the surface:

  • U = horizontal axis, V = vertical axis.
  • (0,0) is the top-left of the texture, (1,1) is the bottom-right under the XNA convention.
  • Values outside [0,1] are controlled by SamplerState: Clamp pins them to the edge colour; Wrap tiles the texture; Mirror alternates the tile direction.
VertexPositionNormalTexture v;
v.Position        = Vector3(0.5f, 0.5f, 0.0f);
v.Normal          = Vector3(0.0f, 0.0f, 1.0f);
v.TextureCoordinate = Vector2(1.0f, 0.0f);  // top-right of texture

SamplerState for Filtering

Set the sampler state on the graphics device before drawing textured geometry:

// Smooth bilinear filtering, clamp to edge (no tiling)
gd.SamplerStates[0] = SamplerState::LinearClamp;

// Nearest-neighbour — pixelated retro look
gd.SamplerStates[0] = SamplerState::PointClamp;

// Bilinear + wrap — use for tiling terrain/floor textures
gd.SamplerStates[0] = SamplerState::LinearWrap;

The index [0] refers to texture unit 0 (the first sampler slot). If a shader samples from additional texture units, set SamplerStates[1], [2], etc.

Mipmaps

Mipmaps are a pre-computed chain of progressively halved copies of a texture (full size, half, quarter, …). The GPU selects the appropriate mip level based on how many pixels on screen the texture covers, eliminating the shimmering aliasing that appears when a large texture is viewed from far away.

ContentManager loads mipmaps automatically when the source asset is a KTX or DDS file that already contains the mip chain. For PNG/JPEG assets without mipmaps you can generate them manually:

auto tex = content.Load<Texture2D>("textures/crate");
tex->GenerateMipMaps(TextureFilter::Linear);

Use SamplerState::LinearMipLinear (trilinear filtering) to blend smoothly between mip levels.

Full Example: Textured Cube with Runtime Texture Swap

This example builds a UV-mapped cube from VertexPositionNormalTexture vertices, loads two crate textures, and lets you press T to toggle between them at runtime. An orbit camera circles the cube automatically.

#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/VertexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/IndexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexPositionNormalTexture.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"
#include "Microsoft/Xna/Framework/Graphics/SamplerState.hpp"
#include "Microsoft/Xna/Framework/Content/ContentManager.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"

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

class TexturedCubeGame final : public Game {
public:
    TexturedCubeGame() : graphics_(this) {
        graphics_.setPreferredBackBufferWidth(800);
        graphics_.setPreferredBackBufferHeight(600);
    }

protected:
    void LoadContent() override {
        effect_ = std::make_unique<BasicEffect>(getGraphicsDeviceProperty());
        effect_->setVertexColorEnabled(false);
        effect_->setTextureEnabled(true);
        effect_->setLightingEnabled(true);
        effect_->EnableDefaultLighting();

        // Load two textures to demonstrate runtime swap
        tex0_ = content_.Load<Texture2D>("textures/crate");
        tex1_ = content_.Load<Texture2D>("textures/crate_alt");
        activeTexture_ = tex0_.get();

        BuildCube();
    }

    void Update(GameTime& gt) override {
        auto kb = Keyboard::GetState();
        if (kb.IsKeyDown(Keys::Escape)) Exit();

        // Press T to swap texture
        if (kb.IsKeyDown(Keys::T) && !prevT_) {
            activeTexture_ = (activeTexture_ == tex0_.get()) ? tex1_.get() : tex0_.get();
        }
        prevT_ = kb.IsKeyDown(Keys::T);

        // Orbit the camera
        angle_ += static_cast<float>(gt.ElapsedGameTime.TotalSeconds()) * 0.5f;
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color::CornflowerBlue);

        // Smooth filtering
        gd.SamplerStates[0] = SamplerState::LinearClamp;

        float camX = std::cos(angle_) * 3.0f;
        float camZ = std::sin(angle_) * 3.0f;

        effect_->setWorld(Matrix::Identity);
        effect_->setView(Matrix::CreateLookAt(
            Vector3(camX, 1.5f, camZ), Vector3::Zero, Vector3::Up));
        effect_->setProjection(Matrix::CreatePerspectiveFieldOfView(
            MathHelper::PiOver4, 800.0f / 600.0f, 0.1f, 100.0f));
        effect_->setTexture(*activeTexture_);

        gd.setVertexBuffer(*vb_);
        gd.setIndexBuffer(*ib_);

        for (auto& pass : effect_->getCurrentTechnique().Passes) {
            pass.Apply();
            gd.DrawIndexedPrimitives(PrimitiveType::TriangleList,
                0, 0, 24, 0, 12);  // 12 triangles = 6 faces * 2
        }
        gd.Present();
    }

private:
    void BuildCube() {
        // 6 faces, 4 vertices each = 24 vertices
        // Each face has its own normal and UV-mapped quad
        using V = VertexPositionNormalTexture;
        V verts[24];
        uint16_t idx[36];

        auto face = [&](int f, Vector3 n, Vector3 up, Vector3 right) {
            Vector3 centre = n * 0.5f;
            verts[f*4+0] = { centre - right*0.5f + up*0.5f, n, {0,0} };
            verts[f*4+1] = { centre + right*0.5f + up*0.5f, n, {1,0} };
            verts[f*4+2] = { centre + right*0.5f - up*0.5f, n, {1,1} };
            verts[f*4+3] = { centre - right*0.5f - up*0.5f, n, {0,1} };
            int b = f * 6, v = f * 4;
            idx[b+0]=v; idx[b+1]=v+1; idx[b+2]=v+2;
            idx[b+3]=v; idx[b+4]=v+2; idx[b+5]=v+3;
        };

        face(0,  Vector3::Forward,  Vector3::Up,    Vector3::Right);  // front
        face(1,  Vector3::Backward, Vector3::Up,    Vector3::Left);   // back
        face(2,  Vector3::Left,     Vector3::Up,    Vector3::Forward);// left
        face(3,  Vector3::Right,    Vector3::Up,    Vector3::Backward);// right
        face(4,  Vector3::Up,       Vector3::Backward, Vector3::Right);// top
        face(5,  Vector3::Down,     Vector3::Forward,  Vector3::Right);// bottom

        auto& gd = getGraphicsDeviceProperty();
        vb_ = std::make_unique<VertexBuffer>(gd,
            VertexPositionNormalTexture::VertexDeclaration, 24, BufferUsage::WriteOnly);
        vb_->SetData(verts, 24);

        ib_ = std::make_unique<IndexBuffer>(gd,
            IndexElementSize::SixteenBits, 36, BufferUsage::WriteOnly);
        ib_->SetData(idx, 36);
    }

    GraphicsDeviceManager graphics_;
    Content::ContentManager content_{"Content"};
    std::unique_ptr<BasicEffect>  effect_;
    std::unique_ptr<VertexBuffer> vb_;
    std::unique_ptr<IndexBuffer>  ib_;
    std::shared_ptr<Texture2D> tex0_, tex1_;
    Texture2D* activeTexture_ = nullptr;
    float angle_ = 0.0f;
    bool  prevT_ = false;
};

int main() { TexturedCubeGame g; g.Run(); }

Key Points

  • Use VertexPositionNormalTexture (not VertexPositionColor) for textured meshes.
  • Call setVertexColorEnabled(false) and setTextureEnabled(true) together.
  • Set SamplerStates[0] before drawing to choose filtering mode.
  • Each ModelMeshPart owns its effect independently — iterate all parts to apply a uniform texture override.
  • Call GenerateMipMaps() for PNG/JPEG textures that will be viewed at varying distances.