Metal 系列教程(1)- Metal 介绍及基本使用

24,210

Metal 介绍及基本使用

最近做的一个技术研究,metal 的国内相关资料很少,所以整理了这一系列文章,希望能帮到有用的人。

什么是 Metal

Metal 是一个和 OpenGL ES 类似的面向底层的图形编程接口,通过使用相关的 api 可以直接操作 GPU ,最早在 2014 年的 WWDC 的时候发布,并于今年发布了 Metal 2。
Metal 是 iOS 平台独有的,意味着它不能像 OpenGL ES 那样支持跨平台,但是它能最大的挖掘苹果移动设备的 GPU 能力,进行复杂的运算,像 Unity 等游戏引擎都通过 Metal 对 3D 能力进行了优化, App Store 还有相应的运用 Metal 技术的游戏专题。

Metal 具有特点

  • GPU 支持的 3D 渲染
  • 和 CPU 并行处理数据 (深度学习)
  • 提供低功耗接口
  • 可以和 CPU 共享资源内存

这样可能有些抽象,层级的关系大概如下,我们平时更多的接触的上面两层。:
UIKit -> Core Graphics -> Metal/OpenGL ES -> GPU Driver -> GPU

GPU 相关知识

为了更好的理解 Metal 的工作流程和机制,这里补充一些 GPU 工作相关流程。

手机包含两个不同的处理单元,CPU 和 GPU。CPU 是个多面手,并且不得不处理所有的事情,而 GPU 则可以集中来处理好一件事情,就是并行地做浮点运算。事实上,图像处理和渲染就是在将要渲染到窗口上的像素上做许许多多的浮点运算。
通过有效的利用 GPU,可以成百倍甚至上千倍地提高手机上的图像渲染能力。如果不是基于 GPU 的处理,手机上实时高清视频滤镜是不现实,甚至不可能的。
精细到屏幕绘制的每一帧上,每次准备画下一帧前,屏幕会发出一个垂直同步信号(vertical synchronization),简称 VSync
屏幕通常以固定频率进行刷新,这个刷新率就是 VSync 信号产生的频率。

一般来说,计算机系统中 CPU、GPU、屏幕是以上面这种方式协同工作的。CPU 计算好显示内容提交到 GPU,GPU 渲染完成后将渲染结果放入帧缓冲区,随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给屏幕显示。

基础流程

这边以通过 Metal 渲染一个三角形作为例子,来介绍一下基本的使用。

Xcode 版本 8.3.3 ,语言 Objective-C

需要注意的是 Metal 必须在真机上运行,并且至少要是 A7 处理器,就是 5s 或者以上。

初始化

新建一个普通的工程 Single View Application,在 VC 中导入 Metal Framework。

#import <Metal/Metal.h>
MTLDevice

都说是操作 GPU 了,当然我们要拿到 GPU 对象,Metal 中提供了 MTLDevice 的接口,代表了 GPU。


//获取设备
id<MTLDevice> device = MTLCreateSystemDefaultDevice();
if (device == nil) {
    NSLog(@"don't support metal !");
    return;
}

当设备不支持 Metal 的时候会返回空。

MTLDevice 代表 GPU 的接口,提供了如下的能力:

  • 查询设备状态
  • 创建 buffer 和 texture
  • 指令转换和队列化渲染进行指令的计算
MTLCommandQueue

有了 GPU 之后,我们需要一个渲染队列 MTLCommandQueue,队列是单一队列,确保了指令能够按顺序执行,里面的是将要渲染的指令 MTLCommandBuffer,这是个线程安全的队列,可以支持多个 CommandBuffer 同时编码。
通过 MTLDevice 可以获取队列


id<MTLCommandQueue> queue = self.device.newCommandQueue;
MTKView

要用 Metal 来直接绘制的话,需要用特殊的界面 MTKView,同时给它设置对应的 device 为我们上面获取到 MTLDevice,并把它添加到当前的界面中。

