学计算机的那个

不是我觉到、悟到,你给不了我,给了也拿不住;只有我觉到、悟到,才有可能做到,能做到的才是我的.

0%

iOS Rendering 渲染解析

计算机渲染原理

CPU与GPU的架构

设计目的

CPU 是运算核心与控制核心,需要有很强的运算通用性,兼容各种数据类型,同时也需要能处理大量不同的跳转、中断等指令,因此 CPU 的内部结构更为复杂。

而 GPU 则面对的是类型统一、更加单纯的运算,也不需要处理复杂的指令,但也肩负着更大的运算任务。

英伟达的CUDA文档里给了这样一幅图:

ALU就是“算术逻辑单元

从上图中也可以看出

  1. CPU和GPU进行计算的部分都是ALU,GPU绝大部分的芯片面积都是ALU,而且是超大阵列排布的ALU。这些ALU都是可以并行运行的,所以浮点计算速度就特别高了。
  2. CPU 拥有更多的缓存空间 Cache 以及复杂的控制单元,计算能力并不是 CPU 的主要诉求。
  3. CPU大多数面积都需要给控制单元和Cache,因为CPU要承担整个计算机的控制工作,没有GPU那么单纯。

GPU的程序控制能力相比CPU来说不强

GPU的强项是并行运算能力比CPU强(多个不同任务的并行运算GPU也无法胜任 GPU只适合处理单个可并行任务的并行运算) 而不是浮点运算能力强(这个谣言也不知道谁传出来的 非要强调浮点运算 非要说浮点运算的话 不如说大多数老式GPU甚至没有整数运算能力 因为根本没有整数指令 最早设计目的是加速图形渲染 基本都是浮点运算 所以GPU核心的SIMD指令 只有浮点指令 没有整数指令 新型GPU因为不光光用于图形渲染 还想推广到通用计算 就是所谓的GPGPU 所以开始加入整数运算的支持)

CPU每个核心有独立的缓存
GPU基本是所有核心共享一个缓存(GPU主要做图形渲染 所有核心都执行同一份指令 获取同样的数据 CPU主要是执行多个串行任务 每个核心可以处理不同的任务 从不同地方获取不同的数据)

CPU 是设计目标是低时延,更多的高速缓存也意味着可以更快地访问数据;同时复杂的控制单元也能更快速地处理逻辑分支,更适合串行计算。

而 GPU 拥有更多的计算单元 Arithmetic Logic Unit,具有更强的计算能力,同时也具有更多的控制单元。GPU 基于大吞吐量而设计,每一部分缓存都连接着一个流处理器(stream processor),更加适合大规模的并行计算。

图像渲染流水线

图像渲染流程粗粒度大概分为下面这些步骤

上述图像渲染流水线中,除了第一部分 Application 阶段,后续主要都由 GPU 负责
Application 应用处理阶段:得到图元
Geometry 几何处理阶段:处理图元
Rasterization 光栅化阶段:图元转换为像素
Pixel 像素处理阶段:处理像素,得到位图

屏幕成像与卡顿

GPU最后一步渲染结束之后像素信息,被存在帧缓冲器(Framebuffer)中,之后视频控制器(video controller)会读取帧缓冲器中的信息,经过数模转换传递给显示器(Monitor),进行显示。流程如下图:

经过GPU处理之后的像素集合,也就是位图,会被帧缓冲器缓存起来,供之后的显示使用。显示器的电子束会从屏幕的左上角开始逐行扫描,屏幕上的每个点的图像信息都从帧缓冲器中的位图进行读取,在屏幕上对应地显示。扫描的流程如下图:

电子束扫描的过程中,屏幕就能呈现出对应的结果,每次整个屏幕被扫描完一次后,就相当于呈现了一幅完整的图像。屏幕不断地刷新,不停呈现新的帧,就能呈现出连续的影像。而这个屏幕刷新的频率,就是帧率(Frame per Second,FPS)。

屏幕撕裂

在单一缓存模式下,理想的情况就是一个流畅的流水线:每次电子束从头开始新的一帧的扫描时,CPU+GPU 对于该帧的渲染流程已经结束,渲染好的位图已经放入帧缓冲器中。这种完美的情况是非常脆弱的,很容易产生屏幕撕裂。

在电子束开始扫描新的一帧时,位图还没有渲染好,而是在扫描到屏幕中间时才渲染完成,被放入帧缓冲器中 —- 那么已扫描的部分就是上一帧的画面,而未扫描的部分则会显示新的一帧图像,这就造成屏幕撕裂

垂直同步Vsync + 双缓冲机制

解决屏幕撕裂、提高显示效率的一个策略就是使用垂直同步信号 Vsync 与双缓冲机制 Double Buffering

垂直同步信号(vertical synchronisation,Vsync)相当于给帧缓冲器加锁:当电子束完成一帧的扫描,将要从头开始扫描时,就会发出一个垂直同步信号。只有当视频控制器接收到 Vsync 之后,才会将帧缓冲器中的位图更新为下一帧,这样就能保证每次显示的都是同一帧的画面,因而避免了屏幕撕裂。

