开发
开发
选择平台

领先的 GPU 管线以及加载、存储与传递

本主题简要介绍了 OpenGL 和 Vulkan 的多种移动 GPU 渲染架构,如何正确配置和分析这些架构,以及固定注视点渲染 (FFR) 如何使用这些架构运作。以下各部分内容对于任何从事移动 GPU 渲染器相关工作的人员来说都是至关重要的知识。

概览

在所有基于光栅化的 GPU 中,屏幕上的每个三角形都会输出像素,这些像素会针对深度缓冲区中其像素位置上的当前值进行深度测试。如果它们通过了深度测试,就会将新的颜色和深度值反复写入颜色 / 深度附件中,直至该帧的所有三角形都渲染完毕。
这意味着对于每个三角形的每个像素,GPU 必须执行至少一次读取(用于深度测试),并且可能多次执行两次写入(用于颜色和深度)。在进行颜色混合的情况下,还会额外进行一次颜色读取操作,以便计算最终的颜色值。几何图形重叠得越多,读写操作也就越多。

图块渲染器

图块渲染器优化读写速度的原理如下:
  1. 任何给定的像素都不依赖于该帧中其他像素的值。
  2. 在帧渲染过程中无需展示颜色和深度的值。
图块渲染器将屏幕分割成多个图块,并逐一进行渲染。渲染器对每个图块均会使用一个快速的小型缓存(图形内存/图块内存)来进行读写操作,直至计算出最终的像素值为止。最终值之后写入到 RAM(系统)中供以后使用。
Qualcomm Snapdragon 835 和 Qualcomm Snapdragon XR2 芯片组(分别用于 Meta Quest 和 Quest 2)都拥有 1 MB 的图块内存。在 XR2 芯片上的多视图渲染管线中,一个图块会同时包含左右眼视图。运行 4xMSAA、32 位颜色以及 24/8 位深度/模板缓冲区的应用程序将会有 96×176 个图块。每个像素包含(4 字节颜色 + 4 字节深度)×4 倍多重采样抗锯齿 = 32 字节的信息,因此其合乎逻辑。96×176×2(多视野)×32 = 约 1 MB。更改诸如 MSAA 一类的逐像素设置将会改变图块大小,因为 GPU 驱动程序会在保持图块大小处于 1 MB 以内的同时,尽力使单个图块所包含的像素数量最大化(以减少屏幕上图块的总数,并最大限度地提高缓存利用率)。

加载与存储

至此,我们已将图块渲染器架构介绍完毕,可以据此推断出,其针对每个图块的工作流程如下:
  1. 将预先存在的深度和颜色缓冲区数据从 RAM 加载到图块内存中
  2. 将所有三角形/片段渲染到图块内存中
  3. 将最终的深度和颜色缓冲区数据从图块内存存储到 RAM 中
