一、背景和技术选型
关于技术方案的选型,最权威的肯定是Metal for OpenGL Developers。
API | 优势 | 劣势 |
---|---|---|
OpenGLES | 跨端 | 不支持多线程操作;不支持异步处理;将于iOS12弃用 |
Metal | 低CPU开销(Low CPU overhead)、多线程执行(Multithread execution)、资源和同步控制(Resource and synchronization control) | 无法做到跨端共享 |
下面是我在OpenGLES中文网站上找到关于坐标系统的一张图:
局部空间和世界空间
局部空间:在建模软件中创建一个模型(比如一个正方体),针对该模型自身而言:它所有的顶点都是在局部空间。就比如后面我们要指定的顶点坐标;
世界空间:各个不同的模型都堆砌到一起的空间。将某个模型放置在世界的不同位置,使用模型变换矩阵来实现。比如平移、旋转、缩放等等;
观察空间
即用我们眼睛或者摄像机观察世界空间内的各个模型。这里相机和模型之间就形成了一个向量(Look-at
direction),然后我们再定义基于相机的Up向量(Up direction),如下图:
这就构成了一个二维坐标系,然后基于这个二维坐标系的单位向量计算叉乘得到第三个向量。这里主要做的就是将相机、物体,以及up向量(用于表征相机向上的向量)构成的坐标系,变换到标准笛卡尔坐标系中来:
裁剪空间
我们期望所有的坐标都能落在一个特定的范围内,且任何在这个范围之外的点都应该被裁剪掉(Clipped),比如下图中绿色的球。被裁剪掉的坐标就会被忽略,所以剩下的坐标就将变为屏幕上可见的片段;从观察坐标到裁剪坐标的变换是由投影变换矩阵实现的,主要有正射投影和透视投影:
屏幕空间
屏幕对应的空间它对应的是归一化屏幕坐标,使用左手坐标系。具体可以看下图:
二、Device和library
下图是WWDC中提到的各个类之间的相互关系:
Device
Device在Metal中的概念和OpenGL中的Context概念是类似的:
Device的创建代码如下:
_device = MTLCreateSystemDefaultDevice();
Library
它的作用是什么呢?通俗来说和我们平时见到的Static Library(静态库)类似,它就是包含了基于MSL(Metal
Shader Language)编写的函数,比如基于vertex的顶点着色器、基于fragment的片元着色器以及基于kernel数据计算函数。
- 1、newDefaultLibraryWithBundle在指定bundle下面加载名称为default.metallib文件;
- 2、newLibraryWithFile\newLibraryWithURL\newLibraryWithData和第一点的区别在于,我们可以自己指定Metal Library的名称,即xxx.metallib。例如基于xxxxParaboloidShaders.metal编译的xxxxParaboloidShaders.metallib;
- 3、newLibraryWithSource常用于加载比较轻量的shader,它的参数是传入一个字符串的源码(和openGL类似);
生成自定义的Library,我们可以通过Xcode的File->New->Target->Metal Library来生成:
这里我们用前面介绍的方式生成了一个自定义的Library,然后在代码中去创建对应的对象:
_library = [_device newLibraryWithFile:[[NSBundle mainBundle] pathForResource:@"xxxx" ofType:@"metallib"] error:&error];
三、渲染
流水线
这是Metal基本的渲染流水线:
- Vertices:表示确定的所有顶点;
- Vertex function:对于可编程管线中,使用类C++的Metal shader language(MSL)语言编写。GPU会在处理每一个顶点的时候都会调用该函数;
- Rasterization:光栅化阶段,可以简单理解为由顶点确定了三角形之后,需要将该三角形切成多个正方形格子来填充三角形。它来调用下面Fragment function;
- Fragment function:对于可编程管线中,用于处理每个片段。这里可以简单理解为处理每一个像素点;
- Pixels:生成最终的像素点;
RenderPipleline
由于现在都是可编程管线,所以Metal引入了MTLRenderPipelineDescriptor作为流水线的描述符。它描述了流水线所需的各种配置(比如上图中的vertext
Function、Fragment Function,在这里先简单配置这两个着色器):
MTLRenderPipelineDescriptor *pipelineDescriptor = [[MTLRenderPipelineDescriptor alloc] init];
pipelineDescriptor.vertextFunction = vertextFunciton;
pipelineDescriptor.fragmentFunction = fragmentFunction;
pipelineDescriptor.vertexDescritpor = vertexDescriptor;
id<MTLRenderPipelineState> plState = [self.device newRenderPipelineStateWithDescriptor:pipelineDescritptor error:&error];
Vertex Descriptor
顶点描述符的作用主要是为了描述顶点的数据的组合方式以及步长等等。在语音音波按钮这边的顶点数据类型为:
基于前面的描述,对应的vertex Descriptor实现:
Depth Buffer
深度缓冲(或z缓冲(z-buffer))中的深度值(Depth Value),是用来确定一个片段是否处于其它片段的后方。下图描述了深度测试在流水线中的位置:
深度测试(Depth Testing)被启用的时候,Metal会将一个片段的深度值与深度缓冲的内容进行对比。如果对比通过(即未被覆盖),深度缓冲将会更新为新的深度值。如果深度测试失败了,片段将会被丢弃。
Depth Texture
现在我们回到pipelineDescriptor,并将其depthAttachmentPixelFormat的值设置为MTLPixelFormatDepth32Float。如果直接运行Metal会告诉我们,如果没有指定对应的纹理信息需要将depthAttachmentPixelForma设置为MTLPixelFormatInvalid。因此接着是创建对应的纹理信息:
Blending
除了上面介绍的顶点着色器和片元着色器之外,还需要指定流水线的颜色混合模式,在Metal中是使用混合因子来实现颜色混合的。首先看看Blend在流水线中的位置,很显然,它是在Fragment Function之后的。
Blending——混合因子和Operations
为了理解混合因子以及相关的操作,Apple官方的文档是这样说的:Blending使用高可配置的操作,使得FragmentFunction(片元着色器)返回的值和每个像素 attachment 的值进行混合。
四个常数混合因子:
五个BlendOperation:
混合操作将源值(source)乘以 MTLBlendFactor,将目标值乘以目标混合因子(DBF, Destination Blend Factor),并用 MTLBlendOperation 指定的方式进行组合(如果MTLBlendOperation将值同时设置为min或者max,那么将会忽略SBF和DBF)。
一种常见的混合操作是用source alpha定义目标颜色:
RGB = (Source.rbg * 1.0) + (Dest.rgb * (1 - Source.a));
ALPHA = (Source.rbg * 1.0) + (Dest.a * (1 - Source.a));
对应的Metal实现细节:
RenderPass
在看了RenderPipeline配置之后,现在就来看看单次渲染。首先是MTLRenderPassDescriptor,它描述了在每次渲染过程中各个像素目标的颜色、以及深度相关的信息(就是我们前面讲到的内容再整合到renderPass中)。
在上面提到的loadAction可以简单的理解为,我们要渲染一帧图像时我们需要做什么?常规的操作有加载原有的纹理信息(比如上一帧的残存);clear清除所有的数据,以指定的clearColor属性来作为初始值;最后一种就是完全不关心渲染开始时候的操作。在这里我们选择的是清除所有信息,以指定的值作为渲染开始时像素的初始值;
而storeAction则和loadAction是相反的,它是在渲染结束的时候我们要做的操作。这里我们使用存储当前这一帧的数据(MTLStoreActionStore)。
CommandBuffer和CommandEncoder
通俗的讲buffer和encoder,encoder是将当前某一帧数据渲染所需的所有信息进行编码,而buffer管理者这些encoder。并随后统一提交到commandQueue中。这里有个比较经典的图,整合了前面所讲的相关内容:
由于上面图中有一些和我们这一节讲的内容不相关的元素,下图是我将那些内容去掉之后结合我自己的理解画的:
下面代码展示了commandQueue和commandEncoder整合前面提及的内容:
异步渲染
它稍微和上面的图形渲染关系疏远一点,离我们平时的OC开发接近一点,因此就拿它作为本节的收尾吧。在这里我们使用NSThread作为常驻线程的方式来实现,首先是创建子线程并启动它:
在创建完线程之后,我们需要创建一个CADisplayLink来获取当前屏幕刷新的回调。并设置对应的刷新帧率,在当前的业务场景下设置的刷新帧率是30fps:
接着我们去方法runThread中实现线程的常驻工作。这里需要值得注意的地方是:我们自定义了一个runLoopMode:kMMSVoiceSearchParaboloidRunloopMode,其目的是通过不同的Mode将我们的渲染任务和其他任务不会相互影响。若我们想要停止该线程,只需要将_continueRunLoop设置为NO即可。
四、顶点
顶点生成先以二维平面的方式平铺顶点的方式,比如我这边只生成60*60个顶点(来构成一个二维平面)。其大致的图示如下:
这里用到的数学公式。其中第一个是双曲抛物面公式(用于生成马鞍面,公式一):
平滑过渡圆角则是基于公式二:
x的取值范围是[-1.0,1.0],基于此公式二我们可以计算出当前的y值。在计算了y值之后,我们可以根据公式一计算出对应z值。最后我们通过Metal提供的API,将这些顶点数据封装成MTLBuffer以便Metal后续使用:
在生成了顶点之后,我们需要告诉Metal各个定点之间是如何关联起来的?即这么多顶点,到底是谁先谁后呢?这时候就需要指定这些顺序了,大致的思想如下:
同样的我们也生成对应MTLBuffer以供后续使用:
在前面的『CommandBuffer和CommandEncode』遗留未尽的事情,现在就来将对应的顶点数据写入到commandEncoder中。这里有个通俗的概念说一下:我们作为生产者的内存数据(顶点,全局变量等等)放入到commandEncoder中,而流水线里面的vertext
shader和fragment shader作为消费者则可以假想去commandEncoder中获取数据。
这里的Index是在顶点着色器中获取对应数据的下标,这个下标随后解释。这个offset是对于相同index情况下,使用不同的offset来存数据。现在我们就根据前面的顶点数据来绘制面片。不过drawIndexedPrimitives比较重要,所以着重说一下:
- primitiveType:图元的类型。常见的有Point(点)、Line和LineStrip(线)、Triangle和TriangleStrip(三角形)。其中不带Strip后缀的Line和Triangle,对于Line而言如果顶点数目是不是偶数则丢弃最后一个顶点,对于Triangle而言如果顶点数目不是3的倍数则丢弃剩下的顶点;反之,带Strip后缀的值其表示不会丢弃任何顶点;由于在我们这个场景下不能丢弃顶点,而且我们在设计顶点索引的时候也是基于三角形的,因此这里我们选择MTLPrimitiveTypeTriangleStrip。
- indexCount:顶点索引个数;
- indexBuffer:就是我们前面生成的顶点索引数据;
- indexBufferOffset:偏移量。我们这里只有一份顶点索引,因此偏移量为0即可;
- instanceCount:表示我们要用同一份数据绘制的模型个数。我们这里需要绘制3个面片,因此这里输入3;
着色器
就如我们最开始看到的那样,这里我们需要顶点着色器和片元着色器。编写着色器需要使用基于C++的Metal Shader Language,我们先基于下图简单得看看你们关键字和语法(语法基本上就是C++的语法):
vertex:顶点着色器;
fragment:片元着色器;
我们看到无论是顶点着色器函数,还是片元着色器函数他们的参数都有一个限定符。那么我们就依次来看这里的限定符:
- [[buffer(x)]]:这个限定符表明该参数是咱们前面commandEncoder中调用setVertexBuffer写入的数据。而这里x在上图中是0或者1,就是在调用setVertexBuffer方法时的index值(不是offset);
- [[vertext_id]]:顶点着色器是在渲染每个顶点的时候都会执行依次该函数。因此这个vertex_id当前的顶点下标(因为我们所有的顶点是一个数组);
- [[stage_in]]:在片元着色器中的表示该参数是由顶点着色器传入进来的。即顶点着色器会return一个数据,而这个数据会根据stage_in限定符传入到片元着色器中;
- [[instance_id]]:这个限定符在上图中没有呈现,但是由于我们在commandEncoder中绘制的时候是传入的实例个数是3。所以我们需要根据instance_id来区分这3个实例中的不同实例;
着色器中的坐标变换
坐标变化就是将一个模型坐标一步一步转换为屏幕坐标的过程(实际上只需要我们转换到裁剪坐标即可,最后一步是设置了视口之后Metal帮我们完成了):
到这里渲染部分基本上结束了,而MSL更加详细的文档可以参考Apple的 《Metal Shading Language Specification》。