iOS开发:Metal初探(画五角星+将图片3D化)

背景

在 WWDC 2014 上,Apple为游戏开发者推出了新的平台技术 Metal,该技术能够为3D图像提高 10 倍的渲染性能,充分利用GPU的运算能力。
在现阶段,AVFoundation、⼈脸识别等大量需要显示计算的时候,苹果采用了硬件加速器驱动GPU工作;在音视频方面,⾳频编码/解码 / 视频编码/解码 ->压缩任务都与硬件加速器分不开,苹果提供的Metal,能发挥GPU/CPU的最大性能,并且管理我们的资源,苹果想用metal替代opengl作为底层绘制框架。metal常见应用于一些游戏、滤镜、相机类的app。
设备支持:iOS 8以上,A7处理器以上,因此只有iphone5以上机型才支持metal,并且不支持模拟器运行,只支持真机。

基本概念

坐标系

这里的坐标系先不讲坐标空间,只是最基础的顶点坐标和纹理坐标。
跟平时我们开发写UI以左上角为原点不一样,Metal的顶点坐标系跟openGL一样,是以屏幕中心为原点,归一化的坐标系。
四维均匀向量 ( x,y,z,w) 指定一个三维点剪辑空间坐标。顶点着色器在剪辑空间坐标中生成位置。Metal分 x ,y,和z值由w将剪辑空间坐标转换为标准化设备坐标左下角位于( x,y)的坐标(- 1.0,-1.0) 而上角在(1.0,1.0) 。


顶点坐标.png

如果只是绘制单色形状的话,只用顶点坐标然后填充颜色就行,但如果要绘制图片,或者给形状贴上纹理,就需要用到纹理坐标系。在metal中,纹理的原点坐标在左上角,这和openGL是不同的(OpenGL的纹理原点坐标在左下角)


纹理坐标.png

着色器

metal的着色器有主要顶点着色器、片元着色器、内核计算函数
vertex: 表示该函数是一个顶点着色函数,它将为顶点数据流中的每一个顶点执行一次然后为每一个顶点生成数据输出到绘制管线。
示例代码:

vertex Vertex vertex_func(constant Vertex *vertices [[buffer(0)]],uint vid [[vertex_id]]){
    return vertices[vid];
}

fragment: 表示该函数是一个片元函数,它将为片元数据流中的每一个片元和其关联执行一次然后将每一个片元的颜色数据输出到绘制管线中。
示例代码:

fragment float4 fragment_func(Vertex vert [[stage_in]]){
    return float4(1.0, 1.0, 0.0, 1.0);
}

kernel:表示该函数是一个并行计算着色函数,它可以被分配在一维/二维/三维线程中去执行。
示例代码:

kernel void blend(texture2d imageTexture [[texture(0)]],
                  texture2d faceTexture [[texture(1)]],
                  texture2d blendTexture [[texture(2)]],
                  uint2 gid [[thread_position_in_grid]]) {
    float width = faceTexture.get_width();
    float height = faceTexture.get_height();
    if ((gid.x >= width) || (gid.y >= height)) {
        return;
    }
    float4 face = faceTexture.read(gid);
    if(face.a > 0.0){
        blendTexture.write(face, gid);
    }
    else {
        uint2 pos = uint2(gid.x, gid.y);
        float4 image = imageTexture.read(pos);
        blendTexture.write(image, gid);
    }
}

着色器的基础语法规范可以参考苹果官方文档,文档比较复杂,还是全英文的,中文翻译可以参考这个专栏

Metal工作流程

Metal渲染管道流程


渲染管道流程.jpg

在OpenGLES中,图元装配有9种,在Metal中,图元装配只有五种,他们分别是:
MTLPrimitiveTypePoint = 0, 点
MTLPrimitiveTypeLine = 1, 线段
MTLPrimitiveTypeLineStrip = 2, 线环
MTLPrimitiveTypeTriangle = 3, 三角形
MTLPrimitiveTypeTriangleStrip = 4, 三角型扇
metal的驱动GPU进行绘制的流程如图所示,下面进行一些参数名词的解释