但这一工作流程并未经过优化。第一步通常是一次不必要的带宽传输,因为前一帧的所有内容通常都会被清除或覆盖。因此,可以通过告知 GPU 驱动程序清除或使前一帧内容失效的方式来避免第一步操作。在 OpenGL 中,开发者可在绑定帧缓冲区之后、进行首次绘制调用之前使用 glClearglInvalidateFramebuffer。在 Vulkan 中有更为明确的系统,渲染通道配置包含 loadOpstoreOp 属性,如下所示:
loadOp and storeOp attributes
在此处,清除内容或使内容失效这两种操作的差别微乎其微,因为 QCOM GPU 会设法将清除操作所产生的成本隐藏在其他必要的设置工作背后,不过在某些特定情况下,这种差异还是能被测量出来的。然而,执行清除内容或使内容失效这二者其一却至关重要。对于个人电脑的 GPU 而言,避免进行清除操作属于一种常见的优化手段;但对于这些芯片组来说,这样却会因导致 GPU 在每一帧都将前一帧的数据从 RAM 载到图块内存中,从而严重影响其性能。
如果在完成一帧的处理后已不再需要某些附件,那么第三步也属于不必要的带宽传输。例如,在 Vulkan 中的 MSAA 附件,以及在 OpenGL 和 Vulkan 中都存在的深度附件,通常情况下,没必要在一帧结束后进行保存。届时,使这些附件失效将大有裨益,这样可以告知 GPU 驱动程序“不要将那些来自图块内存的内容存储到 RAM 中,它们已经用不到了,任由其在下一次图块执行期间被覆盖掉即可。”此处可以使用 OpenGL 中的同一个 glInvalidateFramebuffer 函数,不过,关键在于需要明确,在当前情况下该函数所发挥的作用和在第一步时的作用截然不同,而且这两种用法都必不可少。在第一步中使缓冲区失效,是为了在进行任何渲染操作之前避免发生从 RAM 到图块内存的加载操作;而在第三步中,则是为了在完成所有渲染操作之后,避免出现从图块内存到 RAM 的存储操作。在 Vulkan 中,通过将 VK_ATTACHMENT_STORE_OP_DONT_CARE 置于 storeOp 渲染通道属性中来实现这一点。
在 OpenGL 中,它不像 Vulkan 那样有明确的 storeOp 以及刷新指令,因此在 GPU 决定执行上述帧缓冲区的内容之前调用 glInvalidateFramebuffer 至关重要,否则使内容失效这一操作将不会被 GPU 考虑在内。您应在 glFlush 之前调用使内容失效的相关函数,但有一个有趣的情况值得了解,例如插入一个计时器查询操作也会强制进行刷新(因为如果不进行刷新,就无法测量截至某个特定点的操作耗时),因此,如果您在进行使内容失效操作前插入了一个计时器查询操作,那么您使内容失效的操作将不会被考虑在内,并且您还需要对深度缓冲区进行解析处理。
Vulkan 有明确的 MSAA 附件(不像 OpenGL,其中即便使用了 MSAA 帧缓冲区,纹理也不具备 MSAA 功能),而且务必不要将所有 MSAA 子样本都存储到 RAM 中,这一点极为关键(这样做会导致带宽随着 MSAA 级别呈线性增长,却没有任何收益)。然后,重要的是使用最后一个子通道的 pResolveAttachment 属性来绑定非 MSAA 附件,并且仅存储该附件。在以下截图中,您将看到 4xMSAA 附件为何带有 STORE_OP_DONT_CARE 属性,而 1xMSAA 附件则带有 STORE_OP_STORE 属性。
Comparing MSAA attributes
GPU 有一个可执行 MSAA 解析的硬件加速芯片,因此请充分加以利用。

工具

无论是编写自定义引擎还是在 Unity 中搭建场景,使用合适的工具来展示加载、存储、渲染通道配置等情况都很重要,这样才能确保 GPU 如您所需地工作。
ovrgpuprofiler 是一款专门用于显示此类信息的渲染阶段追踪工具。ovrgpuprofiler 是一款可靠且使用方便的 Android Debug Bridge 框架工具,其运行仅需几秒。以下是一个针对 1216x1344 渲染通道输出内容的示例,该渲染通道没有正确执行第一步第三步
Surface 1    | 1216x1344 | color 32bit, depth 24bit, stencil 8 bit, MSAA 2 | 28  320x192 bins | 10.62 ms | 171 stages :  Binning : 0.305ms LoadColor : 0.71ms Render : 2.926ms StoreColor : 1.525ms Preempt : 2.964ms LoadDepthStencil : 0.828ms StoreDepthStencil : 0.871ms
您可以看到 LoadColorLoadDepthStencil(在共 10 毫秒中耗时 1.5 毫秒)以及 StoreDepthStencil 的用时情况,而在大多数情况下,这些操作本是不必要的。

多通道:第 1 部分 —— 独立执行

