Nic*_*las 18
为了渲染场景,必须发生许多事情。您需要遍历场景图来找出存在哪些对象。对于每个存在的对象,您现在需要确定它是否可见。对于每个可见的对象,您需要弄清楚其几何图形的存储位置、将使用哪些纹理和缓冲区来渲染该对象、使用哪些着色器来渲染该对象等等。然后渲染该对象。
处理这个问题的“传统”方法是让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 的数据。glDrawArrays/Elements
从 CPU 获取许多参数。因此,即使我们使用 GPU 生成该数据,我们也需要完全的 GPU/CPU 同步,以便 CPU 可以读取数据……并将其直接返回给 GPU。
那没有帮助。
OpenGL 4 为我们提供了各种形式的间接渲染。基本思想是,这些参数不是从函数调用中获取,而是存储在 GPU 内存中的数据。CPU 仍然需要进行函数调用来启动渲染操作,但该调用的实际参数只是存储在 GPU 内存中的数据。
另一半要求 GPU 能够以间接渲染可以读取的格式将数据写入 GPU 内存。从历史上看,GPU 上的数据朝一个方向发展:读取数据的目的是将其转换为渲染目标中的像素。我们需要一种从其他任意数据生成半任意数据的方法,所有这些都在 GPU 上进行。
较旧的机制是(滥用)使用变换反馈来实现此目的,但现在我们只使用SSBO,或者如果失败,则使用图像加载/存储。计算着色器在这里也有帮助,因为它们被设计为在标准渲染管道之外,因此不受其限制。
GPU 驱动渲染的理想形式使场景图成为渲染操作的一部分。还有一些较小的形式,例如让 GPU 除了按对象视口剔除之外什么也不做。但让我们看看最理想的过程。从CPU的角度来看,这看起来像:
当然,天下没有免费的午餐。在 GPU 上进行完整的场景图处理需要以对 GPU 处理有效的方式构建场景图。更重要的是,可见性剔除机制的设计必须考虑到高效的 GPU 处理。这就是复杂性,我不打算在这里讨论。
相反,让我们看看使绘图部分工作的具体细节。我们必须在这里解决很多事情。
看,间接渲染命令仍然是常规的旧渲染命令。虽然多重绘制形式绘制多个不同的“对象”,但它仍然是一个 CPU 渲染命令。这意味着,在此命令的持续时间内,所有渲染状态都是固定的。
因此,此多重绘制操作范围内的所有内容都必须使用相同的着色器、绑定缓冲区和纹理、混合参数、模板状态等。这使得实现 GPU 驱动的渲染操作有点复杂。
如果您在渲染操作中需要混合或类似的基于状态的差异,那么您将不得不发出另一个渲染命令。因此,在混合情况下,您的场景图处理将必须计算多组渲染命令,每组都针对一组特定的混合模式。您可能还需要让该系统对透明对象进行排序(除非您使用 OIT 机制渲染它们)。因此,您拥有的不是只有一个渲染命令,而是少量的渲染命令。
但本练习的目的并不是只有一个渲染命令;而是只有一个渲染命令。关键是 CPU 渲染命令的数量不会随着渲染内容的多少而改变。场景中有多少对象并不重要;CPU 将发出相同数量的渲染命令。
当谈到着色器时,这种技术需要某种程度的“ubershader”风格:您拥有很少数量的相当灵活的着色器。您想要参数化您的着色器,而不是拥有数十或数百个着色器。
然而事情可能无论如何都会以这种方式发生,特别是在延迟渲染方面。延迟渲染器的几何通道倾向于使用相同类型的处理,因为它们只是进行顶点变换和提取材质参数。最大的区别通常在于进行蒙皮渲染与非蒙皮渲染,但这实际上只有 2 个着色器变体。您可以像混合情况一样处理它。
说到延迟渲染,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
只需从采样器数组中获取值即可将索引转换为 a 。该数组描述符中纹理的唯一限制是它们必须全部具有相同的类型和基本数据格式(浮点 2D sampler2D
、无符号整数立方体贴图usamplerCube
等)。格式、纹理大小、mipmap 计数等细节都是无关紧要的。
如果您担心 Vulkan 采样器阵列与 OpenGL 的无绑定相比的成本差异,请不要担心;无论如何,bindless 的实现只是在你背后做这件事。
归档时间: |
|
查看次数: |
1761 次 |
最近记录: |