iOS之从OpenGL深入探究离屏渲染及性能优化

探究内容

  • 到底什么是离屏渲染?是在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 到到屏幕。
    Core Animation的渲染过程示意图

  • 现今移动设备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。
    Masking操作的示意图

  • 对于离屏渲染,就存在多个Rendering Pass了,上面是Masking操作的示意图,一共有3步操作,对应3个Rendering Pass。最后的Compisiting pass输出到最后的帧缓存,是屏幕内渲染,而前面的pass1和pass2是绘制到texture供最后一个pass所用,即离屏渲染。

  • UIBlurEffect,同样无法通过一次遍历完成,如图所示:
    UIBlurEffect渲染原理

离屏渲染在GPU上面执行还是在CPU上面执行?

  • 通过离屏渲染的概念可以看出离屏渲染是在GPU上面执行的。Apple提供了检测离屏渲染的工具:模拟器的Debug中的Color Off-screen RenderedApple检测离屏渲染工具

  • 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;
}

完整代码传送门

iOS之从OpenGL深入探究离屏渲染及性能优化

猜你喜欢

转载自blog.csdn.net/Forever_wj/article/details/107154004