许多 Quest 平台上的项目使用简单的单通道前向渲染器。随着 Quest 2 的推出,GPU 的功能变得更加强大,越来越多的开发者希望运行更复杂的 GPU 渲染管道。然而,在处理多重通道管线时,有若干需要留意的重要事项。
在 OpenGL 和 Vulkan 这两种图形应用编程接口中,实现多重通道的基础方式是先进行一次主通道或前向通道渲染,在所有渲染工作完成后,将其颜色缓冲区复制到 RAM 中。将该颜色缓冲区绑定为第二次通道的纹理输入,然后第二次通道会应用某些效果(比如色调映射)来生成最终的由合成器分配的交换链。如此便增加了一个额外通道,包括存储到 RAM 的操作(该操作与复杂度无关,仅取决于分辨率和带宽。新通道采用单次绘制调用的方式,这一点无关紧要 —— 因为存储属于固定开销,它仅取决于纹理分辨率)。与标准前向渲染器的视觉质量相比,如果开发者有此需求,这将是一种不错的权衡,在 Quest 2 平台上更是如此。
不过,尤其在 OpenGL 中,此处存在一个严格的限制。FFR 由纹理驱动,且由合成器而非应用程序来应用,并且它仅影响渲染到 VrApi 交换链中的帧缓冲区(在该情况下,指的是第二通道)。因此,如果开发者启用了 FFR,那么开销较大的、用于生成片段的主通道将不会进行注视点渲染,它会把未经注视点渲染处理的像素存储到 RAM 中,随后由开销较小的色调映射通道对这些像素进行注视点渲染(在此过程中会丢失此前费尽周折获得的精度)。在 OpenGL 中,这个问题尚无妥善的解决方案,因为 OpenGL 不允许合成器通过其对抗锯齿 QCOM_texture_foveated 的调用在主通道上驱动 FFR 设置。然而,Vulkan 通过 RG8_UNORM 注视点控制纹理为开发者提供注视点参数,该纹理的内容由合成器控制,并且开发者可将其绑定到任何渲染通道(最终通道、主通道或其他通道)上。
如果您还记得关于分块渲染器合理性原因的相关解释,便会理解其目标是在计算最终像素时,优化对颜色缓冲区和深度缓冲区中每个像素的多次读写操作。在进行全屏特效渲染的特定情况下,不使用深度缓冲区和 MSAA 渲染全屏四边形时,GPU 实际上不会在循环中读取/写入 - 唯一计算的片段将是最终的像素颜色。在该情况下,从 GPU 核心到图块内存再到 RAM 这样的操作意义不大,因此 QCOM GPU 会运用启发式的方法来检测这类行为,并直接从 GPU 核心到 RAM,此时它充当的是即时模式 GPU,而非分块式 GPU。这被称为直接模式渲染,而这也是 Unity 的色调映射的执行方式。鉴于我们 GPU 上的 FFR 是针对每个图块的效果(分辨率会根据图块进行修改),当采用直接模式渲染表面时,FFR 就会被停用。这也解释了为什么在 Unity 中采用 OpenGL 的双通道渲染时,即便在项目设置里启用了 FFR,却完全不会进行应用:
  • 通道 1 因其并非在向 VrApi 交换链进行渲染,而不会应用 FFR。
  • 通道 2 同样不会应用 FFR,因为尽管其在向 VrApi 交换链进行渲染,但这一无深度缓冲区的全屏通道以直接模式执行,从而导致了 FFR 被停用。
在工具层面,要弄清楚某个东西是否是以直接模式进行渲染非常简单。整个表面将作为“单一图块”进行渲染
Surface 1 | 1216x1344 | color 32bit, depth 0bit, stencil 0 bit, MSAA 1 | 1 1216x1344 bins | 2.01 ms | 1 stages : Render 2.01ms

多通道:第 2 部分 —— 子通道理论

Vulkan 引入了一种对图块更为友好的方式来执行多通道渲染,即子通道。一些扩展试图在 OpenGL ES 上模拟此类行为,比如 ARM_framebuffer_fetch,但在一些情况下,并不鼓励使用这类模拟,在使用 MSAA 时更是如此。
在第一部分中,已经对执行通道的“标准”方式进行了阐释,即 GPU 会准确执行完通道 1 的所有操作,将其输出结果存储在 RAM 中,然后通过读取通道 1 在 RAM 中的内容来执行通道 2 的所有操作,并同样将通道 2 的输出结果存储在 RAM 中。然而,如果通道 2 的像素仅查看通道 1 输出中与其自身像素坐标对应的内容,那么就可以跳过通道间的存储和加载操作,并在图块内存中按顺序执行它们,这样一来,您仅需将通道 2 的输出存储到 RAM 即可。对于色调映射和晕影这类全屏效果来说确实如此,但对于泛光和景深效果而言却又不然(因为这些效果需要依赖周围像素的值来为任意给定像素着色)。一份十分粗略的 ovrgpuprofiler -t -v 实际输出结果将从:
Surface1 (Pass1)
render
store
render
store
Surface2 (Pass2)
render
store
render
store
至:
Surface1
render
render
store
render
render
store
这就是子通道的作用所在:使其保持在一个图面执行过程内,并允许存在按顺序的、基于图块内存的依赖关系。这等同于 Apple Metal API 中的 图块着色。您可以利用这一点来制作一个基于片上内存的色调映射渲染器,其中子通道 0 输出一个色调映射前的颜色缓冲区,子通道 1 从该缓冲区(在图块内存中!)读取数据,对其进行色调映射,然后将色调映射后的结果存储在 VrApi 交换链中。在此情况下,子通道 0 的输出被称作子通道 1 的输入附件。在此情况下,色调映射前的颜色缓冲区不会被存储到 RAM 中,这将带来显著的性能优势,而且色调映射通道会从高速图块内存而非 RAM 中读取其输入附件。
Unreal Engine 4.25 及以上版本使用了子通道功能,以便为半透明着色器提供读取不透明通道深度的选项;该引擎会在两个独立的子通道中分别渲染不透明物体和半透明物体。首先渲染不透明物体,随后将不透明子通道的深度缓冲区作为输入附件绑定到半透明子通道上,这样半透明物体就能在其像素着色器中从中读取数据,以实现基于深度的低成本效果。这样一来,便能够开发一个可动态启用或禁用的、基于子通道的自定义色调映射器。

