如今,我从不同的地方听说了所谓的GPU驱动渲染,这是一种完全不需要绘制调用的新渲染模式,并且它得到了OpenGL和Vulkan API的新版本的支持。有人能解释一下它实际上是如何在概念层面上工作的吗?与传统方法的主要区别是什么?
概述
为了渲染场景,必须执行许多操作。您需要遍历场景图,以确定存在哪些对象。对于每个存在的对象,现在需要确定它是否可见。对于每个可见的对象,您需要弄清楚其几何体存储在哪里,哪些纹理和缓冲区将用于渲染该对象,哪些着色器将用于渲染对象,等等。然后渲染该对象。
处理这个问题的"传统"方法是由CPU来处理这个过程。场景图位于CPU可访问的内存中。CPU在该场景图上执行可见性剔除。CPU获取可见对象,并访问有关几何体(OpenGL缓冲区对象和纹理名称、Vulkan描述符集和VkBuffer
s等)、着色器等的一些CPU数据,将其作为状态数据传输到GPU。然后CPU发出一个GPU命令来渲染具有该状态的对象。
现在,如果我们回到更远的地方,最"传统"的方法根本不涉及GPU。CPU只需要获取这些网格和纹理数据,进行顶点转换、光栅化等,在CPU内存中生成图像。然而,我们开始将其中一些卸载到一个单独的处理器中。我们从光栅化开始(最早的图形芯片只是光栅化器;CPU处理所有的顶点T&L)。然后我们将顶点转换合并到GPU中。当我们这样做的时候,我们开始不得不将顶点数据存储在GPU可访问的内存中,这样GPU就可以在自己的时间读取它。
我们做了所有这些,将这些东西卸载到一个单独的处理器,原因有两个:GPU(快得多),CPU现在可以花时间做其他事情。
GPU驱动的渲染只是这个过程的下一个阶段。我们从没有GPU,到光栅化GPU,到顶点GPU,再到现在的场景图级GPU。"传统"方法将如何渲染卸载到GPU;GPU驱动的渲染减轻了渲染内容的决策。
机制
现在,我们一直没有这样做的原因是,基本的渲染命令都会获取来自CPU的数据。CCD_ 2从CPU获取许多参数。因此,即使我们使用GPU生成数据,我们也需要完全的GPU/CPU同步,这样CPU才能读取数据。。。并将其直接返回给GPU。
这没有帮助。
OpenGL 4为我们提供了各种形式的间接渲染。基本思想是,它们只是存储在GPU内存中的数据,而不是从函数调用中获取这些参数。CPU仍然需要进行函数调用来启动渲染操作,但该调用的实际参数只是存储在GPU内存中的数据。
另一半要求GPU能够以间接渲染可以读取的格式将数据写入GPU内存。从历史上看,GPU上的数据只有一个方向:读取数据的目的是将其转换为渲染目标中的像素。我们需要一种从其他任意数据生成半任意数据的方法,所有这些都在GPU上。
旧的机制是(ab)为此目的使用变换反馈,但现在我们只使用SSBO,否则,图像加载/存储。计算着色器在这里也有帮助,因为它们被设计为在标准渲染管道之外,因此不受其限制。
GPU驱动渲染的理想形式使场景图成为渲染操作的一部分。还有一些较小的形式,例如让GPU只执行每个对象视口的剔除。但让我们看看最理想的过程。从CPU的角度来看,这看起来像:
- 更新GPU内存中的场景图
- 发出一个或多个用于生成多重绘制间接渲染命令的计算着色器
- 发出一个多绘制间接调用,绘制所有
现在当然没有免费午餐了。在GPU上进行全场景图形处理需要以对GPU处理有效的方式构建场景图形。更重要的是,可见性剔除机制必须考虑到高效的GPU处理。这就是我不打算在这里讨论的复杂性。
实施
相反,让我们来看看使绘图部分工作的螺母和螺栓。我们必须在这里解决很多问题。
请参见,间接渲染命令仍然是常规的旧渲染命令。虽然多重绘制表单绘制多个不同的"对象",但它仍然是一个CPU渲染命令。这意味着,在该命令的持续时间内,所有渲染状态都是固定的。
因此,在这个多重绘制操作的范围内的所有东西都必须使用相同的着色器,绑定缓冲区&纹理、混合参数、模具状态等等。这使得实现GPU驱动的渲染操作有点复杂。
状态和着色器
如果在渲染操作中需要混合或类似的基于状态的差异,则必须发出另一个渲染命令。因此,在混合的情况下,场景图处理将不得不计算多个集的渲染命令,每个集都用于一组特定的混合模式。您可能还需要让这个系统对透明对象进行排序(除非您使用OIT机制来渲染它们)。因此,您不需要只有一个渲染命令,而是需要少量的渲染命令。
但是,本练习的重点并不是只有一个渲染命令;关键是CPU渲染命令的数量不会随着渲染内容的多少而改变。场景中有多少对象并不重要;CPU将发出相同数量的渲染命令。
当涉及到着色器时,此技术需要一定程度的"ubershader"样式:其中只有极少数相当灵活的着色器。您希望参数化着色器,而不是拥有数十个或数百个着色器。
然而,无论如何,事情可能会以这种方式发展,尤其是在延迟渲染方面。延迟渲染器的几何体过程往往使用相同类型的处理,因为它们只是进行顶点变换和提取材质参数。最大的区别通常是进行蒙皮渲染与非蒙皮渲染,但实际上只有两种着色器变体。您可以类似于混合情况来处理。
说到延迟渲染,GPU驱动的进程还可以遍历灯光图,从而为灯光过程生成绘制调用和渲染数据。因此,尽管照明过程需要一个单独的绘制调用,但无论灯光数量如何,它仍然只需要一个多绘制调用。
缓冲区
这就是事情开始变得有趣的地方。看,如果GPU正在处理场景图,这意味着GPU需要以某种方式将多绘制命令中的特定绘制与特定绘制所需的资源相关联。它可能还需要将数据放入这些资源中,比如给定对象的矩阵变换等等。
哦,你还需要以某种方式将顶点输入数据与特定的子绘制联系起来。
最后一部分可能是最复杂的。OpenGL/Vulkan的标准顶点输入方法从中提取的缓冲区是状态数据;它们不能在多重绘制操作的子绘制之间改变。
最好的办法是尝试使用相同的顶点格式将每个对象的数据放在相同的缓冲区对象中。本质上,您有一个巨大的顶点数据数组。然后,可以使用子绘制的图形参数来选择要使用缓冲区的哪些部分。
但是,我们如何处理每个对象的数据(矩阵等),这些数据通常会使用UBO或全局uniform
来处理?如何在CPU渲染命令中有效地更改缓冲区绑定状态?
嗯。。。你不能。所以你作弊了。
首先,您意识到SSBO可以任意大。所以您实际上不需要更改缓冲区绑定状态。您需要的是一个包含每个人的每个对象数据的单一SSBO。对于每个顶点,VS只需要从庞大的数据列表中为该子绘制挑选正确的数据。
这是通过一个特殊的顶点着色器输入:gl_DrawID
来完成的。发出多绘制命令时,VS会获得一个输入值,该值表示多绘制命令中该子绘制操作的索引。因此,您可以使用gl_DrawID
对每个对象的数据表进行索引,以获取该特定对象的适当数据。
这也意味着,生成该子绘制的计算着色器还需要使用该子绘制中的索引来定义将该子绘制每个对象的数据放在数组中的何处。因此,编写子绘图的CS还需要负责设置与子绘图匹配的每个对象数据。
纹理
OpenGL和Vulkan对可以绑定的纹理数量有相当严格的限制。实际上,与传统渲染相比,这些限制相当大,但在GPU驱动的渲染领域,我们需要一个CPU渲染调用来访问任何纹理。这更难。
现在,我们有gl_DrawID
;结合上面提到的表,我们可以检索每个对象的数据。那么:我们如何将其转换为纹理?
有多种方式。我们可以将一堆2D纹理放入阵列纹理中。然后,我们可以使用gl_DrawID
从每个对象数据的SSBO中获取数组索引;该数组索引成为我们用来获取"我们的"纹理的数组层。请注意,我们不直接使用gl_DrawID
,因为多个不同的子绘制可能使用相同的纹理,并且因为设置绘制调用数组的GPU代码无法控制纹理在数组中出现的顺序。
数组纹理有明显的缺点,其中最值得注意的是我们必须尊重数组纹理的局限性。数组中的所有元素都必须使用相同的图像格式。它们必须大小相同。此外,阵列纹理中的阵列层数也有限制,因此您可能会遇到这些限制。
数组纹理的替代品在API行上有所不同,尽管它们基本上可以归结为同一件事:将数字转换为纹理。
在OpenGL领域,您可以使用无绑定纹理(对于支持它的硬件)。该系统提供了一种机制,允许生成表示特定纹理的64位整数句柄,将该句柄传递给GPU(因为它只是一个整数,请使用任何您想要的机制),然后将该64位句柄转换为sampler
类型。因此,您可以使用gl_DrawID
从每个对象的数据中提取一个64位句柄,然后将其转换为适当类型的sampler
并使用它
在Vulkan,您可以使用采样器阵列(用于支持它的硬件)。请注意,这些不是阵列纹理;在GLSL中,这些是排列的sampler
类型:uniform sampler2D my_2D_textures[6000];
。在OpenGL中,这将是一个编译错误,因为每个数组元素代表纹理的不同绑定点,并且不能有6000个不同绑定点。在Vulkan中,阵列采样器只表示单个描述符,无论该数组中有多少元素。Vulkan实现对这样的阵列中可以有多少元素有限制,但支持您需要使用的功能(shaderSampledImageArrayDynamicIndexing
)的硬件通常会提供很大的限制。
因此,着色器使用gl_DrawID
从每个对象的数据中获取索引。只需从采样器数组中获取值,就可以将索引转换为sampler
。该阵列描述符中纹理的唯一限制是它们必须具有相同的类型和基本数据格式(浮点2D用于sampler2D
,无符号整数立方体映射用于usamplerCube
,等等)。格式、纹理大小、mipmap计数等细节都无关紧要。
如果你担心Vulkan的采样器阵列与OpenGL的无绑定采样器相比的成本差异,不要担心;无论如何,bindless的实现只是在背后做这件事。