Tutorial 36: Texturing 3D Models

3D Rendering  ·  Intermediate

ℹ

What you’ll learn

  • Assigning a Texture2D to a mesh through BasicEffect::Texture.
  • Handling meshes that need more than one texture.
  • What UV coordinates control, and choosing a SamplerState.
  • When mipmaps help.

Before you start — Tutorial 35: Loading 3D Models (a loaded model to texture) and Tutorial 08: Loading and Drawing Textures (loading the texture itself). Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the 2D-only renderer (SDL_RENDERER) throws on 3D calls by default.

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

Texture2D on Model Meshes

A .cnj Model descriptor can name a texture for each mesh entry — and gltf_to_cnj writes those entries out when it converts a glTF file. When ContentManager::Load<Model> processes the descriptor it automatically loads the referenced Texture2D assets and stores them on each ModelMeshPart's effect. A model loaded directly from glTF, or compiled to .cnb by cna-content, arrives with its textures wired up the same way (in a .cnb a model’s textures are external references: the texture assets must exist in the content root under the names the model records, which cna_tool_cnb_info file.cnb --refs lists, and cna-content’s generateChildAssets parameter publishes a glTF’s extracted textures as sibling assets).

If the model was exported without embedded texture references — or you want to override the texture at runtime — you access the effect directly. Load<Texture2D> returns a value, and setTextureProperty() stores a non-owning Texture2D*, so keep the texture in a member that outlives the model’s use:

// crateTexture_ is a Texture2D member: Texture2D crateTexture_ = ...Load<Texture2D>("textures/crate");
for (ModelMesh* mesh : model.getMeshesProperty()) {
    for (ModelMeshPart* part : mesh->getMeshPartsProperty()) {
        auto* be = dynamic_cast<BasicEffect*>(part->getEffectProperty());
        if (be != nullptr) {
            be->setTextureEnabledProperty(true);
            be->setTextureProperty(&crateTexture_);
        }
    }
}

Applying Texture via BasicEffect::Texture

Two properties must be set together:

  • setTextureEnabledProperty(true) — tells the effect to sample from the texture stage.
  • setTextureProperty(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 multiplies the per-vertex colour into the result (in the stock shaders the texture sample is multiplied by the vertex colour and the diffuse colour), so it must match your vertex data. VertexPositionNormalTexture carries no colour element, so set VertexColorEnabled = false (it is false by default) when you texture geometry built from it. Turning both on is legitimate only for a vertex type that has a colour element, where the vertex colours then tint the texture.

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:

ModelMesh* car = model.getMeshesProperty()[0];
car->getMeshPartsProperty()[0]->setEffectProperty(bodyEffect);   // painted metal
car->getMeshPartsProperty()[1]->setEffectProperty(glassEffect);  // transparent glass
car->getMeshPartsProperty()[2]->setEffectProperty(tyreEffect);   // rubber tread

Each effect holds its own Texture2D reference and state, so they render independently on each draw call. setEffectProperty() takes a raw Effect*, so bodyEffect and friends must stay alive for as long as the model draws with them.

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.getSamplerStatesProperty()[0] = SamplerState::LinearClamp;

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

// Bilinear + wrap — use for tiling terrain/floor textures
gd.getSamplerStatesProperty()[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.

A loose PNG, JPEG or similar file is decoded to a single mip level, and there is no GenerateMipMaps() call on Texture2D. A mip chain comes from one of three places:

  • Content built ahead of time. Give cna-content the texture processor parameter generateMipmaps (true) in your .cna-content.json and the .cnb (or .xnb) it writes carries the full chain; Tutorial 145 shows the file. When the output is .xnb, the XNA Reach profile additionally requires mipmapped sizes to be powers of two; a .cnb has no target profile and no such limit.
  • A texture you create. Texture2D(device, width, height, /*mipMap*/ true, SurfaceFormat::Color) allocates the chain, and SetData takes a mip level argument.
  • Cube maps load their levels from a .dds that already contains them.
// Filtering is chosen by the sampler: the Linear presets use TextureFilter::Linear,
// which is XNA's trilinear mode (linear within a level and between levels).
gd.getSamplerStatesProperty()[0] = SamplerState::LinearWrap;

There is no separate “trilinear” preset name: SamplerState::LinearClamp and LinearWrap already filter between mip levels, and TextureFilter::LinearMipPoint and its relatives give finer control when you build your own SamplerState.

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/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_.setPreferredBackBufferWidthProperty(800);
        graphics_.setPreferredBackBufferHeightProperty(600);
    }

protected:
    void LoadContent() override {
        effect_ = std::make_unique<BasicEffect>(getGraphicsDeviceProperty());
        effect_->VertexColorEnabled = false;
        effect_->setTextureEnabledProperty(true);
        effect_->setLightingEnabledProperty(true);
        effect_->EnableDefaultLighting();

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

        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_) ? &tex1_ : &tex0_;
        }
        prevT_ = kb.IsKeyDown(Keys::T);

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

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

        // Smooth filtering
        gd.getSamplerStatesProperty()[0] = SamplerState::LinearClamp;

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

        effect_->setWorldProperty(Matrix::getIdentityProperty());
        effect_->setViewProperty(Matrix::CreateLookAt(
            Vector3(camX, 1.5f, camZ), Vector3::Zero, Vector3::Up));
        effect_->setProjectionProperty(Matrix::CreatePerspectiveFieldOfView(
            MathHelper::PiOver4, 800.0f / 600.0f, 0.1f, 100.0f));
        effect_->setTextureProperty(activeTexture_);

        gd.SetVertexBuffer(vb_.get());
        gd.SetIndexBuffer(ib_.get());

        for (auto& pass : effect_->getCurrentTechniqueProperty()->getPassesProperty()) {
            pass.Apply();
            gd.DrawIndexedPrimitives(PrimitiveType::TriangleList,
                0, 0, 24, 0, 12);  // 12 triangles = 6 faces * 2
        }
        // No gd.Present(): Game presents in EndDraw, after Draw() returns.
    }

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::getVertexDeclarationStatic(), 24, BufferUsage::WriteOnly);
        vb_->SetData(verts, 24);

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

    GraphicsDeviceManager graphics_;
    std::unique_ptr<BasicEffect>  effect_;
    std::unique_ptr<VertexBuffer> vb_;
    std::unique_ptr<IndexBuffer>  ib_;
    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 VertexColorEnabled = false and setTextureEnabledProperty(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.
  • Loose PNG/JPEG textures have one mip level; build a mip chain with cna-content's generateMipmaps parameter for textures viewed at varying distances.