WWDC 2018:写给 OpenGL 开发者们的 Metal 开发指南

Session 604 : Metal for OpenGL Developers前端

关于做者:能够在这里找到一些关于个人信息。c++

引言

Metal 是 Apple 开发的一款图形引擎。本文将对比 OpenGL,详细介绍 Metal 的对象模型以及开发思想,旨在帮助 OpenGL 开发者更容易地转向 Metal 开发。数组

因为 Metal 与 OpenGL 同为底层图形引擎,所以阅读本文须要必定的图形基础。本文假定读者已经具有必定图形学知识并对 OpenGL 熟悉。markdown

为什么选择 Metal

对于广大图形开发者来讲,有着很是多的工具可供选择。数据结构

上层框架

对于普通的 2D、3D 图形开发者来讲,有 Apple 原生的SpriteKitSceneKit等框架,而对于游戏开发者来讲,有Unity虚幻等强大的第三方游戏引擎。多线程

在条件容许的状况下,开发者们应该尽量使用上层框架进行开发,以专一于业务,屏蔽图形学细节。以上上层框架应是开发者们的首选。闭包

OpenGL

但在某些特定场景如要求跨平台、包大小限制等场景下,开发者们可能不得不使用 OpenGL 来开发。由于 OpenGL 跨平台,性能佳,且不占用过大的包大小等优势,使 OpenGL 至今仍然普遍被使用。并发

但有着超过25年历史的 OpenGL 技术自己,随着现代图形技术的发展,遇到了一些问题:app

  • 现代 GPU 的渲染管线已经发生变化。
  • 不支持多线程操做。
  • 不支持异步处理。

随着图形学的发展,OpenGL 自己设计上存在的问题已经影响了 GPU 真正性能的发挥,所以 Apple 设计了 Metal。框架

Metal

为了解决这些问题,Metal 诞生了。

它为现代 GPU 设计,并面向 OpenGL 开发者。它拥有:

  • 更高效的 GPU 交互,更低的 CPU 负荷。
  • 支持多线程操做,以及线程间资源共享能力。
  • 支持资源和同步的控制。

Metal 简化了 CPU 参与渲染的步骤,尽量地让 GPU 去控制资源。与此同时,拥有更现代的设计,使操做处于可控,结果可预测的状态。在优化设计的同时,它仍然是一个直接访问硬件的框架。与 OpenGL 相比,它更加接近于 GPU,以得到更好的性能。

小结

古老的 OpenGL 已经没法适应现代图形技术的发展,而 Metal 为现代图形技术而设计,是 OpenGL 的优良替代品。

Apple 早在 2014 年就推出了 Metal,通过四年的铺垫,于今年 WWDC 祭出了大杀器:

  • OpenGL 和 OpenCL 将于 macOS 10.14 弃用。
  • OpenGL ES 将于 iOS 12 弃用。

虽然目前 API 还可以使用,可是被标记弃用的 API 极可能会在将来的某一刻被永远抹去。

所以,是时候开始使用 Metal 了。

Metal 的对象模型

在 OpenGL 中,全部资源如 Buffer,Texture 等都依附于一个上下文(Context)。

而在 Metal 中,状况则彻底不一样。Metal 使用一系列更小,职责更加清晰的对象去分别管理各种资源,开发者们从对象名中一眼就能认出他的职责。

Metal Device

Metal Device 能够看作是 GPU 的入口,今后为入口能够去生成,操做更加具体的资源和对象。

由 Metal Device 能够继续构造出 Texture、Buffer 和 Pipeline(Pipeline 中包含了着色器程序)等资源对象。

熟悉 OpenGL 的读者会发如今 OpenGL 中也存在这些概念。它们的用途是大同小异的,但在构造和管理等的工程设计上有很大的区别。

Metal 资源对象

纹理(Texture)、缓冲区(Buffer)等资源对象将直接从 Metal Device 对象建立,建立之后,对象是不可变的,但内部的图像数据是可变的。