_mtkView = [[MTKView alloc] initWithFrame:self.view.frame device:_device];
[self.view addSubview:_mtkView];

渲染

我们配置好 MTLDevice,MTLCommandQueue 和 MTKView 之后,我们开始准备需要渲染到界面上的内容了,就是要塞进队列中的缓冲数据 MTLCommandBuffer 。
简单的流程就是先构造 MTLCommandBuffer ,再配置 CommandEncoder ,包括配置资源文件,渲染管线等,再通过 CommandEncoder 进行编码,最后才能提交到队列中去。

MTLCommandBuffer

有了队列之后,我们开始构建队列中的 MTLCommandBuffer,一开始获取的 Buffer 是空的,要通过 MTLCommandEncoder 编码器来 Encode ,一个 Buffer 可以被多个 Encoder 进行编码。

MTLCommandBuffer 是包含了多种类型的命令编码 - 根据不同的 编码器 决定 包含了哪些数据。 通常情况下,app 的一帧就是渲染为一个单独的 Command Buffer。MTLCommandBuffer 是不支持重用的轻量级的对象,每次需要的时候都是获取一个新的 Buffer。

Buffer 有方法可以 Label ,用来增加标签,方便调试时使用。

临时对象,在执行之后,唯一有效的操作就是等到被执行或者完成的时候的回调,同步或者通过 block 回调,检查 buffer 的运行结果。

创建

  • MTLCommandQueue - commandBuffer 方法 ,只能加到创建它的队列中。
  • 获取 retain 的对象 commandBufferWithUnretainedReferences 能够重用 一般不推荐

这里我们通过如下方法创建

//command buffer
    id<MTLCommandBuffer> commandBuffer = [_queue commandBuffer];

执行

  • enqueue 顺序执行
  • commit 插队尽快执行 (如果前面有 commit 就还是排队等着)

监听结果

commandBuffer.addCompletedHandler { (buffer) in
}
commandBuffer.waitUntilCompleted()

commandBuffer.addScheduledHandler { (buffer) in
}
commandBuffer.waitUntilScheduled()
创建 Metal 资源

接下来我需要把我们需要绘制的内容 encode 到我们上面生成 MTLCommandBuffer 中。

现在我们要配置需要绘制的内容,即资源。
在 Metal 中资源分为两种:

  • MTLBuffer 代表着未格式化的内存,可以是任何类型的数据。 Buffer 用来做顶点着色和计算状态。
  • MTLTexture 代表着有着特殊纹理类型和像素格式的格式化的图像数据。用来做顶点,面和计算的源

我们这里是要画一个三角形,所以要有三个顶点,然后需要绘制三角形的图片。
分别用 MTLBuffer 来读入三个顶点。

在 Metal 中是归一化的坐标系,以屏幕中心为原点(0, 0, 0),且是始终不变的。面对屏幕,你的右边是x正轴,上面是y正轴,屏幕指向你的为z正轴。长度单位这样来定:窗口范围按此单位恰好是(-1,-1)到(1,1),即屏幕左下角坐标为(-1,-1),右上角坐标为(1,1)。

所以我们要画在中间一个正三角形的话,三个顶点分别为

(0.577, -0.25, 0.0, 1.0)
(-0.577, -0.25, 0.0, 1.0)
(0.0, 0.5, 0.0, 1.0)

在 Metal 里面代表顶点需要 4 个 float ,代表 x,y,z,w。最后二位我们绘制 2D 界面的时候默认为0.0 和 1.0,w 是为了方便 3D 计算的。

我们要把顶点数据转为字节,通过 MTLDevice 的 - (id )newBufferWithBytes:(const void *)pointer length:(NSUInteger)length options:(MTLResourceOptions)options;
方法构造为 MTLBuffer 。

static const float vertexArrayData[] = {
        // 前 4 位 位置 x , y , z ,w
        0.577, -0.25, 0.0, 1.0,
        -0.577, -0.25, 0.0, 1.0,
        0.0,  0.5, 0.0, 1.0,
    };

