GPU帧缓冲内存: 了解图块化

原文: GPU Framebuffer Memory: Understanding Tiling

现代的图形硬件在描绘的操作过程中有大量的内存的带宽需求. 增加外部的内存带宽代价非常的昂贵, 因为需要增加额外的空间和电量, 而对于移动设备的渲染来说尤其困难. 这篇文章要讨论基于图块的渲染, 这种渲染方法在大多数的移动图形硬件上使用, 并且逐步地向桌面硬件发展.

立即模式光栅器

传统的图形API给出的接口是将三角形按顺序提交, 然后GPU渲染器依次渲染各个三角形. 光栅化的过程如下图所示:

下面的图片, 包括之后所有的图片, 都是左侧显示颜色缓冲, 右侧显示深度缓冲

简单的立即模式渲染过程

这些三角形一提交就会被硬件处理, 如上图所示, 称之为立即模式渲染器(IMR). 过去, 桌面和主机的GPU的做法都可以概略地认为是这样.

在立即模式渲染器中, 图形渲染管线从上至下地处理各个原语, 逐原语的方式访问内存.

![IMR管线](/gpu-framebuffer/images/tech_GPUFramebuffer_03 - cn.svg)

立即模式渲染器的内存使用

一个单纯的IMR的实现可能会花费大量的内存带宽. 下面这张图展示了即便对帧缓冲的颜色与深度做了一个简单的缓存, 也会造成光栅化过程中大量的内存数据传送. IMR在访问内存时的顺序是不可预测的, 取决于三角形提交的顺序.

在下面这张图中, 图片的上方显示了内存中连续4个的缓存行在渲染过程中的情况. 在每个缓存行上方有一个小的矩形, 代表这个缓存行落在了帧缓冲的哪个位置: 红色线条代表缓存行被写入, 处于脏的状态, 绿色代表数据和内存一致, 处于干净的状态, 随着写入的时间推移, 红色会越来越浅. 而在下方的帧缓冲图像中, 颜色缓冲上粉红色代表脏的缓存行, 深度缓冲中则是用白色来代表.

使用缓存行进行渲染

图块化内存

减少内存带宽的第一步是把每个缓存行覆盖内存中一个块二维的区域(一个图块). 在空间中相互邻近的三角形往往也会一起提交, 在上面这个例子中, 可以看到物件的每个尖刺都是先整体描绘完才去画下一个的, 更好地对缓存区域进行分组能够获得更好的命中率. 使用正方形的缓存区域, 总面积和线型区域是一样的, 但是会囊括更多的描绘, 就能减少内存数据的传输的频繁程度, 这样就能减少额外的带宽了! 相同的技术也常用于纹理的存储, 因为纹理数据的读取也表现除了空间上的引用局部性.

这个例子是被简化过的 - 现在的硬件会使用更复杂的像素与内存之间的映射机制来提高引用局部性.

如下图所示, 此时的4个缓存行分别覆盖帧缓冲和深度缓冲上的一块正方形区域. 上方显示了覆盖区域在两个缓冲中的位置. 这些缓存行覆盖的像素和之前是一样多的.

使用正方形的缓存图块进行渲染

使用图块进行光栅化

在实际情况中, 帧缓冲会比缓存图块大很多. 当要渲染一个大的三角形, 并且简单地从上之下地那么逐行渲染, 就会造成缓存抖动, 因为屏幕上的一条水平线所包含的图块要比缓存中的图块来的多. 要解决这个问题, 我们可以改变三角形中像素的光栅化顺序: 我们可以先把一个图块中属于这个三角形的所有像素先画出来, 然后再移动到下一个图块.

由于在这个简单的例子中, 缓存的图块正好能填满帧缓冲的宽, 所以使用这种方式进行光栅化并不能减少内存数据的传输数量. 但是, 我们能看到两者之间的区别 - 先把一个缓存图块内的三角形部分画完, 再去处理下一个图块.

下面这个动画花费的时间比上一个动画长, 因为这个动画在三角形内的一个图块更新时都会截取一帧, 而上一个动画则是在一个三角形完成渲染后或者图块与内存之间进行了数据传输后才会截取一帧. 在真实的硬件上, 两者的性能应该是一致的, 并且, 如果上一个方法造成了缓存的抖动, 那么这个版本的性能会更优.

一次渲染一个图块

我们还可以继续优化对内存的访问, 不要在处理完当前图块的一个三角形内的像素就处理下一个图块, 可以把场景中所有的三角形都处理完, 再移动到下一个图块. 这个就是基于图块的渲染器(TBR)所使用的优化方法.

分箱(Binning)