Metal驱动GPU绘制流程.png

基础名词解释

MTLDevice

可以理解为GPU对象,可以用如下方法获得:

let device = MTLCreateSystemDefaultDevice()
guard device != nil else {
   print("Metal is not supported on this device")
   return
}

上面说过只有iOS8以及A7芯片以上才支持Metal,所以MTLDevice可以为空,需要判断

MTLCommandQueue

有了GPU之后,需要创建一个渲染队列MTLCommandQueue,队列是单一队列,确保了指令能够按顺序执行,里面的对象是需要渲染的指令MTLCommandBuffer,可以支持多个CommandBuffer同时编码。通过MTLDevice可以获取MTLCommandQueue:

let queue:MTLCommandQueue = device?.makeCommandQueue()

MTKView

承接Metal绘制的视图,初始化方法为let viewiew = MTKView.init(frame: self.bounds, device: MTLCreateSystemDefaultDevice)
MTKView有两个delegate:

- (void)mtkView:(nonnull MTKView *)view drawableSizeWillChange:(CGSize)size;
- (void)drawInMTKView:(nonnull MTKView *)view;

drawInMTKView:方法是MetalKit每帧的渲染回调,可以在内部做渲染的处理
drawableSizeWillChange:方法是绘制区域发生改变的方法,在里面可以给绘图区域大小重新赋值
在demo中没有使用这两个delegate,而是直接重写了MTKView的draw方法,也能实现同样的效果
不用MTKView也可以使用CAMetalLayer,添加到当前view的layer上。
剩下的一些参数会通过一个画五角星例子具体说明。

Metal绘制步骤

1. 新建MTKView

可以用let viewiew = MTKView.init(frame: self.bounds, device: MTLCreateSystemDefaultDevice)初始化MTKView,demo中是使用storyBoard拖入使用的

2. 设置顶点数据

demo是画一个三角形、五角星,三角形的话需要提供三个顶点的坐标,五角星的话,由于Metal绘制都是通过画三角形去绘制,所以,需要绘制下图所示的十个三角形,加上中心点总计10个顶点数据


五角星顶点.jpg
func setFiveAngleData() {
    vertexData = [0.0, 0.0, 0.0, 1.0,
                  0.0, BIG_R*Y_SCALE, 0.0, 1.0,
                  SMALL_R*cos(54*RAD), SMALL_R*sin(54*RAD)*Y_SCALE, 0.0, 1.0,
                  BIG_R*cos(18*RAD), BIG_R*sin(18*RAD)*Y_SCALE, 0.0, 1.0,
                  SMALL_R*cos(18*RAD), -SMALL_R*sin(18*RAD)*Y_SCALE, 0.0, 1.0,
                  BIG_R*cos(54*RAD), -BIG_R*sin(54*RAD)*Y_SCALE, 0.0, 1.0,
                  0.0, -SMALL_R*Y_SCALE, 0.0, 1.0,
                  -BIG_R*cos(54*RAD), -BIG_R*sin(54*RAD)*Y_SCALE, 0.0, 1.0,
                  -SMALL_R*cos(18*RAD), -SMALL_R*sin(18*RAD)*Y_SCALE, 0.0, 1.0,
                  -BIG_R*cos(18*RAD), BIG_R*sin(18*RAD)*Y_SCALE, 0.0, 1.0,
                  -SMALL_R*cos(54*RAD), SMALL_R*sin(54*RAD)*Y_SCALE, 0.0, 1.0]
    indexData = [0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4 ,5, 0, 5, 6, 0, 6, 7, 0, 7, 8, 0, 8, 9, 0, 9 , 10, 0, 10, 1]
}

其中,vertexData是顶点数据,四个代表一个点(x,y,z,w);indexData是索引数据,三个为一组,代表顶点组成三角形的顺序

3. 设置纹理数据