但是在这种情况下,视频控制器在接受到Vsync之后,就要将下一帧的位图传入,这意味着整个CPU+GPU的渲染流程都要在一瞬间完成,这是明显不现实的。所以双缓冲机制会增加一个备用缓冲器(back buffer)。渲染结果将会预先保存在back buffer中,在接收到Vsync信号的时候,视频控制器会将back buffer中的内容置换到frame buffer中,此时就能保证置换操作几乎在一瞬间完成(实际上是交换了内存地址)。

掉帧

启用Vsync信号以及双缓存机制之后,能够解决屏幕撕裂的问题,但是会引入新的问题:掉帧 如果在接收到Vsync之时CPU和GPU还没有渲染好新的位图,视频控制器就不会去替换frame buffer中的位图。这是屏幕就会重新扫描呈现出上一帧一摸一样的画面。相当于两个周期显示了同样的画面,这就是所谓掉帧的情况。

三缓冲 Triple Buffering

事实上上述策略还有优化空间,在掉帧的时候,CPU和GPU有一段时间处于闲置状态:当A的内容正在被扫描显示在屏幕上,而B的内容已经被渲染好,此时CPU和GPU就处于闲置状态。那么如果我们增加一个帧缓冲器,就可以利用这段时间进行下一步的渲染,并将渲染结果暂存于新增的帧缓冲器中,从而减少掉帧的次数。

屏幕卡顿的本质

  1. 屏幕卡顿的根本原因:CPU 和 GPU 渲染流水线耗时过长,导致掉帧。
  2. Vsync 与双缓冲的意义:强制同步屏幕刷新,以掉帧为代价解决屏幕撕裂问题。
  3. 三缓冲的意义:合理使用 CPU、GPU 渲染性能,减少掉帧次数

iOS中渲染框架

iOS的渲染框架依然复合渲染流水线的基本架构

Core Image:Core Image 是一个高性能的图像处理分析的框架,它拥有一系列现成的图像滤镜,能对已存在的图像进行高效的处理。

Metal:Metal 类似于 OpenGL ES,也是一套第三方标准,具体实现由苹果实现。Core Animation、Core Image、SceneKit、SpriteKit 等等渲染框架都是构建于 Metal 之上的。

Core Animation是什么

Render, compose, and animate visual elements. —- Apple

Core Animation,它本质上可以理解为一个复合引擎,主要职责包含:渲染、构建和实现动画。前身叫做 Layer Kit

CALayer是显示的基础: 存储bitmap

1
2
3
4
5
6
7
8
/** Layer content properties and methods. **/

/* An object providing the contents of the layer, typically a CGImageRef,
* but may be something else. (For example, NSImage objects are
* supported on Mac OS X 10.6 and later.) Default value is nil.
* Animatable. */

@property(nullable, strong) id contents;

实际上,CALayer 中的 contents 属性保存了由设备渲染流水线渲染好的位图 bitmap(通常也被称为 backing store),而当设备屏幕进行刷新时,会从 CALayer 中读取生成好的 bitmap,进而呈现到屏幕上。

CALayer 与 UIView的关系

Offscreen Rendering 离屏渲染

通常的渲染流程是这样的:

离屏渲染的流程是这样的:

与普通情况下 GPU 直接将渲染好的内容放入 Framebuffer 中不同,需要先额外创建离屏渲染缓冲区 Offscreen Buffer,将提前渲染好的内容放入其中,等到合适的时机再将 Offscreen Buffer 中的内容进一步叠加、渲染,完成后将结果切换到 Framebuffer 中

离屏渲染的效率问题

离屏渲染时由于App需要提前对部分内容进行额外的渲染并保存到Offscreen Buffer,以及需要在必要时刻对Offscreen Buffer和Frame buffer进行内容切换,所以会需要更长的处理时间

Offscreen Buffer本身就需要额外的空间,大量的离屏渲染可能造成内存的过大的压力。与此同时,Offscreen Buffer的总大小也有限,不能超过屏幕总像素的2.5倍。

可见离屏渲染的开销非常大,一旦需要离屏渲染的内容过多,很容易造成掉帧的问题。

为什么使用离屏渲染

主要因为下面两个原因

  1. 一些特殊效果需要使用额外的 Offscreen Buffer 来保存渲染的中间状态,所以不得不使用离屏渲染
  2. 处于效率目的,可以将内容提前渲染保存在 Offscreen Buffer 中,达到复用的目的。

对于第一种情况,一般都是系统自动触发的,比如阴影,圆角等等。

最常见的情形之一就是:使用 mask 蒙版

如图所示,由于最终的内容是由两层渲染结果叠加,所以必须要利用额外的内存空间对中间的渲染结果进行保存,因此系统会默认触发离屏渲染。

又比如下面这个例子,iOS 8 开始提供的模糊特效 UIBlurEffectView:

整个模糊过程分为多步:Pass 1 先渲染需要模糊的内容本身,Pass 2 对内容进行缩放,Pass 3 4 分别对上一步内容进行横纵方向的模糊操作,最后一步用模糊后的结果叠加合成,最终实现完整的模糊特效。

