使用批处理优化

简介

游戏引擎必须向GPU发送一组指令,以告诉GPU要画什么和在哪里画.这些指令是使用称为 :abbr:`APIs(Application Programming Interfaces)`的通用指令发送的.图形API的例子有OpenGL、OpenGL ES和Vulkan.

不同的API在绘制对象时产生的成本不同.OpenGL在GPU驱动中为用户处理了很多工作,但代价是要付出更昂贵的绘制调用.因此,通常可以通过减少绘制调用的次数来加快应用程序的速度.

注解

目前只有在使用GLES2渲染器时才支持2D批处理.

绘制调用

在2D中,我们需要告诉GPU渲染一系列基本单元(矩形、线条、多边形等).最明显的技术是告诉GPU一次渲染一个基本单元,告诉它一些信息,如使用的纹理、材质、位置、大小等,然后说 “Draw!”(这叫做绘制调用).

虽然从引擎方面来看,这在概念上很简单,但以这种方式使用时,GPU的运行速度非常慢.如果你告诉GPU在一次绘制调用中全部绘制一些类似的基本单元,GPU的工作效率要高得多,我们称之为 “批处理”.

事实证明,这样使用时,它们不仅仅是工作速度快一点,而是工作速度 快许多 .

由于 Godot 被设计为通用引擎,进入 Godot 渲染器的基本单元可以以任何顺序排列,有时相似,有时不同.为了使Godot的通用性与GPU的批处理偏好相匹配,Godot具有一个中间层,它可以在可能的情况下自动将基本单元分组,并将这些批处理发送到GPU上.这可以提高渲染性能,同时只需要对您的Godot项目进行少量(如果有的话)更改.

它的运作方式

指令以一系列项目的形式从游戏中进入渲染器,每个项目可以包含一个或多个命令.这些项目对应场景树中的节点,而命令则对应矩形或多边形等基本单元.有些项(如 TileMaps 和文本)可以包含大量命令(如图块和字形).其他项目,如精灵,可能只包含一个命令(一个矩形).

批处量使用两种主要技术将基本单元分组:

  • 连续的项目可以连接到一起.

  • 一个项目中的连续命令可以连接成一个批次.

中断批处理

只有当项目或命令足够相似,可以在一次绘制调用中呈现时,才能进行批处理.某些变化或技术,在必要时,阻止形成一个连续的批次,被称为”打破批次”.

批处理将被下列事项打破(其中包括):

  • 纹理的变化.

  • 材质的变化.

  • 改变基本单元类型(比如从矩形到线条).

注解

例如,如果你绘制一系列的精灵,每个精灵都有不同的纹理,那么就没有办法将它们进行批处理.

确定渲染顺序

问题来了,如果只有相似的物品才能批量绘制在一起,那我们为什么不把一个场景中的所有物品都浏览一遍,把所有相似的物品都分组,然后绘制在一起呢?

在3D中,这往往正是引擎的工作方式.然而,在Godot的2D渲染器中,项目是按照 “绘制顺序”,从后到前绘制的.这确保了当前面的项目重叠时,它们会被绘制在前面的项目之上.

这也就意味着,如果我们试图在每个纹理的基础上绘制对象,那么绘制的顺序可能会被打破,对象将以错误的顺序绘制.

在Godot,这种从后到前的顺序由以下因素确定的:

  • 场景树中对象的顺序.

  • 对象的Z索引.

  • Canvas Layers(画布层).

  • YSort 节点.

注解

您可以将类似的对象分组,以便于进行批处理.虽然这样做并不是您的必须,但可以将其视为一种可选的方法,在某些情况下以提高性能.请参阅 :ref:`doc_batching_diagnostics`一节来帮助做出选择.

诀窍

现在,来点小技巧.尽管绘制顺序的概念是物体从后到前渲染,但考虑三个物体 ABC ,它们包含两种不同的纹理:草和木头.

../../_images/overlap1.png

按照绘画者的排列顺序如下:

  1. A - wood
  2. B - grass
  3. C - wood

由于纹理的变化,它们不能被批量化,将在3次绘制调用中呈现.

然而,绘制的顺序只是在假设它们将被绘制在 之上 的前提下才需要.如果我们放宽这个假设,即如果这3个对象都不重叠,就 不需要 保留绘制顺序.渲染的结果将是一样的.如果能利用这一点呢?

