Chapters

Hide chapters

Metal by Tutorials

Fifth Edition · macOS 26, iOS 26 · Swift 6, Metal 3 · Xcode 26

Section I: Beginning Metal

Section 1: 10 chapters
Show chapters Hide chapters

Section II: Intermediate Metal

Section 2: 8 chapters
Show chapters Hide chapters

Section III: Advanced Metal

Section 3: 8 chapters
Show chapters Hide chapters

8. Textures
Written by Marius Horga & Caroline Begbie

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

So far, you’ve learned how to use fragment functions and shaders to add colors and details to your models. Another option is to use image textures, which you’ll learn how to do in this chapter. More specifically, you’ll learn about:

  • UV coordinates: How to unwrap a mesh so that you can apply a texture to it.
  • Texturing a model: How to read the texture in a fragment shader.
  • Asset catalog: How to organize your textures.
  • Samplers: Different ways you can read (sample) a texture.
  • Mipmaps: Multiple levels of detail so that texture resolutions match the display size and take up less memory.

Textures and UV Maps

The following image shows a house model with twelve vertices. The wireframe is on the left (showing the vertices), and the textured model is on the right.

A low poly house
A low poly house

Note: If you want a closer look at this model, you’ll find the Blender and texture files in the resources/LowPolyHouse folder for this chapter.

To texture a model, you first have to flatten that model using a process known as UV unwrapping. UV unwrapping creates a UV map by unfolding the model. To unfold the model, you mark and cut seams using a modeling app. The following image shows the result of UV unwrapping the house model in Blender and exporting its UV map.

The house UV map
The house UV map

Notice that the roof and walls have marked seams. Seams are what make it possible for this model to lie flat. If you print and cut out this UV map, you can easily fold it back into a house. In Blender, you have complete control of the seams and how to cut up your mesh. Blender automatically unwraps the model by cutting the mesh at these seams. If necessary, you can also move vertices in the UV Unwrap window to suit your texture.

Now that you have a flattened map, you can “paint” onto it by using the UV map exported from Blender as a guide. The following image shows the house texture (made in Photoshop) that was created by cutting up a photo of a real house.

Low poly house color texture
Low poly house color texture

Note how the edges of the texture aren’t perfect, and the copyright message is visible. In the spaces where there are no vertices on the map, you can add whatever you want since it won’t show up on the model.

Note: It’s a good idea to not match the UV edges exactly, but instead to let the color bleed, as sometimes computers don’t accurately compute floating-point numbers.

You then import that image into Blender and assign it to the model to get the textured house that you saw above.

When you export a UV mapped model from Blender, Blender adds the UV coordinates to the file. Each vertex has a two-dimensional coordinate to place it on the 2D texture plane. The top-left is (0, 1) and the bottom-right is (1, 0).

The following diagram indicates some of the house vertices with some matching coordinates listed.

UV coordinates
UV coordinates

One of the advantages of mapping from 0 to 1 is that you can swap in lower or higher resolution textures. If you’re only viewing a model from a distance, you don’t need a highly detailed texture.

This house is easy to unwrap, but imagine how complex unwrapping curved surfaces might be. The following image shows a UV map of the train (which is still a simple model):

The train's UV map
The train's UV map

Photoshop, naturally, is not the only solution for texturing a model. You can use any image editor for painting on a flat texture. In the last few years, several other apps that allow painting directly on the model have become mainstream, such as:

  • Blender (free)
  • Procreate on iPad ($)
  • Substance Designer and Substance Painter by Adobe ($$): In Designer, you can create complex materials procedurally. Using Substance Painter, you can paint these materials on the model.
  • 3DCoat by 3Dcoat.com ($$)
  • Mari by Foundry ($$$)

In addition to texturing, using Blender, 3DCoat or Nomad Sculpt on iPad, you can sculpt models in a similar fashion to ZBrush and then remesh the high poly sculpt to create a low poly model. As you’ll find out later, color is not the only texture you can paint using these apps, so having a specialized texturing app is invaluable.

The Starter App

➤ Open the starter project for this chapter, and build and run the app.

The starter app
Swo whojlem ohk

1. Loading the Texture

A model typically has several submeshes that reference the same texture. Since you don’t want to repeatedly load this texture, you’ll create a central TextureController to hold your textures.

import MetalKit

enum TextureController {
  static var textures: [String: MTLTexture] = [:]
}
static func loadTexture(texture: MDLTexture, name: String) -> MTLTexture? {
  // 1
  if let texture = textures[name] {
    return texture
  }
  // 2
  let textureLoader = MTKTextureLoader(device: Renderer.device)
  // 3
  let textureLoaderOptions: [MTKTextureLoader.Option: Any] =
    [.origin: MTKTextureLoader.Origin.bottomLeft]
  // 4
  let texture = try? textureLoader.newTexture(
    texture: texture,
    options: textureLoaderOptions)
  print("loaded texture from USD file")
  // 5
  textures[name] = texture
  return texture
}

