iOS的渲染

1、像素是如何显示到屏幕上


从最初的电子枪显示器说起,电子枪逐行读取像素点,逐行发射到屏幕上,每当一行扫描完成,显示器会发出水平同步信号HSync;然后继续下一行,直到最后一行完成一帧的绘制,电子枪恢复到起点继续下一帧的绘制,显示器会发出一个垂直同步信号VSync。对于iOS设备,VSync信号的间隔是16.7ms,也就是1秒60帧。

实际绘制过程中,由CPU发出各种绘制指令,GPU执行绘制指令,得到最终的像素,像素会输出到帧缓存(Frame Buffer)中,视频控制器每隔16.7ms从头读取Frame Buffer来输出到最终的显示器上面。
对比示意图,假设只有一个Frame Buffer,意味着GPU和CPU必须在VSync发出的瞬间完成前面所有的工作,否则在视频控制器显示的过程中修改Frame Buffer,那么显示器就会在这一帧的前半部分显示上一帧的内容,下半部分显示当前帧的内容,造成画面断层的怪异现象。为了解决这个问题,大多数平台都引入了多缓存机制,比如iOS平台的双缓存,Android平台的三缓存机制。

双缓存技术中,Frame Buffer分为前帧缓存和后帧缓存,视频控制器永远只读取前帧缓存,GPU只控制后帧缓存的更新,在VSync信号发出的时候,前帧缓存和后帧缓存会瞬间切换(指针的交换)。这样当视频控制器在显示当前帧的时候,GPU可以同时渲染后一帧到后帧缓存中。
当CPU和GPU在16.7ms的时间内无法完成前面的工作,那么后帧缓存就无法提交,这一帧会被丢弃,视频控制器继续显示之前的一帧,从而产生卡顿。

2、GPU在干啥

CPU的逻辑运算单元众多,适合做通用的,复杂的计算任务,但是核心数少,一般不会超过2位数。而GPU是专门设计用来做图形处理的,核心数众多,逻辑处理单元少,因此GPU适合做浮点运算,并行计算。总的来说GPU的工作大部分是计算量大,但没什么技术含量,而且要重复很多很多次的。比方你要算几亿次一百以内加减乘除,最好的办法就是雇上几十个小学生一起算,一人算一部分,反正这些计算也没什么技术含量,纯粹体力活而已,这几十个小学生就是GPU的几十个核心。



CPU会从内存中读取数据进行各种运算处理,GPU也是一样的工作原理,只不过它控制专门的内存。CPU可以高效的读写自己控制的内存,但是不能直接读写GPU控制的内存,同样GPU也可以高效读写自己控制的内存,而不能直接读写CPU控制的内存。OpenGL、Metal这类的图形API的作用就是协调GPU和CPU的工作。实际的图形绘制中,CPU通过OpenGL/Metal来给GPU发布各种绘制指令,同时将自己内存的数据拷贝给GPU控制的内存以供调用,从而完成最终的绘制。因此,引起GPU绘制卡顿,可能是下面两方面的原因:
1、绘制指令过于复杂
2、频繁拷贝大量内存数据



基于GPU多核,并行计算的特点,主要应用在图形渲染和大数据计算两个方面:
1、图形渲染

前面说GPU核数众多,适合简单重复性的工作。而图形渲染中大部分都是这种类型的计算,比如大量的顶点矩阵计算,颜色插值等等,这类工作都是简单的加减乘除,而且不存在计算之间的相互依赖,对于GPU来说都是小case。
2、并行计算
这几年火起来的大数据处理,人工智能等,使得GPU有了另一个用武之地。特别是神经网络这类需要大量数据训练,存在大量简单数据计算的场景。Apple的Metal中,甚至直接提供了卷积神经网络的相应API。

3、CPU在干啥