项目重新排序

../../_images/overlap2.png

事实证明,我们可以对项目进行重新排序.但是,只有在物品满足重叠测试的条件下才能做到这一点,以确保最终的结果和没有重新排序一样.重叠测试在性能上非常廉价,但并不是绝对免费的,所以,提前查看项目决定是否可以重新排序是有一点成本的.为了平衡项目中的成本和收益,可以在项目设置中设置提前查看重排序的项目数量(见下图).

  1. A - wood
  2. C - wood
  3. B - grass

由于纹理只变化一次,所以我们只需要2次绘制调用就可以呈现上面的内容.

灯光

虽然批处理系统的工作通常很简单,但当使用 2D 灯光时,它就变得复杂很多.这是因为灯光是通过额外的通道绘制,每个影响基本单元的灯光都有一个通道.考虑2个精灵 AB ,具有相同的纹理和材质.在没有灯光的情况下,它们将被分批在一起,并在一次绘制调用中绘制.但如果有3个灯光,它们将按如下方式绘制,每条线都是一个绘制调用:

../../_images/lights_overlap.png

  1. A
  2. A - light 1
  3. A - light 2
  4. A - light 3
  5. B
  6. B - light 1
  7. B - light 2
  8. B - light 3

那是很多绘制调用.仅仅是2个精灵就需要8次调用,考虑到要绘制1000个精灵,绘制调用的次数很快就会变成天文数字,性能也会受到影响.这也是为什么灯光有可能大大降低2D渲染速度的部分原因.

不过,如果你还记得我们的魔术师在物品重新排序时的技巧,也可以用同样的技巧来绕过绘制对灯光的排序!

如果 AB 不重合,可以将它们一起批量渲染,绘制过程如下:

../../_images/lights_separate.png

  1. AB
  2. AB - light 1
  3. AB - light 2
  4. AB - light 3

也就是只有4个绘制调用.还不错,因为减少了2倍.然而,考虑到在真实的游戏中,可能会绘制接近1000个精灵.

  • 之前: 1000 × 4 = 4,000 绘制调用.

  • 之后:1 × 4 = 4 绘制调用.

这就减少了1000倍的绘制调用,应该会给性能带来巨大的提升.

重叠测试

然而,与项目重新排序一样,事情并不那么简单.必须首先执行重叠测试,以确定是否可以加入这些基本单元.这种重叠测试的成本很小.同样,您可以在重叠测试中选择要提前查看的基本单元数量,以平衡收益与成本.对于灯光,收益通常远远大于成本.

此外,根据视图中基本单元的排列,重叠测试有时会失败(因为基本单元重叠,因此不应连接).在实践中,绘制调用的减少可能不如在完全没有重叠的完美情况下那么显著.但是,性能通常远高于没有这种照明优化的情况.

光裁剪

批处理会使剔除不受光线影响或部分影响的物体变得更加困难.这可能会增加不少填充率要求,并减慢渲染速度. 填充率 是指像素被着色的速度.这是另一个与绘制调用无关的潜在瓶颈.

为了解决这个问题(并在总体上加快光照速度),批处理引入了光线裁剪.使用OpenGL命令 glScissor() ,它可以识别一个区域,在这个区域之外,GPU不会渲染任何像素.我们可以通过识别光线和基本单元之间的交叉区域,并将光线渲染限制在 该区域 ,从而大大优化填充率.

光线裁剪是通过 scissor_area_threshold 项目设置来控制的.这个值在1.0和0.0之间,1.0为关闭(不裁剪),0.0为在任何情况下都裁剪.设置的原因是,在某些硬件上进行裁剪操作可能会有一些小成本.也就是说,当你在使用2D照明时,裁剪通常应该会带来性能的提升.

阈值与裁剪操作是否发生之间的联系并不总是直接的.一般来说,它代表了裁剪操作可能 “保存” 的像素区域(即保存的填充率).在1.0时,整个屏幕的像素都需要被保存,而这种情况很少发生,所以它被关闭.在实践中,有用的值接近于0.0,因为只有一小部分像素需要被保存,操作才是有用的.

具体关系可能不需要用户操心,但出于兴趣,将其列入附录: 光线裁剪阈值计算

Light scissoring example diagram