id<MTLBuffer> vertexBuffer = [_device newBufferWithBytes:vertexArrayData
                                         length:sizeof(vertexArrayData)
                                        options:0];

有了顶点 Vertex 之后,我们来构建面 Fragment。这里我们用一张图片作为我们的三角形的贴图。
首先获取图片的 image 对象:

UIImage *image = [UIImage imageNamed:name];

接下来通过 MTKTextureLoader 来构建 MTLTexture

 MTKTextureLoader *loader = [[MTKTextureLoader alloc]initWithDevice:self.device];
    NSError* err;
    id<MTLTexture> sourceTexture = [loader newTextureWithCGImage:image.CGImage options:nil error:&err];

    return sourceTexture;
Shader (着色器) 和 Pipeline (渲染管线)

资源有了,我们要告诉 GPU 怎么去使用这些数据,这里就需要 Shader 了,这部分代码是在 GPU 中执行的,所以要用特殊的语言去编写,即 Metal Shading Language,它是 C++ 14的超集,封装了一些 Metal 的数据格式和常用方法。
你可以添加多个 Metal 文件,最后都会编译到二进制文件default.metallib 中。
通过 Xcode 的 File - New - File 菜单,新建一个 Metal 文件。

meta
meta

添加下面两个函数,分别代表顶点的处理函数,和 片段处理函数。

#include <metal_stdlib>

using namespace metal;


typedef struct
{
    float4 position;
    float2 texCoords;
} VertexIn;


typedef struct
{
    float4 position [[position]];
    float2 texCoords;
}VertexOut;



vertex VertexOut myVertexShader(const device VertexIn* vertexArray [[buffer(0)]],
                                unsigned int vid  [[vertex_id]]){

    VertexOut verOut;
    verOut.position = vertexArray[vid].position;
    verOut.texCoords = vertexArray[vid].texCoords;
    return verOut;

}






fragment float4 myFragmentShader(
                                VertexOut vertexIn [[stage_in]],
                            texture2d<float,access::sample>   inputImage   [[ texture(0) ]],
                                 sampler textureSampler [[sampler(0)]]
                             )
{
    float4 color = inputImage.sample(textureSampler, vertexIn.texCoords);
    return color;

}

两个结构体
VertexIn 和 VertexOut
里面的 float4 和 float2 代表着 4 个和 2 个浮点数的向量。
可以通过如下方式构造和取值,具体的不展开可以查看相关文档。

float4(1.0) = float4(1.0,1.0,1.0,1.0)
float4 test = float4(1,2,3,4)
test.x = test.r = 1
test.y = test.g = 2
test.z = test.b = 3
test.w = test.a = 4
...

myVertexShader 为方法名,vertex 代表是一个顶点函数 VertexOut 代表返回值,该方法有两个入参。

  • vertexArray 后面的 buff(0) 代表去后面配置的 index 为 0 的 MTLBuffer 资源
  • vid 代表着进入的顶点的 id 即顺序。
    其实还有很多入参通过查阅文档可以看到

    • [[vertex_id]]
    • [[instance_id]]
    • [[base_vertex]]
    • [[base_instance]]

这里可以对顶点进行处理,如转向,3D 场景下的光影的计算等等,然后返回处理之后的顶点信息,这里直接返回,并没有做额外的处理。

myFragmentShader 同上,fragment 代表是一个处理片段的方法,方法有三个入参

  • VertexOut vertexIn [[stage_in]] 代表着从顶点返回的顶点信息

  • texture2d inputImage [[ texture(0) ]] 读入的图片资源

  • sampler textureSampler 采样器

顶点着色器返回了 VertexOut 结构体,通过 [[stage_in]] 入参,它的值会是根据你的渲染的位置来插值。所以这个方法的主要内容就是根据,之前返回的顶点信息,去图像中采样得到相应位置的样色,并返回颜色。

