Metal Tutorial with Swift 3 Part 3: Adding Texture
In part 3 of our Metal tutorial series, you will learn how to add textures to 3D objects using Apple’s built-in 3D graphics framework. By Andrew Kharchyshyn.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Metal Tutorial with Swift 3 Part 3: Adding Texture
35 mins
- Getting Started
- Reusing Uniform Buffers (optional)
- The Problem
- The Solution
- A Wild Race Condition Appears!
- Like A Ninja
- Performance Results
- Texturing
- Texture Coordinates
- Using Textures in Metal
- MetalTexture
- Handling Texture on the GPU
- Colorizing a Texture (Optional)
- Adding User Input
- Debugging Metal
- Fixing Drawable Texture Resizing
- Where To Go From Here?
MetalTexture
Now that you understand how this will work, it’s time to bring this texture to life. Download and copy MetalTexture.swift to your project and open it.
There are two important methods in this file. The first is:
init(resourceName: String,ext: String, mipmaped:Bool)
Here you pass the name of the file and its extension, and you also indicate whether you want mipmaps
.
But wait, what’s a mipmap?
When mipmaped
is true
, it generates an array of images instead of a single image when the texture loads, and each image in the array is two times smaller than the previous one. The GPU automatically selects the best mip level from which to read texels.
The other method to note is this:
func loadTexture(device: MTLDevice, commandQ: MTLCommandQueue, flip: Bool)
This method is called when MetalTexture
actually creates MTLTexture
. To create this object, you need a device object (similar to the way you use buffers). Also, you pass in MTLCommandQueue
, which is used when mipmap levels are generated. Usually textures are loaded upside down, so this also has a flip
param to deal with that.
Okay — it’s time to put it all together.
Open Node.swift, and add two new variables:
var texture: MTLTexture
lazy var samplerState: MTLSamplerState? = Node.defaultSampler(device: self.device)
For now, Node
holds just one texture and one sampler.
Now add the following method to the end of the file:
class func defaultSampler(device: MTLDevice) -> MTLSamplerState {
let sampler = MTLSamplerDescriptor()
sampler.minFilter = MTLSamplerMinMagFilter.nearest
sampler.magFilter = MTLSamplerMinMagFilter.nearest
sampler.mipFilter = MTLSamplerMipFilter.nearest
sampler.maxAnisotropy = 1
sampler.sAddressMode = MTLSamplerAddressMode.clampToEdge
sampler.tAddressMode = MTLSamplerAddressMode.clampToEdge
sampler.rAddressMode = MTLSamplerAddressMode.clampToEdge
sampler.normalizedCoordinates = true
sampler.lodMinClamp = 0
sampler.lodMaxClamp = FLT_MAX
return device.makeSamplerState(descriptor: sampler)
}
This method generates a simple texture sampler that basically holds a bunch of flags. Here you’ve enabled “nearest-neighbor” filtering, which is faster than “linear”, as well as “clamp to edge”, which instructs Metal how to deal with out-of-range values. You won’t have out-of range values in this tutorial, but it’s always smart to code defensively.
Find the following code in render
:
renderEncoder.setRenderPipelineState(pipelineState)
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, at: 0)
And add this below it:
renderEncoder.setFragmentTexture(texture, at: 0)
if let samplerState = samplerState{
renderEncoder.setFragmentSamplerState(samplerState, at: 0)
}
This simply passes the texture and sampler to the shaders. It’s similar to what you did with vertex and uniform buffers, except that now you pass them to a fragment shader because you want to map texels to fragments.
Now you need to modify init
. Change its declaration so it matches this:
init(name: String, vertices: Array<Vertex>, device: MTLDevice, texture: MTLTexture) {
Now find this:
vertexCount = vertices.count
And add this just below it:
self.texture = texture
Each vertex needs to map to some coordinates on the texture. So open Vertex.swift and replace its contents with the following:
struct Vertex{
var x,y,z: Float // position data
var r,g,b,a: Float // color data
var s,t: Float // texture coordinates
func floatBuffer() -> [Float] {
return [x,y,z,r,g,b,a,s,t]
}
};
This adds two floats that hold texture coordinates.
Now open Cube.swift, and change init
so it looks like this:
init(device: MTLDevice, commandQ: MTLCommandQueue){
// 1
//Front
let A = Vertex(x: -1.0, y: 1.0, z: 1.0, r: 1.0, g: 0.0, b: 0.0, a: 1.0, s: 0.25, t: 0.25)
let B = Vertex(x: -1.0, y: -1.0, z: 1.0, r: 0.0, g: 1.0, b: 0.0, a: 1.0, s: 0.25, t: 0.50)
let C = Vertex(x: 1.0, y: -1.0, z: 1.0, r: 0.0, g: 0.0, b: 1.0, a: 1.0, s: 0.50, t: 0.50)
let D = Vertex(x: 1.0, y: 1.0, z: 1.0, r: 0.1, g: 0.6, b: 0.4, a: 1.0, s: 0.50, t: 0.25)
//Left
let E = Vertex(x: -1.0, y: 1.0, z: -1.0, r: 1.0, g: 0.0, b: 0.0, a: 1.0, s: 0.00, t: 0.25)
let F = Vertex(x: -1.0, y: -1.0, z: -1.0, r: 0.0, g: 1.0, b: 0.0, a: 1.0, s: 0.00, t: 0.50)
let G = Vertex(x: -1.0, y: -1.0, z: 1.0, r: 0.0, g: 0.0, b: 1.0, a: 1.0, s: 0.25, t: 0.50)
let H = Vertex(x: -1.0, y: 1.0, z: 1.0, r: 0.1, g: 0.6, b: 0.4, a: 1.0, s: 0.25, t: 0.25)
//Right
let I = Vertex(x: 1.0, y: 1.0, z: 1.0, r: 1.0, g: 0.0, b: 0.0, a: 1.0, s: 0.50, t: 0.25)
let J = Vertex(x: 1.0, y: -1.0, z: 1.0, r: 0.0, g: 1.0, b: 0.0, a: 1.0, s: 0.50, t: 0.50)
let K = Vertex(x: 1.0, y: -1.0, z: -1.0, r: 0.0, g: 0.0, b: 1.0, a: 1.0, s: 0.75, t: 0.50)
let L = Vertex(x: 1.0, y: 1.0, z: -1.0, r: 0.1, g: 0.6, b: 0.4, a: 1.0, s: 0.75, t: 0.25)
//Top
let M = Vertex(x: -1.0, y: 1.0, z: -1.0, r: 1.0, g: 0.0, b: 0.0, a: 1.0, s: 0.25, t: 0.00)
let N = Vertex(x: -1.0, y: 1.0, z: 1.0, r: 0.0, g: 1.0, b: 0.0, a: 1.0, s: 0.25, t: 0.25)
let O = Vertex(x: 1.0, y: 1.0, z: 1.0, r: 0.0, g: 0.0, b: 1.0, a: 1.0, s: 0.50, t: 0.25)
let P = Vertex(x: 1.0, y: 1.0, z: -1.0, r: 0.1, g: 0.6, b: 0.4, a: 1.0, s: 0.50, t: 0.00)
//Bot
let Q = Vertex(x: -1.0, y: -1.0, z: 1.0, r: 1.0, g: 0.0, b: 0.0, a: 1.0, s: 0.25, t: 0.50)
let R = Vertex(x: -1.0, y: -1.0, z: -1.0, r: 0.0, g: 1.0, b: 0.0, a: 1.0, s: 0.25, t: 0.75)
let S = Vertex(x: 1.0, y: -1.0, z: -1.0, r: 0.0, g: 0.0, b: 1.0, a: 1.0, s: 0.50, t: 0.75)
let T = Vertex(x: 1.0, y: -1.0, z: 1.0, r: 0.1, g: 0.6, b: 0.4, a: 1.0, s: 0.50, t: 0.50)
//Back
let U = Vertex(x: 1.0, y: 1.0, z: -1.0, r: 1.0, g: 0.0, b: 0.0, a: 1.0, s: 0.75, t: 0.25)
let V = Vertex(x: 1.0, y: -1.0, z: -1.0, r: 0.0, g: 1.0, b: 0.0, a: 1.0, s: 0.75, t: 0.50)
let W = Vertex(x: -1.0, y: -1.0, z: -1.0, r: 0.0, g: 0.0, b: 1.0, a: 1.0, s: 1.00, t: 0.50)
let X = Vertex(x: -1.0, y: 1.0, z: -1.0, r: 0.1, g: 0.6, b: 0.4, a: 1.0, s: 1.00, t: 0.25)
// 2
let verticesArray:Array<Vertex> = [
A,B,C ,A,C,D, //Front
E,F,G ,E,G,H, //Left
I,J,K ,I,K,L, //Right
M,N,O ,M,O,P, //Top
Q,R,S ,Q,S,T, //Bot
U,V,W ,U,W,X //Back
]
//3
let texture = MetalTexture(resourceName: "cube", ext: "png", mipmaped: true)
texture.loadTexture(device: device, commandQ: commandQ, flip: true)
super.init(name: "Cube", vertices: verticesArray, device: device, texture: texture.texture)
}
Taking each numbered comment in turn:
Note that you also need to create vertices for each side of the cube individually, rather than reusing vertices. This is because the texture coordinates might not match up correctly otherwise. It’s okay if the process of adding extra vertices is a little confusing at this stage — your brain will grasp it soon enough.
- As you create each vertex, you also specify the texture coordinate for each vertex. To understand this better, study the following image, and make sure you understand the s and t values of each vertex.
Note that you also need to create vertices for each side of the cube individually, rather than reusing vertices. This is because the texture coordinates might not match up correctly otherwise. It’s okay if the process of adding extra vertices is a little confusing at this stage — your brain will grasp it soon enough.
- Here you form triangles, just as you did in part two of this tutorial series.
- You create and load the texture using the
MetalTexture
helper class.
Since you aren’t drawing triangles anymore, delete Triangle.swift