而第二种情况,为了复用提高效率而使用离屏渲染一般是主动的行为,是通过 CALayer 的 shouldRasterize 光栅化操作实现的。

shouldRasterize 光栅化

When the value of this property is YES, the layer is rendered as a bitmap in its local coordinate space and then composited to the destination with any other content.

开启光栅化后,会触发离屏渲染,Render Server 会强制将 CALayer 的渲染位图结果 bitmap 保存下来,这样下次再需要渲染时就可以直接复用,从而提高效率。

而保存的 bitmap 包含 layer 的 subLayer、圆角、阴影、组透明度 group opacity 等,所以如果 layer 的构成包含上述几种元素,结构复杂且需要反复利用,那么就可以考虑打开光栅化。

圆角、阴影、组透明度等会由系统自动触发离屏渲染,那么打开光栅化可以节约第二次及以后的渲染时间。而多层 subLayer 的情况由于不会自动触发离屏渲染,所以相比之下会多花费第一次离屏渲染的时间,但是可以节约后续的重复渲染的开销

不过使用光栅化的时候需要注意以下几点:

  1. 如果 layer 不能被复用,则没有必要打开光栅化
  2. 如果 layer 不是静态,需要被频繁修改,比如处于动画之中,那么开启离屏渲染反而影响效率
  3. 离屏渲染缓存内容有时间限制,缓存内容 100ms 内如果没有被使用,那么就会被丢弃,无法进行复用
  4. 离屏渲染缓存空间有限,超过 2.5 倍屏幕像素大小的话也会失效,无法复用

圆角的离屏渲染

设置圆角通常会首先像下面这行代码一样进行设置:

1
view.layer.cornerRadius = 2

上述代码只会默认设置 backgroundColor 和 border 的圆角,而不会设置 content 的圆角,除非同时设置了 layer.masksToBounds 为 true(对应 UIView 的 clipsToBounds 属性):

如果只是设置了 cornerRadius 而没有设置 masksToBounds,由于不需要叠加裁剪,此时是并不会触发离屏渲染的。而当设置了裁剪属性的时候,由于 masksToBounds 会对 layer 以及所有 subLayer 的 content 都进行裁剪,所以不得不触发离屏渲染。

在没有必要使用圆角裁剪的时候,尽量不去触发离屏渲染而影响效率

离屏渲染的具体逻辑

图层的叠加绘制大概遵循“画家算法”,在这种算法下会按层绘制,首先绘制距离较远的场景,然后用绘制距离较近的场景覆盖较远的部分。

在普通的 layer 绘制中,上层的 sublayer 会覆盖下层的 sublayer,下层 sublayer 绘制完之后就可以抛弃了,从而节约空间提高效率。所有 sublayer 依次绘制完毕之后,整个绘制过程完成,就可以进行后续的呈现了。假设我们需要绘制一个三层的 sublayer,不设置裁剪和圆角,那么整个绘制过程就如下图所示:

而当我们设置了 cornerRadius 以及 masksToBounds 进行圆角 + 裁剪时,如前文所述,masksToBounds 裁剪属性会应用到所有的 sublayer 上。这也就意味着所有的 sublayer 必须要重新被应用一次圆角+裁剪,这也就意味着所有的 sublayer 在第一次被绘制完之后,并不能立刻被丢弃,而必须要被保存在 Offscreen buffer 中等待下一轮圆角+裁剪,这也就诱发了离屏渲染,具体过程如下:

实际上不只是圆角+裁剪,如果设置了透明度+组透明(layer.allowsGroupOpacity+layer.opacity),阴影属性(shadowOffset 等)都会产生类似的效果,因为组透明度、阴影都是和裁剪类似的,会作用与 layer 以及其所有 sublayer 上,这就导致必然会引起离屏渲染。

避免圆角离屏渲染

可行的实现方法大概有下面几种:

  1. 【换资源】直接使用带圆角的图片,或者替换背景色为带圆角的纯色背景图,从而避免使用圆角裁剪。不过这种方法需要依赖具体情况,并不通用。
  2. 【mask】再增加一个和背景色相同的遮罩 mask 覆盖在最上层,盖住四个角,营造出圆角的形状。但这种方式难以解决背景色为图片或渐变色的情况。
  3. 【UIBezierPath】用贝塞尔曲线绘制闭合带圆角的矩形,在上下文中设置只有内部可见,再将不带圆角的 layer 渲染成图片,添加到贝塞尔矩形中。这种方法效率更高,但是 layer 的布局一旦改变,贝塞尔曲线都需要手动地重新绘制,所以需要对 frame、color 等进行手动地监听并重绘。
  4. 【CoreGraphics】重写 drawRect:,用 CoreGraphics 相关方法,在需要应用圆角时进行手动绘制。不过 CoreGraphics 效率也很有限,如果需要多次调用也会有效率问题。

参考

1.知乎CPU和GPU的运算能力
2.iOS Rendering 渲染全解析(长文干货)