渲染管线

着色器这边的工作已经完成,下面我们需要把它和我们的 CommandBuffer 关联起来,就需要我们的 PipelineState 渲染管线了。

渲染管线就好比是 CPU 和 GPU 直接的管道,通过它来配置运行在 GPU 中的顶点和段着色器,就是我们写在 metal 中的编译好的代码,多个 c++ 函数的组合。

PipelineState 对象是线程安全的,所以这个对象是可以复用的,不同的 CommandBuffer 都可以使用它,创建它是有性能消耗的,建议和 Device 和 Queue 一起初始化并作为全局对象。

生成 PipelineState 对象需要获取我们刚刚写在 Metal 中的几个函数。
通过下面的方法,我们可以得到代表整个 Metal 的函数库 MTLLibrary 对象。

id<MTLLibrary> library = [_device newDefaultLibrary];

通过 MTLLibrary 的 newFunctionWithName 方法,可以得到对应的方法。

[library newFunctionWithName:@"myVertexShader"];

下面我们开始构造我们的 MTLRenderPipelineState

    //构造Pipeline
    MTLRenderPipelineDescriptor *des = [MTLRenderPipelineDescriptor new];


    //获取 shader 的函数
    id<MTLLibrary> library = [_device newDefaultLibrary];
    des.vertexFunction = [library newFunctionWithName:@"myVertexShader"];
    des.fragmentFunction = [library newFunctionWithName:@"myFragmentShader"];
    des.colorAttachments[0].pixelFormat = MTLPixelFormatBGRA8Unorm;

    //生成 MTLRenderPipelineState
    NSError *error;
    _pipelineState = [_device newRenderPipelineStateWithDescriptor:des
                                                             error:&error];
MTLCommandEncoder 编码器

有了资源文件,渲染管线之后,我们可以开始做最后的步骤了,构造 MTLCommandEncoder 编码器。
指令编码器包括 渲染 计算 位图复制三种编码器。

  • MTLRenderCommandEncoder 渲染 3D 编码器
  • MTLComputeCommandEncoder 计算编码器
  • MTLBlitCommandEncoder 位图复制编码器 拷贝 buffer texture 同时也能生成 mipmap

mipmap 指的是一种纹理映射技术,将低一级图像的每边的分辨率取为高一级图像的每边的分辨率的二分之一,而同一级分辨率的纹理组则由红、绿、蓝三个分量的纹理数组组成。由于这一个查找表包含了同一纹理区域在不同分辨率下的纹理颜色值,因此被称为 Mipmap。比如一张 64x64 的图片,会生成 32x32,16x16 等,需要 20x20 的话就会用 32x32 和 16x16 的进行计算,大大的提高渲染的效率。

这里我们是为了渲染一个三角形,所以这里用的是 MTLRenderCommandEncoder 。
相关代码如下

  1. 创建 MTLRenderPassDescriptor 描述符 配置一些基本参数
  2. 通过描述符构建 Encoder
  3. 配置 VertexBuffer 后面的 index 就是 Shader 里面对应 [[buffer[0]]] 的 0 【index 最多是 31 个】
  4. 配置 FragmentTexture
  5. 设置渲染的顶点配置(这里设置为三角 从第一个顶点开始取 取 3 个)
  6. 编码结束
 //render des
    MTLRenderPassDescriptor *renderDes = [MTLRenderPassDescriptor new];
    renderDes.colorAttachments[0].texture = drawable.texture;
    renderDes.colorAttachments[0].loadAction = MTLLoadActionClear;
    renderDes.colorAttachments[0].storeAction = MTLStoreActionStore;
    renderDes.colorAttachments[0].clearColor = MTLClearColorMake(0.5, 0.65, 0.8, 1); //background color


    //command encoder
    id<MTLRenderCommandEncoder> encoder = [commandBuffer renderCommandEncoderWithDescriptor:renderDes];
    [encoder setCullMode:MTLCullModeNone];
    [encoder setFrontFacingWinding:MTLWindingCounterClockwise];
    [encoder setRenderPipelineState:self.pipelineState];
    [encoder setVertexBuffer:self.vertexBuffer offset:0 atIndex:0];
    [encoder setFragmentTexture:textture atIndex:0];

   //set render vertex
    [encoder drawPrimitives:MTLPrimitiveTypeTriangle
                vertexStart:0
                vertexCount:3];

    [encoder endEncoding];

