TBR架构的Tiling Pass解析
开工之前的分拣工作从一个快递分拣中心说起双十一。一个快递分拣中心收到了100万个包裹。每个包裹上贴着收件地址。这些包裹要送到全市2040个小区。有两种分拣方式。方式一不分拣。快递员拿起一个包裹看地址开车送过去。回来再拿一个看地址开车送过去。100万个包裹100万次出车。每次出车可能去不同的小区。今天去东边下一趟去西边再下一趟又回东边。方式二先分拣。在仓库里摆2040个筐每个筐对应一个小区。100万个包裹一个一个看地址扔到对应的筐里。分拣完毕后快递员拿起第一个筐一次性把这个筐里的所有包裹送到对应小区。然后拿第二个筐。2040个筐2040次出车。每次出车只去一个小区把那个小区的所有包裹一次送完。方式一是IMR。方式二是TBR。分拣的过程就是Tiling Pass。一、Tiling Pass在做什么主场景渲染开始之前GPU要先做一件事把所有三角形分配到它们覆盖的Tile里。屏幕被切成一个个小方块Tile通常是16×16或32×32像素。1920×1080的屏幕按32×32切分得到60×34 2040个Tile。场景里有几万个三角形。每个三角形经过顶点着色器变换后在屏幕上占据一定的区域。这个区域可能覆盖一个Tile也可能覆盖几十个、几百个Tile。Tiling Pass的任务对每个三角形算出它覆盖了哪些Tile然后把这个三角形的引用添加到那些Tile的列表里。输入所有三角形的屏幕空间坐标 输出每个Tile的三角形列表 Tile (0,0): [三角形 5, 三角形 23, 三角形 107, ...] Tile (1,0): [三角形 5, 三角形 24, 三角形 108, ...] Tile (2,0): [三角形 23, 三角形 24, 三角形 55, ...] ... Tile (59,33): [三角形 892, 三角形 1024, ...]这个过程完成后GPU就知道了每个Tile里有哪些三角形需要画。然后GPU一个Tile一个Tile地渲染。处理Tile (0,0)的时候只画Tile (0,0)列表里的三角形。处理Tile (1,0)的时候只画Tile (1,0)列表里的三角形。Tiling Pass是TBR架构的第一步。没有它后面的逐Tile渲染就无从谈起。二、Tiling Pass的详细流程2.1 第一步顶点着色Tiling Pass开始之前所有三角形的顶点要先经过顶点着色器。模型空间坐标 → 世界空间坐标 → 观察空间坐标 → 裁剪空间坐标 → NDC坐标 → 屏幕空间坐标顶点着色器把每个顶点从3D世界变换到2D屏幕上。变换完之后每个三角形有三个屏幕空间坐标。三角形A的三个顶点 V0 (120.5, 340.2) V1 (890.3, 120.8) V2 (450.7, 780.1)注意顶点着色器在Tiling Pass之前就执行了。Tiling Pass拿到的是已经变换好的屏幕空间坐标。有些资料把顶点着色也算作Tiling Pass的一部分。有些把它算作独立的阶段。这不重要。重要的是Tiling Pass需要屏幕空间坐标才能工作。2.2 第二步计算包围盒对每个三角形计算它在屏幕上的轴对齐包围盒AABB。三角形A V0 (120.5, 120.8) ← 取三个顶点的x最小值和y最小值 V1 (890.3, 780.1) ← 取三个顶点的x最大值和y最大值 包围盒 x_min 120.5, x_max 890.3 y_min 120.8, y_max 780.1为什么要算包围盒因为判断一个三角形覆盖了哪些Tile最快的方法是先用包围盒粗略判断再精确判断。2.3 第三步包围盒映射到Tile坐标把包围盒的像素坐标转换成Tile坐标。Tile大小 32×32 三角形A的包围盒 x_min 120.5 → Tile_x_min floor(120.5 / 32) 3 x_max 890.3 → Tile_x_max floor(890.3 / 32) 27 y_min 120.8 → Tile_y_min floor(120.8 / 32) 3 y_max 780.1 → Tile_y_max floor(780.1 / 32) 24 三角形A覆盖的Tile范围 x方向第3列到第27列 25列 y方向第3行到第24行 22行 总共25 × 22 550个Tile一个三角形覆盖了550个Tile。这个三角形的引用要添加到550个Tile的列表里。2.4 第四步精确裁剪可选包围盒是粗略的。三角形是斜的包围盒是方的。包围盒的角落里可能有些Tile其实不被三角形覆盖。┌─────────────────────┐ │ ╱ │ ← 包围盒的右上角 │╱ 三角形 │ 三角形没有覆盖这里 │╲ │ 但包围盒覆盖了 │ ╲ │ │ ╲ │ └─────────────────────┘精确裁剪会进一步判断包围盒范围内的每个Tile是否真的被三角形覆盖。怎么判断用三角形的三条边做半平面测试。一个Tile的四个角点如果都在三角形的某条边的外侧那这个Tile就不被三角形覆盖。精确裁剪能减少无效的Tile分配但本身也有计算开销。大多数GPU会做一定程度的精确裁剪但不会做到像素级别的精确——那就变成光栅化了不是Tiling Pass该做的事。2.5 第五步写入Tile列表对每个被覆盖的Tile把三角形的引用通常是一个索引或指针添加到那个Tile的三角形列表里。Tile (5, 7) 的列表 [三角形12] → [三角形12, 三角形A] → [三角形12, 三角形A, 三角形89] → ...这个列表存在哪里主内存。片上内存太小存不下所有Tile的所有三角形列表。2040个Tile每个Tile可能有几十到几百个三角形。总数据量可能有几MB到几十MB。Tiling Pass的输出——Tile列表——存在主内存里。后续逐Tile渲染的时候GPU从主内存读取对应Tile的三角形列表加载到片上内存然后开始渲染。三、Tiling Pass的开销分析Tiling Pass不是免费的。它有三种开销。3.1 计算开销每个三角形都要计算包围盒几次比较运算映射到Tile坐标几次除法可能的精确裁剪半平面测试写入Tile列表内存写入场景里有10万个三角形。每个三角形做一次上述操作。计算量不大。跟片段着色器的纹理采样和光照计算比起来Tiling Pass的计算量微不足道。几次整数运算和比较运算GPU几个时钟周期就搞定了。Tiling Pass的计算开销通常在0.1-0.3ms。3.2 带宽开销Tile列表要写入主内存。每个三角形引用占多少空间通常4-8字节一个32位或64位的索引/指针。一个三角形平均覆盖多少个Tile这取决于三角形的大小。小三角形几个像素大覆盖1-2个Tile 中等三角形几百个像素覆盖5-20个Tile 大三角形几千个像素覆盖几十到几百个Tile 全屏三角形覆盖所有2040个Tile假设场景有10万个三角形平均每个覆盖10个Tile。总Tile引用数 10万 × 10 100万 每个引用8字节 总数据量 100万 × 8 8MB8MB写入主内存。在移动端30-50GB/s的带宽下这不算多但也不是零。3.3 内存开销Tile列表本身占内存。100万个引用 × 8字节 8MB。加上每个Tile的列表头信息起始地址、长度等总共可能10-15MB。在内存紧张的移动端10-15MB不是小数目。而且Tile列表的大小是动态的。场景简单的时候可能只有2MB场景复杂的时候可能有20MB。GPU需要预分配足够大的缓冲区来存放Tile列表或者用动态分配的方式。四、大三角形的噩梦Tiling Pass最头疼的问题是大三角形。一个覆盖整个屏幕的三角形比如天空盒的一个面、全屏后处理的三角形会被分配到所有2040个Tile里。一个全屏三角形 覆盖2040个Tile 写入2040个Tile引用 2040 × 8字节 16KB16KB不多。但如果有100个大三角形呢100个大三角形 × 2040个Tile × 8字节 1.6MB而且问题不只是带宽。每个Tile在渲染的时候都要处理这100个大三角形。Tile (0,0) 的三角形列表里有这100个大三角形。Tile (0,1) 的列表里也有。Tile (59,33) 的列表里也有。每个Tile都要对这100个大三角形做光栅化虽然只处理Tile内的部分。100个大三角形 × 2040个Tile 204000次光栅化操作。如果这100个大三角形只是普通大小的三角形每个覆盖10个Tile那只有100 × 10 1000次光栅化操作。大三角形让光栅化的工作量膨胀了200倍。4.1 解决方案三角形裁剪在Tiling Pass之前对大三角形做裁剪。把一个大三角形切成多个小三角形每个小三角形只覆盖少量Tile。一个全屏三角形 → 裁剪成64个小三角形 每个小三角形覆盖约32个Tile 总Tile引用 64 × 32 2048跟原来的2040差不多裁剪没有减少Tile引用的总数但减少了每个Tile列表的长度。因为每个小三角形只出现在少量Tile的列表里而不是所有Tile的列表里。但裁剪本身有开销。而且裁剪会增加三角形的数量增加顶点着色器的工作量。4.2 解决方案分层Tiling有些GPU用分层的Tile结构。第一层大Tile256×256像素 1920×1080 → 8×5 40个大Tile 第二层中Tile64×64像素 每个大Tile包含4×4 16个中Tile 第三层小Tile16×16像素 每个中Tile包含4×4 16个小Tile大三角形只分配到第一层的大Tile里。小三角形直接分配到第三层的小Tile里。中等三角形分配到第二层。大三角形只需要写入40个大Tile的列表而不是2040个小Tile的列表。渲染的时候GPU先处理大Tile再细分到中Tile再细分到小Tile。大三角形在大Tile级别就被处理了不需要在每个小Tile里重复处理。五、小三角形的另一个问题大三角形让Tiling Pass的输出膨胀。小三角形让Tiling Pass的输入膨胀。现代游戏的模型越来越精细。一个角色可能有10万个三角形。很多三角形在屏幕上只有几个像素大甚至小于一个像素。小于一个像素的三角形叫做微三角形Micro Triangle。微三角形对Tiling Pass来说是纯粹的浪费。它覆盖一个Tile甚至不覆盖任何像素但Tiling Pass还是要处理它计算包围盒、映射Tile坐标、写入Tile列表。10万个微三角形每个只覆盖1个Tile。Tiling Pass要处理10万次但最终只产生10万个Tile引用。如果这10万个三角形合并成1000个大一点的三角形Tiling Pass只需要处理1000次。微三角形让Tiling Pass的效率急剧下降。5.1 解决方案LOD远处的物体用低模。减少三角形数量。减少微三角形。5.2 解决方案Mesh ShaderMesh Shader是现代GPU的新特性。它允许在GPU上动态生成和剔除三角形。可以在Mesh Shader里把微三角形合并成更大的三角形或者直接剔除不可见的三角形。在Tiling Pass之前就减少三角形数量。5.3 解决方案Nanite的做法虚幻5的Nanite系统在GPU上做了一套完整的LOD选择和三角形剔除。它保证送到光栅化阶段的三角形大小适中——不会太大避免Tile列表膨胀也不会太小避免微三角形浪费。Nanite本质上是在帮Tiling Pass减负。六、Tiling Pass的输出长什么样让我画一个具体的例子。假设屏幕只有8×8像素Tile大小是4×4。所以有2×2 4个Tile。场景里有5个三角形A、B、C、D、E。屏幕8×8像素4个Tile ┌───────┬───────┐ │Tile │Tile │ │(0,0) │(1,0) │ │ A,B │ B,C │ │ │ │ ├───────┼───────┤ │Tile │Tile │ │(0,1) │(1,1) │ │ A,D │ C,D,E│ │ │ │ └───────┴───────┘ 三角形覆盖情况 A覆盖Tile(0,0)和Tile(0,1) → 左边两个 B覆盖Tile(0,0)和Tile(1,0) → 上面两个 C覆盖Tile(1,0)和Tile(1,1) → 右边两个 D覆盖Tile(0,1)和Tile(1,1) → 下面两个 E只覆盖Tile(1,1) → 右下角Tiling Pass的输出Tile (0,0) 的三角形列表[A, B] → 2个三角形 Tile (1,0) 的三角形列表[B, C] → 2个三角形 Tile (0,1) 的三角形列表[A, D] → 2个三角形 Tile (1,1) 的三角形列表[C, D, E] → 3个三角形在主内存中的存储方式┌─────────────────────────────────────────┐ │ Tile列表头每个Tile的起始位置和长度 │ │ │ │ Tile(0,0): offset0, count2 │ │ Tile(1,0): offset2, count2 │ │ Tile(0,1): offset4, count2 │ │ Tile(1,1): offset6, count3 │ ├─────────────────────────────────────────┤ │ 三角形引用数组 │ │ │ │ [0]: A │ │ [1]: B │ │ [2]: B ← B出现了两次覆盖两个Tile │ │ [3]: C │ │ [4]: A ← A也出现了两次 │ │ [5]: D │ │ [6]: C ← C也出现了两次 │ │ [7]: D ← D也出现了两次 │ │ [8]: E │ └─────────────────────────────────────────┘5个三角形产生了9个引用。因为有些三角形覆盖了多个Tile它们的引用出现了多次。引用总数 ≥ 三角形总数。覆盖越多Tile的三角形引用越多。七、Tiling Pass对开发者的影响7.1 你看不到Tiling PassTiling Pass是GPU硬件自动完成的。你不需要写任何代码来触发它。你提交Draw CallGPU自动在渲染之前做Tiling。你在Vulkan/Metal的API里看不到任何跟Tiling相关的函数调用。但你能感受到它的存在。当你的场景有大量三角形的时候即使片段着色器很简单帧率也可能下降。因为Tiling Pass本身成了瓶颈。7.2 你能影响Tiling Pass的效率虽然你不能直接控制Tiling Pass但你的渲染决策会影响它的效率。减少三角形数量。用LOD。用遮挡剔除。用视锥体剔除。送到GPU的三角形越少Tiling Pass越快。避免极大的三角形。全屏的后处理三角形、天空盒的大面片都会让Tile列表膨胀。如果可能把大三角形拆成小的。避免极小的三角形。微三角形浪费Tiling Pass的处理能力。用LOD确保屏幕上的三角形大小适中。理想的三角形大小覆盖几个到几十个像素。不要太大Tile列表膨胀不要太小处理浪费。7.3 RenderPass的设计每次开始一个新的RenderPassGPU都要重新做一次Tiling Pass如果这个Pass里有3D几何体的话。两个RenderPass 两次Tiling Pass。如果你把主场景拆成两个Pass比如先画不透明物体再画半透明物体GPU要做两次Tiling。尽量把所有几何体放在同一个RenderPass里。用Subpass来分阶段处理而不是用多个RenderPass。Subpass不会触发新的Tiling Pass。最后Tiling Pass是TBR架构的基石。没有它就没有逐Tile渲染。没有逐Tile渲染就没有片上内存的优势。没有片上内存的优势移动端GPU就跟PC GPU一样费电手机就变成了暖手宝。Tiling Pass本身不产生任何可见的像素。它不画任何东西。它只是在分拣。把三角形分到对应的Tile里。但正是这个看不见的分拣工作让后面的渲染能够在小小的片上内存里高效完成。就像快递分拣中心。分拣员不送一个包裹。但没有分拣员快递员就要满城乱跑。Tiling Pass是那个不送包裹的分拣员。它不画画。但没有它画就画不好。