由于GPU只适合做一些简单的重复运算,不适合做复杂的运算,单靠GPU是无法完成日常的渲染准备任务的,比如各种元素的布局,文本的布局,计算等,这些复杂的计算都是由CPU完成的。iOS提供了简单易用的上层渲染框架,比如Core Animation,UIKit等,这些框架都在CPU上面完成了大量的工作,最后提取出GPU绘制指令和数据,交由GPU完成最后的渲染。
1、对象的创建,维护,销毁
比如UIView和CALayer等,UIView是对CALayer的进一步封装,并且添加了点击事件的处理,这些构成了UIKit和Core Animation的基础。
2、图层树的维护,解析,绘制指令和数据的生成
CALayer是一个树形结构,实际上,Core Animation为了显示动画,除了图层树之外,还有呈现树和渲染树。Core Animation会对这三种树进行维护,同时每一帧都会打包所有图层和动画属性,然后通过IPC(内部处理通信)发送到渲染服务。渲染服务使用渲染树对每一帧做如下操作:
a、对所有的图层属性计算中间值,设置OpenGL/Metal几何形状(纹理化的三角形,即顶点数据)
b、设置GPU计算需要的其他参数和状态,如变换矩阵等
c、设置纹理
d、在屏幕上渲染可见的三角形(在GPU中执行)
3、布局,Autolayout的计算
UIView和CALayer的frame最终会映射为GPU中的顶点数据,这个计算是在CPU中完成,另外如果使用了Autolayout,CPU还会计算一堆方程组。
4、文本的布局,绘制
GPU是无法直接绘制文本的,所有的文本绘制最终都是转为Texture然后交由GPU绘制,而文本内容转为Texture的过程涉及到文本的大小,布局,合成等计算,都是在CPU上面完成。具体来说iOS提供了TextKit和CoreText等框架来实现文本的异步绘制和缓存。
5、图片的加载,解码
对于图片资源,是不能直接上传到GPU内存中成为Texture的,除了某些特定的压缩格式之外(比如PVRTC)。对于普通的jpg或者png图片都是需要解压为Bitmap然后再上传到GPU内存成为Texture的。[UIImage imageWithNamed:]加载一张png图片,得到的UIImage是没有解压的,如果把它应用到UIImageView上面,解压操作会在将要渲染之前进行,这些细节被Core Animation隐藏了。这个操作是在主线程中进行的,并且很耗时,如果只是展示一些静态的图片,不会有大的问题,但到了列表滑动展示大量图片的时候,这种耗时就不能忽略了。
为了优化这种卡顿问题,YYImage和SDWebImage等第三方库都提供了在后台线程预先解码图片的方案,下面的代码截取自SDWebImage:


6、Core Graphics绘制
如果实现了UIView的-drawRect:方法,或者CALayerDelegate的-drawLayer:inContext:方法,那么会在CPU内存中开辟一个等大小的Bitmap画布,绘制结束后通过IPC将Bitmap传到渲染服务,然后上传到GPU成为Texture进行渲染,而在CPU中开辟内存进行渲染比较耗时,同时不同的上传Texture做合成也是耗时操作,这也是为什么尽量不要实现-drawRect:方法的原因。

4、OpenGL,Metal

OpenGL是跨平台的2D,3D渲染接口,现今大部分平台都支持OpenGL,各大游戏引擎也都支持OpenGL。OpenGL在移动平台上叫做OpenGL ES,现在iOS和Android上主流的版本是OpenGL ES2和OpenGL ES3。除了OpenGL之外,底层渲染API还有:
Windows的DirectX;
Apple在iOS8推出的Metal,现在已经发展到Metal2;
Khronos Group(OpenGL也是他们搞的)新推出Vulkan,旨在替代OpenGL,其前身是AMD的Mantle。Android7开始支持Vulkan,但是往后看iOS不大可能会支持Vulkan。
不论是OpenGL还是Metal,在做渲染工作的时候,主要的渲染流程都是一样的,都是基于管线(Pipeline)进行的,下面对GPU的通用渲染概念做简单的介绍:

渲染管线(Pipeline)

可以看做一个流水线,数据经过一个个的工序加工成最后的结果输出到帧缓存,如下图所示,其中标注为Programmable的是可编程的工序,对应为顶点着色器(Vertex Shader)和片段着色器(Fragment Shader)。