渲染管线(Render Pipeline)、深度模板(Depth Stencil)等对象经过状态描述(Descriptor)建立,对象以及内部数据都是不可变的。

因为不可变对象的存在,使得 Metal 在只须要在建立对象时检查一次对象便可。而 OpenGL 在每次绘制之前都须要检查对象是否发生变化,在这一点上 Metal 将会得到更好的性能。

除此以外,因为不可变对象的存在,在多线程中,Metal 不须要使用线程锁,所以也会获得更好的性能。

Metal 命令系统

Metal 将渲染进一步抽象成了命令,以命令和命令队列的形式进行管理。

命令队列

上文中提到的 Metal Device,能够生成一个命令队列(Command Queue)。这个命令队列由 Metal 自行维护,而开发者只须要往这个队列里面丢命令就能够了。

命令

Metal 中的命令(Command Buffer)是 GPU 任务的抽象封装,近似于OpenGL 中的一次绘制调用(draw call)。若是读者阅读过 cocos2d-x 的源码的话就会发现,cocos2d-x 的渲染系统也进行了相似的封装,以便于进行合并、批量回执等优化操做。

Metal 中的命令分为四种类型:

  • 渲染命令(Render Command):渲染图像的命令。
  • 块传输命令(Blit Command):纹理和缓冲区进行复制的命令。
  • 计算命令(Compute Command):GPU 并行计算的命令。
  • 并行渲染命令(Parallel Render Command):并发的渲染命令。

这些命令被添加到命令队列之后,Metal 会自行按顺序执行命令。

命令编码器

命令如何被建立呢,能够经过命令编码器(Command Buffer Encoder)建立。上文提到命令有四种类型,因此命令编码器也有四种类型,分别编码四个类型的命令。命令编码阶段彻底由 CPU 负责。

在命令编码器中,开发者能够具体设置命令的各项参数,并最终生成命令对象交于命令队列。

小结

Metal 的对象模型在设计上有许多优于 OpenGL 的地方。例如不可变对象的存在能够简化多线程操做以及节约对象检查的时间,命令系统的存在使渲染系统能更好地进行优化。

除了这些,Metal 还拥有优秀的面向对象封装。相比于 API 晦涩,处处使用数字句柄的 OpenGL,在开发和维护的效率上都有质的飞跃。

在了解了 Metal 的对象模型之后,就能够开始实战了。

以 OpenGL 程序移植为例,来具体体验一下 Metal 的实战。

Metal 实战

本节将会分构建时、初始化时和渲染时这三个阶段来说。

构建时

在程序编译构建之时,Metal 的着色器程序将会被提早编译。

Metal 着色器语言

Metal 所使用的着色器语言 Metal SL 是一套基于 C++ 扩展的语言。class、namespace、enum 等 C++ 中的特性均可以应用在 Metal 的着色器中,甚至还可使用 template。相比基于C语言扩展的 OpenGL SL,可谓是质的改变。

固然,毫无疑问的,向量矩阵运算,图形相关的类必然也是内建好的,下面来具体看看 Metal 的着色器程序该怎么写。

渲染时所需的顶点着色器:

vertex VertexOutput myVertexShader(uint vid [[ vertex_id ]],
                                   device Vertex * vertices [[ buffer(0) ]],
                                   constant Uniforms & uniforms [[ buffer(1) ]])
{
    VertexOutput out;
    out.clipPos = vertices[vid].modelPos * uniforms.mvp;
    out.texCoord = vertices[vid].texCoord;
    return out;
}
复制代码

vertex前缀表明这是一个顶点着色器,VertexOutput是函数的返回值,这是一个自定义的结构体,具体结构暂且先无论。myVertexShader为函数名,后面跟的是参数。

这个函数有两个参数,一个是uint类型的参数名为vid,然后面跟的[[ vertex_id ]]是参数的句柄。

这是一个新的概念,Metal 给每一个参数扩展了一个句柄,这和 OpenGL 相似。每一个参数会有个句柄,在 CPU 往 GPU 传递参数时须要这个对应的句柄才能够传过来。那么这里vid参数的句柄为vertex_id,这是一个内建的句柄,表示绘制时顶点的索引数。

