iOS 底层渲染原理

作者: 皮皮Warrior | 来源:发表于2020-01-11 22:42 被阅读0次

OpenGL render theory on iOS

iOS 底层渲染原理

写在前面

下半年做过一次分享会,是以板书的形式分享。当时留下了一些手稿,最近整理一下分享给更多的人。

iOS 系统是如何实现 AddSubView 的?

在 iOS 开发中绝大部分的 UI 操作都靠 UIKit 完成,苹果已经让 UIKit 足够好用。

在你使用 UIKit 的 AddSubView 时,有没有想过 iOS 是如何更改屏幕上的对应像素的?苹果为什么不让开发者在辅助线程使用UIKit?

本文将从 AddSubView 出发,带你了解 iOS 底层是如何完成渲染的。

我用 OpenGL 来描述系统的渲染行为,虽然新的系统中 OpenGL 已经被 Metal 代替,但原理是类似的。

iOS 渲染框架

iOS 的渲染渲染框架有 UIKit,CoreAnimation/Quartz,OpenGL,Metal。

iOS 渲染框架

UIKit,CoreAnimation 可以看做是底层渲染技术的 shell 层,它们并不直接提交渲染命令给 GPU,而是做数据的组装和传递。此外 OpenGL 在 A11 以后已经不再直接调用系统驱动,而是调用 Metal 完成渲染。

UIKit 已经帮你做了数据的抽象,UI 工作可以很简单地完成。CoreAnimation 层能接触到更多的数据,如动画时间曲线,CAlayer 等。UIView 的操作单位是 Point,CALayer 的是 Pixels。

UIKit 不用关注 Pixels 的变化,而 Pixels 的操作流程正是整个渲染流程的关键,也是这次分享的主题。

硬件相关

为了更容易理解底层渲染技术,了解一些 GPU 的硬件知识是有必要的。

GPU-CPU硬件结构

GPU 与 CPU 的最大区别就是它是高度专用化的,它的目的就是更快地渲染。这个问题等效于更快计算出每个像素的值,所以 GPU 在硬件结构上做了对应的取舍。

GPU 大大弱化了逻辑控制的单元,把大部分芯片空间都让给了算术逻辑单元(ALU),Excution context 管理计算的上下文。这样 SIMD(单指令多数据)架构上保证了一批像素的同一个计算,只需要一次操作就可以完成,这也是数据并行加速的原理。

移动端 GPU

ARM 架构下,GPU 主要由 Imagination 和高通垄断,其中 A11 之后 iOS 上 GPU 由苹果自研。

CPU 和 GPU 之间靠总线传递数据,而移动端的总线带宽相比 PC 低很多,所以经常出现 GPU 空等 CPU 的情况。

虽然 GPU 自身的处理能力很强,但带宽限制了数据的吞吐量,所以压缩纹理和 TBR/TBDR 的应运而生。

bandwidth瓶颈

需要关注什么?

除了压缩纹理和 TBR/TBDR,各个厂家同样在驱动层为了同样的目的做了一些优化。对于 UI 操作需要关注的是:

  1. 渲染命令异步提交

你在 CPU 提交的渲染命令是异步提交给 GPU 的(前提是可以异步的命令,同步命令 CPU 要等 GPU 执行完)。具体流程是

CPU -> CPU command queue -> 系统调度 -> GPU command queue -> GPU

所以在更改 UI 的当前 RunLoop ,GPU 实际还没有开始绘制,不要预期有同步的渲染结果。

因为是异步提交命令,所以OpenGL Crash的现场并不是真正的问题所在,而是之前的命令导致状态机错误,引发了Crash。

  1. 多帧缓存

系统底层驱动一般至少会做两帧的缓冲,保证当前的渲染命令不影响当前上屏。但一些驱动会做更多的缓冲,iPhone8 以上至少会做 3 份缓冲,而这些缓冲是拿不到的,所以理论上从你的 UI 操作提交到 GPU,到真正展示会隔一小段时间。所以不要试图精准地拿到对应的 CALayer 数据。

  1. 每一帧清屏是个好习惯

现在大部分移动端GPU都是TBR/TBDR的,如果要保留上一帧渲染结果,会有额外的GPU内存读取操作,每一帧都清空画布会带来更高的性能,这也是苹果推荐用不透明视图的原因(部分原因)。苹果默认会这样设置CALayer,这也是开启多帧缓冲的先决条件。

render pipeline

渲染管线是一次 GPU 渲染数据的操作流程,这个过程中 OpenGL 完成了屏幕 坐标的确认和对应屏幕像素值得计算。

render pipeline

渲染管线可以分为两部分:

1. geometry pipeline

这个过程确定了输入的 3D 坐标在屏幕上对应的 2D 坐标。

对于 CPU 传来的 3D 顶点数据,会经过 Vertex processing 和 vertex post processing 两个过程,转换成 NDC 坐标(标准化设备坐标),经过图元组装后,就可以知道当前图形影响屏幕上哪些像素。

对于 AddSubView 来说,就是通过 subView 的 frame,确认了 subView 对应的像素。

下面讲一下 3D 坐标转 2D 坐标的过程,不感兴趣的可以跳过。

geometry pipeline

geometry pipeline 可以分为 3 个部分:

1. vertex processing