绘制

编码结束之后,就可以开始准备提交到 GPU 了。
配置需要绘制的 Layer,获取 MTKView 的 Layer 就可以。

CAMetalLayer *metalLayer = (CAMetalLayer*)[_mtkView layer];
id<CAMetalDrawable> drawable = [metalLayer nextDrawable];

//commit
[commandBuffer presentDrawable:drawable];
[commandBuffer commit];

现在所有的工作就都完成了,运行项目就可以看到如下的三角形了,里面填充的是我之前导入的图片。

调试

如何进行调试和评估性能呢?
这里 iOS 提供了两个工具

  • Xcode 中的 Capute GPU Frame
  • Instruments 中的 Metal System Trace

Capute GPU Frame
第一个是用来 Debug 的工具,运行的时候点击 Debug ,选择 Capute GPU Frame,就会看到如下的界面,相关的说明我已经附在图上了,用法和 Capute View Hierachy 很像。


比较强大的一个功能是点击动态更新的按钮可以在修改完之后直接应用,避免了 app 编译带来的时间消耗。

Metal System Trace

  1. 打开 Instruments 之后选择需要调试的应用
  2. 点击 record 之后开始录制
  3. 完成之后点击停止,分析之后会有如下界面

从上到下分别是 Application 在 CPU 中执行,对应的是 Buffer 和 Encoder 的初始化工作
随着箭头往下是 Graphic Driver Activity ,在 GPU 驱动处理,这部分操作也是在 CPU 中。
再往下就是进入到 GPU 了,就部分才是真正的工作。
最后是到 Display 就是展示界面了,在 Display 下面是 Vsync 信号,代表着同步信号,用来刷新界面。

放大之后可以看到详细的 Buffer / Render ,而且上面显示的名字,正是 之前设置的 Label 的名字。

总结

流程总结

最后我们再来通过下面这个图来梳理下的流程。

  1. 配置 Device 和 Queue
  2. 获取 CommandBuffer
  3. 配置 CommandBufferEncoder
  4. 配置 PipelineState
  5. 创建资源
  6. Encoder Buffer 【如有需要的话可以用 Threadgroups 来分组 Encoder 数据】
  7. 提交到 Queue 中

Metal 能力

根据不同的 CommandBufferEncoder 可以提供不同的能力,除了优秀的 3D 渲染能力,Metal 还能提供强大的计算能力。

在 WWDC 2015,苹果发布了 Metal Performance Shaders (MPS) 框架,iOS 9 上的一组高性能的图像滤镜,其实就是边写好的 Shaders,提供了优秀的图像处理能力。同时还提供了高性能的矩阵运算的 Shaders ,能用来做机器学习的运算,在 GPU 上运行卷积神经网络。

而且非常棒的是,今年的 WWDC 2017 上 Metal 也将开始支持 macOS 。
更多的实践可以参考苹果的官方文档:
Metal 的最佳实践

我们可以用来做什么?

  • 图片处理 滤镜/调整
  • 视频处理
  • 机器学习
  • 大计算工作 分担 CPU 压力

参考

MetalProgrammingGuide : developer.apple.com/library/con…
metal-image-processing : www.invasivecode.com/weblog/meta…
Metal Shading Language : developer.apple.com/metal/Metal…
the-metal-shading-language-in-practice : www.objc.io/issues/18-g…
metal-performance-shaders-in-swift : metalbyexample.com/metal-perfo…