使用批处理优化

前言

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

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

绘制调用

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

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

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

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

它的运作方式

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

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

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

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

中断批处理

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

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

  • 纹理的变化.

  • 材质的变化.

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

注解

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

确定渲染顺序

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

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

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

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

  • 场景树中对象的顺序。

  • 对象的 Z 索引。

  • 画布层。

  • YSort 节点.

注解

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

诀窍

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

../../_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 项目设置, 它可以用来解决这个问题.

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

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

附录

批量处理图元

并不是所有图元都支持做批量处理。系统也无法保证一定会进行批量处理,图元使用抗锯齿边缘时尤其无法保证。可以使用以下类型的图元:

  • RECT

  • NINEPATCH(由 wrapping mode 环绕模式决定)

  • POLY

  • LINE

如果图元未做批量处理,你可能可以通过在 _draw() 函数中手动绘制多边形来获得性能提升。更多信息见 2D 中的自定义绘图

光线裁剪阈值计算

实际用作阈值的屏幕像素面积比例是 scissor_area_threshold 值的4次方.

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

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

  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

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