什么是 GPU 驱动渲染?

What is GPU driven rendering?

现在我从不同的地方听说所谓的 GPU 驱动渲染,这是一种新的渲染范例,根本不需要绘制调用,并且它得到了新版本的 OpenGL 和 Vulkan 的支持蜜蜂。谁能解释一下它在概念层面上的实际运作方式以及与传统方法的主要区别是什么?

概览

为了渲染场景,需要做很多事情。您需要遍历场景图以找出存在哪些对象。对于每个存在的对象,您现在需要确定它是否可见。对于每个可见的对象,您需要确定其几何图形的存储位置、将使用哪些纹理和缓冲区来渲染该对象、使用哪些着色器来渲染该对象等等。然后你渲染那个对象。

处理这个的"traditional"方法是为了CPU处理这个过程。场景图存在于 CPU 可访问内存中。 CPU 对该场景图进行可见性剔除。 CPU 获取可见对象并访问一些关于几何体的 CPU 数据(OpenGL 缓冲区对象和纹理名称、Vulkan 描述符集和 VkBuffers 等)、着色器等,将其传输作为 GPU 的状态数据。然后 CPU 发出 GPU 命令来渲染具有该状态的对象。

现在,如果我们回溯得更远,大多数 "traditional" 方法根本不涉及 GPU。 CPU 只会获取此网格和纹理数据,进行顶点变换、光栅化等,在 CPU 内存中生​​成图像。但是,我们开始将其中一些卸载到单独的处理器中。我们从光栅化开始(最早的图形芯片只是光栅化器;CPU 完成了所有顶点 T&L)。然后我们将顶点变换合并到 GPU 中。当我们这样做时,我们开始必须将顶点数据存储在 GPU 可访问内存中,以便 GPU 可以自行读取它。

我们做了所有这些,将这些事情卸载到一个单独的处理器上有两个原因:GPU 速度更快(很多),CPU 现在可以花时间做其他事情了。

GPU 驱动的渲染只是该过程的下一个阶段。我们从没有 GPU,到光栅化 GPU,再到顶点 GPU,再到现在的场景图级 GPU。 "traditional" 方法卸载如何渲染到 GPU; GPU 驱动的渲染卸载了渲染内容的决定

机制

现在,我们一直没有这样做的原因是因为基本渲染命令都采用来自 CPU 的数据。 glDrawArrays/Elements 从 CPU 中获取了一些参数。因此,即使我们使用 GPU 生成该数据,我们也需要完全 GPU/CPU 同步,以便 CPU 可以读取数据...并将其立即返回给 GPU。

这没有帮助。

OpenGL 4 给了我们 indirect rendering of various forms。基本思想是,不是从函数调用中获取这些参数,它们只是存储在 GPU 内存中的数据。 CPU 仍然需要进行函数调用以启动渲染操作,但该调用的实际参数只是存储在 GPU 内存中的数据。

另一半要求 GPU 能够以间接渲染可以读取的格式将数据写入 GPU 内存。从历史上看,GPU 上的数据只有一个方向:读取数据是为了在渲染目标中转换为像素。我们需要一种从 other 任意数据生成半任意数据的方法,全部在 GPU 上。

旧的机制是(滥用)为此目的使用变换反馈,但现在我们只使用 SSBOs or failing that, image load/store。计算着色器在这里也有帮助,因为它们被设计为在标准渲染管道之外,因此不受其限制。

GPU 驱动渲染的理想形式使场景图成为渲染操作的一部分。还有较少的形式,例如让 GPU 只做每个对象的视口剔除。但让我们看看最理想的过程。从 CPU 的角度来看,这看起来像:

  1. 更新 GPU 内存中的场景图。
  2. 发出一个或多个生成多绘制间接渲染命令的计算着色器。
  3. 发出绘制所有内容的单个多重绘制间接调用。

当然,天下没有免费的午餐。在 GPU 上进行全场景图处理需要以一种对 GPU 处理有效的方式构建场景图。更重要的是,在设计可见性剔除机制时必须考虑高效的 GPU 处理。这是我不打算在这里解决的复杂性。

实施

相反,让我们看看使绘图部分起作用的具体细节。这里要整理很多东西

看,间接渲染命令仍然是一个常规的旧渲染命令。虽然多重绘制形式绘制了多个不同的 "objects",但它仍然是一个 CPU 渲染命令。这意味着,在此命令的持续时间内,所有渲染状态 都是固定的。

因此,此多重绘制操作范围内的所有内容都必须使用相同的着色器、绑定缓冲区和纹理、混合参数、模板状态等。这使得实现 GPU 驱动的渲染操作有点复杂。

状态和着色器

如果在渲染操作中需要混合或类似的基于状态的差异,那么您将不得不发出另一个渲染命令。因此,在混合情况下,您的场景图处理将必须计算多个 渲染命令,每个集用于一组特定的混合模式。您可能还需要让此系统对透明对象进行排序(除非您使用 OIT 机制渲染它们)。因此,您拥有的不是只有一个渲染命令,而是一小部分。

但是这个练习的重点不是只有一个渲染命令;关键是 CPU 渲染命令的数量不会随着您渲染的内容而改变。场景中有多少物体无关紧要; CPU 将发出相同数量的渲染命令。

当涉及到着色器时,此技术需要某种程度的 "ubershader" 风格:您只有很少数量的相当灵活的着色器。您想参数化您的着色器,而不是拥有数十个或数百个着色器。

