使用批处理优化

前言

游戏引擎必须向 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 , 它们包含两种不同的纹理: 草和木头. 按照绘画者的排列顺序如下:
    1. A - woodB - grassC - wood
    由于纹理的变化, 它们不能被批量化, 将在3次绘制调用中呈现. 之上 的前提下才需要. 如果我们放宽这个假设, 即如果这3个对象都不重叠, 就 不需要 保留绘制顺序. 渲染的结果将是一样的. 如果能利用这一点呢?

    项目重新排序

    事实证明, 我们可以对项目进行重新排序. 但是, 只有在物品满足重叠测试的条件下才能做到这一点, 以确保最终的结果和没有重新排序一样. 重叠测试在性能上非常廉价, 但并不是绝对免费的, 所以, 提前查看项目决定是否可以重新排序是有一点成本的. 为了平衡项目中的成本和收益, 可以在项目设置中设置提前查看重排序的项目数量(见下图).
    1. A - woodC - woodB - grass
    由于纹理只变化一次, 所以我们只需要2次绘制调用就可以呈现上面的内容.

    灯光

    AB , 具有相同的纹理和材质. 在没有灯光的情况下, 它们将被分批在一起, 并在一次绘制调用中绘制. 但如果有3个灯光, 它们将按如下方式绘制, 每条线都是一个绘制调用:
    1. AA - light 1A - light 2A - light 3BB - light 1B - light 2B - light 3
    那是很多绘制调用. 仅仅是2个精灵就需要8次调用, 考虑到要绘制1000个精灵, 绘制调用的次数很快就会变成天文数字, 性能也会受到影响. 这也是为什么灯光有可能大大降低2D渲染速度的部分原因. 不过, 如果你还记得我们的魔术师在物品重新排序时的技巧, 也可以用同样的技巧来绕过绘制对灯光的排序! AB 不重合, 可以将它们一起批量渲染, 绘制过程如下:
    1. ABAB - light 1AB - light 2AB - light 3
    也就是只有4个绘制调用. 还不错, 因为减少了2倍. 然而, 考虑到在真实的游戏中, 可能会绘制接近1000个精灵.
  • 之前: 1000 × 4 = 4,000 绘制调用.
  • 之后:1 × 4 = 4 绘制调用. 这就减少了1000倍的绘制调用, 应该会给性能带来巨大的提升.

    重叠测试

    然而, 与项目重新排序一样, 事情并不那么简单. 必须首先执行重叠测试, 以确定是否可以加入这些基本单元. 这种重叠测试的成本很小. 同样, 您可以在重叠测试中选择要提前查看的基本单元数量, 以平衡收益与成本. 对于灯光, 收益通常远远大于成本. 此外, 根据视图中基本单元的排列, 重叠测试有时会失败(因为基本单元重叠, 因此不应连接). 在实践中, 绘制调用的减少可能不如在完全没有重叠的完美情况下那么显著. 但是, 性能通常远高于没有这种照明优化的情况.

    光裁剪

    填充率 是指像素被着色的速度. 这是另一个与绘制调用无关的潜在瓶颈. glScissor() , 它可以识别一个区域, 在这个区域之外,GPU不会渲染任何像素. 我们可以通过识别光线和基本单元之间的交叉区域, 并将光线渲染限制在 该区域 , 从而大大优化填充率. scissor_area_threshold 项目设置来控制的. 这个值在1.0和0.0之间,1.0为关闭(不裁剪),0.0为在任何情况下都裁剪. 设置的原因是, 在某些硬件上进行裁剪操作可能会有一些小成本. 也就是说, 当你在使用2D照明时, 裁剪通常应该会带来性能的提升. 阈值与裁剪操作是否发生之间的联系并不总是直接的. 一般来说, 它代表了裁剪操作可能 “保存” 的像素区域(即保存的填充率). 在1.0时, 整个屏幕的像素都需要被保存, 而这种情况很少发生, 所以它被关闭. 在实践中, 有用的值接近于0.0, 因为只有一小部分像素需要被保存, 操作才是有用的. 光线裁剪阈值计算 右下角是一盏灯, 红色区域是裁剪操作保存的像素, 只有交叉点需要渲染.

    顶点烘焙

    GPU着色器主要通过2种方式接收到需要画什么的指令:
  • 着色器uniform(例如, 调整颜色, 项目变换).
  • 顶点属性(顶点颜色, 局部变换). 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 2604items joined_item 1 refs batch D 0-0 batch D 0-2 n n batch R 0-1 [0 - 0] {255 255 255 255 } joined_item 1 refs batch D 0-0 batch R 0-1 [0 - 146] {255 255 255 255 } batch D 0-0 batch R 0-1 [0 - 146] {255 255 255 255 } joined_item 1 refs batch D 0-0 batch R 0-2560 [0 - 144] {158 193 0 104 } MULTI batch D 0-0 batch R 0-2560 [0 - 144] {158 193 0 104 } MULTI batch D 0-0 batch R 0-2560 [0 - 144] {158 193 0 104 } MULTIcanvas_end
    这是一个典型的诊断方法.
  • joined_item: 一个joined项可以包含1个或多个项(节点)的引用. 一般来说, 包含多个引用的jianed_items比包含单个引用的许多jianed_items要好. 项目是否能被加入, 将由其内容和与前一个项目的兼容性决定.
  • batch R: 一个包含矩形的批次. 第二个数字是矩形的数量. 方括号内的第二个数字是Godot纹理ID, 大括号内的数字是颜色. 如果批次中包含多个矩形, 则会在行中添加 MULTI, 以便于识别. 看到 MULTI 是好的, 因为它表示批处理成功.
  • batch D: 一个默认的批次, 包含其他一切当前没有批次的东西.

    默认批次

    默认批次后面的第二个数字是该批次中的命令数,后面是内容的简单摘要:
    1. l - linePL - polyliner - rectn - ninepatchPR - primitivep - polygonm - meshMM - multimeshPA - particlesc - circlet - transformCI - 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.000482250.00048225 ^ (1/4) = 0.14819
    scissor_area_threshold 0.15 是一个合理的尝试值. scissor_area_threshold 为 0.5
    1. 0.5 ^ 4 = 0.06250.0625 * 2073600 = 129600 pixels
    如果保存的像素数大于该阈值, 则剪刀被激活.