第二个参数为类型为Vertex指针,Vertex也是个自定义结构体,具体内容暂且无论。它是一个结构体指针,句柄为[[ buffer(0) ]]。在 Metal 中,不一样类型的参数的句柄是分开计算的。vertices参数的句柄为buffer0

同理,下面是个片元着色器函数:

fragment float4 myFragmentShader(VertexOutput in [[ stage_in ]],
                                 constant Uniforms & uniforms [[ buffer(3) ]],
                                 texture2d<float> colorTex [[ texture(0) ]],
                                 sampler texSampler [[ sampler(1) ]])
{
    return colorTex.sample(texSampler, in.texCoord * uniforms.coordScale);
}

复制代码

它的输入时顶点着色器的输出,且拥有纹理、采样器等参数。返回值是四维浮点数组,即该片元的颜色值。

下面来看看这两个自定义的结构:

struct Vertex {
    float4 modelPos; 
    float2 texCoord;
};

struct VertexOutput {
    float4 clipPos [[position]];
    float2 texCoord;
};
复制代码

Vertex由 CPU 输入,得到模型的三维坐标以及贴图的映射坐标,通过顶点着色器处理以后输出VertexOutput,这个结构做为片元着色器的输入,进入片元着色器计算片元颜色。这个流程与传统的 OpenGL 相同。

结构中包含内简句柄position的参数,片元着色器的输入结构中必须包含有此句柄的参数,不然会没法经过编译。

使用着色器程序

有了上文中的着色器程序,使用时如何传递参数给着色器程序呢?

在 OpenGL 中,开发者须要首先要根据参数名得到参数句柄,而后利用句柄进行参数传递(高版本的 OpenGL 也支持经过 layout 写死句柄),而使用 Metal 时,是在编码器编码阶段经过编码器和句柄直接进行参数传递。

[renderEncoder setFragmentBuffer:myUniformBuffer offset:0 atIndex:0];
[renderEncoder setFragmentTexture:myColorTexture atIndex:0];
[renderEncoder setFragmentSampler:mySampler atIndex:1];
复制代码

函数中使用的 index 便是该参数的句柄。经过编码器的这一系列操做,已经可以将参数正确传递到着色器程序中了。

SIMD

SIMD 是 Apple 提供的一款方便原生程序与着色器程序共享数据结构的库。

开发者能够在头文件中定义一系列结构,在原生代码和着色器程序中经过#include包含这个头文件,二者就都有了这个结构的定义。

使用 SIMD 能最大程度减小因为结构 layout 上的不一样引起的问题。

其余

Metal 的着色器程序在编译时会被编译成类型为metallib的文件,在这个文件中,着色器程序并无真正被编译成二进制,而是只通过了编译器前端的中间态。在运行时会被真正编译成二进制,这一步仅须要彻底编译的时间的一半。

固然,Metal 也支持在运行时编译着色器源码,但 Apple 并不支持这么作。这样许多问题没法在编译时定位,不方便开发与维护。

小结

Metal 使用了比 OpenGL 更为高级的着色器语言,并在编译时编译着色器代码以快速暴露错误,以及 SIMD 等工具库能够帮助开发者们更快速地开发与维护图形代码。

初始化时

在 Metal 初始化时,须要根据上文提到的对象模型,构造一系列对象。

Device

Device 象征着一个 GPU,全部纹理、缓冲区等都基于这个对象产生。

id<MTLDevice> device = MTLCreateSystemDefaultDevice();
复制代码

在 iOS 中,只有一个 GPU,所以只会有一个MTLDevice对象,在 macOS 中,多块显卡就会带来多 GPU,即多个MTLDevice对象。

Command Queue

id<MTLCommandQueue> commandQueue = [device newComandQueue];
复制代码

Texture

建立 Texture 时,将使用一个 TextureDescriptor 对象。它包含了一系列 Texture 所须要的属性,并使用这个 Descriptor,从 Device 建立一个 Texture Object 对象。Texture Object 会管理一块内存,真正存放纹理的数据。

