从OpenGL再说离屏渲染

离屏渲染应该是所有iOS开发者绕不开的话题,关于离屏渲染的文章也有很多。objc.io 的文章绘制像素到屏幕上说过:
一般情况下,你需要避免离屏渲染,因为这是很大的消耗。直接将图层合成到帧的缓冲区中(在屏幕上)比先创建屏幕外缓冲区,然后渲染到纹理中,最后将结果渲染到帧的缓冲区中要廉价很多。因为这其中涉及两次昂贵的环境转换(转换环境到屏幕外缓冲区,然后转换环境到帧缓冲区)。
之前不懂GPU的工作原理,不懂OpenGL/Metal这些底层绘制API,对这一段话理解的非常模糊,后来学了下OpenGL和Metal,再结合之前看的文章和自己的理解,对离屏渲染(Offscreen Rendering)做一次梳理。有些地方是我自己的理解,不见得正确。先抛出下面的问题:
1、到底什么是离屏渲染?是在GPU上面还是CPU上面执行的?
2、为什么要有离屏渲染?什么情况下会产生离屏渲染?
2、帧缓冲区是什么?当前屏幕缓冲区和屏幕外缓冲区又是什么?
3、切换缓冲区是什么操作?真的比较耗时吗?

什么是离屏渲染?

2014年的WWDC Advanced Graphics and Animations for iOS Apps 对Core Animation的渲染机制做了详细解释:

图1

现今移动设备GPU都是采用Tile Based Rendering方式绘制,关于Tile Based Rendering在这篇文章有详细描述 Performance Tuning for
Tile-Based Architectures

Core Animation打包图层和动画信息到Render Server(这是一个单独的进程,所有app都会与这个进程通信以完成最终的绘制),由Render Server调用OpenGL/Metal指令,最终在GPU上面完成绘制。而在GPU上面的工作对于了解OpenGL的人比较熟悉了,顶点着色器(Vertex Shader)和GPU tiling一起构成Tiler操作,然后通过片元着色器(Pixel Shader)做Renderer操作,最后输出到渲染缓存(Render Buffer)中。这对应一个渲染管线的完整流程,实际上渲染管线还包括诸如光栅化,剔除,混合等操作,在上面的示意图中省略了。对于普通的屏幕内渲染,GPU只有一个Rendering Pass。
图2

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

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

前面的图2很明确指出离屏渲染是在GPU上面执行的,但是有很多文章说CPU上面也会有离屏渲染,比如使用Core Graphics绘制的时候。Apple提供了检测离屏渲染的工具:Color Off-screen Rendered

图3

我重写UIView的drawRect方法(使用Core Graphics绘制),用Color Off-screen Rendered检测(iOS12模拟器)没有离屏渲染。因此严格来说CPU渲染不应该算作离屏渲染,离屏渲染发生在GPU上面。而且CPU渲染导致的卡顿和GPU的离屏渲染导致的卡顿原理完全不一样,在做性能优化的时候应该区别对待。
Core Graphics做绘制的时候,会有上下文Context,也有一个Bitmap画布,但是这个Bitmap画布是在CPU内存上面的,上下文Context也和上面说的环境转换:昂贵的环境转换(转换环境到屏幕外缓冲区,然后转换环境到帧缓冲区)不是一码概念。因此把CPU绘制归为离屏渲染个人感觉非常不妥。
UIKit 早期成员 Andy Matuschak,在一篇回复中有这样一段话:
In particular, a few (implementing drawRect and doing any CoreGraphics drawing, drawing with CoreText [which is just using CoreGraphics]) are indeed “offscreen drawing,” but they’re not what we usually mean when we say that. They’re very different from the rest of the list. When you implement drawRect or draw with CoreGraphics, you’re using the CPU to draw, and that drawing will happen synchronously within your application. You’re just calling some function which writes bits in a bitmap buffer, basically.

离屏渲染性能不好在哪里?

Advanced Graphics and Animations for iOS Apps 这个 session 以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、更多的Rendering Pass,GPU运算量增大;
2、Rendering Pass之间的Context Switch导致的Idle Time。

图4

为什么要有离屏渲染?

