體驗一下用Metal畫圖

語言: CN / TW / HK

前言

事情是這樣的,最近接觸了一些關於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渲染圖

原始碼地址:metal-study