iOS 图像渲染过程解析
我们先假设这样一个场景:就是点击一个按键,然后实现一张图片的动画移动。
场景.gif代码如下:
#import "FJFNineViewController.h"
@interface FJFNineViewController ()
// boxImageView
@property (nonatomic, strong) UIImageView *boxImageView;
@end
@implementation FJFNineViewController
- (void)viewDidLoad {
[super viewDidLoad];
UIButton *tmpButton = [[UIButton alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
tmpButton.backgroundColor = [UIColor redColor];
[tmpButton addTarget:self action:@selector(tmpButtonClicked:) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:tmpButton];
self.boxImageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 400, 120, 120)];
self.boxImageView.contentMode = UIViewContentModeScaleAspectFill;
self.boxImageView.clipsToBounds = YES;
self.boxImageView.image = [UIImage imageNamed:@"ic_red_box.png"];
[self.view addSubview:self.boxImageView];
}
#pragma mark -------------------------- Response Event
- (void)tmpButtonClicked:(UIButton *)sender {
[UIView animateWithDuration:0.5 animations:^{
self.boxImageView.frame = CGRectMake(300, 80, 80, 80);
}];
}
@end
一.图形渲染过程
1.视图渲染
-
UIKIt
是开发中最常用的框架,可以通过设置UIKit
组件的布局以及相关属性来绘制界面,显示和动画都通过Core Anmation
. -
Core Animation
是一个复合引擎
,其职责是尽可能快地组合屏幕上不同的可视内容,这些可视内容可被分解成独立的图层(即 CALayer),这些图层会被存储在一个叫做图层树的体系之中
。从本质上而言,CALayer
是用户所能在屏幕上看见的一切的基础。Core Animation
依赖于OpenGL ES
或Metal
做GPU
渲染,Core Graphics
做CPU
渲染。 -
最底层是
Graphics Hardware
是图形硬件。
下图是图形渲染的另一种表现形式:
图形渲染技术栈.png如上图所示:在屏幕上显示视图,需要CPU
和GPU
一起协作。一部数据通过CoreGraphics
、CoreImage
由CPU预处理。最终通过OpenGL ES
或Metal
将数据传送到 GPU
,最终显示到屏幕。
Core Graphics
Core Graphics
基于Quartz
高级绘图引擎,主要用于运行时绘制图像。开发者可以使用此框架来处理基于路径的绘图,转换,颜色管理,离屏渲染,图案,渐变和阴影,图像数据管理,图像创建和图像遮罩以及
当开发者需要在 运行时创建图像 时,可以使用Core Graphics
去绘制。与之相对的是 运行前创建图像,例如用Photoshop
提前做好图片素材直接导入应用。相比之下,我们更需要Core Graphics
去在运行时实时计算、绘制一系列图像帧来实现动画。
Core Image
Core Image
与Core Graphics
恰恰相反,Core Graphics
用于在 运行时创建图像,而Core Image
是用来处理 运行前创建的图像 的。Core Image
框架拥有一系列现成的图像过滤器,能对已存在的图像进行高效的处理。
大部分情况下,Core Image
会在GPU
中完成工作,但如果GPU
忙,会使用CPU
进行处理。
CoreImage支持CPU、GPU两种处理模式。
2.显示逻辑
-
CoreAnimation
提交会话,包括自己和子树(view hierarchy)
的layout
状态等; -
RenderServer
解析提交的子树状态,生成绘制指令; -
GPU
执行绘制指令; - 显示渲染后的数据;
二.提交流程(Commit Transaction)
在 Core Animation
流水线中,app
调用 Render Server
前的最后一步 Commit Transaction
其实可以细分为 4 个步骤:
Layout
Display
Prepare
Commit
Layout
Layout
阶段主要进行视图构建,包括:LayoutSubviews
方法的重载,addSubview:
方法填充子视图等。
int main(int argc, char * argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}
当程序运行main函数的时候,由于传入的principalClassName
是nil
,那么它的值将从Info.plist
去获取,如果Info.plist
没有,则默认为UIApplication
。UIApplication
设置AppDelegate
为代理,然后通过代理方法:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
self.window.rootViewController = [[ViewController alloc] init];
[self.window makeKeyAndVisible];
self.window.backgroundColor = [UIColor whiteColor];
return YES;
}
生成window
,window
设置ViewController
实例为rootViewController
,因此生成如下所示的视图层级:
视图树如下所示:
如上图所示,我们可以看到视图树
比Xcode
所展示的视图层级
,多了UIApplication
,这里的UIApplication
是一个App进程
的代表,作为视图树
的根节点
,起到一种起始标志
的作用,并不参与视图的处理过程,渲染服务进程
是以UIApplication
来识别渲染App进程
的相关图层。
因为这里涉及到视图的生成,并将视图添加到对应的视图树中,因此会将这些视图标记为待处理,并提交到一个全局的容器中。
这里以tmpButton
和boxImageView
为例子说一下视图创建:
UIButton *tmpButton = [[UIButton alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
tmpButton.backgroundColor = [UIColor redColor];
[tmpButton addTarget:self action:@selector(tmpButtonClicked:) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:tmpButton];
self.boxImageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 400, 120, 120)];
self.boxImageView.contentMode = UIViewContentModeScaleAspectFill;
self.boxImageView.clipsToBounds = YES;
self.boxImageView.image = [UIImage imageNamed:@"ic_red_box.png"];
[self.view addSubview:self.boxImageView];
当通过addSubview
添加到视图树
上面的self.View
,这时对应会生成tmpButton
和boxImageView
的CALayer
,同时CALayer对应会存储着
tmpButton
和boxImageView
设置的相关属性frame、backgroundColord
等属性。
Display
Display
阶段主要进行视图绘制,这里仅仅是设置要成像的图元数据。重载视图的 drawRect:
方法可以自定义 UIView
的显示,其原理是在 drawRect:
方法内部绘制寄宿图,该过程使用 CPU
和内存
。
CALayer
包含一个contents
属性指向一块缓存区backing store
,可以存放位图(Bitmap)
。
绘制完成的寄宿图就放在,缓存区的backing store
。
注意:绘制文本字符串比如说UILabel
的文本,会默认调用drawRect
来生成寄宿图
。
具体详见:iOS 图像渲染原理
由于我们代码并不涉及到调用drawRect
来绘制寄宿图,因此不进行分析。
Prepare
Prepare
阶段属于附加步骤,一般处理图像的解码和转换等操作。
因为self.boxImageView.image = [UIImage imageNamed:@"ic_red_box.png"];
进行了图片的赋值操作,所以在Prepare
阶段会进行图片数据的解码和图片格式转换操作。
Commit
Commit
阶段主要将图层进行打包(序列化),并将它们发送至 Render Server
。该过程会递归执行,因为图层和视图都是以树形结构存在。
当runloop
即将进入休眠(BeforeWaiting)
和退出(Exit)
,会通知苹果之前注册Observer监听
,回调去执行一个很长的函数: _ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()
。这个函数里会待处理的图层进行打包,并发送至 Render Server
。
这里的Core Animation
会创建一个OpenGL ES
纹理,并确保在boxImageView
图层中的位图被上传到对应的纹理中。
同样当你重写-drawInContext
方法时,Core Animation
会请求分配一个纹理,同时确保Core Graphics
会将你在-drawInContext
中绘制的东西放入到纹理的位图数据中。
动画渲染原理
- (void)tmpButtonClicked:(UIButton *)sender {
[UIView animateWithDuration:0.5 animations:^{
self.boxImageView.frame = CGRectMake(300, 80, 80, 80);
}];
}
iOS 动画
的渲染也是基于上述 Core Animation
流水线完成的。这里我们重点关注app
与 Render Server
的执行流程。
日常开发中,如果不是特别复杂的动画,一般使用 UIView Animation
实现,iOS
将其处理过程分为如下三部阶段:
-
Step 1:
调用animationWithDuration:animations:
方法
-Step 2:
在Animation Block
中进行Layout,Display,Prepare,Commit
等步骤。 -
Step 3:
Render Server
根据Animation
逐帧进行渲染。
这里只进行一次将图层树
和CAAnimation对象
提交到渲染服务进程
,然后由渲染服务进程
根据CAAnimation参数
和图层树
信息,去渲染动画过程中的每一帧。
三.渲染服务(Render Server)
Render Server.png-
渲染服务进程
首先将打包上来的图层进行解压(反序列化)
,得到图层树
。 -
然后依据图层树中图层的顺序、
RGBA值
、图层的frame
等,对被遮挡的图层进行过滤。比如说视图A
在视图B
上面,视图A
背景色是不透明的,视图A
遮挡了视图B
一部分,这样视图B
在渲染服务进程
这里会被CoreAnimation
过滤掉视图B
被遮挡的那部分。 -
Core Animation
进行过滤以后将图层树
转化为渲染树
。
渲染树就是指图层树对应每个图层的信息,比如顶点坐标、顶点颜色这些信息,抽离出来,形成的树结构,就叫渲染树了
-
然后将
渲染树
信息递归提交给OpenGL ES /Metal
。 -
OpenGL ES /Metal
会编译、链接可编程的顶点着色器和片元着色器程序(如果有对顶点着色器和片元着色器进行自定义),并结合固定的渲染管线,生成绘制命令,并提交到命令缓冲区CommandBuffer
`,供GPU读取调用。
四. 图形渲染管线(Graphics Rendering Pipeline)
OpenGL ES /Metal
的作用是通过它提供给我们的API
,最终在CPU
上生成GPU
可以理解的一系列指令(Commands)
,然后提交给GPU
去执行它。
Graphics Rendering Pipeline
,图形渲染管线,实际上指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程。通常情况下,渲染管线可以描述成 vertices(顶点)
到 pixels(像素)
的过程。
无论是OpenGL ES /Metal
都有图形渲染管线这个概念,它是各类图形API通用的一个概念。
图形渲染管线
的主要工作可以被划分为两个部分:
- 把
3D
坐标转换为2D
坐标 - 把
2D
坐标转变为实际的有颜色的像素
GPU 图形渲染管线
的具体实现可分为六个
阶段,如下图所示。
顶点着色器(Vertex Shader)
形状装配(Shape Assembly),又称 图元装配
几何着色器(Geometry Shader)
光栅化(Rasterization)
片段着色器(Fragment Shader)
测试与混合(Tests and Blending)
我们以最经典的三角形绘制
为例,如下是一个典型的管线处理
过程:
1.顶点着色器
顶点着色器
对每个顶点执行一次运算,它可以使用顶点数据
来计算该顶点的坐标,颜色,光照,纹理坐标
等,在渲染管线
中每个顶点
都是独立地被执行。
每个顶点
都对应一组顶点数组
,可以激活(启用)
最多可达8
个数组,每个数组用于存储不同类型的数据:顶点坐标、表面法线、RGBA颜色、辅助颜色、颜色索引、雾坐标、纹理坐标以及多边形的边界标志
等。
如果设置了相关的属性就会激活相关的数组,比如tmpButton
激活了顶点坐标、RGBA颜色
,boxImageView
激活了顶点坐标、纹理坐标
。
接下来对顶点坐标
进行变换,应用程序中设置的图元顶点坐标通常是针对本地坐标系
的,本地坐标系
简化了程序中的坐标计算,但是GPU
并不识别本地坐标系
,所以在顶点着色器
中要对本地坐标执行
如下变换。
- 模型变换—— 从模型坐标系到世界坐标系
模型坐标系(局部坐标系):
image.png
世界坐标系:
- 视变换—— 从世界坐标系到相机坐标系
世界坐标系到相机坐标系:
- 投影变换 —— 从相机坐标到裁剪坐标系
正投影:
image.png
透视投影:
image.png- 透视除法 —— 从裁剪坐标系到规范化设备坐标系(去除W)
W = 3:
image.png
W = 1:
image.png- 视口变换 —— 从规范化设备坐标系到屏幕坐标系
视口变换是在投影变换之后,将空间中的物体变换到视口中:
image.png引用自:OpenGl从零开始之坐标变换
2.形状(图元)装配
该阶段将顶点着色器
输出的所有顶点
作为输入,并将所有的点装配成指定图元的形状。图中则是一个三角形
。图元(Primitive)
用于表示如何渲染顶点数据
,如:点、线、三角形
。
这里很关键的一点就是,在顶点着色器程序输出顶点坐标
之后,各个顶点被按照绘制命令中的图元类型参数,以及顶点索引数组
被组装成一个个图元。
在 Metal
中,支持的图元类型如下,也是由点,线,三角形组成。
typedef NS_ENUM(NSUInteger, MTLPrimitiveType) {
MTLPrimitiveTypePoint = 0,
MTLPrimitiveTypeLine = 1,
MTLPrimitiveTypeLineStrip = 2,
MTLPrimitiveTypeTriangle = 3,
MTLPrimitiveTypeTriangleStrip = 4,
} NS_ENUM_AVAILABLE(10_11, 8_0);
image.png
同样的OpenGL
支持图元类型如下:
每个图元由一个
或者多个顶点
组成,每个顶点定义一个点,一条边的一端或者三角形的一个角。每个顶点关联一些数据,这些数据包括顶点坐标,颜色,法向量以及纹理坐标
等。所有这些顶点相关的信息就构成顶点数据
。
这里由于顶点数据
较多,因此性能更高的做法是,提前分配一块显存,将顶点数据
预先传入到显存当中。这部分的显存,就被称为顶点缓冲区
。
另外,在绘制图像
时,总是会有一些顶点
被多个图元共享,而反复对这个顶点进行运算常常是没有必要的(也有某些特殊场景需要)。因此对通过索引数据
,指示OpenGL
绘制顶点的顺序,不但能防止顶点的重复运算,也能在不修改顶点数据
的情况下,一定程度的重新组合图像。
和顶点数据
一样,索引数据
也可以以索引数组
的形式存储在内存当中,调用绘制函数时传入;或者提前分配一块显存
,将索引数据
存储在这块显存当中,这块显存就被称为索引缓冲区
。同样的,使用缓冲区的方式,性能一般会比直接使用索引数组
的方式更加高效。
举个例子:
比如生成一个正方形,会生成如下的顶点坐标数组和索引数组:
显存
,也被叫做帧缓存
,它的作用是用来存储显卡芯片处理过或者即将提取的渲染数据
。如同计算机的内存一样,显存
是用来存储要处理的图形信息
的部件。
3. 几何着色器。
该阶段把图元形式
的一系列顶点的集合作为输入
,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。例子中,它生成了另一个三角形。
通俗来讲:几何着色器
就是提供图元相互之间的连接信息,将原本独立的图元连接起来。
如下图所示:
注意:几何着色器是一个可选的阶段,比如我们创建的
tmpButton
,在图元装配阶段,就可以根据顶点坐标、索引值、图元类型(GL_QUADS)
就可以确定这是一个正方形
,就无需再经过几何着色器
这个阶段。
4.光栅化
在光栅化
阶段,基本图元
被转换为供片段着色器使用的片段(Fragment)
,Fragment
表示可以被渲染到屏幕上的像素
,它包含位置,颜色,纹理坐标
等信息,这些值是由图元的顶点信息
进行插值计算得到的。这些片元接着被送到片元着色器
中处理。这是从顶点数据
到可渲染在显示设备上的像素的质变过程。
在片段着色器
运行之前会执行裁切(Clipping)
。裁切
会丢弃超出你的视图以外的所有像素,用来提升执行效率。
红色区域即表示真正会进入片段着色器 (Fragment function)
中进行处理的片段。
5.片段着色器
片段着色器的主要作用是计算每一个片段最终的颜色值。
可编程的片段着色器是实现一些高级特效如纹理贴图,光照,环境光,阴影
等功能的基础,这就是最精彩的部分。
在片段着色器之前的阶段,渲染管线
都只是在和顶点,图元
打交道。而在 3D 图形程序
开发中,贴图
是最重要的部分,我们的 Resources
,可以包含纹理
等数据,这些纹理
可以被片段着色器
使用。片段着色器
可以根据顶点着色器
输出的顶点纹理坐标
对纹理进行采样,以计算该片段的颜色值。从而调整成各种各样不同的效果图。
另外,片段着色器
也是执行光照等高级特效的地方,比如可以传给片段着色器
一个光源位置
和光源颜色
,可以根据一定的公式计算出一个新的颜色值,这样就可以实现光照特效
。
6. 测试与混合
测试:
在着色器程序完成之后,我们得到了像素数据。这些数据必须要通过测试才能最终绘制到画布,也就是帧缓冲上的颜色附着上。
测试
主要可以分为像素所有者测试(PixelOwnershipTest)、裁剪测试(ScissorTest)、模板测试(StencilTest)和深度测试(DepthTest)
,执行的顺序也是按照这个顺序进行执行。
-
最开始进行的测试是
像素所有者测试
,主要是剔除不属于当前程序的像素运算。 -
之后
裁剪测试
,主要是剔除窗口区域之外的像素。这两个测试都是由OpenGL/Metal
内部实现的,无需开发者干预。 -
模板测试
是通过模板测试程序
去决定最终的像素是否丢弃,同样也是根据OpenGL / Metal
的模板覆写状态决定是否更新像素的模板值。模板测试给开发者提供了高性能的裁剪方案, 三维物体 的描边技术,就是 模板测试 典型的用处之一。 -
深度测试
,主要是通过对像素的运算出来的深度,也就是像素离屏幕的距离进行对比,根据OpenGL / Metal
设定好的深度测试程序,决定是否最终渲染到画布上。一般默认的程序是将离屏幕较近的像素保留,而将离屏幕较远的像素丢弃。如果像素最终被渲染到画布上,根据设定好的OpenGL / Metal
深度覆写状态,可能会更新帧缓冲区
上深度附着
的值,方便进行下一次的比较。
混合:
在 测试阶段 之后,如果像素依然没有被剔除,那么 像素的颜色 将会和 帧缓冲区 中颜色附着上的颜色进行混合, 混合的算法可以通过 OpenGL/ Metal
的函数进行指定。但是OpenGL/ Metal
提供的混合算法是有限的,如果需要更加复杂的混合算法,一般可以通过像素着色器进行实现,当然性能会比原生的混合算法差一些。
抖动:
在混合阶段
过后,根据OpenGL/Metal
的状态设置,会决定是否有抖动这个阶段。
抖动
是一种针对对于可用颜色较少
的系统,可以以牺牲分辨率为代价,通过颜色值
的抖动来增加可用颜色数量
的技术。抖动操作是和硬件相关的,允许程序员所做的操作就只有打开或关闭抖动操作。实际上,若机器的分辨率已经相当高,激活抖动操作根本就没有任何意义。默认情况下,抖动是激活的。
这里由于demo
中的UIWindow
被设置为白色、UIViewController
的view
没有设置背景色,tmpButton
为红色、boxImageView
加载图片,图片没有设置alpha
,所以这里被视图都是不透明(UIWindow、tmpButton、boxImageView
)或者完全透明(self.View
)因此在渲染服务进程处理阶段,会根据图层树中图层顺序、图层位置、图层的RGBA值
,进行过滤,因此最后留下的图层RGBA值
,就是要显示屏幕的RGBA值
。因此这里并不涉及到图层颜色的混合。
经历了测试和混合后,帧缓冲区绑定的颜色缓冲区就是最终要显示到屏幕上面的颜色值。
五.屏幕显示
image.png通常来说,计算机系统中CPU、GPU、显示器
是以上面这种方式协同工作的。
CPU
计算好显示内容提交到 GPU
,GPU
渲染完成后将渲染结果放入帧缓冲区
。
随后视频控制器
会按照 VSync
信号逐行读取帧缓冲区
的数据,经过可能的数模转换传递
给显示器显示。
iOS
设备会始终使用双缓存,并开启垂直同步。
由于垂直同步的机制,如果在一个 VSync
时间内,CPU
或者 GPU
没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿
的原因。
双缓冲工作原理:GPU
会预先渲染一帧放入一个缓冲区中,用于视频控制器
的读取。当下一帧渲染完毕后,GPU 会直接把视频控制器的指针指向第二个缓冲器
。
这里介绍屏幕图像显示
的原理,需要先从 CRT 显示器原理
说起,如下图所示。
CRT
的电子枪从上到下逐行扫描,扫描完成后显示器就呈现一帧画面。然后电子枪回到初始位置进行下一次扫描。为了同步显示器的显示过程和系统的视频控制器,显示器会用硬件时钟产生一系列的定时信号。当电子枪换行进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization)
,简称 HSync
;而当一帧画面绘制完成后,电子枪回复到原位,准备画下一帧前,显示器会发出一个垂直同步信号(vertical synchronization),简称 VSync
。显示器通常以固定频率
进行刷新,这个刷新率就是VSync 信号
产生的频率。虽然现在的显示器基本都是液晶显示屏了,但其原理基本一致。
阅读延伸:
Metal入门教程总结
Metal【1】—— 概述
深入理解RunLoop
OpenGL全流程详细解读
iOS 图像渲染原理
iOS开发-视图渲染与性能优化
计算机那些事(7)——图形图像渲染原理