将一张UIImage渲染到MKTView上,需要用到纹理数据MTLTexture,可以由以下方法获取:

func getTexture() {
        do {
            self.imageTexture = try MTKTextureLoader(device: self.device).newTexture(cgImage: image.cgImage!, options: [MTKTextureLoader.Option.SRGB:false])
        } catch {
            assertionFailure("Could not create Texture - \(error) ")
        }
        render(texture: imageTexture)
    }

然后需要将纹理坐标(二维)加入到顶点坐标中

4. 设置渲染管道

func render() {
    let library = device?.makeDefaultLibrary()!
    let vertex_func = library?.makeFunction(name: "vertex_func")
    let frag_func = library?.makeFunction(name: "fragment_func")
    let rpld = MTLRenderPipelineDescriptor()
    rpld.vertexFunction = vertex_func
    rpld.fragmentFunction = frag_func
    rpld.colorAttachments[0].pixelFormat = .bgra8Unorm
    do{
        try rps = device?.makeRenderPipelineState(descriptor: rpld)
    }catch let error{
        fatalError("\(error)")
    }
}

其中,MTLRenderPipelineDescriptor是渲染管道的描述符,可以设置顶点处理函数、片元处理函数、输出颜色格式等;

5. 具体渲染过程

override func draw(_ rect: CGRect) {
    if let drawable = currentDrawable, let rpd = currentRenderPassDescriptor {
        let dataSize = vertexData!.count * MemoryLayout.size
        // 设置顶点buffer
        vertexBuffer = device?.makeBuffer(bytes: vertexData!, length: dataSize, options: [])
        // 设置索引buffer
        indexBuffer = device?.makeBuffer(bytes: indexData!, length: MemoryLayout.size * indexData!.count , options: [])
        // 设置背景色为红色
        rpd.colorAttachments[0].clearColor = MTLClearColorMake(1.0, 0.0, 0.0, 1.0)
        // 创建commandBuffer
        let commandBuffer = commandQueue!.makeCommandBuffer()
        let commandEncode = commandBuffer?.makeRenderCommandEncoder(descriptor: rpd)
        commandEncode?.setRenderPipelineState(rps!)
        // 设置顶点缓存
        commandEncode?.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
        // 绘制
        commandEncode?.drawIndexedPrimitives(type: .triangle, indexCount: indexBuffer!.length / MemoryLayout.size, indexType: MTLIndexType.uint16, indexBuffer: indexBuffer!, indexBufferOffset: 0)
        // 结束设置
        commandEncode?.endEncoding()
        // 显示绘制内容
        commandBuffer?.present(drawable)
        // 提交命令编码器
        commandBuffer?.commit()
    }
}

绘制的第一步是从commandQueue里面创建commandBuffer,commandQueue是整个app绘制的队列,commandBuffer存放每次渲染的指令,commandQueue内部存在着多个commandBuffer,CommandEncoder是命令编码器

6. Shader处理

#include 
using namespace metal;

struct Vertex {
    float4 position [[position]];
};

vertex Vertex vertex_func(constant Vertex *vertices [[buffer(0)]],uint vid [[vertex_id]]){
    return vertices[vid];
}

fragment float4 fragment_func(Vertex vert [[stage_in]]){
    return float4(1.0, 1.0, 0.0, 1.0);
}

Shader的语法与C++类似,参数名前面的是类型,后面的[[ ]]是描述符。
其中vertex函数是读取顶点信息,fragment函数是进行颜色填充处理,这里填充的是黄色。
运行出来的效果:

Metal五角星.jpeg

demo地址

Metal图片处理

上面第一个例子通过一个五角星讲了最基础的Metal用法,接下来会用另一个例子来讲一下图片的一些处理效果,是将一张平面图片转化为三维图片。

实现思路