Loading the Submesh Texture

Each submesh of a model’s mesh has a different material characteristic, such as roughness, base color and metallic content. For now, you’ll focus only on the base color texture. In Chapter 11, “Maps & Materials”, you’ll look at some of the other characteristics. Conveniently, Model I/O loads a model complete with all the materials and textures. It’s your job to extract them from the loaded asset in a form that suits your engine.

asset.loadTextures()
struct Textures {
  var baseColor: MTLTexture?
}

var textures: Textures
// 1
private extension Submesh.Textures {
  init(material: MDLMaterial?) {
    baseColor = material?.texture(type: .baseColor)
  }
}

// 2
private extension MDLMaterialProperty {
  var textureName: String {
    stringValue ?? UUID().uuidString
  }
}

// 3
private extension MDLMaterial {
  func texture(type semantic: MDLMaterialSemantic) -> MTLTexture? {
    if let property = property(with: semantic),
    property.type == .texture,
    let mdlTexture = property.textureSamplerValue?.texture {
      return TextureController.loadTexture(
        texture: mdlTexture,
        name: property.textureName)
    }
    return nil
  }
}
textures = Textures(material: mdlSubmesh.material)
The render hasn't changed
Tge ponjad lurg'm pwiyxuf

2. Passing the Loaded Texture to the Fragment Function

In a later chapter, you’ll learn about several other texture map types and how to send them to the fragment function using different indices.

typedef enum {
  BaseColor = 0
} TextureIndices;
extension TextureIndices {
  var index: Int {
    return Int(self.rawValue)
  }
}
encoder.setFragmentTexture(
  submesh.textures.baseColor,
  index: BaseColor.index)

3. Updating the Fragment Function

➤ Open Fragment.metal, and add the following new argument to fragment_main, immediately after VertexOut in [[stage_in]],:

texture2d<float> baseColorTexture [[texture(BaseColor)]]
constexpr sampler textureSampler;
float3 baseColor = baseColorTexture.sample(
  textureSampler,
  in.uv).rgb;
return float4(baseColor, 1);
The textured house
Kya jorzenih taele

The Ground Plane

It’s time to add some ground to your scene. Instead of loading a USD model, you’ll create a ground plane using one of Model I/O’s primitive types, just as you did in the first chapters of this book.

lazy var ground: Model = {
  Model(name: "ground", primitiveType: .plane)
}()
ground.scale = 40
ground.rotation.z = Float(90).degreesToRadians
ground.rotation.y = sin(timer)
ground.render(
  encoder: renderEncoder,
  uniforms: uniforms,
  params: params)
The ground plane
Yha fzoulh mzuso

The Asset Catalog

When you write your full game, you’re likely to have many textures for the different models. If you use USD format models, the textures will generally be included. However, you may use different file formats that don’t hold textures, and organizing these textures can become labor-intensive. Plus, you’ll also want to compress images where you can send textures of varying sizes and color gamuts to different devices. The asset catalog is where you’ll turn.

The grass texture
Jka hjeyb veqxofa

static func loadTexture(name: String) -> MTLTexture? {
  // 1
  if let texture = textures[name] {
    return texture
  }
  // 2
  let textureLoader = MTKTextureLoader(device: Renderer.device)
  let texture: MTLTexture?
  texture = try? textureLoader.newTexture(
    name: name,
    scaleFactor: Renderer.scaleFactor,
    bundle: Bundle.main,
    options: nil)
  // 3
  if texture != nil {
    print("loaded texture: \(name)")
    textures[name] = texture
  }
  return texture
}
extension Model {
  func setTexture(name: String, type: TextureIndices) {
    if let texture = TextureController.loadTexture(name: name) {
      switch type {
      case BaseColor:
        meshes[0].submeshes[0].textures.baseColor = texture
      default: break
      }
    }
  }
}
lazy var ground: Model = {
  let ground = Model(name: "ground", primitiveType: .plane)
  ground.setTexture(name: "grass", type: BaseColor)
  return ground
}()
static var scaleFactor: CGFloat = 1
#if os(macOS)
  Self.scaleFactor = NSScreen.main?.backingScaleFactor ?? 1
#elseif os(iOS)
  Self.scaleFactor = metalView.traitCollection.displayScale
#endif
Dark grass texture
Jexp xjahx guswoju

sRGB Color Space

The rendered texture looks much darker than the original image because ground.png is an sRGB texture, and your view’s render target is not sRGB.

sRGBcolor = pow(linearColor, 1.0/2.2);
metalView.colorPixelFormat = .bgra8Unorm_srgb
View with sRGB color pixel format
Viuc sahs vYKB tided ronic hepbun

Capture GPU Workload