1、顶点数据经过Vertex Shader的处理做顶点变换;
2、图元装配,比如装配成三角形,点,线等;
3、光栅化为像素点;
4、经过Fragment Shader着色;
5、图层混合;
6、输出到帧缓存。

数据拷贝(Data Transfer)

前面已经说过GPU是无法直接读取CPU控制的内存数据的,因此需要将数据从RAM拷贝到VRAM,在PC上GPU有专门的显存,在移动平台上,CPU和GPU共享同一块内存(不代表可以直接读取)。GPU计算的时候再从自己控制的内存VRAM中读取数据:


图元(Primitives)

所有的复杂图形都是由简单图形构成的,GPU能绘制的简单图形点,线,三角形。比如下面的几种形式对应OpenGL中的GL_POINTS、GL_LINES、GL_LINE_STRIP、GL_TRIANGLES、GL_TRIANGLES_STRIP



下面的锥形是由众多的三角形构成的,游戏中几乎所有的渲染对象,人物,UI,皮肤等等都是由或多或少的三角形构成的,一个最简单的UIView是由两个三角形构成的。这些三角形经过光栅化之后,要么贴图,要么着色,最终成为呈现在屏幕上的像素。



为什么会是三角形作为最小的图元单位(点和线是特殊情况才用到)?我个人的理解:
1、三角形可以构造各种复杂的形状,包括3D物体;

2、复杂图形的三角化的生成和处理有比较成熟高效的算法;
3、可以很方便的计算某个点在三角形内还是外面;
4、三角形可以确定一个平面,可以根据三个点的顺序确定正面还是反面。

光栅化(Rasterization)

光栅化就是确定哪些像素点用来绘制图元,对于三角形图元,即测试像素的中心点是否在三角形内。


顶点着色器(Vertex Shader)

属于渲染管线中的可编程节点,最大的作用就是做顶点位置计算,比如将UIKit坐标系的点映射为设备坐标系下的点,还有骨骼动画里面计算关节位置等,粒子动画里面计算粒子发射位置等。

片段着色器(Fragment Shader)

属于渲染管线中的可编程节点,可以用来着色,设置纹理贴图,还可以做各种滤镜效果,光照效果等,凡是对像素做的操作均可以在这里实现。GPUImage中的各种滤镜效果其实就是一个个写好的Fragment Shader,这些Fragment Shader叠加起来又可以产生其他的滤镜效果。

着色

着色是对光栅化之后的像素点赋予RGBA值,比如下面的三个顶点是红蓝绿的图元,每个像素点的像素值采用线性插值进行赋值,可以得到渐变颜色的三角形。如果想绘制纯色的三角形,三个顶点用一个颜色值即可。


纹理采样贴图

贴图也是对光栅化之后的像素点赋予RGBA值,不同的是RGBA值是从纹理中取出来的,而不是从顶点颜色值插值而来。由于图片大小不可能完全与要显示的像素大小相同,这里就涉及到纹理采样。比如一张100X100的纹理显示在200X200的像素上,那么纹理上的一个RGBA可能需要展示多次,或者两个RGBA插值来展示在中间的像素上,这就是不同的采样算法。


颜色混合(Blend)

具有透明度的图元叠加在一起展示的时候,就涉及到混合。混合就是GPU以之前帧缓存中的像素颜色D和当前绘制图元的颜色S为参数,根据特定的计算公式得到最终的颜色值,比如iOS中的图层混合用的下面的公式,Sa为当前图元颜色的透明度。OpenGL和Metal中还提供了很多其他的混合公式。


Draw Call

接触过游戏引擎的同学应该大多听说过Draw Call这个概念,它是衡量游戏性能的
0 (2).gif

重要指标,顾名思义,Draw Call即绘制调用,就是CPU命令GPU执行绘制。对应到OpenGL,就是glDrawArrays和glDrawElements等函数。



