音视频-iOS使用metal渲染图像(二)

概要

        前文从Metal的工作原理和使用方法上介绍了使用Metal渲染图片的过程,这一篇就从代码的实现来看看Metal的工作流程。主要分成以下几个方面:

  1. MTKView以及Metal组件的创建;
  2. 根据图像数据创建纹理;
  3. 顶点数据、vertex和fragment shader;
  4. 渲染过程;
  5. 结果以及注意事项。

实践

        接下来开始以以上步骤,一步步来完成图片的渲染。首先是Metal相关的初始化。

MTKView以及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在前文介绍过,不再介绍,看看后面三个是什么。

MTLLibrary

        从代码中也能看出来,肯定是shader方法相关的,其实就是一个保存shader方法的集合,在makeFunction方法中,传入的参数就是我们定义的shader方法名称,后面会看到。它的另一个作用就是在app代码编译时编译指定的shader代码,这也是和OpenGL中不一样的地方,OpenGL中往往是需要在程序运行起来之后通过代码去编译shader代码,可以提高运行效率,但是失去了一定的灵活性。

MTLRenderPipelineDescriptor

        从名称可以看出来,这个是类起到的是一个描述符的作用,它主要是用来描述当前这个渲染管线的一些状态,比如光栅化、可见性、混合、细化以及图像处理的方法,也包括色彩空间。我的代码中主要使用它来设置顶点和片段阶段的方法以及颜色空间,如上面代码所示。通过这个对象,可以创建出另一个对象,MTLRenderPipeLineState。

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,可以简单理解为sizeof(uint8_t)。需要借助CGcontext才能将数据写入到data指向的内存区中。然后在Swift中,UnSafeMutableRawPointer可以理解为对C语言中的void指针,指向一块儿类型无关的内存区域。这里有一点提一下,在OC中,context是需要使用者自己去释放,但是在Swift中这个也是自动管理的,不需要手动释放。

        接下来看看纹理的操作,直接看代码:

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方法将图像数据拷贝到纹理中。纹理对象是可以重复使用的,所以在图像格式尺寸没有变化的情况下,不需要重复创建。接下来是着色器代码以及顶点数据的处理。

顶点数据、vertex和fragment shader

        关于顶点数据,之前提到过纹理坐标是左上角(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。

        暂且介绍这些,如果其中有错误或者不当的地方,欢迎大家指正。后续我还会继续记录更多的知识点。要一篇记录真的好累呀,真佩服那些博客大神,记录那么多!!!

你可能感兴趣的:(Metal,音视频,ios)