二维图片顶点坐标z轴的值都为0,要变成三维,只需要z轴不为0即可,因此可以在将图片显示到MTKView上后,处理顶点着色器,给每个顶点的z轴都赋上相应的值,这里可以用每个像素点的RGB转YUV的算法,取每个像素的亮度Y的值,作为z方向上的深度值,具体转化为:
inVertex.position.z = 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
如果只给z轴不为0的值,图片虽然是三维的,但是依旧只能看到正面的部分,z轴的深度部分是看不到的,所以还需要给图片加入相应的空间矩阵变换,会用到三种矩阵MVP:分别是模型矩阵(model)、观察矩阵(view)、投影矩阵(Projection):

投影矩阵:GLKMatrix4MakePerspective

透视投影是模仿人眼观察物体,有远小近大的效果,所以这种投影更加真实
由于Metal并没有对应的矩阵运算的框架(不知道到底有没有,我没找到),所以这里采用的是GLKit框架里面的矩阵,下同理。
来看一下这个投影矩阵的参数,是个4X4矩阵

public func GLKMatrix4MakePerspective(_ fovyRadians: Float, _ aspect: Float, _ nearZ: Float, _ farZ: Float) -> GLKMatrix4
投影矩阵.jpg

其中参数fovyRadians定义视野在Y-Z平面的角度,范围是[0.0,180.0];参数aspect是投影平面宽度与高度的比率;参数nearZ和farZ分别是近远裁剪面到视点(沿Z负轴)的距离,它们总为正值。

观察矩阵:GLKMatrix4MakeLookAt

来看一下这个观察矩阵的参数,是个4X4矩阵

public func GLKMatrix4MakeLookAt(_ eyeX: Float, _ eyeY: Float, _ eyeZ: Float, _ centerX: Float, _ centerY: Float, _ centerZ: Float, _ upX: Float, _ upY: Float, _ upZ: Float) -> GLKMatrix4

这个矩阵模拟了人眼或者摄像机在空间的一些位置参数,设置这9个参数以控制摄像机从不同的角度观察物体:
eyeX, eyeY, eyeZ定义摄像机的位置;
centerX, centerY, centerZ摄像机看向的点;
相机还可以旋转360,upX, upY, upZ三个参数确定相机向上的朝向。

模型矩阵

模型矩阵就设为单位矩阵GLKMatrix4Identity,这里可以给他加上旋转变换矩阵GLKMatrix4Rotate,给模型矩阵加上旋转矩阵是让物体自己动,如果修改上面观察矩阵的一些参数,就是摄像机或人眼围绕着物体在动,无论哪一种方法都能模拟出物体旋转的效果,这里选择的是物体自己动,也就是在模型矩阵上加入旋转变化。
然后将mvp直接相乘,结果再与顶点坐标相乘。注意相乘的顺序先进行模型矩阵变换,再是观察矩阵,最后是投影矩阵变换,所以应为
P * V * M * vertex.position。

绘制

基本流程跟上面说的大体一致,但是需要额外设置一些东西