右下角是一盏灯,红色区域是裁剪操作保存的像素,只有交叉点需要渲染.

顶点烘焙

GPU着色器主要通过2种方式接收到需要画什么的指令:

  • 着色器uniform(例如,调整颜色、项目变换).

  • 顶点属性(顶点颜色,局部变换).

然而,在一个单一的绘制调用(批处理)中,我们不能改变uniforms.这意味着,我们不能将改变 final_modulate 或一个项目变换或命令批处理在一起.不幸的是,这在很多情况下都会发生.例如,精灵通常是单独的节点,有自己的项目变换,它们也可能有自己的颜色调制.

为了解决这个问题,批处理可以将部分uniforms “烘焙” 到顶点属性中.

  • 项目变换可以与局部变换相结合,并以顶点属性发送.

  • 最后的调制颜色可以与顶点颜色相结合,并以顶点属性发送.

在大多数情况下,这都能正常工作,但如果着色器希望这些值单独可用,而不是组合在一起,这个快捷方式就会失效,这可能发生在自定义着色器中.

自定义着色器

由于上述限制,自定义着色器中的某些操作将阻止顶点烘烤,因此减少了批量化的可能性.虽然我们正在努力减少这些情况,但目前适用以下注意事项:

  • 读取或写入``COLOR`` or ``MODULATE``禁用顶点颜色烘焙.

  • 读取``VERTEX`` 禁用顶点位置烘焙.

项目设置

为了微调批处理,有许多项目设置可用.在开发过程中,您通常可以将这些设置保持为默认状态,但最好进行试验,以确保获得最大的性能.花一点时间调整参数,往往可以用很少的精力获得可观的性能提升.更多信息请参见项目设置中悬停时工具提示.

rendering/batching/options

  • use_batching - 打开或关闭批处理.

  • use_batching_in_editor 在Godot编辑器中开启或关闭批处理.这个设置不会以任何方式影响正在运行的项目.

  • single_rect_fallback —这是一种更快的绘制不可批处理矩形的方式.然而,它可能会导致某些硬件上的闪烁,所以不推荐使用.

rendering/batching/parameters

  • max_join_item_commands - 实现批处理的最重要方法之一是将合适的相邻项目(节点)连接在一起,然而只有当它们所包含的命令兼容时,才能被连接.因此,系统必须对一个项中的命令做提前查看,以确定它是否可以被加入.这样做每个命令的成本很小,而命令数量多的项目不值得加入,所以最佳价值可能取决于项目.

  • colored_vertex_format_threshold - 将颜色烘焙到顶点中会导致顶点格式更大.除非在加入的项目中有大量的颜色变化,否则不一定值得.这个参数表示包含颜色变化的命令和总命令的比例,超过这个比例就会切换到烘焙颜色.

  • batch_buffer_size--这决定了一个批次的最大大小,它对性能的影响不大,但如果内存很重要的话,那么就值得将其降低.

  • item_reordering_lookahead - 项目重新排序可以帮助,特别是使用不同纹理的交错精灵.重叠测试的提前查看的成本很小,所以每个项目的最佳值可能会改变.

rendering/batching/lights

  • scissor_area_threshold- 请参考灯光剪裁.

  • max_join_items - 在照明前加入项目可以显著提高性能.这需要进行重叠测试,成本较小,因此成本和收益可能取决于项目,因此这里使用的代价最高.

rendering/batching/debug

  • flash_batching - 这纯粹是一个调试功能,用于识别批处理和遗留渲染器之间的回归.当它被打开时,批处理和遗留渲染器会在每一帧中交替使用.这将会降低性能,不应该用于最终的输出,而只是用于测试.

  • diagnose_frame - 这将定期打印诊断批处理日志到Godot IDE/控制台.

rendering/batching/precision

  • uv_contract - 在某些硬件上(尤其是某些Android设备),有报告称图块贴图的绘制略微超出其UV范围,导致边缘伪影,如图块周围的线条.如果你看到这个问题,请尝试启用uv收缩.这将使UV坐标小幅收缩,以补偿设备上的精度误差.

  • uv_contract_amount--希望默认的数量能够解决大多数设备上的伪装问题,但这个值仍然可以调整,以防万一.

诊断

