探究iOS离屏幕渲染本质——OpenGL模版测试、帧缓冲

352 阅读8分钟

iOS从绘制到渲染的整个流程参考 。本文讨论问题主要在GPU渲染阶段。

以下内容,熟悉OpenGL 渲染管线,会比较好理解。

一、OpenGL 模版测试(Stencil Test)

在片段着色器处理完后,模版测试会开始执行,模版测试根据一个模版缓冲(Stencil Buffer)来进行的,我们可以通过模版缓冲选择丢弃或保留某些片段,如下图所示:

模板测试的作用就是限制渲染的图元区域,按照窗口宽高创建一个矩阵,矩阵由0,1组成,可以选择由1或0组成的区域代表相匹配的图元需要提交到后续流程进行测试和绘制.

我们可以利用已经本次绘制的物体,产生一个区域,在下次绘制中利用这个区域做一些效果。

启用模版测试一个3D箱子加上边框, 效果如下:

渲染代码关键步骤示例:


glEnable(GL_DEPTH_TEST);        //开始深度测试
glEnable(GL_STENCIL_TEST);      //开启模版测试
glStencilFunc(GL_NOTEQUAL, 1, 0xFF);  //模版缓冲中不等于1的部分所对应的物体的位置渲染
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);    

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);  //将模版缓冲清除为0

glStencilMask(0x00);     //每一位在写入模板缓冲时都会变成0
normalShader.use();
DrawFloor()              //渲染地板

glStencilFunc(GL_ALWAYS, 1, 0xFF);    //所有片段都通过模版测试被渲染
glStencilMask(0xFF);                  //每一位写入模板缓冲时都保持原样
DrawTwoContainers();

glStencilFunc(GL_NOTEQUAL, 1, 0xFF);    //此时模版缓冲被绘制的地方更新为1了,
                                        GL_NOTEQUAL保证箱子上对应模版值不为1的部分
                                        被渲染
glStencilMask(0x00);                    //每一位在写入模板缓冲时都会变成0
glDisable(GL_DEPTH_TEST);
shaderSingleColor.use(); 
DrawTwoScaledUpContainers();
glStencilMask(0xFF);
glEnable(GL_DEPTH_TEST); 

启用模版测试,还可以做出如下效果:

利用变换放大物体,高斯模糊,然后利用模版在非物体区域绘制模糊后的轮廓。

思考:iOS中为控件添加边框、阴影、mask是否是启用模版测试实现的?

二、OpenGL 帧缓冲

深度缓冲、模版缓冲、颜色缓冲统称为帧缓冲(FrameBuffer), 显示器通过扫描颜色缓冲区的数据显示在屏幕上。

在不自定义帧缓冲的情况下,所有操作都是在默认帧缓冲中进行的。

通过自定义帧缓冲,可以将渲染的场景或物体渲染一个纹理上, OpengGL 可以以一个纹理图像的方式访问已渲染场景或物体中的每一个像素,然后做出一些有趣的后期处理效果,如下图所示:

模糊效果

锐化核

在自定义帧缓冲中渲染好正常的场景生成纹理对象后,切换到默认帧缓冲,绑定纹理,然后渲染(对应的片段着色器程序中加入以上效果处理代码)。

注:纹理类型只是自定义帧缓冲生成的附件一种类型,也可生成渲染缓冲对象(Renderbuffer Object),本文不展开,为便于理解,本文中 纹理对象 可指代这两种类型中的任何一种。

渲染到一个自定义的帧缓冲就叫做离屏渲染(Off-screen Rendering),为保证所有渲染操作在屏幕上有视觉效果,最后需切换到默认帧缓冲完成渲染。

离屏渲染需使用额外的内存空间,以及会有帧缓冲切换的过程,这就是离屏渲染在不考虑缓存重复利用的情况下会增加性能消耗的原因。

看一段OpenGL 利用自定义帧缓冲的代码:

/ 第一处理阶段(Pass)
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);  //激活自定义帧缓冲
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); 
glEnable(GL_DEPTH_TEST);
DrawScene();          //绘制场景