TBR的第一步就是确定每个图块分别受到哪些三角形的影响. 一个TBR的最原始的实现是对每个图块把场景中的所有三角形渲染一遍, 再将超出图块的部分裁剪掉. 但在实际使用的时候, 帧缓冲相对于图块来说非常得大, 这个方法非常的没有效率. 取而代之的方法是, 当一个三角形被提交, 先不立即对它进行光栅化, 而是分箱到一个内存中的结构中, 这个结构定义了它影响到了哪些图块. 注意, 这个操作包含了顶点着色, 因为这和三角形的位置相关, 不过和片元着色无关.

下面这张图演示了场景中的各个三角形是如何分箱到12个图块中, 整个帧缓冲正好可以分成4x3个图块. 下方的帧缓冲显示当前提交的三角形, 上方则按照4x3的方式排列着12个缩小的帧缓冲, 每个帧缓冲只显示被分箱到对应图块的三角形, 图块对应的区域由一条红线框出.

将三角形分箱

基于图块的光栅化

当三角形完成分箱后, 光栅器就可以按箱来进行处理, 每次只对一个图块的内存进行写入, 直到这个图块处理完毕. 由于每个图块只处理一次, 那么缓存就减少到了一个图块那么大. 这个顺序操作包含了清空帧缓冲, 在图块处理过程中, 整个帧缓冲都是脏的.

按图块进行渲染

渲染分成了两个阶段: 分箱, 这步需要写内存; 光栅化, 需要读取箱内数据. 几何数据的中间存储相对于帧缓冲一般而言是更小的, 并且会顺序地进行访问.

![](/gpu-framebuffer/images/tech_GPUFramebuffer_16 - cn.svg)

由于需要等到所有的几何数据提交完毕才能开始光栅化, 所以和立即模式相比, 基于图块的渲染会有一个延迟. 而这个延迟带来的回报则是减少带宽, 增快光栅化速度. 在某些TBR的硬件上, 分箱和光栅化是流水线化的. 因此, 任何限制了并行性的操作都会造成性能问题, 比如说一个顶点着色器需要使用前一帧的输出, 一个纹理每一帧都会修改, 并且没有使用双缓冲. 另外, 某些TBR的硬件限制了分箱阶段的几何数据的数量.

尽管如此, 带宽的节省对于移动设备来说还是最重要的, 所以几乎所有的移动设备都使用了TBR. 甚至传统的桌面IMR供应商也在他们的新硬件中部分地使用基于图块的方法. 这意味着桌面和移动端都能在新的支持图块的API中收益, 如Vulkan Subpasses.

由于我们要一次处理一个图块上的所有三角形, 所以不太必要去读取帧缓冲上的前一帧的数据, 可以把数据清空当成图块处理的一部分, 也可以避免一次读取的带宽消耗, 除非我们真的一定要读取前一帧的数据. 同样大多数情况也不需要对内存中的深度缓冲进行写入, 因为大多情况深度值都是在渲染过程中使用的, 并不需要在帧与帧之间保存.

帧缓冲对外的数据往来只有每个图块的一次写入, 如果这个图块上没有三角形被渲染, 也需要用背景色来清空帧缓冲.

多重采样

TBR提供了一个低带宽消耗的方法来实现反走样: 我们可以正常地渲染一个图块, 在写入内存时把对像素求平均作为操作的一部分. 这步对图块缓冲的降采样被称作解算. 当进行多重采样时(对应于超采样), 并不是所有的片上像素都被着色.

如果图块缓冲是固定大小的, 那么反走样意味着图像需要分割为更多个图块, 从图块缓冲到帧缓冲的写入次数会更多, 但是总的写入量却是不受多重采样的级数的影响. 完整分辨率版本的帧缓存, 也就是降采样之前的那个版本, 是不需要写入到内存的, 只要对这个渲染对象没有其他操作. 这能节省大量的带宽, 对于简单的场景, 多重采样几乎是没有代价的.

在下面这个动画中, 使用了2x2的反走样, 所以使得图块在帧缓冲中占据的面积变小了, 于是需要更多的步数. 光栅化后的几何体在图块内存中占用的空间会翻番, 但在写入到帧缓冲时会压缩. 深度缓冲只需要在片上即可, 不需要写入到内存, 所以整个帧缓冲只显示了颜色缓冲, 片上的深度值在图块处理完成后就会被抛弃.

TBR中的多重采样

传统的延迟着色

在渲染的过程当中, 一般来时是不能去读取帧缓冲内的数据的. 但有些技术依赖于读取前一次写入操作的结果.