离屏渲染既然不好,为什么它还存在?这要从OpenGL/Metal和GPU说起,GPU有少量的逻辑处理单元和大量的核心,CPU则相反。CPU适合做逻辑运算,复杂的运算,而GPU适合做简单运算,大量重复运算。对于Tiler中的大量顶点运算,Renderer中的着色混合等都适合在GPU上面并行运算。但是GPU不适合做逻辑运算,所以一次只能绘制简单的图元(Primitives),对应到OpenGL中就是GL_POINTS、GL_LINES、GL_LINE_STRIP、GL_TRIANGLES、GL_TRIANGLES_STRIP,比如一个CALayer就由两个三角形(GL_TRIANGLES)组成,绘制普通Layer的时候GPU只需要一个Render Pass即可。而mask效果是将一个层作为“形状”来绘制另一个层,而这个“形状”是无法通过点,线,三角形这些基本图元来描述的,因此mask效果无法用GPU一步绘制出来,但是可以多步组合绘制出来,如上图2描述的三个Rendering Pass组合绘制mask效果。
除此之外,我们知道CALayer的shouldRasterize属性可以强制离屏渲染。Advanced Graphics and Animations for iOS Apps 这样描述:

图5

Rasterization会使用GPU将多个Layer绘制到一个image(Texture)中,并且这个image是会缓存的,以便后续直接使用缓存进行渲染。在Rendering阶段,存在一个颜色混合(https://learnopengl-cn.readthedocs.io/zh/latest/04%20Advanced%20OpenGL/03%20Blending/)的操作,即对应到每一个像素点,在绘制的时候都会取rendertBuffer中的原有颜色与当前颜色按照指定公式计算得到颜色值。对应到OpenGL就是:

glEnable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);

这个操作在GPU上面是比较耗时的(因为在移动平台GPU上,取rendertBuffer中取颜色是消耗操作)。如果CALayer树结构比较复杂,图层众多,GPU每一帧都得混合所有的层,导致GPU消耗巨大。Rasterization的作用在于可以指定用GPU混合一些复杂的CALayer成Texture,后续直接使用,从而避免GPU的混合消耗。可见离屏渲染不一定降低性能,有时候还可以优化性能。注意图4指明Rasterization会增加内存消耗,同时只适合在图层内容变化不频繁的场景。

帧缓冲区是什么?

帧缓冲区(Frame Buffer)在OpenGL和Metal里面是最基础的概念,可以理解为一块内存画布,类似于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指定,比如:

// rendering pass1
glGenFramebuffers(1, &framebuffer1);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer1);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture1, 0);
glDrawArrays 或者 glDrawElements

// rendering pass2
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为切换帧缓冲区,对于离屏渲染会有更多的内存分配和切换操作。从上面分析离屏消耗消耗主要在于:
1、glGenFramebuffers导致更多的内存消耗;
2、更多的glDrawArrays和glDrawElements导致GPU有更多绘制操作;
3、切换缓冲区带来的消耗:实际上glBindFramebuffer命令并不会有过多的消耗,根据Andy Matuschak,在回复中的原话:It’s expensive for the GPU to switch contexts from on-screen to off-screen drawing (it must flush its pipelines and barrier),意思是离屏渲染的Context切换会导致渲染管线的flush操作,众所周知OpenGL中的glFlush操作是非常昂贵的,所以我理解这才是Context切换昂贵的最终原因。

为什么Context切换会导致flush

先从表面看图2,mask效果需要3个renderIng pass,只有最后一个rendering pass是输出到屏幕显示,而前面两个rendering pass都是渲染出Texture作为最后一个的输入,而最后一个rendering pass要想获得正确的结果,前面的rendering pass必须先完成。GPU是多核并行计算,而这种依赖关系导致rendering pass无法真正并行执行。
实际上,只要是将Frame Buffer渲染到Texture,Texture又用于后续的渲染,那么后续的渲染都会等待前面的Texture渲染完成,即glFlush操作,以保证最终结果的正确。Performance Tuning for
Tile-Based Architectures
这篇文章也指明使用render to texture的结果会导致flush。

参考

https://objccn.io/issue-3-1/
https://www.seas.upenn.edu/~pcozzi/OpenGLInsights/OpenGLInsights-TileBasedArchitectures.pdf
https://github.com/seedante/iOS-Note/wiki/Mastering-Offscreen-Render
https://developer.apple.com/videos/play/wwdc2014/419/
https://learnopengl-cn.readthedocs.io/zh/latest/04%20Advanced%20OpenGL/03%20Blending/

推荐阅读更多精彩内容