前言
事情是这样的,最近接触了一些关于Metal画图的内容。所以想着写一篇文章记录一些基础和研究过程中遇到的问题及解决的方法。有关Metal的相关介绍也可以参考该博客。里面包括Metal的基础知识、提高内容以及相应的demo。画图时,可能希望图片可以变形,想着可以在顶点坐标上处理,将点切分成多份进行绘制。所以下文会涉及部分坐标切分后的计算。
关于顶点索引
有关顶点索引的概念可以看看这篇文章:OpenGL缓冲区对象之EBO。索引的概念与OpenGL相同,可作为参考。 以下以图片水平(x)和垂直(y)方向都均分成2份为例,其顶点索引的定义可以是这样的,如下图所示。
整体思路是:一个矩形可以分成两个三角形,一个三角形包含3个索引,可占数组的3位。 则最终对于上图的顶点索引可以为
let indices:[UInt32] = [
0, 1, 4,
0, 3, 4,
1, 2, 5,
1, 4, 5,
3, 4, 7,
3, 6, 7,
4, 5, 8,
4, 7, 8
]
而后,需要利用数组生成一个MTLBuffer用于后续渲染前MTLRenderCommandEncoder的装载
let indexBuffer = device.makeBuffer(bytes: indices, length: indices.count * 4, options: .storageModeShared)
...
encoder.drawIndexedPrimitives(type: .triangle, indexCount: indices.count,
indexType: .uint32, indexBuffer: indexBuffer, indexBufferOffset: 0)
根据上述的思路,平均分n * m份时,顶点索引计算可以为
var index = 0
var indices = [UInt32](repeating: 0, count: xTileN * yTileN * 2 * 3)
for j in 0..<yTileN {
for i in 0..<xTileN {
let value: UInt32 = UInt32(0 + (xTileN + 1) * j + i)
indices[index] = value
indices[index + 1] = value + 1
indices[index + 2] = value + (UInt32(xTileN) + 1) + 1
indices[index + 3] = value
indices[index + 4] = value + UInt32((xTileN + 1))
indices[index + 5] = value + (UInt32(xTileN) + 1) + 1
index += 6
}
}
ps: 上述的xTileN及yTileN是x方向及y方向平均分成多少份的意思,下文就不作解释了。
关于着色器函数的加载
Metal的着色器代码可以定义在一个后缀为.metal的文件当中,像这样:
比较人性化的一点时,我们只需要创建一个.metal文件,编译时Xcode就会自动将文件编译,编译的产物默认命名为“default.metallib”,位于包的第一级目录当中。 值得注意的是:默认情况下,会将项目中所有的.metal文件统一打包到default.metallib当中。而上述截图中的着色器函数命名需要注意命名重复的问题,因为后续加载时需要通过函数名找到对应的映射。
let library = device.makeDefaultLibrary()
let vertexFunc = library.makeFunction(name: "vertexShader")
let fragmentFunc = library.makeFunction(name: "fragmentShader")
上述代码就是通过api将着色器shader函数加载映射到MTLFunction对象的过程。library(device.makeDefaultLibrary())实际上就是默认加载包的第一级目录下default.metallib。
在子模块中如何获取其定义的着色器函数
根据上述Metal编译产物的特性,不难衍生出另一个问题。因为有些项目是有子模块概念的,那其default.metallib的路径就自然后落到模块打包后生成的framwork的目录下。如果使用device.makeDefaultLibrary()获取的话,路径明显不正确,这就造成加载失败的情况。 针对这种情况,我们可以将上述代码改造一下:
let bundle = Bundle.init(for: self.classForCoder)
let metallibpath = bundle.url(forResource: "default", withExtension: "metallib")!
let library = device.makeLibrary(filepath: metallibpath.path)
let vertexFunc = library.makeFunction(name: "vertexShader")
let fragmentFunc = library.makeFunction(name: "fragmentShader")
通过获取当前模块的Bundle,获取到自己模块对应的default.metallib路径,使用文件路径加载library即可解决上述的问题。
关于空纹理的创建
本次画图是输出到一个空纹理上,当然也可以通过绑定CAMetalLayer,将结果直接输出到layer上。 所以首先我们需要创建一个空纹理,在Metal中我们可以通过MTLTextureDescriptor进行配置后创建输出一个MTLTexture,该对象可以理解为已经在内存中申请了一块空间。后续可以将内容填充入内。
let targetTextDesc = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: .bgra8Unorm,
width: Int(view.bounds.width),
height: Int(view.bounds.height),
mipmapped: false)
targetTextDesc.usage = MTLTextureUsage.init(rawValue: MTLTextureUsage.shaderRead.rawValue | MTLTextureUsage.renderTarget.rawValue)
let targetTexture = device.makeTexture(descriptor: targetTextDesc)
值得注意的是:因为该纹理有两个作用:1、作为渲染目标;2、后续需要将纹理转换成UIImage。 所以这里的usage被定义为.renderTarget和.shaderRead。
关于加载纹理
接下来就是需要将需要渲染到屏幕的图片文件加载成纹理MTLTexture对象,用于后续的MTLRenderCommandEncoder装载,这个留到后面讲。
let loader = MTKTextureLoader.init(device: device)
let image = UIImage.init(named: "aa.jpg")!
let options = [
MTKTextureLoader.Option.textureUsage: NSNumber(value: MTLTextureUsage.shaderRead.rawValue),
MTKTextureLoader.Option.SRGB: false
]
let texture = try loader.newTexture(cgImage: image.cgImage!, options: options)
使用MTKTextureLoader的前提是需要引用MetalKit
import MetalKit
当然,也可以创建一个空纹理,然后通过读取UIImage对应的二进制流,通过以下api进行写入,可能需要考虑图片翻转的问题,这里就不展开讲了。
// MTLTexture
func replace(region: MTLRegion, mipmapLevel level: Int, withBytes pixelBytes: UnsafeRawPointer, bytesPerRow: Int)
关于顶点坐标和纹理坐标
上图是Metal中的顶点坐标系及纹理坐标系。和OpenGL不同的是,Metal顶点坐标系的y轴是朝下的,所以他的起始点应该是左上角,而OpenGL的y轴是向下的,起始点在左下角。 以上图为例,最终落实到代码的数组定义是:
-
顶点坐标(由于只是2D平面,只涉及到x、y坐标,z、w在上述着色器代码中已被默认为0、1):
let position: [Float] = [ -1, -1, 1, -1, -1, 1, 1, 1 ] -
纹理坐标:
let texturePosition: [Float] = [ 0, 0, 1, 0, 0, 1, 1, 1 ]
正常情况下,顶点坐标和纹理坐标是一一对应的,顶点坐标规定了渲染时落到画布上的具体位置,纹理坐标规定了落到具体位置的该点相对于原数据(这里指上述加载纹理的texture对象)的采样范围。
对于上述提到的将图片平均分n * m份的顶点坐标及纹理坐标可以为:
var position = [Float].init(repeating: 0, count: (xTileN + 1) * (yTileN + 1) * 2)
var texturePosition = [Float].init(repeating: 0, count: (xTileN + 1) * (yTileN + 1) * 2)
var index = 0
for i in 0...yTileN {
let y: Float = -1 + (2.0 / Float.init(yTileN)) * Float(i)
let texY: Float = 0 + (1.0 / Float.init(yTileN)) * Float(i)
for j in 0...xTileN {
let x: Float = -1 + (2.0 / Float.init(xTileN)) * Float(j)
let texX: Float = 0 + (1.0 / Float.init(xTileN)) * Float(j)
position[index] = x
position[index + 1] = y
texturePosition[index] = texX
texturePosition[index + 1] = texY
index += 2
}
}
这里的顶点坐标只是均分的造数据情况,如果需要实现变形,计算的方式则以实际情况而定。
渲染
通过上述流程之后,最后就是创建MTLCommandBuffer及MTLRenderCommandEncoder进行装载,和渲染了。
renderTargetDesc.colorAttachments[0].texture = targetTexture
guard let commandBuffer = queue.makeCommandBuffer() else {
return
}
commandBuffer.label = "Command Buffer"
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderTargetDesc) else {
return
}
encoder.label = "Command Encoder"
encoder.setViewport(
MTLViewport.init(
originX: 0,
originY: 0,
width: Double(view.bounds.width),
height: Double(view.bounds.height),
znear: 0,
zfar: 1))
encoder.setRenderPipelineState(renderPipelineState)
encoder.setVertexBytes(position, length: position.count * 4, index: 0)
encoder.setVertexBytes(texturePosition, length: texturePosition.count * 4, index: 1)
encoder.setFragmentTexture(texture, index: 0)
encoder.drawIndexedPrimitives(type: .triangle, indexCount: indices.count,
indexType: .uint32, indexBuffer: indexBuffer, indexBufferOffset: 0)
encoder.endEncoding()
commandBuffer.commit()
commandBuffer.waitUntilCompleted()
关于MTLTexture转UIImage
渲染结束后,就可以将targetTexture转换成UIImage了。通过查看文档可知,CIImage是支持通过MTLTexture创建的。那我们可以通过CIImage生成UIImage
let ciImage = CIImage.init(mtlTexture: texture, options: [CIImageOption.colorSpace: CGColorSpaceCreateDeviceRGB()])!
let ciContext = CIContext.init()
let cgImage = ciContext.createCGImage(ciImage, from: ciImage.extent)
let image = UIImage.init(cgImage: cgImage!)
在测试中发现,采用上述的方案在iPhone5s上生成的UIImage会是空白的。所以有了以下的适配方案:
let bytesPerPixel = 4
let imageByteCount = texture.width * texture.height * bytesPerPixel
let bytesPerRow = texture.width * bytesPerPixel
var src = [UInt8].init(repeating: 0, count: imageByteCount)
let region = MTLRegionMake2D(0, 0, texture.width, texture.height)
texture.getBytes(&src, bytesPerRow: bytesPerRow, from: region, mipmapLevel: 0)
let colorSpace = CGColorSpaceCreateDeviceRGB()
let ctx = CGContext.init(
data: &src,
width: texture.width,
height: texture.height,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: colorSpace,
bitmapInfo: CGBitmapInfo(rawValue: (CGBitmapInfo.byteOrder32Little.rawValue
| CGImageAlphaInfo.noneSkipFirst.rawValue)).rawValue)
let cgImage = (ctx?.makeImage())!
UIGraphicsBeginImageContext(CGSize.init(width: cgImage.width, height: cgImage.height))
let context = UIGraphicsGetCurrentContext()
context?.draw(cgImage, in: CGRect.init(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
let newCGImage = context?.makeImage()
UIGraphicsEndImageContext()
let image = UIImage.init(cgImage: newCGImage!)
通过MTLTexture的getBytes方法获取到纹理对应的二进制流,进而通过CGContext来创建CGImage。这中间还有一个小插曲是,由于加载到纹理的图片是被翻转了的,所以需要手动画一遍CGContext。
这里留了一个疑问,在OC中,绘制的api为CGContextDrawImage,绘制后还需要手动调用CGContextScaleCTM(context, 1.0, -1.0);进行翻转。而swift中CGContext的draw方法似乎默认实现的翻转,这个没有深入研究。
关于纹理缓存问题
实际开发中,可能涉及多张纹理同时渲染的问题,这时也会存在纹理重复利用。而缓存就需要考虑内存分配的问题了。有一个值得深究的问题是,在手机端的gpu是否存在显存? 针对这个问题,我找到了一篇文章,这篇文章虽然是讲Unity的,但里面有提及到目前智能手机为统一内存设计,cpu与gpu会共用内存。而Metal的资源对象也可以被cpu和gpu共享访问,这也是Metal优于OpenGL的原因之一。
最后
最后看一下按照上述提供的代码,xTileN = 12,yTileN = 16。绘制出来的效果。第一张为原图、第二张为Metal渲染图
源码地址:metal-study