顶点输入后,要在 Vertex shader 中做一系列坐标变换,最终将 3D 坐标转换到一个[-1, 1]的空间内。

之后有可选的 Tessllation 和 Geometry shader 阶段,它们可以动态改变图元:比如改变图元的形状,增加图元等。(使用 Tesslation 或 Geometry shader 后,管线会提前一部分图元装配的工作,这样就保证了它们可以操作图元)。

这一部分在 OpenGL 中是可编程的,我们可以控制顶点数据如何变换。

2. vertex post processing

这一部分是不可编程的,渲染管线会自动调用。

经过前面的处理,顶点坐标被变换成各分量都在[-1,1]之间的裁剪坐标,不在此区间的坐标被丢弃。

接下来会做透视除法,将坐标转换为 NDC 坐标,你可以理解为完成了近大远小的缩放。

最后做 viewport 变换,将 NDC 坐标根据屏幕的像素大小(viewport 参数),映射成屏幕坐标。

变换管线

你可能注意到在 geometry pipeline 阶段我说到了多种不同的坐标,这其实正是 OpenGL 确定 3D 坐标到 2D 坐标的过程,实际上每一种坐标和相应的变换都是固定的。

transform pipeline

在第一部分,CPU 传给 GPU 的顶点坐标为局部坐标,所有位置都是相对于自身原点的。

之后通过 vertex shader 中的model transfrom, view transfrom, projection transfrom,变为裁剪坐标,这一部分是决定如何展示顶点的主要工作,。

然后通过 vertex post-processing 阶段,做透视除法和viewport transform,最终得到屏幕坐标。

最终输出的屏幕坐标,会作为下一步图元装配的输入。

这一部分可能比较难理解,你可以认为这一阶段是在 3D 世界中,如何用照相机照相。

图元装配

图元装配即完成用图元来描述你要绘制的形状。图元一般是三角形,因为在 3D 中,三角形能唯一确定一个平面。

对 AddSubView 来说,subView 会被组装成两个三角形图元。图元装配之后,就可以确定当前图形的屏幕区域。

2. pixels pipeline

pixels pipeline 主要完成了计算每个像素值的工作。

主要是下面三步:

格栅化

图元装配确定了图形的屏幕区域,格栅化是在硬件层面,确定当前图形会影响屏幕上哪些像素。所有包含在图形内的像素,作为下一阶段的输入。

fragment processing

格栅化之后,渲染管线会将这一批像素作为 fragment shader 的输入。这一过程是可编程的。

在 fragment shader 中,需要计算出每个像素是什么值。对 AddSubView 来说,如果 subView 的背景是图像,需要把图像作为纹理传到 GPU,在计算对应像素时去采样纹理。

也就是说这一阶段确定了 subView 的样子。

test & blend

最后这个决定了像素是否应该被展示。

stencil test

如果开启了 stencil test,那么像素对应的 stencil 值不为 0,像素才会被展示。

depth test

如果开启了 depth test,像素必须没有被遮挡(z 轴的前后关系),才会被展示。

blend

如果开启了 blend,像素的会根据 blend function 和 alpha 值,确定最终的像素值。

对 AddSubView 来说,如果 subView 设置了 mask,那么就要设置 stencil 来剔除 mask 之外的像素。如果 subView 是半透明,那么要根据 alpha 值和背景的颜色做 blend 操作。

UIKit 如何实现 addSubView

现在,再来说说 UIKit 如何实现 addSubView 的。

首先计算出 subView 的 frame,获取背景图像并解码成位图,获取 mask,alpha 等属性。

  1. 创建 GLContext。

  2. 取得当前 CALayer,作为当前 GLContext FBO 的 RBO。

  3. 将 frame 的四个顶点传给 vertex shader。将背景图像的位图传给 fragment shader。

  4. 在 vertex shader 中,由于是纯 2D 的界面,可以直接将顶点 z 轴坐标都设为 1。将坐标转为相对于 window 的坐标(或者使用正视投影)。

  5. 在 fragment shader 中,每个 SubView 的像素,都从图像中采样,作为输出的颜色值。

  6. 在最后的 test & blend 中,对 subView 的 alpha,mask 属性做对应处理。

  7. 展示当前 GLContext RBO。

这个流程也是系统 UIKit -> CoreAnimation -> render server 的过程。

OpenGL 状态机

你可以把 GLContext 理解为 OpenGL 的状态机,但实际上它是状态机的超集。

OpenGL 状态机

GLContext 可以看做是一个巨大的结构体,它维护着当前的渲染状态,包括 GLObject 也是一个状态的子集。

每一条渲染命令,其实都是在更改当前的渲染状态,从而最终影响渲染流程。
而苹果会自动在主线程设置一个 GLContext,来实现 UI 的渲染。

最后

每个 iOS 开发者都知道不能在辅助线程做 UI 操作,绝大多数却不知道为什么苹果要做这个限制。

看完了这篇文章,你应该能明白为什么:多线程操作 UI,意味着多线程在一个GLContext上渲染,苹果默认不会提交辅助线程的渲染命令到GPU(虽然苹果现在用的是Metal,并且渲染是单独一个进程,但原理类似,操作GPU的接口不是线程安全的)。

相关文章

网友评论

    本文标题:iOS 底层渲染原理

    本文链接:https://www.haomeiwen.com/subject/qbnbactx.html