// 第二处理阶段
glBindFramebuffer(GL_FRAMEBUFFER, 0);    // 激活默认帧缓冲
glClearColor(1.0f, 1.0f, 1.0f, 1.0f); 
glClear(GL_COLOR_BUFFER_BIT);

screenShader.use();              //初始化绘制程序  
glBindVertexArray(quadVAO);      
glDisable(GL_DEPTH_TEST);
glBindTexture(GL_TEXTURE_2D, textureColorbuffer); //绑定自定义帧缓冲渲染生成的
                                                    //纹理对象
glDrawArrays(GL_TRIANGLES, 0, 6);     //绘制

思考:iOS中的图片模糊效果底层是否也是通过自定义帧缓冲实现的?

三、iOS离屏渲染触发分析

我们知道圆角、阴影、mask、模糊等会触发离,因官方也没有明确内部渲染的实现细节,大多数文章摘录认可度较高的一些想象出来的原因,但如果对OpenGL渲染管线熟悉的话,就会发现依然解释不通为何做这些效果一定要使用离屏渲染。我们涉及到渲染相关的问题,脱离OpengGL 去讨论是不合理的。

CALayer 层次结构:

1、设置圆角

苹果官方文档对cornerRadius的描述:

Setting the radius to a value greater than 0.0 causes the layer to begin drawing rounded corners on its background. By default, the corner radius does not apply to the image in the layer’s contents property; it applies only to the background color and border of the layer. However, setting the masksToBounds property to true causes the content to be clipped to the rounded corners.

layer.cornerRaclius . 0.0 ,只会作用于backgroundColor、border 层.不会设置content 层的圆角,除非设置了layer.masksToBounds 为Ture(view中的clipsToBounds)

UIButton 和 UIImageView 设置背景颜色+圆角

  //1.按钮存在背景图片
    UIButton *btn1 = [UIButton buttonWithType:UIButtonTypeCustom];
    btn1.frame = CGRectMake(100, 30, 100, 100);
    btn1.layer.cornerRadius = 50;
    [self.view addSubview:btn1];
    [btn1 setImage:[UIImage imageNamed:@"btn.png"] forState:UIControlStateNormal];
    btn1.clipsToBounds = YES;
    
  //2.按钮不存在背景图片
    UIButton *btn2 = [UIButton buttonWithType:UIButtonTypeCustom];
    btn2.frame = CGRectMake(100, 180, 100, 100);
    btn2.layer.cornerRadius = 50;
    btn2.backgroundColor = [UIColor blueColor];[self.view addSubview:btn2];
    btn2.clipsToBounds = YES;
    
    //3.UIImageView 设置了图片+背景色;
    UIImageView *img1 = [[UIImageView alloc]init];
    img1.frame = CGRectMake(100, 320, 100, 100);
    img1.backgroundColor = [UIColor blueColor];[self.view addSubview:img1];
    img1.layer.cornerRadius = 50;
    img1.layer.masksToBounds = YES;
    img1.image = [UIImage imageNamed:@"btn.png"];
    
    //4.UIImageView 只设置了图片,无背景色;
    UIImageView *img2 = [[UIImageView alloc]init];
    img2.frame = CGRectMake(100, 480, 100, 100);[self.view addSubview:img2];
    img2.layer.cornerRadius = 50;
    img2.layer.masksToBounds = YES;
    img2.image = [UIImage imageNamed:@"btn.png"];

个人猜想:

CALayer中只有一个部分需要圆角的时候,在渲染的时候,在片段着色器中通过算法过滤掉圆角外片段,如为圆角外片段alpha值设置为0。而多个部分需要圆角时,选择使用离屏渲染开启模版测试圆角外的部分不通过模版测试,多个部分一次性丢弃。

可能这种情况下性能最佳,或是为每一部分在shader(着色器程序)中添加相应过滤算法,开发者通过单个上层属性将无法控制哪个部分启用圆角算法,比如只需要设置背景圆角,让多个部分都执行了过滤算法显然不合适。 具体情况不得而知。