GPU一次执行过多的Draw Call会导致严重的卡顿,但是GPU一个Draw Call里面绘制10个三角形和绘制1000个三角形区别并不大,因此渲染优化的一大手段就是降低Draw Call次数,将多个渲染目标合并到一个Draw Call中进行渲染。我自己做过一个测试,在iPhone 6s等机器上,如果一帧里面Draw Call次数超过50个,那么就会出现明显的掉帧现象。
Command Buffer

CPU不可能等到GPU渲染完成再去工作,因此GPU和CPU是并行工作的,那如何实现并行呢?连接GPU和CPU的是驱动程序,在驱动中有一个Command Buffer。这是一个FIFO的队列,CPU将要发送给GPU的命令填充到队列的尾部,GPU从队列头部取出命令执行。通过Command Buffer GPU和CPU就可以独立地并行工作了。



这里的命令包括Draw Call,还有一些Render State的设置等。GPU什么时候取命令执行?当CPU调用OpenGL的glFlush或者glFinish函数的时候,对应到Metal里面就是MTLCommandBuffer的commit方法。

Metal的优点:

Metal是Apple用来取代OpenGL的,Metal能更进一步压榨硬件的性能,同时采用更加友好的API设计,另外整合了OpenCL中的并行计算功能。在iOS12中,Apple已经将OpenGL标记为Deprecated。
1、性能更好,减少OpenGL中CPU需要做的很多额外操作,据说Metal的Draw Call次数远高于OpenGL。
2、面向对象,OpenGL使用C语言,而Metal使用Swift和OC。
3、去掉了OpenGL中各种context,绑定,解绑,状态检查等繁琐操作。
4、预编译Shader,能在编译时检查Shader语法错误。

5、Core Graphics

上面提到过Core Graphics,这是Apple提供的用于在CPU上渲染的C框架。实际上是在内存中开辟一块空间作为画布,然后提供一系列的C方法来绘制这块画布,比如绘制点,线,各种形状等等。绘制完成之后交由GPU进行显示。Core Graphics的绘制是可以在非主线程进行的,因为它仅仅是操作一块内存画布而已。因此在解决圆角导致的离屏渲染卡顿的时候,通常可以这么干:

    dispatch_async(backgroundQueue, ^{
        CGContextRef ctx = CGBitmapContextCreate(...);
        // 绘制圆角图片...
        CGImageRef img = CGBitmapContextCreateImage(ctx);
        CFRelease(ctx);
        dispatch_async(mainQueue, ^{
            layer.contents = img;
        });
    });

6、Core Animation

Core Animation给人的第一印象往往是它就是做动画的,事实上它做的工作远不止动画,Core Animation是UIKit的基础,iOS上面看到的一切对象,都是由Core Animation提交给GPU进行绘制的。




Core Animation会将一个个的CALayer提交给GPU混合成最后的后帧缓存显示在屏幕上,如下所示,做过OpenGL开发就会知道对应的有一个CAEAGLLayer,这个layer专门用来显示原生OpenGL的内容,对应的Metal也有一个CAMetalLayer,这些layer的内容最后都由Core Animation提交。也就是说你可以在一个界面同时展示OpenGL和Metal渲染的内容。


7、Core Image

Core Image用来对图片作进一步的处理或分析,Core Image框架拥有一系列现成的图像滤镜,能对已存在的图像进行高效的处理。



Core Image能用来做滤镜,实时相机滤镜,人脸识别,二维码生成等等。如下是滤镜的运用,滤镜与滤镜组合还可以制造更多的滤镜效果。


8、通用的渲染优化做法

