编者按:本文是蚂蚁集团 AntV 工程师沧东在 SEE Conf 2022 的演讲内容,包括演讲视频及文字内容,欢迎享用。
大家好,我是来自 AntV 团队的沧东。今天给大家带来一个渲染相关的话题,如何通过减少 draw call 提升渲染性能。今天的分享会分成以下几个部分:介绍优化的场景,draw call 代表的含义,它的数量增长对于渲染性能的影响,结合 Canvas2D 和 WebGL API 减少 draw call 以及其他性能提升手段。
首先我们简单介绍一下我们的优化场景,相信听完第一场演讲的同学应该对图场景有了一个大概的了解。在一个典型的图场景里,我们需要渲染大量的 2D 图形例如,节点、边以及文本。这些 2D 图形相对比较简单,也不需要用到追求真实感的材质,它的显著特点就是数量非常多。我们希望在成千上万的数量级别下,用户在做类似于平移缩放、高亮这样的交互分析过程中,依然能够保持平滑的流畅体验。这里我们展示了优化之前后的效果对比,用户在进行缩放或者拖拽的时候,能看到它的交互是非常卡顿的,帧率在 60 FPS 以下。我们希望在应用一系列的优化手段之后能够达到流畅交互。紧接着我们会引出今天的主题 draw call,我想简单带大家做粗浅的认识一下 CPU、GPU 和浏览器是如何协同工作的。当我们使用浏览器浏览网页时,浏览器本身会去调用系统级别的渲染API,可能是 OpenGL 或者 Vulkan 等等,它依据不同的操作系统而异。这些渲染 API 会去访问机器上底层的显卡,最后把我们的网页渲染到屏幕上,这是浏览器渲染网页的基本流程。对于我们前端开发者来说,在使用 Canvas2D 或者 WebGL 这样的渲染 API 时,同样也会发生从 CPU 到 GPU 的交互。当我们使用 Canvas2D API 绘制 2D 图形或者文本时,浏览器会去调用底层的 2D 渲染库,例如 Skia,由它向 GPU 发送绘制命令。我们把这次从 CPU 到 GPU 的交互或者调用称作为一次 draw call。在我们开头的那个很复杂的图场景下,draw call 数量会很多,因为有成千上万的点和边。那 draw call 的数量这么多,对于我们的渲染性能究竟有哪些影响呢?为了更好地理解这一点,我们展示了一张 CPU 和GPU 的渲染时间线。首先大家可以看到,在这样一条时间线里,CPU 在每一帧需要做同一件事,就是准备 draw call。它得告诉 GPU 要画哪些东西,需要准备的 draw call 数量越少,相应的准备时间也就越短。所以在第一帧里大家能看到这个时间线非常短。但随着要准备的 draw call 数量越来越多,这个准备时间就会被拉长了。GPU在渲染之前,首先需要先拿到 CPU 在上一帧准备好的 draw call。在第二帧中,由于第一帧 draw call 数量少,因此 GPU 很快就能进行渲染,它的渲染时间每一帧会在 16 毫秒之内,也就能达到所谓 60 FPS 的流畅渲染效果。但此时它并不能马上开始下一帧的渲染,因为它需要等待 CPU 完成这个第二帧的准备工作,因此 GPU 会有一段空闲时间。由于有这个空闲时间的存在,可以直观的看到在第二帧中渲染时间就被拉长了。这给用户的直观的感受就是帧率从 60 FPS 下降到 45 FPS。因此在了解清楚了这一切之后,我们就能得到一个核心观点,就是 CPU 准备 draw call 的时间是比较长的。GPU 相对来说渲染是比较快的,因此它有大量的空闲时间。所以我们接下去的优化就会围绕怎么去把这个 CPU 准备 draw call 的时间给降下来,让它准备尽量少的 draw call,这样我们的渲染性能就能得到提升。我们接下去会围绕 Canvas 2D 以及 WebGL 这两种 API 给出三种减少 draw call 的优化方法:剔除、脏矩形渲染以及合批渲染。我们在衡量优化性能的效果的时提供了两个指标。第一个当然是这个 draw call 次数,第二个 CPU 求交次数,后面会解释这个指标它代表的含义。总之我们希望这两个指标的数量尽可能少。首先会给大家介绍第一种优化手段:剔除。当用户在一个大图中进行缩放操作时,能看到的区域其实只是图的局部,能见范围之外的图形就可以不用渲染。大家可以看右边这张图,在实际上用户能看到的这个区域内可能只有三个图形,除此之外的 45678 号图形都不用渲染。那我们怎么判断一个图形是否要渲染呢?这个标准我们会通过包围盒与视口求交的方式来判断。大家可能会有疑惑,就为什么你这儿的图形都是一个个块儿,而我们实际看到的那些 2D 图形可能有圆、椭圆、直线甚至文本。这是因为我们为了把求交的过程变得简单和快,会用一个包围盒去框住每一个 2D 图形。不管 2D 图形再怎么复杂,总归可以用一个矩形去把它框住。在右侧这个简单场景里,通过求交能够把 draw call 数量从八个变成三个。但随之而来的是我们为了减少 draw call,又给CPU 加了一个活儿,它需要去依次遍历场景中的每个图形。因此会增加八次求交运算。回到我们回到我们开头的那个场景里,虽然 draw call 次数降低了,但是随之而来的求交次数上升了。因此我们需要对剔除这个场景进行一些优化过程。第一个优化手段是借助场景图的信息。用户在使用渲染引擎 API 时,通常都会使用类似于场景图这样的方法去描述场景。用户提供的层次化的信息,能够有效的帮助我们进行求交加速。同样还是以右侧这个简单场景为例,一旦我们知道了4567 之间存在父子关系,就可以跳过对于 567 的求交检测,因为我们发现4号已经在视口之外了。除了利用场景图信息帮助我们加速这个过程,还可以利用空间索引。对于判断或者检测视口里面到底有哪些图形这个问题,我们其实可以换一种想法,把它转换成区域查询这样的思路。它有点类似于我们在数据库里去查询数据,我们希望通过一次的查询,一次性拿到我们当前的视口里面到底有哪些图形。我们这里使用 R 树这样的空间索引,它能够为靠的比较近的图形自动创建索引,或者把它合并成一个节点。在右侧这个例子里面大家能看到,因为 45678 这几个图形靠的比较近。所以当我们在建立索引的时候,会自动的把它合成为一个节点。这样在进行包围盒的检测过程中,整个 45678 只要通过一次的求交检测,就完全能够排除在视口之外了。求交次数会从5次下降到4次。在我们开头那个非常大的场景中,能够显著减少 CPU 的求交次数。讲完了剔除,下一个优化手段叫脏矩形渲染。当我们在图表里做类似高亮操作时,被高亮图形之外的其他区域其实都是不变的。对于渲染引擎来说,不希望去重复的渲染这些不变的区域,只需要渲染这些发生增量更新的区域。它有点像 React 这样视图框架中的 diff 算法,需要去精准的找到真正发生的部分,然后以最小粒度重绘它。在右侧这个场景里,当鼠标悬停到这个1号图形时,第一步需要去找到这个所谓的“脏矩形”,同样会利用到刚刚介绍的包围盒以及区域查询的概念。这个虚线区域可以理解为是一个需要重绘和更新的区域。找到这个区域之后,下一步我们还是需要去通过一次区域查询去找到这个脏矩形里面到底包含了哪些图形。也就是我们这次需要去重绘的部分。这里我们很快找到是1号2号这两个图形。紧接着我们只需要清空这个局部区域,而非整个画布。最后重绘1号2号图形就完成了这次更新。在这样的过程中,我们会发现 draw call 数量从四次下降到两次。接下来要介绍的优化手段,如果大家对 WebGL API 不熟悉也没有关系。我们简单介绍它的核心思想。图场景里有大量重复的图形,比如所有的节点可能都是圆,所有的边都是直线。这些大量重复的图形可以合并成一个图形,合批渲染就使用了这样的核心思路。在使用 Canvas 2D 这样的 API 绘制时,当我们想画 8000个圆,就不得不重复的调用 8000 次 API。而且它是串行的,发生在 CPU 端,一旦我们使用像 WebGL 这样更加底层的 API 时,就有机会去做一个整合。我们可以把这些不同的圆中不同的属性,比如它的颜色、位置或者半径,都把它整合成一起。然后一次性的提交给底层的渲染 API。因此利用 WebGL 只需要用一次 draw call 就能完成这样大量同类图形绘制。利用它我们可以最大程度的把 draw call 次数从千或者万级别下降到个位数。因此它的性能提升效果也是最明显的。以上介绍的三种手段都是围绕减少 draw call。当然除了它之外,大家看到的各种渲染引擎中还有其他的性能提升手段。比如像 Oasis 这样的注重于真实感或模型渲染的渲染引擎,会尽可能去压缩模型或者贴图。再比如像 G6 这样的上层图应用会采用 LOD 手段,例如当用户的缩放等级比较高时,可以人为的把一些小的文本隐藏掉。我们接下来介绍的一种优化手段,希望让 GPU 承担更多的计算任务,从而减轻 CPU 的计算压力。回到刚刚的时间线里,大家可以看到 GPU 无论是渲染还是计算都是非常快速的。因此在等待 CPU 准备 draw call 或者完成其他任务时,可以尽量利用好这部分空闲时间。这里有一个常见的优化点,当我们在对整个图或者画布进行拖拽,即画布级别的操作时。比如大家看右侧从左边拖到右边,整个场景其实都发生了变化。因此这个场景中每个图形必然也要发生重绘。在这样的场景里面每个图形的位置显然发生了变化。如果我们把位置的计算放在 CPU 端进行,这仍然是一个串行的过程,我们会写一个 8000 次左右的循环,依次去计算每一个图形在世界坐标系下的变换矩阵。大家仔细想想,对于每一个图形变换矩阵的计算,实际上是可以通过并行完成的。我们把它交给 GPU 写在 shader 中计算。虽然计算次数上没有变化,但它从串行变成并行了,也能够很好的利用 GPU 的空闲时间。除此之外,GPU 当然还可以做很多和渲染无关的操作,我们把它叫做 GPGPU 通用计算。在很多现代化的渲染引擎中会提出类似 GPU 驱动的渲染这样的概念。我们之前介绍的剔除实际上也是可并行的,现在我们把剔除放在 CPU 端做,未来借助像 WebGPU 这样的更底层、能力更强的 API,我们可以把剔除工作交给 GPU 做。以上我们介绍的那些手段已经整合在 AntV 底层渲染引擎中。扫码可以了解我们更多优化相关的话题。