Textures

This tutorial builds on the triangle example to show how to render a textured quad using Metal and Metal-C++. It covers texture sampling, UV mapping, and image loading with stb_image.

What Are Textures?

Textures are 2D images applied onto 3D objects to add realistic color, patterns, or surface detail without increasing geometry complexity. Think of textures as “skins” that wrap around your models.

They let you reuse image data efficiently by mapping 2D UV coordinates onto geometry vertices.

  • U and V represent horizontal and vertical axes, normalized from 0.0 to 1.0.
  • These UVs flow from the vertex shader to the fragment shader, where the GPU samples colors from the texture.
flowchart LR
    A["Image (texture.png)"] --> B[Upload to GPU Texture]
    B --> C{Vertex Shader}
    C --> D1[UVs]
    C --> D2[Position]
    C --> D3[Color]
    D1 --> E{Fragment Shader}
    D2 --> E{Fragment Shader}
    D3 --> E{Fragment Shader}
    E --> F[Sample Color from Texture]
    F --> G[Draw Textured Quad]

Textures live in GPU memory as MTL::Texture objects in efficient formats.

Sampling means retrieving color data from a texture at given UV coordinates inside the fragment shader.

Defining the Vertex Structure and Quad

We’ll define a quad using 4 vertices, each holding:

  • 3D position (X, Y, Z)
  • 2D texture coordinate (U, V)
struct Vertex {
    simd::float3 position;
    simd::float2 uv;
};

Define the quad with a triangle strip (2 triangles, 4 vertices):

Vertex quadVertices[] = {
    {{-0.5f, -0.5f, 0.0f}, {0.0f, 1.0f}},  // Bottom-left
    {{ 0.5f, -0.5f, 0.0f}, {1.0f, 1.0f}},  // Bottom-right
    {{-0.5f,  0.5f, 0.0f}, {0.0f, 0.0f}},  // Top-left
    {{ 0.5f,  0.5f, 0.0f}, {1.0f, 0.0f}},  // Top-right
};

UV (0,0) corresponds to the top-left corner of the texture here, which matches stb_image’s default image orientation. Adjust if your images differ.

Loading a Texture with stb_image

stb_image is a lightweight, popular single-header library for loading images in many formats.

Here’s a function that loads an image file and uploads it into a Metal texture:

MTL::Texture* LoadTexture(MTL::Device* device, const char* filepath) {
    int width, height, channels;
    stbi_uc* pixels = stbi_load(filepath, &width, &height, &channels, STBI_rgb_alpha);
    if (!pixels) return nullptr;

    auto desc = MTL::TextureDescriptor::texture2DDescriptor(
        MTL::PixelFormatRGBA8Unorm, width, height, false);
    desc->setStorageMode(MTL::StorageModeManaged); // Managed for CPU-GPU sync on macOS
    desc->setUsage(MTL::TextureUsageShaderRead);

    MTL::Texture* texture = device->newTexture(desc);
    texture->replaceRegion(MTL::Region(0, 0, width, height), 0, pixels, width * 4);

    stbi_image_free(pixels);
    return texture;
}

Shader Code

Vertex Shader

Attributes map explicitly:

  • [[attribute(0)]] → position
  • [[attribute(1)]] → uv
#include <metal_stdlib>
using namespace metal;

struct VertexIn {
    float3 position [[attribute(0)]];
    float2 uv       [[attribute(1)]];
};

struct VertexOut {
    float4 position [[position]];
    float2 uv;
};

vertex VertexOut vertexShader(uint vid [[vertex_id]], const device VertexIn* in [[buffer(0)]]) {
    VertexOut out;
    out.position = float4(in[vid].position, 1.0);
    out.uv = in[vid].uv;
    return out;
}

Fragment Shader

The sampler controls filtering — here we use linear filtering to smooth texture sampling when scaling:

fragment float4 fragmentShader(VertexOut in [[stage_in]],
                               texture2d<float> tex [[texture(0)]]) {
    constexpr sampler textureSampler(mag_filter::linear, min_filter::linear);
    return tex.sample(textureSampler, in.uv);
}

Creating and Using the Pipeline

  • Match your pipeline’s vertex descriptor to the new Vertex layout (position + uv).
  • Bind your vertex and fragment shaders correctly.
  • Set the texture in the fragment shader with setFragmentTexture(texture, 0) before drawing.

Rendering the Textured Quad

In your render loop:

encoder->setRenderPipelineState(renderPipeline);
encoder->setVertexBuffer(quadVertexBuffer, 0, 0);
encoder->setFragmentTexture(texture, 0);
encoder->drawPrimitives(MTL::PrimitiveTypeTriangleStrip, 0, 4);
  • Triangle strip with 4 vertices draws 2 triangles forming the quad.
  • No index buffer required here.

Troubleshooting Tips

  • If the texture appears flipped vertically, double-check your UV coordinates and image loading orientation.
  • A missing texture often means loading failed—verify file paths and formats.

Build And Run

Build (Cmd+B) and Run (Cmd+R) the project. You should see your texture rendered onto a quad centered on the screen. Experiment by tweaking UVs for flipping or tiling effects, or try modifying the fragment shader for creative effects.

Textures

Download The Project Files

If you encounter issues with the code or simply want to test with the correct setup, you can download the latest project files below:

Download Project Files


Copyright © 2025 Gabriele Vierti. Distributed by an MIT license.