Metal 练习:第三篇-添加Texture
此篇练习是基于前一篇 Metal 练习:第二篇-3D 的拓展
此篇练习完成后,将会学到如何给立方体添加Texture,过程中还会学到:
- 如何重用 uniform buffers
- 如何给3D模型使用Texture
- 如何给应用添加触控输入
- 如何调试Metal
第一步
打开Metal 练习:第二篇-3D实现的工程,虽然前面已经对
ViewController
类作了重构,但仍然分工不是很明确,在此在其拆分为两个类:
MetalViewController
:包含通用的Metal设置代码的基类ViewController
:包含创建和渲染模型代码特定用于当前应用的子类
拆分之后这里新增加了一个协议MetalViewControllerDelegate
,让ViewController
作为代理并实现方法,这是就是渲染和更新要处理的地方
protocol MetalViewControllerDelegate: class {
func updateLogic(timeSinceLastUpdate: CFTimeInterval)
func renderObjects(drawable: CAMetalDrawable)
}
重用 uniform buffers
问题
在上一篇练习中,知道如何给每一帧分配新的uniform buffers,但这样做的效率不好。主要是我们的帧率是60FPS,也就就
Node.swift
中的render()
方法每秒会调用60次,其中uniformBuffer
就会创建多少次,如下图可见相关数据
方案
用一个缓存池来代替每次分配一个缓存,为代码耦合度低,可以封装所有创建和重用的逻辑为一个类
BufferProvider
,负责创建一个缓存池,同时提供一个方法去获取下一个可重用的buffer,此类的功能如下图
// BufferProvider 类添加如下代码
import Metal
class BufferProvider {
// 1. 缓存池存放buffer的数量
let inflightBuffersCount: Int
// 2. 用来存储自己的buffers
private var uniformsBuffers: [MTLBuffer]
// 3. 下一个可用的buffer的index
private var avaliableBufferIndex: Int = 0
init(device: MTLDevice, inflightBuffersCount: Int, sizeOfUniformsBuffer: Int) {
self.inflightBuffersCount = inflightBuffersCount
uniformsBuffers = [MTLBuffer]()
for _ in 0 ... inflightBuffersCount - 1 {
let uniformsBuffer = device.makeBuffer(length: sizeOfUniformsBuffer, options: [])!
uniformsBuffers.append(uniformsBuffer)
}
}
// 获取下一个可用的buffer
func nextUniformsBuffer(projectionMatrix: Matrix4, modelViewMatrix: Matrix4) -> MTLBuffer {
// 1.从缓存数组中获取buffer
let buffer = uniformsBuffers[avaliableBufferIndex]
// 2.获取 `void *` 指针
let bufferPointer = buffer.contents()
// 3.将传进的矩阵拷贝到buffer中
memcpy(bufferPointer, modelViewMatrix.raw(), MemoryLayout<Float>.size * Matrix4.numberOfElements())
memcpy(bufferPointer + MemoryLayout<Float>.size * Matrix4.numberOfElements(), projectionMatrix.raw(), MemoryLayout<Float>.size * Matrix4.numberOfElements())
// 4.更新avaliableBufferIndex
avaliableBufferIndex += 1
if avaliableBufferIndex == inflightBuffersCount {
avaliableBufferIndex = 0
}
return buffer
}
}
// 在 Node.swift 文件中
// 添加一个属性
var bufferProvider: BufferProvider
// 在 init 方法末尾添加
self.bufferProvider = BufferProvider(device: device, inflightBuffersCount: 3, sizeOfUniformsBuffer: MemoryLayout<Float>.size * Matrix4.numberOfElements() * 2)
// 然后将以下几代码
let uniformBuffer = device.makeBuffer(length: MemoryLayout<Float>.size * Matrix4.numberOfElements() * 2, options: [])!
let bufferPointer = uniformBuffer.contents()
memcpy(bufferPointer, nodeModelMatrix.raw(), MemoryLayout<Float>.size * Matrix4.numberOfElements())
memcpy(bufferPointer + MemoryLayout<Float>.size * Matrix4.numberOfElements(), projectionMatrix.raw(), MemoryLayout<Float>.size * Matrix4.numberOfElements())
// 替换为下面这句
let uniformBuffer = bufferProvider.nextUniformsBuffer(projectionMatrix: projectionMatrix, modelViewMatrix: nodeModelMatrix)
竞争
到此程序可以正常且丝滑的运行起来,但这里隐藏了一个问题,如下图
当CPU获取到下一个可用的buffer,填充到数据,然后发送到GPU处理,但此时可能数据还在上一轮的处理没有结束。因为CPU的处理速度远比GPU的要快的多。当然可以增加缓存的数量来减少这种竞争,但这也不能100%避免。对于此种情况,最好的办法就是使用信号量。信号量允许你跟踪有限可用资源的数量,当没有更多可用资源时阻塞。在本例中使用信号量如下:
在初始化buffers的数量时,初始化信号量
访问buffer前要等待。当访问buffer时,要先询问下信号量是否等待,如果所有的buffers都在使用,那此时会阻塞,直到有可用的buffer,当释放一个buffer信号量也会-1。
当处理完一个buffer就发一个信号出来,又有可用的buffer了
// 添加信号量的代码
// 在 BufferProvider.swift 中添加一个属性并在 init 方法中初始化
var avaliableResourcesSemaphore: DispatchSemaphore
self.avaliableResourcesSemaphore = DispatchSemaphore(value: inflightBuffersCount)
// 然后在 Node.swift 中的 render() 方法最上面加上下面这句,将会使CPU等待到有空闲的资源
_ = bufferProvider.avaliableResourcesSemaphore.wait(timeout: .distantFuture)
// 还在 render() 方法中 let commandBuffer = commandQueue.makeCommandBuffer()! 下面加上一句
commandBuffer.addCompletedHandler { (_) in
self.bufferProvider.avaliableResourcesSemaphore.signal()
}
// 当对象销毁时要记得清空信号量,否则当信号量一直在等待,你销毁了对象,就会crash
deinit {
for _ in 0 ... self.inflightBuffersCount {
self.avaliableResourcesSemaphore.signal()
}
}
Texture
什么是Texture?简单来说,就是映射到3D模型的2D图像。
与OpenGL左下角原点相反,Metal的原点在右左上角。通常坐标轴记为s,t。如下图
为了区分iOS设备的像素与纹理像素,称纹理像素为纹理元素,纹理有512x512个纹理元素,且使用归一化坐标系(范围0~1)。因此 左上角:(0.0, 0.0),左下角:(0.0, 1.0),右上角:(1.0, 0.0),右下角:(1.0, 1.0)。当然不强制使用归一化坐标系,但使用这个归一化坐标系有好处。例如,当你想转换纹理的分辨率为256x256时,只要新的纹理映射正确,就会正常工作。
在Metal中使用Texture
在Metal中,只要遵循
MTLTexture
协议的任何对象都可代表纹理。Metal中有无数类型的纹理,但是现在我们只需要MTLTexture2D
这个类型。
另外一个重要的协议MTLSamplerState
,遵循此协议的对象指导CPU如何使用纹理。当传入一个纹理时,同时也会传入一个取样器。
下图简单说明如何使用texture
MetalTexture
为了方便使用texture,创建了一个
MetalTexture
类,可以参考MetalByExample.com。类中有两个重要的方法
init(resourceName: String, ext: String, mipmaped: Bool)
:传入文件名及扩展名,然后是否要mipmaps
func loadTexture(device: MTLDevice, commandQ: MTLCommandQueue, flip: Bool)
: 当实际要创建MTLTexture
时调用。
What is mipmap ??? 当mipmap = true
时加载texture,会生成一个图像数组代替单个图像,并且数组中的每个图像是前面一个的1/2。GPU会自动选择最佳的mip级别来读取像素
整合代码
// 在Node.swift中添加两个新的成员
var texture: MTLTexture
lazy var samplerState: MTLSamplerState? = Node.defaultSampler(device: self.device)
// 添加一个类方法,生成一个简单的纹理采样器,包含一堆标志
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)!
}
// 在该类的 render() 方法中的 renderEncoder.setVertexBuffer(self.vertexBuffer, offset: 0, index: 0) 语句下面添加
renderEncoder.setFragmentTexture(texture, index: 0)
renderEncoder.setFragmentSamplerState(samplerState, index: 0)
// 修改 init 方法
init(name: String, vertices: Array<Vertex>, device: MTLDevice, texture: MTLTexture) {
// 且在最后给 texture 赋值
self.texture = texture
// 修改 Vertex 类如下
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]
}
}
// 修改 Cubic 的 init 方法
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)
}
先看图,然后对上面的几步做简单解释
- 创建每个顶点同时指定纹理坐标,具体坐标值看上面的图。要注意,你要为每个面都要创建独立的顶点,因为纹理的坐标可能不匹配
- 按逆时针组装三角形
- 用
MetalTexture
类创建和加载纹理
GPU上处理Texture
// 用下面的代码替换Shaders.metal的内容
#include <metal_stdlib>
using namespace metal;
// 1. 添加纹理坐标
struct VertexIn{
packed_float3 position;
packed_float4 color;
packed_float2 texCoord;
};
struct VertexOut{
float4 position [[position]];
float4 color;
float2 texCoord;
};
struct Uniforms{
float4x4 modelMatrix;
float4x4 projectionMatrix;
};
vertex VertexOut basic_vertex(
const device VertexIn* vertex_array [[ buffer(0) ]],
const device Uniforms& uniforms [[ buffer(1) ]],
unsigned int vid [[ vertex_id ]]) {
float4x4 mv_Matrix = uniforms.modelMatrix;
float4x4 proj_Matrix = uniforms.projectionMatrix;
VertexIn VertexIn = vertex_array[vid];
VertexOut VertexOut;
VertexOut.position = proj_Matrix * mv_Matrix * float4(VertexIn.position,1);
VertexOut.color = VertexIn.color;
// 2. 将纹理坐标传给 VertexO
VertexOut.texCoord = VertexIn.texCoord;
return VertexOut;
}
// 3. 接收传入的纹理
fragment float4 basic_fragment(VertexOut interpolated [[stage_in]],
texture2d<float> tex2D [[ texture(0) ]],
// 4. 接收取样器
sampler sampler2D [[ sampler(0) ]]) {
// 5. 在纹理上用 sample() 获取指定纹理坐标
float4 color = tex2D.sample(sampler2D, interpolated.texCoord);
return color;
}
添加用户输入
// 在 ViewController 类中添加属性和方法
let panSensivity: Float = 5.0
var lastPanLocation: CGPoint!
func setupGestures() {
let pan = UIPanGestureRecognizer(target: self, action: #selector(ViewController.pan))
self.view.addGestureRecognizer(pan)
}
@objc func pan(panGesture: UIPanGestureRecognizer) {
if panGesture.state == .changed {
let pointInView = panGesture.location(in: self.view)
let xDelta = Float((lastPanLocation.x - pointInView.x) / self.view.bounds.width) * panSensivity
let yDelta = Float((lastPanLocation.y - pointInView.y) / self.view.bounds.height) * panSensivity
objectToDraw.rotationX -= xDelta
objectToDraw.rotationY -= yDelta
lastPanLocation = pointInView
} else if panGesture.state == .began {
lastPanLocation = panGesture.location(in: self.view)
}
}
// ----------------------------------------------------
// 然后在 viewDidLoad 中调用 setupGestures 方法
// 记得将 Cubic 中的 updataWithDelta 方法注掉或删掉
// ----------------------------------------------------
此时工程可以正常运行,但会发现好像有点一对劲,横屏及边缘锯齿
// 解决方法就是旋转屏幕时及时更新,重写下面的方法
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if let window = view.window {
let scale = window.screen.nativeScale
let layerSize = view.bounds.size
// 获取设备的拉伸因子(有的为2,有的为3),为了优化锯齿
view.contentScaleFactor = scale
metalLayer.frame = CGRect(x: 0, y: 0, width: layerSize.width, height: layerSize.height)
metalLayer.drawableSize = CGSize(width: layerSize.width * scale, height: layerSize.height * scale)
}
projectionMatrix = Matrix4.makePerspectiveViewAngle(Matrix4.degrees(toRad: 85.0), aspectRatio: Float(self.view.bounds.size.width / self.view.bounds.size.height), nearZ: 0.01, farZ: 100.0)
}
Well Done !!!