虽然你可以改变参数并检查对帧率的影响,但这可能会让人感觉像盲目地工作,不知道下面发生了什么.为了帮助解决这个问题,批处理提供了一个诊断模式,它将定期打印出(到IDE或控制台)正在处理的批处理列表.这可以帮助确定批处理没有按照预期发生的情况,并帮助你修复这些情况以获得最佳性能.

阅读诊断

  1. canvas_begin FRAME 2604
  2. items
  3. joined_item 1 refs
  4. batch D 0-0
  5. batch D 0-2 n n
  6. batch R 0-1 [0 - 0] {255 255 255 255 }
  7. joined_item 1 refs
  8. batch D 0-0
  9. batch R 0-1 [0 - 146] {255 255 255 255 }
  10. batch D 0-0
  11. batch R 0-1 [0 - 146] {255 255 255 255 }
  12. joined_item 1 refs
  13. batch D 0-0
  14. batch R 0-2560 [0 - 144] {158 193 0 104 } MULTI
  15. batch D 0-0
  16. batch R 0-2560 [0 - 144] {158 193 0 104 } MULTI
  17. batch D 0-0
  18. batch R 0-2560 [0 - 144] {158 193 0 104 } MULTI
  19. canvas_end

这是一个典型的诊断方法.

  • **joined_item:**一个joined项可以包含1个或多个项(节点)的引用.一般来说,包含多个引用的jianed_items比包含单个引用的许多jianed_items要好.项目是否能被加入,将由其内容和与前一个项目的兼容性决定.

  • **batch R:**一个包含矩形的批次.第二个数字是矩形的数量.方括号内的第二个数字是Godot纹理ID,大括号内的数字是颜色.如果批次中包含多个矩形,则会在行中添加``MULTI``,以便于识别.看到``MULTI``是好的,因为它表示批处理成功.

  • **batch D:**一个默认的批次,包含其他一切当前没有批次的东西.

默认批次

默认批次后面的第二个数字是该批次中的命令数,后面是内容的简单摘要:

  1. l - line
  2. PL - polyline
  3. r - rect
  4. n - ninepatch
  5. PR - primitive
  6. p - polygon
  7. m - mesh
  8. MM - multimesh
  9. PA - particles
  10. c - circle
  11. t - transform
  12. CI - clip_ignore

您可能会看到包含无命令的 “虚拟 “默认批次,您可以忽略这些.

常见问题

当启用批量处理时,性能并没有大幅提升.

  • 试着诊断一下,看看发生了多少批处理的情况,是否可以改进

  • 尝试改变项目设置中的批处理参数.

  • 考虑到批处理可能不是你的瓶颈(见瓶颈).

使用批处理会降低性能.

  • 尝试上述步骤来增加批处理的机会.

  • 尝试启用 single_rect_fallback.

  • 单一矩形回退法是在不进行批处理的情况下使用的默认方法,它的速度大约是原来的两倍.然而,它可能会导致某些硬件上的闪烁,因此不鼓励使用它.

  • 在尝试了上面的方法后,如果你的场景表现仍然较差,可以考虑关闭批处理.

我使用了自定义着色器,但项目没有批量化.

  • 自定义着色器在批处理时可能会出现问题,请参阅自定义着色器部分

我看到线程出现在某些计算机硬件上.

  • 参见 uv_contract 项目设置,它可以用来解决这个问题.

我使用了大量的纹理,所以很少有项目被批量化.

  • 考虑使用纹理图集.除了允许批处理外,这些图集还减少了与改变纹理相关的状态变化的需求.

附录

光线裁剪阈值计算

实际用作阈值的屏幕像素面积比例是 :ref:`scissor_area_threshold <class_ProjectSettings_property_rendering/batching/lights/scissor_area_threshold>`值的4次方.

例如,在1920×1080的屏幕尺寸上,有2,073,600个像素.

在1,000像素的阈值下,该比例将是:

  1. 1000 / 2073600 = 0.00048225
  2. 0.00048225 ^ (1/4) = 0.14819

所以 scissor_area_threshold 0.15 是一个合理的尝试值.

另辟蹊径,比如用 scissor_area_threshold0.5:

  1. 0.5 ^ 4 = 0.0625
  2. 0.0625 * 2073600 = 129600 pixels

如果保存的像素数大于该阈值,则剪刀被激活.