对于真正存放纹理数据的内存,开发者选择存储模式以控制 CPU 和 GPU 如何管理这片内存:

  • Shared Storage:CPU 和 GPU 都可读写这块内存。
  • Private Storage: 仅 GPU 可读写这块内存,能够经过 Blit 命令等进行拷贝。
  • Managed Storage: 仅在 macOS 中容许。仅 GPU 可读写这块内存,但 Metal 会建立一块镜像内存供 CPU 使用。

Apple 推荐在 iOS 中使用 shared mode,而在 macOS 中使用 managed mode。

在了解了内存管理方法后,建立一个 Texture 实现以下:

MTLTextureDescriptor *textureDescriptor = [MTLTextureDescriptor new];
textureDescriptor.pixelFormat = MTLPixelFormatBGRA8Unorm;
textureDescriptor.width = 512;
textureDescriptor.height = 512;
textureDescriptor.storageMode = MTLStorageModeShared;

id<MTLTexture> texture = [device newTextureWithDescriptor:textureDescriptor];
复制代码

填充图像数据:

NSUInteger bytesPerRow = 4 * image.width;

MTLRegion region =
{
    {0,0,0}, //Origin
    { 512, 512, 1 } // Size
};

[texture replaceRegion:region
           mipmapLevel:0
             withBytes:imageData
           bytesPerRow:bytesPerRow];
复制代码

与 OpenGL 不一样的是,Metal 中的 Texture:

  • Metal 中采样器不属于纹理的一部分。
  • Metal 不会翻转纹理,原点在左上角,OpenGL 的原点是左下角。
  • Metal 不转换数据格式。

Metal 还提供了 MetalKit 来快速建立 Texture。

Buffer

Metal 中,全部无结构的数据都使用 Buffer 来管理。与 OpenGL 相似的,顶点、索引等数据都经过 Buffer 管理。

因为数据是无结构的,所以如何管理由开发者本身制定。如如下方法能够利用编译器来计算数据偏移来管理数据:

id<MTLBuffer> buffer = [device newBufferWithLength:bufferDataByteSize
                                       options:MTLResourceStorageModeShared];

struct MyUniforms *uniforms = (struct MyUniforms*) buffer.contents;
uniforms->modelViewProjection = modelViewProjection;
uniforms->sunPosition = sunPosition;
复制代码

这种方式下,开发者须要考虑内存对其因素。float3int3uint3等结构占用的内存空间并不是12字节,而是16字节。若是确实须要这样打包数据,须要使用packed_float3等这类数据结构。

Pipeline

Pipeline 一样须要经过一个 Descriptor 来建立。如下是建立一个渲染管线须要的参数:

在制定了着色器函数,各种渲染状态之后,就可使用这个 Descriptor,经过 Device 建立一个 Pipeline 对象。如下是建立渲染管线的实现:

id<MTLLibrary> defaultLibrary = [device newDefaultLibrary];

id<MTLFunction> vertexFunction = [defaultLibrary newFunctionWithName:@"vertexShader"];
id<MTLFunction> fragmentFunction = [defaultLibrary newFunctionWithName:@"fragmentShader"];

MTLRenderPipelineDescriptor *pipelineStateDescriptor = [MTLRenderPipelineDescriptor new];
pipelineStateDescriptor.vertexFunction = vertexFunction;
pipelineStateDescriptor.fragmentFunction = fragmentFunction;
pipelineStateDescriptor.colorAttachments[0].pixelFormat = MTLPixelFormatRGBA8Unorm;
id<MTLRenderPipelineState> pipelineState;
pipelineState = [device newRenderPipelineStateWithDescriptor:pipelineStateDescriptor
                                                       error:nil];
复制代码

在 OpenGL 中,一个 Shader Program 对象只包含顶点着色器和片元着色器,而在 Metal 中,包含了以上描述提到的全部属性。所以在构造一个渲染管线之前,必须肯定所有这些参数之后才可以建立。

小结

