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。
UIKit,CoreAnimation 可以看做是底层渲染技术的 shell 层,它们并不直接提交渲染命令给 GPU,而是做数据的组装和传递。此外 OpenGL 在 A11 以后已经不再直接调用系统驱动,而是调用 Metal 完成渲染。
UIKit 已经帮你做了数据的抽象,UI 工作可以很简单地完成。CoreAnimation 层能接触到更多的数据,如动画时间曲线,CAlayer 等。UIView 的操作单位是 Point,CALayer 的是 Pixels。
UIKit 不用关注 Pixels 的变化,而 Pixels 的操作流程正是整个渲染流程的关键,也是这次分享的主题。
硬件相关
为了更容易理解底层渲染技术,了解一些 GPU 的硬件知识是有必要的。
GPU 与 CPU 的最大区别就是它是高度专用化的,它的目的就是更快地渲染。这个问题等效于更快计算出每个像素的值,所以 GPU 在硬件结构上做了对应的取舍。
GPU 大大弱化了逻辑控制的单元,把大部分芯片空间都让给了算术逻辑单元(ALU),Excution context 管理计算的上下文。这样 SIMD(单指令多数据)架构上保证了一批像素的同一个计算,只需要一次操作就可以完成,这也是数据并行加速的原理。
ARM 架构下,GPU 主要由 Imagination 和高通垄断,其中 A11 之后 iOS 上 GPU 由苹果自研。
CPU 和 GPU 之间靠总线传递数据,而移动端的总线带宽相比 PC 低很多,所以经常出现 GPU 空等 CPU 的情况。
虽然 GPU 自身的处理能力很强,但带宽限制了数据的吞吐量,所以压缩纹理和 TBR/TBDR 的应运而生。
需要关注什么?
除了压缩纹理和 TBR/TBDR,各个厂家同样在驱动层为了同样的目的做了一些优化。对于 UI 操作需要关注的是:
- 渲染命令异步提交
你在 CPU 提交的渲染命令是异步提交给 GPU 的(前提是可以异步的命令,同步命令 CPU 要等 GPU 执行完)。具体流程是
CPU -> CPU command queue -> 系统调度 -> GPU command queue -> GPU
所以在更改 UI 的当前 RunLoop ,GPU 实际还没有开始绘制,不要预期有同步的渲染结果。
因为是异步提交命令,所以OpenGL Crash的现场并不是真正的问题所在,而是之前的命令导致状态机错误,引发了Crash。
- 多帧缓存
系统底层驱动一般至少会做两帧的缓冲,保证当前的渲染命令不影响当前上屏。但一些驱动会做更多的缓冲,iPhone8 以上至少会做 3 份缓冲,而这些缓冲是拿不到的,所以理论上从你的 UI 操作提交到 GPU,到真正展示会隔一小段时间。所以不要试图精准地拿到对应的 CALayer 数据。
- 每一帧清屏是个好习惯
现在大部分移动端GPU都是TBR/TBDR的,如果要保留上一帧渲染结果,会有额外的GPU内存读取操作,每一帧都清空画布会带来更高的性能,这也是苹果推荐用不透明视图的原因(部分原因)。苹果默认会这样设置CALayer,这也是开启多帧缓冲的先决条件。
render pipeline
渲染管线是一次 GPU 渲染数据的操作流程,这个过程中 OpenGL 完成了屏幕 坐标的确认和对应屏幕像素值得计算。
渲染管线可以分为两部分:
1. geometry pipeline
这个过程确定了输入的 3D 坐标在屏幕上对应的 2D 坐标。
对于 CPU 传来的 3D 顶点数据,会经过 Vertex processing 和 vertex post processing 两个过程,转换成 NDC 坐标(标准化设备坐标),经过图元组装后,就可以知道当前图形影响屏幕上哪些像素。
对于 AddSubView 来说,就是通过 subView 的 frame,确认了 subView 对应的像素。
下面讲一下 3D 坐标转 2D 坐标的过程,不感兴趣的可以跳过。
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 坐标的过程,实际上每一种坐标和相应的变换都是固定的。
在第一部分,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 等属性。
创建 GLContext。
取得当前 CALayer,作为当前 GLContext FBO 的 RBO。
将 frame 的四个顶点传给 vertex shader。将背景图像的位图传给 fragment shader。
在 vertex shader 中,由于是纯 2D 的界面,可以直接将顶点 z 轴坐标都设为 1。将坐标转为相对于 window 的坐标(或者使用正视投影)。
在 fragment shader 中,每个 SubView 的像素,都从图像中采样,作为输出的颜色值。
在最后的 test & blend 中,对 subView 的 alpha,mask 属性做对应处理。
展示当前 GLContext RBO。
这个流程也是系统 UIKit -> CoreAnimation -> render server 的过程。
OpenGL 状态机
你可以把 GLContext 理解为 OpenGL 的状态机,但实际上它是状态机的超集。
GLContext 可以看做是一个巨大的结构体,它维护着当前的渲染状态,包括 GLObject 也是一个状态的子集。
每一条渲染命令,其实都是在更改当前的渲染状态,从而最终影响渲染流程。
而苹果会自动在主线程设置一个 GLContext,来实现 UI 的渲染。
最后
每个 iOS 开发者都知道不能在辅助线程做 UI 操作,绝大多数却不知道为什么苹果要做这个限制。
看完了这篇文章,你应该能明白为什么:多线程操作 UI,意味着多线程在一个GLContext上渲染,苹果默认不会提交辅助线程的渲染命令到GPU(虽然苹果现在用的是Metal,并且渲染是单独一个进程,但原理类似,操作GPU的接口不是线程安全的)。