可能有人问,为什么不能在当前默认的帧缓冲中启用模版测试,丢弃掉圆角以外的部分? 因为当前的帧缓冲中开启模版测试将为整个屏幕需要渲染的内容创建对应的模版缓冲,如果圆角以外的部分不通过模版测试,当前CALayer 以外的部分将都不会被渲染,显然这不是我们想要的效果。

圆角避免离屏渲染优化:

针对上面所描述又要边框或背景同时有图片的情况

1、使用圆角切图

2、CoreGraphics 绘制圆角图片

2、shadow

上文中模版测试在默认帧缓冲中启用模版测试,绘制了箱子外侧类似阴影的效果,并未使用离屏渲染。

但我们知道iOS中如果如下代码设置阴影,会产生离屏渲染:


    view.backgroundColor = UIColor.whiteColor;
    
    view.layer.shadowColor = shadowColor.CGColor;
    view.layer.shadowOffset = CGSizeMake(0, 0);
    view.layer.shadowOpacity = 1;
    view.layer.shadowRadius = 5;
    
    view.layer.borderColor = shadowColor.CGColor;
    view.layer.borderWidth = 1 / UIScreen.mainScreen.scale;
    view.layer.cornerRadius = 10;
    

原因也类似,当前帧缓冲中存储的是整个屏幕的渲染数据,启用模版测试将对帧个屏幕上其他Layer得渲染造成影响,所以新创建帧缓冲单独用来处理阴影的Layer,渲染完成后的数据给默认帧缓冲来显示。

如果backgroudColor是半透明的,和shadow 会触发混合(Blending),混合会增加GPU的消耗

阴影避免离屏渲染:

在上面代码上加上:

view.layer.shadowPath = UIBezierPath路径;

加上这句后,阴影将会在CPU上绘制出来,GPU拿到绘制的数据渲染。不涉及到模版测试,创建帧缓冲等操作。

3、Mask

添加Mask,可以说是应用层接口启用OpenGL 模版测试,设置模版测试参数最直观的方式,可以直接决定哪一部分渲染,哪一部分不渲染。


    let path = UIBezierPath.init(rect: UIScreen.main.bounds)
    let rect = tipView.convert(tipView.bounds, to: from)
    path.append(UIBezierPath(roundedRect: rect, cornerRadius: 10).reversing())
    let maskLayer = CAShapeLayer.init()
    maskLayer.path = path.cgPath
    self.layer.mask = maskLayer

触发离屏渲染的原因跟圆角\Shadow也是类似的,需要在自定义的帧缓冲渲染好当前Layer,再切换到默认帧缓冲。

Mask必须在帧缓冲上操作,不存在CPU处理的情况,iOS是有Mask时会有帧缓冲切换,就无法避免离屏渲染。

mask也可以用来设置圆角

4、UIBlurEffect

在上文 OpenGL 帧缓冲 小节中, 讲述了做一些后期特效的处理过程。模糊效果的渲染,就是自定义帧缓冲在应用层的一个具体应用。

类似的全屏的灰度处理等,这些都是通过离屏渲染出整个场景并生成一个纹理对象作为默认帧缓冲的输入,然后在着色器通过算法做一些特效。

离屏渲染对性能的影响

  • 离屏渲染需要额外的存储空间,渲染空间大小的上限是2.5倍的屏幕像素大小,超过无法使用离屏渲染。
  • 帧缓冲切换过程造成时间上的消耗,离屏渲染耗时的增加会导致最终渲染结果存入默认帧缓存区的时候,已经超过了16.67ms,产生掉帧造成卡顿。

在tableView或者collectionView中,滚动的每一帧变化都会触发每个cell的重新绘制,因此一旦存在离屏渲染,将会存在频繁的上下文切换,甚至每一帧有几十张的图片要处理,每16ms就需要根据当前滚动位置渲染整个tableView,会对GPU的性能造成挑战。