以上资源对象都须要付出昂贵的开销。

Pipeline 建立须要后台编译,Texture 和 Buffer 须要分配内存。所以这些操做应尽量在初始化时一次性操做。

渲染时

初始化结束之后,程序将会进入主题 —— 渲染循环。

在 Metal 中,命令系统对渲染循环进行了封装。开发者们只要在一个渲染循环内将要作的事编码成命令后丢入命令队列便可。

命令

上文中已经介绍了 Metal 中的四种命令,它们都派生自同一个父类,它们的使用方法是同样的。一个完整的命令执行闭环是这样的:

  • 使用 CPU 进行命令编码,放入编码队列等待执行。
  • 等待过程当中,开发者能够选择阻塞 CPU,或让 CPU 去作别的事情。
  • GPU 按次序执行命令,执行完毕释放阻塞或经过闭包回调 CPU。

阻塞式的完整的闭环实现以下:

id<MTLCommandBuffer> commandBuffer = [commandQueue commandBuffer];

// 编码命令...

[commandBuffer commit];

[commandBuffer waitUntilCompleted];
复制代码

非阻塞式的闭环实现以下:

id<MTLCommandBuffer> commandBuffer = [commandQueue commandBuffer];

// 编码命令...

commandBuffer addCompletedHandler:^(id<MTLCommandBuffer> commandBuffer) {
	// 回调 CPU...
}

[commandBuffer commit];
复制代码

固然,非阻塞的使用方法更值得推荐。如下是一种推荐使用的资源三重缓冲的模式,利用回调来使 CPU 和 GPU 更高效地配合。

资源更新

建立三帧的资源缓冲区来造成一个缓冲池。CPU 将每一帧的数据按顺序写入缓冲区供 GPU 使用。

当 GPU 触发回调时,CPU 将释放该帧的缓冲区,并于下一帧使用。

以此来减小 GPU 和 CPU 互相等待的环节,提升性能。三重缓冲的实现以下:

首先构造缓冲区以及信号量:

id <MTLBuffer> myUniformBuffers[3];

dispatch_semaphore_t frameBoundarySemaphore = dispatch_semaphore_create(3);

NSUInteger currentUniformIndex = 0;

复制代码

在渲染循环中经过信号量来实现三重缓冲的循环。

dispatch_semaphore_wait(frameBoundarySemaphore, DISPATCH_TIME_FOREVER);
    
currentUniformIndex = (currentUniformIndex + 1) % 3;

[self updateUniformResource: myUniformBuffers[currentUniformIndex]];

[commandBuffer addCompletedHandler:^(id<MTLCommandBuffer> commandBuffer) {
    dispatch_semaphore_signal(frameBoundarySemaphore);
}];

[commandBuffer commit];

复制代码

以上就是 Apple 推荐的资源更新方式。

渲染

在有了资源的更新方式之后,就要进行渲染了。

渲染命令和渲染管线的状态息息相关,所以在建立渲染命令之前须要知道渲染管线的状态。开发者们能够经过一个状态描述对象来描述渲染管线的状态。

由渲染管线状态得到渲染命令编码器的实现以下:

MTLRenderPassDescriptor * desc = [MTLRenderPassDescriptor new];
desc.colorAttachment[0].texture = myColorTexture;
desc.depthAttachment.texture = myDepthTexture;

id <MTLRenderCommandEncoder> encoder = [commandBuffer renderCommandEncoderWithDescriptor: desc];
复制代码

GPU 渲染图像的步骤大体能够分为:加载、渲染、存储。开发者能够指定这三个步骤具体作什么事。

通过这个步骤会获得最终的图像。注意途中的深度缓冲区在存储步骤时候被标记为了 Don't care,结果会被抛弃(discard),不会被存储。

是否须要抛弃随图像渲染的用途而定,若是是用于显示的图像,那么深度信息已经没有用了,没有必要被存储。而若是是用于其余表面贴图或是用于后处理,深度信息可能仍然有用,须要存储下来。