多通道:第 3 部分 —— 如何为子通道大幅降速

重要的是要认识到,有诸多配置情况均会使 GPU 驱动程序将您所配置的子通道作为独立通道来执行,而不是将其作为顺序执行的子通道处理,这样则会完全瓦解理论上的性能提升。在部分情况下,其实际运行结果将会慢的多得多。开发者遇到的三个主要陷阱包括:
具有 pResolveAttachment 条目的中间(非最终)子通道,用于 MSAA 到非 MSAA 解析。
GPU 的硬件加速 MSAA 解析芯片集成在从图块内存到 RAM 的存储管道中。若您要求一个中间 MSAA 子通道通过 pResolveAttachment 属性将其内容解析为非 MSAA,那么它将致使其内容存储到 RAM 中(如同普通渲染通道)。下一个子通道也将不得不作为另一个通道来执行,而且需要从 RAM 中重新加载其所有通常位于图块内存的输入附件。
在该情况下,您需要让下一个子通道通过 subpassLoad(input,subsampled) GLSL 函数从未解析的 MSAA 输入附件中读取数据,每个子采样进行一次调用,并手动执行自己的 MSAA 解析。请注意不同 MSAA 设置下的性能权衡。在 Adreno 540 (Quest) 和 Adreno 650 (Quest 2) GPU 上,子通道可以并行读取两个输入附件子采样,但不能读取四个,所以 2xMSAA 的着色器读取“无成本”,但 4xMSAA 却并非如此。
带有存储操作附件的中间子通道。
子通道存在的全部意义便在于仅允许最后一个子通道将其内容存储到 RAM 中。请勿忘记查看所有附件,并仅让最后一个子通道的附件(最好是 MSAA 的颜色附件或解析附件 —— 切记不要让 MSAA 子采样数据进入RAM)具备 STORE_OP_STORE 属性。那些属性仅涉及您在渲染通道执行结束时想存储到 RAM 中的内容,而不涉及子通道间的依赖关系。若您需要一个保持在子通道 0 和子通道 1 之间有效的 MSAA 颜色缓冲区,那么它无需具备存储操作。
具有过于保守的子通道依赖关系。
Vulkan 会要求您定义子通道之间的依赖关系,以便知晓哪些子通道可以并行执行,哪些不能并行执行。例如,您将 VK_ACCESS_SHADER_READ_BIT 作为子通道依赖关系中的 dstAccessMask,那么您就相当于告知 GPU 驱动程序“我需要使用子通道 1 的描述符集来让子通道 0 的输出可供着色器读取。”尽管这看起来颇为寻常,但实际上它破坏了子通道模型 —— 如果子通道 1 应该能够使用纹理采样器读取子通道 0 的输出,则它便可以读取子通道 0 输出中的任何纹理元素,包括位于完全不同的图块内存中的纹理元素。如此一来,子通道 0 和子通道 1 就无法在图块内存中按顺序执行,而是会作为独立的渲染通道来执行。此处正确的掩码应为 VK_ACCESS_INPUT_ATTACHMENT_READ_BIT,其强制依赖关系仅作用于同一像素上,因为输入附件的读取函数 subpassLoad 并没有 UV 参数。
对于编写此类代码的开发者来说,使用诸如 ovrgpuprofiler 这样的渲染阶段追踪工具来分析他们的渲染器至关重要,通过该工具可以查看渲染阶段分组,并了解它们是按顺序执行,还是在不同的表面执行,并在各处进行加载和存储。如果使用其他方法,则无法查看您所获得性能表现。