前文从Metal的工作原理和使用方法上介绍了使用Metal渲染图片的过程,这一篇就从代码的实现来看看Metal的工作流程。主要分成以下几个方面:
接下来开始以以上步骤,一步步来完成图片的渲染。首先是Metal相关的初始化。
MTKView的创建没有多说的,额外需要用一个类来实现MTKViewDelegate协议的两个接口,然后将其设置为MTKView的delegate,方法具体的实现放到渲染过程中详细描述。比较主要的是Metal中初始化:如下代码:
// 创建 MTLDevice,绘制设备;
self.renderView.device = MTLCreateSystemDefaultDevice()
self.renderDevice = self.renderView.device!
// 创建 commandQueue;
self.renderCmdQueue = self.renderDevice?.makeCommandQueue()!
// 创建 MTLLibrary 对象;
let defaultLibrary = self.renderDevice?.makeDefaultLibrary()
let vertexFunction = defaultLibrary?.makeFunction(name: "imageVertexShader")
let fragmentFunction = defaultLibrary?.makeFunction(name: "imageFragmentShader")
// 创建 MTLRenderPipelineDescriptor 对象;
let pipelineStateDesc = MTLRenderPipelineDescriptor()
pipelineStateDesc.label = "AVPlayer Image pipeLine"
pipelineStateDesc.vertexFunction = vertexFunction
pipelineStateDesc.fragmentFunction = fragmentFunction
pipelineStateDesc.colorAttachments[0].pixelFormat = self.renderView.colorPixelFormat
// 创建 MTLRenderPipelineState 对象;
self.renderPipeStatus = try! self.renderDevice?.makeRenderPipelineState(descriptor: pipelineStateDesc)
MTLDevice和commandQueue在前文介绍过,不再介绍,看看后面三个是什么。
从代码中也能看出来,肯定是shader方法相关的,其实就是一个保存shader方法的集合,在makeFunction方法中,传入的参数就是我们定义的shader方法名称,后面会看到。它的另一个作用就是在app代码编译时编译指定的shader代码,这也是和OpenGL中不一样的地方,OpenGL中往往是需要在程序运行起来之后通过代码去编译shader代码,可以提高运行效率,但是失去了一定的灵活性。
从名称可以看出来,这个是类起到的是一个描述符的作用,它主要是用来描述当前这个渲染管线的一些状态,比如光栅化、可见性、混合、细化以及图像处理的方法,也包括色彩空间。我的代码中主要使用它来设置顶点和片段阶段的方法以及颜色空间,如上面代码所示。通过这个对象,可以创建出另一个对象,MTLRenderPipeLineState。
这个对象是由前面的MTLDevice和上面的descriptor来创建的,然后用来创建MTL Render CommandEncoder。上面的descriptor是一个class,而这个是个protocol,我猜想这样的设计可能是用来解偶吧(如果有其他考虑,也请大神们指教一下)。这个state对象创建的开销比较大,所以通常在比较早的时候就创建出来,而且不需要轻易更改,这个对象是可以重复使用的。
至此,Metal初始化阶段可以告一段落,接下来看看图片数据和纹理的处理。
图像的来源是前面介绍过的拍照功能,为了方便数据传递,我使用UIImage来作为中转,在拍照完成之后,将图片数据存储为UIImage,然后传到渲染的类中,如下:
let photoData = photo.fileDataRepresentation()
if ((photoData?.isEmpty) == true) {
return ;
}
let image = UIImage(data: photoData!);
然后就根据其中的图像数据来创建绘制需要的纹理:
let textureDescriptor = MTLTextureDescriptor()
textureDescriptor.pixelFormat = .rgba8Unorm
textureDescriptor.width = self.imageData!.imageWidth
textureDescriptor.height = self.imageData!.imageHeight
let imageTexture = self.renderDevice?.makeTexture(descriptor: textureDescriptor)
以上涉及到几个类,MTLTextureDescriptor,与之前的descriptor类似,主要用来描述纹理的相关特性,从图中可以看到,主要是纹理的像素格式、宽、高。此处我用了一个结构来转存了UIImage,包括宽高,以及UIImage对象。
然后就是MTLTexture,这个就不用多说了,指定了像素格式和宽高,就足以创建一个纹理所需要的空间了,接下来就是将图像数据加载到纹理中。
由于纹理需要的数据是主要是uint8类型所以需要一个额外的处理,UIImage中cgImge才是真实的图像数据,所以处理方式如下:
func loadImage(image: UIImage) -> UnsafeMutableRawPointer? {
let imageRef = image.cgImage;
let width = self.imageData!.imageWidth
let height = self.imageData!.imageHeight
let data = calloc(width * height * 4, MemoryLayout.size)
let context = CGContext.init(data: data!, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, space: (imageRef?.colorSpace)!, bitmapInfo: UInt32( CGImageAlphaInfo.premultipliedLast.rawValue))
context?.draw(imageRef!, in: CGRect(x: 0, y: 0, width: width, height: height))
return data
}
图像数据通常是一块儿连续内存区域,使用uint8类型,如以上代码,每个像素需要4个字节,所以内存块的大小就是width*height*4,顺道提一下这MemoryLayout
接下来看看纹理的操作,直接看代码:
let region = MTLRegion(origin: MTLOrigin(x: 0, y: 0, z: 0),
size: MTLSize(width: self.imageData!.imageWidth,
height: self.imageData!.imageHeight,
depth: 1))
let data = self.loadImage(image: imageData.image!)
let unSafeData = UnsafeRawPointer(data)
imageTexture?.replace(region: region,
mipmapLevel: 0,
withBytes: unSafeData!,
bytesPerRow: bytesPerRow)
imageTex = imageTexture
在将数据拷贝到纹理之前,需要创建一个区域,这个区域表示的是图像覆盖的范围,所以区域从(0,0,0)开始,尺寸就是图像的尺寸,2维图像,所以z为0。然后data就是从上面介绍的方法得到,最后使用纹理的replace方法将图像数据拷贝到纹理中。纹理对象是可以重复使用的,所以在图像格式尺寸没有变化的情况下,不需要重复创建。接下来是着色器代码以及顶点数据的处理。
关于顶点数据,之前提到过纹理坐标是左上角(0,0),右下角(1,1),而Metal中的标准化设备坐标是左上角(-1,1),右下角(1,-1),中心是(0,0)。所以需要做一个坐标的转换。先看看顶点着色器中的变换方法:
vertex AVImageRasterizerData
imageVertexShader(uint vertexID [[vertex_id]],
constant AVImageVertex *vertices [[buffer(AVRenderVertexInputIndexVertices)]],
constant vector_uint2 *viewportSizePointer [[buffer(AVRenderVertexInputIndexViewportSize)]]) {
AVImageRasterizerData out;
float2 pixelSpacePosition = vertices[vertexID].position.xy;
vector_float2 viewportSize = vector_float2(*viewportSizePointer);
out.position = vector_float4(0.0, 0.0, 0.0, 1.0);
out.position.xy = pixelSpacePosition / (viewportSize / 2.0);
out.textureCoordinate = vertices[vertexID].textureCoordinate;
return out;
}
AVImageRasterizerData是自己定义的一个结构,其中有两个值,一个转换得到的标准化设备坐标,一个是纹理坐标,表示这个纹理坐标对应到的设备坐标中的坐标。函数中的参数有三个,第一个是有[[vertex_id]]限定符修饰,表示这个参数是Metal为当前点声明的一个唯一的索引值,第二个和第三个都有被[[buffer(n)]]修饰,其实是两个宏定义,分别为0,表示当前这个参数在输入结构中的索引值,这一点在绘制过程中讲到。
然后就是坐标的变换,设备坐标中间点为(0,0),所以需要将点的位置除以宽高的一半来转换,而这个输入的点位置,也是需要计算的,接下来就是。
/*
typedef struct {
vector_float2 position;
vector_float2 textureCoordinate;
} AVImageVertex;
*/
func generateImageVertexData() -> [AVImageVertex]? {
if self.imageVertexs?.isEmpty == false {
return self.imageVertexs
}
var vertexArr = [AVImageVertex]()
if self.drawSize?.width == 0 || self.drawSize?.height == 0 {
return vertexArr
}
let width: CGFloat = self.drawSize!.width
let height: CGFloat = self.drawSize!.height
var pos = vector_float2(x: -Float((width / 2)), y: Float((height / 2)))
var texCoor = vector_float2(0.0, 1.0)
var renderPt = AVImageVertex(position: pos, textureCoordinate: texCoor)
vertexArr.append(renderPt)
// 省略其他点;
...
self.imageVertexs = vertexArr
return vertexArr
}
代码中pos就是到vertex中参与计算设备坐标的值,texCoor就是纹理中的点,最后得到的是一个AVImageVertex数组,这个数组将在渲染过程中传入到Metal中,在顶点方法中计算设备坐标。根据上面的vertex方法,和这个pos,计算之后得到的是(-1,1),就说明纹理坐标中的(0,0)对应的是Metal中的设备坐标(-1,1)。Metal中绘制几何图形是以三角形为单位,所以绘制矩形的时候,是由两个三角形组成的,所以这个数组中一共有6个点。在vertex function处理完顶点数据之后,接下来就是fragment function来处理点的颜色。
fragment function的主要内容如下:
fragment float4
imageFragmentShader(AVImageRasterizerData in [[stage_in]],
texture2d colorTexture [[texture(AVImageTextureIndexBaseColor)]]) {
constexpr sampler textureSampler (mag_filter::linear,
min_filter::linear);
const half4 colorSample = colorTexture.sample(textureSampler,
in.textureCoordinate);
return float4(colorSample);
}
比较好理解,一个参数使用了[[stage_in]]限定符修饰,表示是之前阶段的输出,当前阶段的输入参数,所以就是vertex function中的返回值;fragment function要做的就是要对纹理中的像素进行采样,决定最终的颜色,colorTexture就表示传入的纹理,其sample方法,就是根据第二个参数作为点的坐标,并且根据textureSampler指定的颜色渲染方法,得到一个该点的最终颜色值。构造textureSampler的两个参数,mag_filter和min_filter分别表示在方法和缩小时需要的采样方法。因为此例中无需改变点的颜色,所以并没有做额外的处理,就返回了该点的颜色值。这个步骤结束之后,纹理中的点已经生成了最终的样子,最后GPU只需要直接执行绘制命令即可。
这个部分,就来看看,Metal具体是如何使用我们提供的数据进行绘制的。主要是就是实现的MTKViewDelegate的 draw(in view: MTKView)方法
func drawImage(view: MTKView) {
// 获取要绘制的点的数据,之前介绍过;
let vertexArr = self.generateImageVertexData()
let renderPassDesc = self.renderView.currentRenderPassDescriptor
// 创建command buffer,在之前文章中介绍过;
let renderCmdBuf = self.renderCmdQueue?.makeCommandBuffer()
// 创建render command encoder,此处开始来设置绘制的数据等;
let renderEncoder = renderCmdBuf?.makeRenderCommandEncoder(descriptor: renderPassDesc!)
renderEncoder?.label = "AVRenderEncoder"
// 设置视口尺寸,也就是绘制区域的大小;(1)
// autoResizeDrawable默认为true,会自动调整绘制的尺寸,颜色深度等,
// 所以视口尺寸要跟MTKView的drawableSize保持一致;
renderEncoder?.setViewport(MTLViewport(originX: 0, originY: 0,
width: Double(self.drawSize!.width),
height: Double(self.drawSize!.height),
znear: 0.0, zfar: 1.0))
renderEncoder?.setRenderPipelineState(self.renderPipeStatus!)
// 创建一个buffer,用来保存上面的点的数据,vertexArr就是前面获取的点的数组;
let vertices = self.renderDevice?.makeBuffer(bytes: vertexArr!,
length: MemoryLayout.size * 6,
options: .storageModeShared)
// viewPort也是要取MTKView真实的drawableSize,才能布满整个view; (2)
var viewPort: vector_uint2 = vector_uint2(UInt32(self.drawSize!.width),
UInt32(self.drawSize!.height))
// 将点的buffer编码到encoder中;
renderEncoder?.setVertexBuffer(vertices, offset: 0, index: 0)
// 将视口尺寸编码到encoder中;
renderEncoder?.setVertexBytes(&viewPort,
length: MemoryLayout.size,
index: 1)
// 将fragment 方法中要处理的纹理编码到encoder中;
renderEncoder?.setFragmentTexture(self.imageTex, index: 0)
// 这个就是绘制三角形的命令,编码到encoder中;
renderEncoder?.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
// 所有绘制的数据和命令编码完毕;
renderEncoder?.endEncoding()
// 呈现绘制的数据,可以理解为OpenGL中最后一个swap的操作;
renderCmdBuf?.present(self.renderView.currentDrawable!)
// command buffer提交所有绘制命令,之后,Metal将开始绘制;
renderCmdBuf?.commit()
}
我去掉了部分校验的代码。为了方便我把步骤中需要的介绍都放在注释中。到现在为止,Metal的绘制过程基本介绍完毕了,其实和OpenGL的绘制还是比较有共同点的。
在最后一个代码块中,注释中有一个(1)和(2)结尾的地方。我刚开始绘制的时候,这个地方的尺寸都是使用的MTKView的尺寸,但是绘制出来的结果总是在左上角,而且刚好是整个View的四分之一的大小。我一直不明白原因,直到我在MTKViewDelegate的如下方法中发现了问题:
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
self.drawSize = size
}
这个方法先于draw方法调用的,第二个参数就是MTKView的可绘制范围(drawableSize,我暂时这么理解)。发现这个尺寸刚好是我MTKView尺寸的2倍大小,后来我发现MTKView中有一个变量--autoResizeDrawable,是一个bool值,为true时表示自动调整MTKView的绘制属性,包括颜色深度,区域大小等等,该变量默认为true,所以这就是导致我这个问题出现的原因,但是为什么会这样,我暂时还不清楚,如果有了解,希望指点一下迷津。如果强行把这个变量设置为false的话,就需要我们自己去设置drawableSize,而且是在每一次尺寸改动时都要重新设置。
所以我的改动是,在此方法调用的时候,记录下drawableSize,然后在绘制期间,视口尺寸,点的位置计算,都以drawableSize为基准,这样绘制出来的图像就布满了整个View。
暂且介绍这些,如果其中有错误或者不当的地方,欢迎大家指正。后续我还会继续记录更多的知识点。要一篇记录真的好累呀,真佩服那些博客大神,记录那么多!!!