Before continuing with the texture conversion change, you’ll take a closer look at how the GPU sees your textures, and also examine all the other Metal buffers currently residing there. You’ll do this using the Capture GPU workload tool (also called the GPU Debugger).

GPU capture
WCO bavzonu

A GPU trace
U SQE wcedu

Resources on the GPU
Fupoebhun og zfu SGE

Texture info
Vawjoyu aknu

return TextureController.loadTexture(
  texture: mdlTexture,
  name: property.textureName)
var texture = TextureController.loadTexture(
  texture: mdlTexture,
  name: property.textureName)
if semantic == .baseColor,
  texture?.pixelFormat == .rgba8Unorm {
  texture = texture?.makeTextureView(pixelFormat: .rgba8Unorm_srgb)
  TextureController.textures[property.textureName] = texture
}
return texture
.textureUsage: MTLTextureUsage.pixelFormatView.rawValue
  | MTLTextureUsage.shaderRead.rawValue
Color space correction
Dejob grodi nafbepsiup

Samplers

When sampling your texture in the fragment function, you used a default sampler. By changing sampler parameters, you can decide how your app reads your texels.

constexpr sampler textureSampler(filter::linear);
A smoothed texture
U wreogcep bobnequ

Filtering
Zezverels

constexpr sampler textureSampler(
  filter::linear,
  address::repeat);
float3 baseColor = baseColorTexture.sample(
  textureSampler,
  in.uv * 16).rgb;
The sampler address mode
Wka podfmal ollkiss nodu

Texture tiling
Nemnedi titoqb

uint32_t tiling;
var tiling: UInt32 = 1
params.tiling = tiling
lazy var ground: Model = {
  let ground = Model(name: "ground", primitiveType: .plane)
  ground.setTexture(name: "grass", type: BaseColor)
  ground.tiling = 16
  return ground
}()
float3 baseColor = baseColorTexture.sample(
  textureSampler,
  in.uv * params.tiling).rgb;
Corrected tiling
Bacqavxif nugazp

A moiré example
E joipé ogesqfu

Mipmaps

Check out the relative sizes of the roof texture and how it appears on the screen.

Size of texture compared to on-screen viewing
Jidi oz kahsexa fowgeweg lu ut-crgeav joufipw

Mipmaps
Zihvayw

Mipmap comparison
Teyjaw liknubuhum

let textureLoaderOptions: [MTKTextureLoader.Option: Any] = [
  .origin: MTKTextureLoader.Origin.bottomLeft,
  .textureUsage: MTLTextureUsage.pixelFormatView.rawValue
    | MTLTextureUsage.shaderRead.rawValue,
  .generateMipmaps: true
]
mip_filter::linear
Mipmaps added
Loyzunm axyew

House mipmaps
Weero voxxips

Asset Catalog Attributes

Perhaps you were surprised, since you only changed the USD texture loading method, to see that the ground render improved. The ground is a primitive plane, and you load its texture from the asset catalog.

Texture attributes in the asset catalog
Jeycoha efhhavijoz og yho aqjev sapapar

Mipmap slots
Lamtac ngety

The Right Texture for the Right Job

Using asset catalogs gives you complete control over how to deliver your textures. Currently, you only have one color texture for the grass. However, if you’re supporting a wide variety of devices with different capabilities, you’ll likely want to have specific textures for each circumstance. On devices with less RAM, you’d want smaller graphics.

Custom textures in the asset catalog
Hiwger mefvorat if nti alzuw buvaqix

Anisotropy

Your rendered ground is looking a bit muddy and blurred in the background. This is due to anisotropy. Anisotropic surfaces change depending on the angle at which you view them, and when the GPU samples a texture projected at an oblique angle, it causes aliasing.

max_anisotropy(8)
Anisotropy
Evigosseqv

Challenge

In the resources folder for this chapter, you’ll find two textures:

Barn textures
Watp giwqeteg

Key Points

  • UVs, also known as texture coordinates, match vertices to the location in a texture.
  • During the modeling process, you flatten the model by marking seams. You can then paint on a texture that matches the flattened model map.
  • You can load textures from model files, the asset catalog, or with a bit of extra work, images held in the bundle.
  • A model may be split into submeshes that render with different materials. Each of these submeshes can reference one texture or multiple textures.
  • The fragment function reads from the texture using the model’s UV coordinates passed on from the vertex function.
  • The sRGB color space is the default color gamut. Modern Apple monitors and devices can extend their color space to P3 or wide color.
  • Capture GPU workload is a useful debugging tool. Use it regularly to inspect what’s happening on the GPU.
  • Mipmaps are resized textures that match the fragment sampling. If a fragment is a long way away, it will sample from a smaller mipmap texture.
  • Asset catalogs give you complete control of your textures without having to write cumbersome code. Customization for different devices is easy using the asset catalog.
  • Topics such as color and compression are huge. In the resources folder for this chapter, in references.markdown, you’ll find some recommended articles to read further.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2025 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now