存储的步骤是相对昂贵的,由于显存带宽是很是宝贵的资源,所以应该尽量抛弃没必要要的数据。

如何指定这三个步骤的行为呢?

MTLRenderPassDescriptor * desc = [MTLRenderPassDescriptor new];
desc.colorAttachment[0].texture = myColorTexture;

// 指定三个步骤的行为
desc.colorAttachment[0].loadAction = MTLLoadActionClear;
desc.colorAttachment[0].clearColor = MTLClearColorMake(0.39f, 0.34f, 0.53f, 1.0f);
desc.colorAttachment[0].storeAction = MTLStoreActionStore;

id <MTLRenderCommandEncoder> encoder = [commandBuffer renderCommandEncoderWithDescriptor: desc];
复制代码

显示

在通过一系列绘制命令之后,图像已经被离屏绘制到了一个 Texture 上。那么如何把图像最终显示在屏幕上呢?

关于显示的容器,Apple 为开发者提供了MTKView,这是一个来自MetalKit的视图。这个视图包含了一个 drawable 对象。对于CoreAnimation的开发者来讲 drawable 这个概念应该不会陌生。

有了这个视图,就能够用于显示 Texture 上的图像了。

MTLRenderPassDescriptor* renderPassDescriptor = view.currentRenderPassDescriptor;

id <MTLRenderCommandEncoder> renderCommandEncoder =
[commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor];

// 编码渲染命令...

[renderCommandEncoder endEncoding];

[commandBuffer presentDrawable:view.currentDrawable];
[commandBuffer commit];
复制代码

最终使用 Command Buffer 的presentDrawable方法便可。

当这个命令被 GPU 执行完成之后,就能看到图像被显示在屏幕上了。

小结

本节从 Metal 渲染图像的构建时、初始化时以及渲染时三个步骤详细描述了 Metal 渲染图像的流程。通过本节,相信大多数读者已经清楚 Metal 渲染图像的流程了。

为了更清晰地与 OpenGL 的渲染流程做对比,如下是 OpenGL 和 Metal 渲染图像的流程对比:

Tips

Metal & OpenGL 混编

虽然 Metal 和 OpenGL 的职能是同样的,但并不意味着程序中只能有 Metal 或 OpenGL 一方。

Metal 和 OpenGL 在程序中能够被混合使用。IOSurfaceCVPixelBuffer等数据结构能够提供数据交换支撑。

这意味着开发者们若是想要移植 OpenGL 程序到 Metal,并不须要一口气所有移植过去,由于它们支持混编。

关于混编,开发者们能够参考这里的代码。

多线程编码

因为 Metal 针对 CPU 多线程进行了设计,所以能够尽量发挥多线程的做用,利用多线程进行命令编码。

Apple 已经为开发者作好了多线程编码的准备工做,开发者可使用MTLParallelRenderCommandEncoder编码器来进行多线程并行编码。

GPU 计算

Metal 原生支持 GPU 计算。

这意味着许多能够高并发的任务能够经过 Metal 的计算命令交给 GPU 执行了。

在粒子系统,物理模拟等规模比较大的计算任务上,GPU 能够本身计算,本身渲染,在必定程度上解放了 CPU。

调试工具

关于调试工具,Apple 也已经为开发者们准备好了。

GPU 调试器,可用于单步调试:

着色器调试器,可像调试普通函数同样调试着色器函数:

着色器性能调试器:

渲染管线调试器:

Metal 追踪调试器,可用于调试 Metal 完整行为:

结语

Metal 解决了不少 OpenGL 设计自己存在的问题。它是一款真正为现代设备而设计的图形引擎。它的对象模型,多线程支持通过了精心设计以知足现代开发的须要。通过四年的发展和沉淀,Metal 自己以及配套工具已经日趋成熟。

随着今年 WWDC 苹果宣布 OpenGL 和 OpenCL 被弃用,宣布着 Metal 的时代即将到来。那么,是时候开始使用 Metal 了。

查看更多 WWDC 18 相关文章请前往 老司机x知识小集xSwiftGG WWDC 18 专题目录