然而,无论如何事情可能都会以这种方式结束,特别是在延迟渲染方面。延迟渲染器的几何通道倾向于使用相同类型的处理,因为它们只是进行顶点变换和提取 material 参数。最大的区别通常是在进行蒙皮渲染与非蒙皮渲染方面,但这实际上只有 2 个着色器变体。您可以像混合情况一样处理它。

说到延迟渲染,GPU 驱动的进程也可以遍历光照图,从而为光照通道生成绘制调用和渲染数据。因此,虽然光照通道需要一个单独的绘制调用,但无论灯光数量如何,它仍然只需要一个多绘制调用。

缓冲区

事情开始变得有趣了。看,如果 GPU 正在处理场景图,这意味着 GPU 需要以某种方式将多重绘制命令中的特定绘制与特定绘制所需的资源相关联。它还可能需要将数据放入这些资源中,例如给定对象的矩阵变换等。

哦,您还需要以某种方式将顶点输入数据绑定到特定的子绘图。

最后一部分可能是最复杂的。 OpenGL/Vulkan 的标准顶点输入方法从中提取的缓冲区是状态数据;它们不能在多次绘制操作的子绘制之间改变。

最好的办法是尝试将每个对象的数据放在同一个缓冲区对象中,使用相同的顶点格式。本质上,您拥有一个巨大的顶点数据数组。然后,您可以使用子绘图的绘图参数 select 要使用缓冲区的哪些部分。

但是我们如何处理每个对象的数据(矩阵等),您通常会使用 UBO 或全局 uniform 来做什么?如何在 CPU 渲染命令中有效地更改缓冲区绑定状态?

好吧……你不能。所以你作弊.

首先,您意识到 SSBO 可以任意大。所以你真的不需要改变缓冲区绑定状态。您需要的是一个包含 每个人的 每个对象数据的单个 SSBO。对于每个顶点,VS 只需要从庞大的数据列表中为该子绘图挑选出正确的数据。

这是通过特殊的顶点着色器输入完成的:gl_DrawID。当您发出多绘图命令时,VS 会获得一个输入值,该值代表多绘图命令中此子绘图操作的索引。因此,您可以使用 gl_DrawID 索引到每个对象数据的 table 以获取该特定对象的适当数据。

这也意味着生成此子绘图的计算着色器还需要使用该子绘图的索引来定义数组中的何处放置该子绘图的每个对象数据。所以写子图的CS也需要负责设置匹配子图的per-object数据

纹理

OpenGL 和 Vulkan 对可以绑定的纹理数量有非常严格的限制。实际上这些限制相对于传统渲染来说是相当大的,但是在 GPU 驱动的渲染领域,我们需要一个单一的 CPU 渲染调用来潜在地访问 任何纹理 。更难了。

现在,我们确实有 gl_DrawID;结合上面提到的 table,我们可以检索每个对象的数据。那么:我们如何将其转换为纹理?

有多种方法。我们可以将一堆 2D 纹理放入 array texture。然后我们可以使用 gl_DrawID 从我们的每个对象数据的 SSBO 中获取数组索引;该数组索引成为我们用来获取 "our" 纹理的数组层。请注意,我们不直接使用 gl_DrawID 因为多个不同的子绘图可以使用相同的纹理,并且因为设置绘图调用数组的 GPU 代码不控制纹理在我们的数组中出现的顺序.

数组纹理有明显的缺点,最没有table的是我们必须尊重数组纹理的局限性。数组中的所有元素必须使用相同的图像格式。它们的大小必须相同。此外,阵列纹理中的阵列层数有限制,因此您可能会遇到它们。

数组纹理的替代方案在 API 行中有所不同,尽管它们基本上归结为同一件事:将数字转换为纹理。

在 OpenGL 领域,您可以使用 bindless texturing (for hardware that supports it)。该系统提供了一种机制,允许生成一个代表特定纹理的 64 位整数句柄,将此句柄传递给 GPU(因为它只是一个整数,使用任何你想要的机制),然后将这个 64 位转换为处理成 sampler 类型。因此,您使用 gl_DrawID 从每个对象数据中获取 64 位句柄,然后将其转换为适当类型的 sampler 并使用它。

在 Vulkan 领域,您可以使用采样器阵列(对于 hardware that supports it)。请注意,这些不是数组纹理;在 GLSL 中,这些是排列的 sampler 类型:uniform sampler2D my_2D_textures[6000];。在 OpenGL 中,这将是一个编译错误,因为每个数组元素代表一个纹理的不同绑定点,并且您不能有 6000 个不同的绑定点。在 Vulkan 中,数组采样器只代表一个 单个描述符 ,无论该数组中有多少元素。 Vulkan 实现对此类数组中可以有多少元素有限制,但支持您需要使用此功能的硬件 (shaderSampledImageArrayDynamicIndexing) 通常会提供很大的限制。

因此您的着色器使用 gl_DrawID 从每个对象数据中获取索引。只需从采样器数组中获取值,索引就会变成 sampler。该阵列描述符中纹理的唯一限制是它们必须全部具有相同的类型和基本数据格式([=26= 的浮点 2D,usamplerCube 的无符号整数立方体贴图等)。格式、纹理大小、mipmap 计数等的细节都是无关紧要的。

如果您担心 Vulkan 的采样器阵列与 OpenGL 的无绑定采样器相比的成本差异,请不要担心; implementations of bindless are just doing this behind your back anyway.