从GPU角度看,导致卡顿的原因有三类:大量的Draw Call命令,大量的计算,频繁大量的内存数据拷贝(从RAM到VRAM)。
1、减少Draw Call
Draw Call数量的增加带来的性能消耗远大于顶点数量增加带来的消耗,因此对于相同的绘制方式,资源相同的元素,最好批量绘制,合并为少量的Draw Call。现今主要的游戏引擎比如Cocos,Unity等都提供了批量绘制的方法。
2、屏幕外剔除
一个游戏场景可能包含大量的元素,而能呈现在手机屏幕里面的可能只有少部分,对于一些大地图场景的游戏尤为如此。可以只渲染当前帧处于屏幕内的元素,这样可以大大减少GPU的压力。实际应用的时候需要权衡,因为剔除屏幕外的元素必然会增加CPU的计算量。
3、减少Shader中的复杂计算
GPU适合做简单的大量的重复性运算,不适合做逻辑运算,因此要尽量减少Shader中的逻辑判断。特别是Fragment Shader,Fragment Shader会在渲染的每个像素点都执行一遍,这个运算量非常大。
4、纹理集
纹理集可以大大减少Draw Call次数,同时减少内存占用,加快IO速度,游戏引擎基本都支持纹理集的使用。
5、尽量减少内存数据的拷贝
从CPU拷贝数据到GPU是非常昂贵的操作,因为GPU在渲然的时候是不允许修改数据的,这是一个同步操作,频繁拷贝顶点数据或者纹理等都会导致严重的性能问题。一个通用的做法是只在变化的时候拷贝数据,例如某个精灵第3s的时候移动了,那么只需要在第3s的时候拷贝这个精灵的2个三角形顶点数据即可。其他的时候都可以复用之前的VRAM数据进行渲染。
6、多重缓存避免CPU和GPU的数据竞争
视频控制器在读取帧缓存的时候存在竞争的问题,所以iOS实现了双缓存机制来规避这个问题。同样CPU和GPU在协同工作的时候也存在类似的问题,例如有一块VRAM中的Buffer存储顶点数据,GPU在渲染的时候使用这一块数据,而这块数据CPU又需要不停的更新它(譬如王者荣耀这类的实时对战游戏)。GPU在使用这块Buffer的时候会Block住CPU所有的更新操作,这是GPU的保护机制。这样的话CPU就必须等待GPU把这一帧渲染完毕为止,这样一来在一些要求高帧率的游戏中就达不到理想的帧率。
所以在CPU和GPU交换数据上,为了达到高性能高帧率,往往需要设计多重Buffer的架构。一些优秀的引擎比如Unity(https://unity3d.com/cn/learn/tutorials/topics/best-practices/framebuffer),就在引擎层面上面实现了多重Buffer的设计。iOS应该在Core Animation层面也实现了多重Buffer的设计,因为Apple在OpenGL ES Programming Guide(https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/OpenGLES_ProgrammingGuide/OpenGLESApplicationDesign/OpenGLESApplicationDesign.html#//apple_ref/doc/uid/TP40008793-CH6-SW1)和Metal Best Practices Guide(https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/MTLBestPracticesGuide/TripleBuffering.html#//apple_ref/doc/uid/TP40016642-CH5-SW1)里都提到推荐使用Double Buffering或者Triple Buffering设计。如下是Apple给出的Triple Buffering设计示意图,Apple甚至在Metal Best Practices Guide中贴出了Triple Buffering的示例代码。


要注意的是多个Buffer意味着更多的内存消耗,实际设计时务必根据Buffer大小和帧率要求做实际测试再考虑要不要多个Buffer,不要过度设计。

参考

http://gad.qq.com/article/detail/26926
https://www.jianshu.com/p/ca51c9d3575b#
https://yalantis.com/blog/mastering-uikit-performance/
https://developer.apple.com/videos/play/wwdc2014/419/
https://objccn.io/issue-3-1/
https://unity3d.com/cn/learn/tutorials/topics/best-practices/framebuffer
https://lobste.rs/s/ckm4uw/performance_minded_take_on_ios_design#c_itdkfh
https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/OpenGLES_ProgrammingGuide/OpenGLESApplicationDesign/OpenGLESApplicationDesign.html#//apple_ref/doc/uid/TP40008793-CH6-SW1
https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/MTLBestPracticesGuide/TripleBuffering.html#//apple_ref/doc/uid/TP40016642-CH5-SW1