func draw(renderEncoder: MTLRenderCommandEncoder, texture: MTLTexture, type: MTLPrimitiveType) {
    self.uniforms = Uniforms.init()
    self.vertexData = buildPointData()
    self.indexData = buildIndexData()
    
    // 设置顶点和索引buffer    
    let vertexBufferSize = MemoryLayout.stride * self.vertexData.count
    let indexBufferSize = MemoryLayout.stride * self.indexData.count
    let vertexBuffer = device.makeBuffer(bytes: self.vertexData, length: vertexBufferSize, options: MTLResourceOptions.cpuCacheModeWriteCombined)
    let indexBuffer = device.makeBuffer(bytes: self.indexData, length: indexBufferSize , options: MTLResourceOptions.cpuCacheModeWriteCombined)
    
    // 设置MVP矩阵及其buffer  
    let aspect = self.bounds.width / self.bounds.height
    // 这里将pinch手势的缩放参数传入投影矩阵,就能进行缩放
    var GLKPerspective = GLKMatrix4MakePerspective(GLKMathDegreesToRadians(degree), Float(aspect), 0.1, 10.0)
    var GLKView = GLKMatrix4MakeLookAt(0.0, 0.0, 2.0, 0, 0, 0.0, 0.0, 1.0, 0.0)
    var GLKModel = GLKMatrix4Identity
    // 这里将pan手势的滑动距离参数经过调整后传到旋转矩阵中,就能旋转滑动了
    GLKModel = GLKMatrix4Rotate(GLKModel, centerX, 1, 0, 0)
    GLKModel = GLKMatrix4Rotate(GLKModel, centerY, 0, 1, 0)
    let perspectiveBuffer = device.makeBuffer(bytes: &GLKPerspective, length: MemoryLayout.size, options: .cpuCacheModeWriteCombined)
    let viewBuffer = device.makeBuffer(bytes: &GLKView, length: MemoryLayout.size, options: .cpuCacheModeWriteCombined)
    let modelBuffer = device.makeBuffer(bytes: &GLKModel, length: MemoryLayout.size, options: .cpuCacheModeWriteCombined)
    
    // 设置顶点着色器的缓冲区,index要对应shader   
    renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
    renderEncoder.setVertexBuffer(modelBuffer, offset: 0, index: 1)
    renderEncoder.setVertexBuffer(viewBuffer, offset: 0, index: 2)
    renderEncoder.setVertexBuffer(perspectiveBuffer, offset: 0, index: 3)
    let uniformBuffer = device.makeBuffer(bytes: self.uniforms.data(), length: Uniforms.sizeInBytes(), options: MTLResourceOptions.cpuCacheModeWriteCombined)
    renderEncoder.setVertexBuffer(uniformBuffer, offset: 0, index: 4)
    // 设置纹理缓冲区
    renderEncoder.setFragmentTexture(texture, index: 0)
    // 设置顶点纹理,因为顶点的z轴数据需要获取亮度值,所以需要把纹理传到顶点着色器中
    renderEncoder.setVertexTexture(texture, index: 0)
    
    // 图元装配
    if type == .point {
       renderEncoder.drawPrimitives(type: .point, vertexStart: 0, vertexCount: self.vertexData.count / 5)
    } else if type == .triangle {
       renderEncoder.drawIndexedPrimitives(type: .triangle, indexCount: self.indexData.count, indexType: MTLIndexType.uint32, indexBuffer: indexBuffer!, indexBufferOffset: 0)
    } else {
       renderEncoder.drawPrimitives(type: .lineStrip, vertexStart: 0, vertexCount: self.vertexData.count / 5)
    }
}

着色器shader:

vertex VertexOut vertex_func(uint vid [[vertex_id]],
                             texture2d diffuse [[texture(0)]],
                             // 对应缓冲区的下标
                             const device VertexIn* vertexIn [[buffer(0)]],
                             const device float4x4& model [[buffer(1)]],
                             const device float4x4& view [[buffer(2)]],
                             const device float4x4& perspective [[buffer(3)]],
                             const device Uniforms& uniforms [[buffer(4)]])
{
    VertexOut outVertex;
    VertexIn inVertex = vertexIn[vid];
    float4 color =  diffuse.sample(s, inVertex.uv);
    // 亮度作为z轴深度值
    inVertex.position.z = 0.3 * (0.299 * color.r + 0.587 * color.g + 0.114 * color.b);
    outVertex.uv = inVertex.uv;
    // MVP矩阵相乘
    outVertex.position = perspective * view * model * float4(inVertex.position);
    outVertex.pointSize = uniforms.pointSizeInPixel;
                                                                 
    return outVertex;
};

fragment float4 fragment_func(VertexOut infrag [[stage_in]], texture2d diffuse [[texture(0)]]) {
    
    // 纹理采样
    float4 imageColor = diffuse.sample(s, infrag.uv);
    return imageColor;
};

运行效果


3D图片.gif

demo地址

你可能感兴趣的:(iOS开发:Metal初探(画五角星+将图片3D化))