探究内容
- 到底什么是离屏渲染?是在GPU上面还是CPU上面执行的?
- 为什么要有离屏渲染?什么情况下会产生离屏渲染?
- 帧缓冲区是什么?当前屏幕缓冲区和屏幕外缓冲区又是什么?
- 切换缓冲区是什么操作?真的比较耗时吗?
- 离屏渲染怎么做性能优化?
- iOS避免离屏渲染的圆角处理方式?
OpenGL屏幕渲染方式
OpenGL中,GPU屏幕渲染有以下两种方式:
- On-Screen Rendering:意为当前屏幕渲染,指的是GPU的渲染操作是在当前用于显示的屏幕缓冲区中进行。
- Off-Screen Rendering:意为离屏渲染,指的是GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。
- 一般情况下,OpenGL会将应用提交到Render Server的动画直接渲染显示(基本的Tile-Based渲染流程),但对于一些复杂的图像动画的渲染并不能直接渲染叠加显示,而是需要根据Command Buffer分通道进行渲染之后再组合,这一组合过程中,就有些渲染通道是不会直接显示的;Masking渲染需要更多渲染通道和合并的步骤;而这些没有直接显示在屏幕的上的通道就是离屏渲染。
- 离屏渲染卡顿原因:它需要更多的渲染通道,而且不同的渲染通道间切换需要耗费一定的时间,这个时间内GPU会闲置,当通道达到一定数量,对性能也会有较大的影响。
什么是离屏渲染?
-
定义:如果要在显示屏上显示内容,至少需要一块与屏幕像素数据量一样大的frame buffer,作为像素数据存储区域,而这也是GPU存储渲染结果的地方。如果有时因为面临一些限制,无法把渲染结果直接写入frame buffer,而是先暂存在另外的内存区域,之后再写入frame buffer,那么这个过程被称之为离屏渲染。
-
Core Animation的渲染机制:iOS渲染视图的核心是 Core Animation,从底层到上层依此是 GPU->(OpenGL、Core Graphic) -> Core Animation -> UIKit,如图:
-
Core Animation 流水线
-
Core Animation的渲染过程:
① 布局:在这个阶段,程序设置 view/layer 的层级信息,设置 layer 的属性,如 frame,background color 等。
② 创建 backing image:在这个阶段程序会创建 layer 的 backing image,无论是通过 setContents 将一个 image 传給 layer,还是通过 drawRect:或 drawLayer:inContext:来画出来的。所以 drawRect:等函数是在这个阶段被调用的。
③ 准备:在这个阶段,Core Animation 框架准备要渲染的 layer 的各种属性数据,以及要做的动画的参数,准备传递給 render server,同时在这个阶段也会解压要渲染的 image(除了用 imageNamed:方法从 bundle 加载的 image 会立刻解压之外,其他的比如直接从硬盘读入,或者从网络上下载的 image 不会立刻解压,只有在真正要渲染的时候才会解压)。
④ 提交:在这个阶段,Core Animation 打包 layer 的信息以及需要做的动画的参数,通过 IPC(inter-Process Communication)传递給 render server。
⑤ 当这些数据到达 render server 后,会被反序列化成 render tree。然后 render server 会执行两件事:首先根据 layer 的各种属性(如果是动画的,会计算动画 layer 的属性的中间值),用 OpenGL 准备渲染,然后再渲染这些可视的 layer 到到屏幕。
-
现今移动设备GPU都是采用Tile Based Rendering方式绘制:Core Animation打包图层和动画信息到Render Server(这是一个单独的进程,所有app都会与这个进程通信以完成最终的绘制),由Render Server调用OpenGL/Metal指令,最终在GPU上面完成绘制。而在GPU上面的工作,顶点着色器(Vertex Shader)和GPU tiling一起构成Tiler操作,然后通过片元着色器(Pixel Shader)做Renderer操作,最后输出到**渲染缓存(Render Buffer)**中,这对应一个渲染管线的完整流程,实际上渲染管线还包括诸如光栅化,剔除,混合等操作。对于普通的屏幕内渲染,GPU只有一个Rendering Pass。
-
对于离屏渲染,就存在多个Rendering Pass了,上面是Masking操作的示意图,一共有3步操作,对应3个Rendering Pass。最后的Compisiting pass输出到最后的帧缓存,是屏幕内渲染,而前面的pass1和pass2是绘制到texture供最后一个pass所用,即离屏渲染。
-
UIBlurEffect,同样无法通过一次遍历完成,如图所示:
离屏渲染在GPU上面执行还是在CPU上面执行?
-
通过离屏渲染的概念可以看出离屏渲染是在GPU上面执行的。Apple提供了检测离屏渲染的工具:模拟器的Debug中的Color Off-screen Rendered:
-
Core Graphics做绘制的时候,会有上下文Context,有一个Bitmap画布,这个Bitmap画布是在CPU内存上面的,上下文Context的转换环境到屏幕外缓冲区,然后转换环境到帧缓冲区。
-
特别是,一些(实现drawRect并绘制任何CoreGraphics,用CoreText绘制(它只是使用CoreGraphics))确实是“屏幕外绘制”,但它们不是我们通常所说的那样。它们与列表中的其他部分非常不同。当你实现drawRect或使用CoreGraphics绘制时,你正在使用CPU进行绘制,并且该绘制将在你的应用程序中同步发生。你只是调用了一些函数,它在位图缓冲区中写入位。(来自UIKit 早期成员 Andy Matuschak 的解释)
-
渲染过程的对比说明:
-
离屏渲染的触发:离屏渲染可以被 Core Animation 自动触发,或者被应用程序强制触发。屏幕外的渲染会合并渲染图层树的一部分到一个新的缓冲区,然后该缓冲区被渲染到屏幕上。
-
离屏渲染的性能不好体现在:① 更多的Rendering Pass,GPU运算量增大;② Rendering Pass之间的Context Switch导致的Idle Time。
以UIVisualEffectView为例描述GPU的处理逻辑:有五个Rendering Pass,上面蓝色为Tiler操作的时间分布,红色对应Renderer操作。实际上GPU时间大部分都花在Renderer操作上面,同样最后一个Rendering Pass是屏幕内渲染,那么UIVisualEffectView存在4个屏幕外的Rendering Pass。Rendering Pass之间还还存在黄色的Idle Time,这个就是环境转换(Context Switch)的时间,一个Context Switch大概占用0.1ms-0.2ms的时间,那么UIVisualEffectView的所有Rendering Pass会累积0.5-1.0ms的Idle Time,这个在16.67ms的帧时间内还是很大的。
离屏渲染的情况
- 触发情况
1 、drawRect;
2 、layer.shouldRasterize = true;
3 、有mask或者是阴影(layer.masksToBounds, layer.shadow*):① shouldRasterize(光栅化) ② masks(遮罩)③ shadows(阴影) ④ edge antialiasing(抗锯齿)⑤ group opacity(不透明)
4、Text(UILabel, CATextLayer, Core Text, etc) - 注意:layer.cornerRadius,layer.borderWidth,layer.borderColor并不会Offscreen Render,因为这些不需要加入Mask。 iOS 9.0 之前UIimageView跟UIButton设置圆角都会触发离屏渲染。iOS 9.0 之后UIButton设置圆角会触发离屏渲染,而UIImageView里png图片设置圆角不会触发离屏渲染了,如果设置其他阴影效果之类的还是会触发离屏渲染的。
- 以一张简单的图片显示为例,详细说明:我们键入以下的代码段,然后运行(模拟器中打开Color Off-screen Rendered),然后看下效果:
// 1. button存在背景图片
UIButton *button1 = [UIButton buttonWithType:UIButtonTypeCustom];
button1.frame = CGRectMake(120, 80, 150, 150);
button1.layer.cornerRadius = 75;
[button1 setImage:[UIImage imageNamed:@"girl.jpg"] forState:UIControlStateNormal];
[self.view addSubview:button1];
button1.clipsToBounds = YES;
// 2. button不存在背景图片
UIButton *button2 = [UIButton buttonWithType:UIButtonTypeCustom];
button2.frame = CGRectMake(120, 250, 150, 150);
button2.layer.cornerRadius = 75;
button2.backgroundColor = [UIColor purpleColor];
[self.view addSubview:button2];
button2.clipsToBounds = YES;
// 3.UIImageView设置了图片+背景色
UIImageView *imageView1 = [[UIImageView alloc]init];
imageView1.frame = CGRectMake(120, 420, 150, 150);
imageView1.backgroundColor = [UIColor blueColor];
[self.view addSubview:imageView1];
imageView1.layer.cornerRadius = 75;
imageView1.layer.masksToBounds = YES;
imageView1.image = [UIImage imageNamed:@"girl.jpg"];
// 4.UIImageView 只设置了图片,无背景色
UIImageView *imageView2 = [[UIImageView alloc]init];
imageView2.frame = CGRectMake(120, 590, 150, 150);
[self.view addSubview:imageView2];
imageView2.layer.cornerRadius = 75;
imageView2.layer.masksToBounds = YES;
imageView2.image = [UIImage imageNamed:@"girl.jpg"];
-
模拟的最后运行效果如下:第一和第三的背景色变成黄色警告,这里就出发了“离屏渲染”,而第二和第四则没有触发;由此可以得出:设置一个图层的圆角的时候,①当button设置了背景图时,仅仅设置layer.cornerRadius圆角并不会触发离屏渲染,而当同时也设置了clipsToBounds = YES,这时才会触发离屏渲染了;②当button没有设置背景图时,无论设置layer.cornerRadius还是clipsToBounds = YES都不会触发离屏渲染;③当UIImageView设置了图片+背景色的时候,设置layer.cornerRadius和layer.masksToBounds会触发离屏渲染;④当UIImageView 只设置了图片,无背景色的时候,设置layer.cornerRadius和layer.masksToBounds便会触发离屏渲染。
结论:① 当只设置backgroundColor、border,而contents中没有子视图时,无论maskToBounds / clipsToBounds是true还是false,都不会触发离屏渲染;② 当contents中有子视图时,此时设置 cornerRadius+maskToBounds / clipsToBounds,就会触发离屏渲染。
-
如果shouldRasterize被设置成YES,在触发离屏绘制的同时,会将光栅化后的内容缓存起来,如果对应的layer及其sublayers没有发生改变,在下一帧的时候可以直接复用。这将在很大程度上提升渲染性能。而其它属性如果是开启的,就不会有缓存,离屏绘制会在每一帧都发生。
-
圆角优化,可以使用贝塞尔曲线UIBezierPath和Core Graphics框架画出一个圆角,也可以使用CAShapeLayer和UIBezierPath设置圆角。CAShapeLayer继承于CALayer,可以使用CALayer的所有属性值,CAShapeLayer需要贝塞尔曲线配合使用才有意义(也就是说才有效果)。使用CAShapeLayer(属于CoreAnimation)与贝塞尔曲线可以实现不在view的drawRect(继承于CoreGraphics走的是CPU,消耗的性能较大)方法中画出一些想要的图形,CAShapeLayer动画渲染直接提交到手机的GPU当中,相较于view的drawRect方法使用CPU渲染而言,其效率极高,能大大优化内存使用情况。总的来说就是用CAShapeLayer的内存消耗少,渲染速度快。
-
Core Graphics
UIImageView *imageView = [[UIImageView alloc]initWithFrame:CGRectMake(100,100,100,100)];
imageView.image = [UIImage imageNamed:@"xx"];
//开始对imageView进行画图
UIGraphicsBeginImageContextWithOptions(imageView.bounds.size,NO,1.0);
//使用贝塞尔曲线画出一个圆形图
[[UIBezierPath bezierPathWithRoundedRect:imageView.bounds cornerRadius:imageView.frame.size.width] addClip];
[imageView drawRect:imageView.bounds];
imageView.image = UIGraphicsGetImageFromCurrentImageContext();
//结束画图
UIGraphicsEndImageContext();
[self.view addSubview:imageView];
- CAShapeLayer 方式
UIImageView *imageView = [[UIImageView alloc]initWithFrame:CGRectMake(100, 100, 100, 100)];
imageView.image = [UIImage imageNamed:@"myImg"];
UIBezierPath *maskPath = [UIBezierPath bezierPathWithRoundedRect:imageView.bounds byRoundingCorners:UIRectCornerAllCorners cornerRadii:imageView.bounds.size];
CAShapeLayer *maskLayer = [[CAShapeLayer alloc]init];
//设置大小
maskLayer.frame = imageView.bounds;
//设置图形样子
maskLayer.path = maskPath.CGPath;
imageView.layer.mask = maskLayer;
[self.view addSubview:imageView];
- 至于 mask,圆角半径(特殊的mask)和 clipsToBounds/masksToBounds,可以简单的为一个已经拥有 mask 的 layer 创建内容,比如,已经应用了 mask 的 layer 使用一张图片。如果想根据 layer 的内容为其应用一个长方形 mask,可以使用 contentsRect 来代替蒙板。对于shadow,如果图层是个简单的几何图形或者圆角图形,可以通过设置shadowPath来优化性能,能大幅提高性能。
imageView.layer.shadowColor = [UIColor redColor].CGColor;
imageView.layer.shadowOpacity = 1.0;
imageView.layer.shadowRadius = 2.0;
UIBezierPath *path = [UIBezierPath bezierPathWithRect:imageView.frame];
imageView.layer.shadowPath = path.CGPath;
-
总结来说:
① 当我们需要圆角效果时,可以使用一张中间透明图片蒙上去;
② 使用ShadowPath指定layer阴影效果路径;
③ 使用异步进行layer渲染(Facebook开源的异步绘制框架AsyncDisplayKit);
④ 设置layer的opaque值为YES,减少复杂图层合成;
⑤ 尽量使用不包含透明(alpha)通道的图片资源;
⑥ 尽量设置layer的大小值为整形值;
⑦ 直接让美工把图片切成圆角进行显示,这是效率最高的一种方案;
⑧ 很多情况下用户上传图片进行显示,可以让服务端处理圆角;
⑨ 使用代码手动生成圆角Image设置到要显示的View上,利用UIBezierPath(CoreGraphics框架)画出来圆角图片; -
特殊的“离屏渲染”方式:CPU渲染
如果我们重写了drawRect方法,并且使用任何Core Graphics的技术进行了绘制操作,就涉及到了CPU渲染。整个渲染过程由CPU在App内同步地完成,渲染得到的bitmap最后再交由GPU用于显示。
Core Graphics 的绘制 API 的确会触发离屏渲染,但不是那种 GPU 的离屏渲染。使用 Core Graphics 绘制 API 是在 CPU 上执行,触发的是 CPU 版本的离屏渲染。
当前屏幕渲染、离屏渲染、CPU渲染的选择
- 尽量使用当前屏幕渲染:鉴于离屏渲染、CPU渲染可能带来的性能问题,一般情况下,我们要尽量使用当前屏幕渲染。
- 离屏渲染 VS CPU渲染
由于GPU的浮点运算能力比CPU强,CPU渲染的效率可能不如离屏渲染;但如果仅仅是实现一个简单的效果,直接使用CPU渲染的效率又可能比离屏渲染好,毕竟离屏渲染要涉及到缓冲区创建和上下文切换等耗时操作。
帧缓冲区是什么?
- 帧缓冲区(Frame Buffer)可以理解为一块内存画布,类似于Core Graphics的画布一样。对于屏幕内渲染,会将画布的内容输出到屏幕上,指定目标为Render Buffer,在OpenGL中用glFramebufferRenderbuffer来指定。
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, sampleColorRenderbuffer);
glDrawArrays 或者 glDrawElements
- 对于离屏渲染,画布的内容输出到Texture上,使用glFramebufferTexture2D指定。
glGenFramebuffers(1, &framebuffer1);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer1);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture1, 0);
glDrawArrays 或者 glDrawElements
glGenFramebuffers(1, &framebuffer2);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer2);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture2, 0);
glDrawArrays 或者 glDrawElements
...
// final rendering pass
glBindFramebuffer(GL_FRAMEBUFFER, framebufferFinal);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, sampleColorRenderbuffer);
//
- glGenFramebuffers为开辟帧缓冲区,glBindFramebuffer为切换帧缓冲区,对于离屏渲染会有更多的内存分配和切换操作。离屏消耗消耗主要在于:① glGenFramebuffers导致更多的内存消耗;② 更多的glDrawArrays和glDrawElements导致GPU有更多绘制操作;③ 切换缓冲区带来的消耗。
为什么Context切换会导致flush?
mask效果需要3个renderIng pass,只有最后一个rendering pass是输出到屏幕显示,而前面两个rendering pass都是渲染出Texture作为最后一个的输入,而最后一个rendering pass要想获得正确的结果,前面的rendering pass必须先完成。GPU是多核并行计算,而这种依赖关系导致rendering pass无法真正并行执行。实际上,只要是将Frame Buffer渲染到Texture,Texture又用于后续的渲染,那么后续的渲染都会等待前面的Texture渲染完成,即glFlush操作,以保证最终结果的正确。
离屏渲染的性能优化
由于在iOS10之后,系统的设计风格慢慢从扁平化转变成圆角卡片,即刻的设计风格也随之发生变化,加入了大量圆角与阴影效果,有时处理不当就很容易触发离屏渲染。为此可以采取以下一些措施:
- 即刻大量应用AsyncDisplayKit(Texture)作为主要渲染框架,对于文字和图片的异步渲染操作交由框架来处理;
- 对于图片的圆角,统一采用“precomposite”的策略,也就是不经由容器来做剪切,而是预先使用CoreGraphics为图片裁剪圆角;
- 对于视频的圆角,由于实时剪切非常消耗性能,我们会创建四个白色弧形的layer盖住四个角,从视觉上制造圆角的效果;
- 对于view的圆形边框,如果没有backgroundColor,可以放心使用cornerRadius来做;
- 对于所有的阴影,使用shadowPath来规避离屏渲染;
- 对于特殊形状的view,使用layer mask并打开shouldRasterize来对渲染结果进行缓存;
- 对于模糊效果,不采用系统提供的UIVisualEffect,而是另外实现模糊效果(CIGaussianBlur),并手动管理渲染结果。
针对上面的图片显示,如何高效的为UIImageView创建圆角,这里提供以下的几种方式进行图片的圆角渲染优化处理,让你远离cornerRadius;
- 运用贝塞尔曲线处理图片的圆角绘制,并且还有个意想不到的效果是可以选择哪几个角有圆角效果,过程在CPU内完成:
// 运用贝塞尔曲线处理图片的圆角
- (UIImage *)roundedCornerImageWithCornerRedius:(CGFloat)cornerRadius {
CGFloat w = self.size.width;
CGFloat h = self.size.height;
CGFloat scale = [UIScreen mainScreen].scale;
if (cornerRadius < 0) {
cornerRadius = 0;
} else if (cornerRadius > MIN(w, h)){
cornerRadius = MIN(w, h)/2.0;
}
UIImage *image = nil;
CGRect imageFrame = CGRectMake(0, 0, w, h);
UIGraphicsBeginImageContextWithOptions(self.size, NO, scale);
[[UIBezierPath bezierPathWithRoundedRect:imageFrame cornerRadius:cornerRadius] addClip];
[self drawInRect:imageFrame];
image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return image;
}
- MaskToBounds处理圆角:
- (void)addMaskToBounds:(CGRect)maskBounds {
CGFloat w = maskBounds.size.width;
CGFloat h = maskBounds.size.height;
CGSize size = maskBounds.size;
CGFloat scale = [UIScreen mainScreen].scale;
CGRect imageRect = CGRectMake(0, 0, w, h);
if (self.cornerRadius < 0) {
self.cornerRadius = 0;
} else if (self.cornerRadius > MIN(w, h)){
self.cornerRadius = MIN(w, h)/2.0;
}
UIImage *image = nil;
UIGraphicsBeginImageContextWithOptions(size, NO, scale);
[[UIBezierPath bezierPathWithRoundedRect:imageRect cornerRadius:self.cornerRadius] addClip];
[image drawInRect:imageRect];
self.roundImage = UIGraphicsGetImageFromCurrentImageContext();
self.images = self.roundImage;
UIGraphicsEndImageContext();
}
- 最后附上一个非常优秀的三方框架YYImage的图片圆角处理:
- (UIImage *)yy_imageByRoundCornerRadius:(CGFloat)radius
corners:(UIRectCorner)corners
borderWidth:(CGFloat)borderWidth
borderColor:(UIColor *)borderColor
borderLineJoin:(CGLineJoin)borderLineJoin {
if (corners != UIRectCornerAllCorners) {
UIRectCorner tmp = 0;
if (corners & UIRectCornerTopLeft) tmp |= UIRectCornerBottomLeft;
if (corners & UIRectCornerTopRight) tmp |= UIRectCornerBottomRight;
if (corners & UIRectCornerBottomLeft) tmp |= UIRectCornerTopLeft;
if (corners & UIRectCornerBottomRight) tmp |= UIRectCornerTopRight;
corners = tmp;
}
UIGraphicsBeginImageContextWithOptions(self.size, NO, self.scale);
CGContextRef context = UIGraphicsGetCurrentContext();
CGRect rect = CGRectMake(0, 0, self.size.width, self.size.height);
CGContextScaleCTM(context, 1, -1);
CGContextTranslateCTM(context, 0, -rect.size.height);
CGFloat minSize = MIN(self.size.width, self.size.height);
if (borderWidth < minSize / 2) {
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:CGRectInset(rect, borderWidth, borderWidth) byRoundingCorners:corners cornerRadii:CGSizeMake(radius, borderWidth)];
[path closePath];
CGContextSaveGState(context);
[path addClip];
CGContextDrawImage(context, rect, self.CGImage); CGContextRestoreGState(context);
}
if (borderColor && borderWidth < minSize / 2 && borderWidth > 0) {
CGFloat strokeInset = (floor(borderWidth * self.scale) + 0.5) / self.scale;
CGRect strokeRect = CGRectInset(rect, strokeInset, strokeInset);
CGFloat strokeRadius = radius > self.scale / 2 ? radius - self.scale / 2 : 0;
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:strokeRect byRoundingCorners:corners cornerRadii:CGSizeMake(strokeRadius,borderWidth)];
[path closePath];
path.lineWidth = borderWidth; path.lineJoinStyle = borderLineJoin; [borderColor setStroke];
}
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return image;
}