其中一个技术就是延迟渲染: 只有基础的信息会在三角形光栅化是会被记录下来, 然后下一步使用记录下来的信息作为像素着色操作的输入值, 来渲染整个场景. 延迟渲染可以减少开销较大的状态切换, 增加片元着色器潜在的并发性.

一个简单的延迟渲染的实现是非常消耗带宽的, 因为整个帧缓冲, 包括所有逐像素的值, 都会在延迟着色过程中进行读和写操作.

下面这个例子演示了一个简单的延迟着色的实现中缓存的行为. 第一步简单地记录了Phong氏内插的表面法线, 第二步读取图像中每个像素的信息, 然后使用读取到的法线数据进行光照的计算, 图像中每一行的处理都包括读取和写入两次操作.

IMR中的延迟渲染

图块化与延迟着色

由于延迟着色以及相关的延迟光照技术只需要读取当前像素的数据, 所以整个场景还是可以以图块为单位进行处理. 只有最后的结果需要写入到帧缓冲中.

在Vulkan中的实现是这样的, 整个渲染的过程被当成单次渲染通道, 几何与着色过程被包含在两个子通道中. 在OpenGL ES中也能使用Pixel Local Storage达成类似的方案, 就是不那么正式. 使用这些方法, 延迟着色对于内存访问的消耗就不会比一般的渲染方式来的更大, 而且一样的, 也不需要写入深度缓冲.

下面这个例子演示了TBR下的延迟着色: 三角形先光栅化, 接着在图块内存下进行着色, 只有最终的着色结果, 颜色的RGB值会被写入到帧缓冲中.

TBR中的延迟渲染

TBR的优点

  • 帧缓冲的内存带宽大大降低, 节省电量和提高速度.

  • 移动设备的内存一般来说比桌面端慢, 电量也少, 带宽和CPU共享, 所以访问内存开销很大.

  • 有了API的支持, 片外的内存需求可以减少, 比如可能就不再需要片外的深度缓冲了.

  • 纹理缓存的性能会提升, 如果有多个三角形使用了同一个纹理的话, 按照图块来访问纹理会比按照三角形来访问更效率.

  • 比起一个通用的帧缓冲缓存来说更节约片上的空间.

  • 可以留出更多空间给纹理缓存, 进一步减少带宽.

TBR的限制

在TBR带来多个性能上的好处之时, 它也有着不少技术上的限制:

  • 分箱和片元着色这两步操作会带来延迟

  • 这个延迟可能被流水线隐藏, 并提升了性能, 但也会造成某些操作的开销变的更大

  • 帧缓冲和需要进行渲染的纹理必须进行双缓冲, 这样才能避免停滞流水线

  • 对帧缓冲的读取如果超出了当前的片元, 则会开销很大

  • 哪些要对整个帧缓冲进行写入的操作, 如屏幕空间的光线追踪, 会让TBR像以往那样在抛弃完整分辨率的图像和深度值

  • 重复遍历几何体也有一个额外的开销

  • 对于顶点着色本身就是瓶颈的场景来说, 反而会增加开销

  • 分箱操作本身有限制

  • 有些实现在非常复杂的场景中进行分箱可能会耗尽空间, 或者在遇到一些不寻常的输入时进行优化

  • 切换到其他渲染对象后再切换回来, 会把所有的数据写入到内存, 并在之后读取回来

  • 对于TBR, 很重要的一点是阴影映射纹理以及环境映射纹理都要事先准备好, 而不是在最终渲染过程中需要时才生成处理, 这对于大多数GPU都是一条好的建议

  • 状态(如着色器)的切换会更频繁, 更难以预测

  • 被跳过的几何体意味着状态不需要跟随, 这让增量的状态更新难以实现

在大多数情况下, 基于图块的GPU表现不会比立即模式的GPU差, 如果两者的硬件配置相当的话, 事实上, 有种GPU可以在这两个模式之间进行切换, 但是使用了错误的模式的话丧失TBR带来的优点.

总结

TBR是现代GPU用于减小访问片外帧缓冲内存带宽的一种技术. 在移动端被广泛地使用, 因为外部内存的访问开销巨大, 渲染的需求则较低. 在桌面端GPU也开始部分使用TBR.

Vulkan有特定的功能用于发挥TBR的最佳性能, 包括控制加载或是清空上一次的帧缓冲内容, 是写入还是抛弃附件内容, 控制附件的解算和次通道. OpenGL ES可以通过扩展来完成类似行为, 但并不通用. 要在当前乃至未来的GPU上获得最佳的性能, 必须要正确地使用这些API, 才能让TBR更有效地发挥作用.