Skip to main content

Chapter 18: Primitive Assembly

基元装配(Primitive Assembly)讨论的是一个 draw call 如何把顶点流解释成点、线、三角形、patch 或带邻接信息的图元。上一章已经追踪了顶点属性如何进入 Vertex Shader,本章继续向后看:同一批顶点经过索引、顺序和 topology 解释之后,哪几个顶点会被组合成一个可裁剪、可剔除、可光栅化的几何单位。

读完本章后,读者应能追踪一次 DrawIndexed 中的 index buffervertex orderprimitive topologysubmesh 如何共同决定屏幕上的三角形数量、正反面、材质边界和提交成本。这个能力服务两个工程判断:画面缺面时能定位是索引顺序、拓扑设置还是剔除状态导致;性能受限时能判断该优化索引组织、draw 拆分、meshlet 分块还是 GPU culling。

本章使用一个贯穿材料:一帧中绘制大量矩形广告牌和岩石碎块。矩形广告牌由 4 个顶点和 6 个索引组成,形成 2 个三角形;岩石碎块由多个 submesh 组成,不同 submesh 对应不同材质。这个材料足够小,可以手工验证三角形顺序;也可以扩展到海量几何组织,用来说明 multi-drawindirect draw 和 meshlet 的作用边界。

基元装配处在 Vertex Shader 输出和光栅化之间。API 文档中的命名会有差异:Direct3D 文档把 Input Assembler 描述为读取 vertex/index buffer 并装配 primitive 的阶段,Direct3D Primitive Topologies 用 topology 描述顶点解释方式;Vulkan 使用 VkPrimitiveTopology 枚举 point、line、triangle、adjacency 和 patch。写引擎代码时,应把这些 API 名称收束成同一个判断:当前 draw 的顶点流最终会生成哪些 primitive。

18.1 基元装配的核心目标与流程

基元装配的核心目标是把“有顺序的顶点结果”变成“有边界的几何图元”。顶点缓冲只保存顶点属性,索引缓冲只保存访问顺序,topology 决定每几个顶点组成一个 primitive。光栅器接收的单位通常是 point、line 或 triangle;patch 先进入 tessellation 路径,随后才产生可光栅化的 primitive。

在一个 indexed triangle-list draw 中,输入数据包含三类对象。vertex buffer 保存 position、normal、UV 等属性;index buffer 保存顶点访问序列;pipeline state 中的 topology 写明每 3 个索引组成一个独立三角形。GPU 按索引读取顶点属性,Vertex Shader 对每个顶点产生裁剪空间位置和 varying,装配阶段再按索引序列把这些输出组合成三角形。

下面的图把边界限定在 primitive assembly 周围的数据关系,后续 clipping、culling 和 rasterization 只作为输出方向出现。

这条路径中最容易混淆的是“索引读取”和“primitive 组合”的先后关系。索引缓冲先决定顶点访问顺序,Vertex Shader 处理被引用的顶点,装配阶段再把 shader 输出按 topology 归组。实现会使用缓存、重排和并行执行提高吞吐,但工程判断仍然围绕这三项状态展开:索引序列、顶点输出、拓扑解释。

以一个矩形广告牌为例,四个顶点按左下、右下、左上、右上排列。使用 triangle list 时,索引可以写成 0, 1, 2, 2, 1, 3,它生成两个三角形:第一个使用顶点 0、1、2,第二个使用顶点 2、1、3。这里的 6 个索引形成 2 个独立 primitive,两个三角形共享边,但 topology 并不会自动推断共享关系;共享来自索引值重复引用同一批顶点。

// 简化伪代码:同一个矩形广告牌的 indexed triangle list
uint16_t indices[] = {
0, 1, 2,
2, 1, 3,
};

这段索引序列可以验证三个判断。第一,primitive 数量由 topology 和 index count 共同决定,triangle list 的 primitive 数量是 indexCount / 3。第二,三角形正反面由顶点 winding 决定,后续 backface culling 会依赖这个顺序。第三,顶点复用由重复索引表达,后续 post-transform cache 能减少重复 Vertex Shader 工作。

工具观察时,RenderDoc 或 Xcode GPU capture 中的 draw call state 通常能看到 index buffer、vertex buffer、topology、cull mode 和 shader 输出。排查缺面问题的顺序是先看 topology 是否匹配数据,再看 index buffer 是否按预期分组三角形,然后看 winding 与 cull mode 是否一致,最后看裁剪空间位置是否把 primitive 推到视锥外。

18.2 三角形剖分与几何基本类型

图形管线支持多种 primitive type,但实时渲染最稳定的光栅化单位仍然是三角形。三角形的三个点总是共面,插值规则明确,硬件可以用边函数、重心坐标和固定覆盖规则处理它。四边形、n-gon、曲面和字体轮廓进入实时管线前,通常会被剖分为三角形、线段或 patch 控制点。

基本 topology 的差异来自“连续顶点如何归组”。Point list 把每个顶点看成一个点 primitive;line list 每两个顶点形成一条独立线段;line strip 让相邻线段共享顶点;triangle list 每三个顶点形成一个独立三角形;triangle strip 从第三个顶点开始,每增加一个顶点形成一个新三角形,并与前一个三角形共享一条边。Vulkan 的 VkPrimitiveTopology 明确列出这些 topology,还包含 adjacency 版本和 PATCH_LIST;Direct3D 10+ 基本类型覆盖 point list、line list、line strip、triangle list 和 triangle strip。

Topology顶点归组方式常见用途主要检查点
Point list每个顶点形成一个点粒子、调试点、点云点大小、扩展方式、shader 输出
Line list每两个顶点形成一条线debug wire、辅助线偶数顶点、线宽支持、抗锯齿策略
Line strip连续线段共享顶点折线、轨迹、曲线近似strip restart、段连接、材质一致性
Triangle list每三个顶点形成独立三角形静态网格、skinned mesh、主流资产winding、索引复用、post-transform cache
Triangle strip连续三角形共享边规则带状网格、旧式压缩表达奇偶 winding、restart、退化三角形
Patch list固定数量控制点形成 patchtessellation、曲面细分control point count、tess factor、domain 输出

三角形剖分(Triangulation)的工程目标是把高层几何表达转换成 GPU 容易处理的 triangle primitive。一个凸四边形可以直接切成两个三角形;一个 n-gon 需要根据轮廓、孔洞、凹点和法线方向决定切分;一个曲面 patch 需要 tessellator 或离线工具根据细分参数生成三角形。剖分质量会影响插值、法线、UV 接缝和后续 LOD。

矩形广告牌适合用 triangle list 或 triangle strip 表示。triangle list 需要 6 个索引,分组清晰,材质拆分和调试方便。triangle strip 只需要 4 个顶点顺序就能表达两个三角形,索引数量更少,但 winding 会随奇偶三角形交替处理,多个独立矩形之间还需要 restart 或退化三角形连接。现代引擎中,indexed triangle list 配合索引重排和 post-transform cache,通常更容易维护稳定性能。

Point 和 line 在视觉上常被当成“轻量几何”,但它们也要经过装配、裁剪和光栅化。调试线框、骨骼可视化、运动轨迹和包围盒常使用 line list 或 line strip;粒子系统可以使用 point list 作为输入,再在 shader 或后续阶段扩展成面向相机的 quad。这里的关键判断是:primitive type 决定初始输入形态,最终覆盖多少像素还取决于扩展逻辑和 fragment 阶段成本。

Patch list 属于 tessellation 输入,而 triangle list 属于可直接进入光栅化的输入。一个 patch primitive 可能包含 3、4、16 或更多控制点,具体数量由 pipeline state 设定。Tessellation Control Shader 读取这些控制点并输出 tess factor,固定功能 tessellator 生成细分坐标,Tessellation Evaluation Shader 再把细分坐标映射到真实位置。这个路径的成本来自控制点读取、tess factor、生成 primitive 数量和 displacement 采样。

18.3 非三角形拓扑的转换与处理

非三角形拓扑进入工程管线后,通常会被转换成三类结果:可光栅化三角形、可光栅化线段、或 tessellation / geometry / mesh shader 的输入 primitive。转换发生的位置会影响调试方式。离线导入阶段转换更容易缓存和验证;运行时转换更灵活,但会增加 CPU 或 GPU 工作量;shader 阶段扩展能减少上传数据量,却会把成本转移到 GPU 执行和资源同步。

曲线是最常见的非三角形输入。Bezier、B-Spline 或字体轮廓在资产管线中可以被采样成 polyline,再根据宽度和连接方式生成 triangle strip 或 triangle list。曲线也可以在 GPU 上按参数细分,适合需要动态精度的轨迹、UI path 或矢量图形。判断转换位置时先看曲线是否每帧变化,再看所需屏幕误差,最后看生成后的 primitive 数量是否稳定。

n-gon 和复杂多边形通常在导入阶段剖分。DCC 工具可能允许一个面拥有超过三个顶点,但实时渲染的顶点属性插值和背面剔除都需要稳定三角形。凹多边形、带孔多边形和自交轮廓需要专门的 triangulation 算法;资产导入器还要把法线、UV、切线和材质边界同步拆分。只改变位置索引而保留旧属性索引,容易产生法线错乱或 UV 裂缝。

Sprite 和 billboard 看起来像二维对象,进入管线时常有三种组织方式。第一种是 CPU 直接生成四个顶点和六个索引,把每个 sprite 当成矩形网格。第二种是上传中心点、尺寸和朝向,在 Vertex Shader 中通过 instancing 展开四个角。第三种是使用 point primitive 作为输入,再由 shader 或固定功能点渲染路径扩展。三种方式的取舍取决于 sprite 数量、排序需求、平台对点大小的支持和透明混合成本。

Adjacency topology 用来给 Geometry Shader 提供邻接顶点。Direct3D 文档说明 adjacency primitive 含有目标 primitive 周围的顶点,额外顶点只被 Geometry Shader 读取;Vulkan 也提供 line 和 triangle 的 adjacency topology。典型用途是 silhouette detection、shadow volume extrusion 和几何调试。它的代价是输入顶点数量增加、索引构造更复杂,并且 Geometry Shader 在很多现代 GPU 上吞吐受限。

Triangle fan 是一个需要标注平台边界的 topology。Vulkan 枚举中包含 VK_PRIMITIVE_TOPOLOGY_TRIANGLE_FAN,但 portability subset 可以声明不支持 triangle fan;Direct3D 10+ 的基本 topology 列表中没有 triangle fan。跨平台资产管线应把 fan 转换为 triangle list,保留中心点共享即可。这样能减少 API 兼容分支,也能让后续索引重排、meshlet 构建和压缩工具使用统一输入。

Tessellation patch 也属于非三角形输入。它的转换结果由 tessellation state、shader 输出和 domain 类型共同决定。近距离地形可以用较高 tess factor 生成更多三角形,远距离地形使用较低 tess factor 控制 primitive 数量。检查 patch 问题时,先看 control point count 是否匹配 pipeline state,再看 tess factor 是否产生期望细分密度,最后看 domain shader 输出位置和 winding。

18.4 优化 Primitive 组织方式

Primitive 组织优化要先确定瓶颈类型。顶点处理受限时,索引复用和 vertex cache 命中率更关键;CPU 提交受限时,draw call 合并、multi-draw 和 indirect draw 更关键;fragment 阶段受限时,减少 primitive 数量未必带来主要收益,overdraw、透明排序和材质采样才是检查重点。primitive assembly 只覆盖几何输入路径,优化判断需要和整帧 profile 结果对齐。

索引重排是最基础的优化。目标是让相邻三角形复用相近的顶点,提升 post-transform cache 命中率。对于 indexed triangle list,工具可以重排 index buffer,使近期处理过的顶点更可能被后续三角形再次引用。顶点数据本身也常配合重排,让 vertex fetch 更连续,减少缓存 miss。这个优化保持视觉结果不变,但会改变索引顺序,因此需要重新验证 winding、材质分段和骨骼权重边界。

Triangle strip 的优化价值需要结合平台和资产形态判断。规则网格、带状道路、布料条带适合 strip 表达,因为几何天然连续。通用三角网格拆成长 strip 的成本较高,遇到材质边界、UV seam、LOD 切换和 meshlet 分块时维护复杂度会上升。现代 indexed triangle list 加上缓存友好重排,通常能提供更稳定的工具链和调试体验。

Submesh 合并和 material split 是 primitive 组织中的另一条主线。多个 submesh 使用同一 pipeline、同一材质和相邻资源绑定时,合并可以减少 CPU draw 提交和状态切换。不同材质、不同透明排序、不同 shader variant 或不同剔除边界时,拆分能让渲染顺序和资源状态更清晰。合并和拆分的判断依据应放在每个 draw 是否会引入状态切换、额外绑定和无效几何处理上,submesh 数量只作为辅助信号。

继续使用岩石碎块示例。假设一块岩石有主体、裂缝高亮和苔藓三个材质区域。主体和苔藓使用不同纹理集,裂缝高亮需要单独的 emissive pass。此时把所有三角形合成一个 draw 会让 shader 内部增加材质分支和纹理索引复杂度;拆成三个 submesh 能让资源绑定直接对应材质。若场景中有 5000 块岩石,单块岩石拆成三个 draw 又会增加 CPU 提交压力,下一步就要引入 instancing、multi-draw 或 indirect draw。

Primitive restart 和退化三角形可以把多个 strip 放入一个 index buffer。Primitive restart 使用特殊索引值切断当前 strip,下一段重新开始;退化三角形通过重复顶点生成零面积三角形,使光栅器不产生有效覆盖。二者都服务于减少多次 draw 或多段 buffer 的组织成本。检查这类数据时,应统计有效三角形和退化三角形数量,确认额外索引没有吞噬带宽收益。

下面是一套可迁移检查顺序,适合定位 primitive 组织带来的性能问题。先用 frame profiler 判断瓶颈在 CPU 提交、vertex processing、geometry expansion 还是 fragment shading。再查看 draw call 的 topology、index count、vertex count、instance count 和 submesh 数量。接着检查 index buffer 的局部性、材质切换频率和 pipeline state 切换。最后根据瓶颈选择索引重排、合并 submesh、拆分材质、instancing、multi-draw 或 GPU culling。

18.5 大规模几何装配的性能提升

大规模几何装配的主问题是:如何让海量 primitive 进入 GPU,同时控制 CPU 提交、顶点处理、内存带宽和不可见几何成本。单个 mesh 的 index buffer 优化只能处理局部问题;当场景包含数十万对象或数千万三角形时,组织单位要从“一个 mesh”上升到 chunk、meshlet、draw batch 和 GPU 生成命令。

场景分块是第一层组织。地形、城市、植被和岩石堆可以按空间划分成 chunk,每个 chunk 拥有包围盒、LOD 范围、材质集合和一段索引数据。CPU 或 GPU 先判断 chunk 是否进入可见集,再提交或生成对应 draw。这个做法把 primitive assembly 的输入限制在可见区域内,同时让 streaming、LOD 和资源加载有稳定边界。

Meshlet 是更细粒度的几何包。一个 meshlet 通常包含几十到几百个顶点和三角形,并附带局部包围体、cone culling 信息和压缩索引。它的价值在于让 GPU 能按小块执行剔除、LOD 和装配,减少完全不可见三角形进入后续阶段。Mesh shader 管线可以直接生成 primitive;传统管线也可以在 compute pass 中筛选 meshlet,再写出 indirect draw 参数或压缩后的 index range。

Multi-draw 和 indirect draw 主要降低 CPU 提交开销。Multi-draw 把多个 draw 参数打包到一次 API 调用或少量调用中;indirect draw 把 draw 参数放在 GPU buffer 中,由 GPU 或 CPU 写入后提交。这样,CPU 的逐对象 draw 参数设置会收束为批量命令构建,场景中大量同材质、同 pipeline 的对象可以成批进入管线。它改变的是命令提交路径,primitive assembly 仍然按每个 draw 的 topology 和 index range 解释数据。

GPU culling 将“是否提交这个几何块”的判断移动到 GPU。常见流程是 compute pass 读取对象或 meshlet 的包围体,执行 frustum、LOD 或粗粒度遮挡测试,把通过测试的项写入 compacted list 或 indirect draw buffer。随后 graphics pass 消费这些命令,按筛选结果进入 primitive assembly。这个模式减少 CPU 往返和无效 draw,但会增加 GPU 前置 pass、buffer 写入、同步和调试复杂度。

下面的图展示大规模几何进入 primitive assembly 的组织路径。它强调 chunk、meshlet 和 indirect draw 的分工,不展开遮挡算法细节。

这条路径的调试证据来自三个位置。第一,culling pass 输出的可见 chunk 或 meshlet 数量,回答“有多少几何进入后续绘制”。第二,indirect draw buffer 或 multi-draw 参数,回答“GPU 将执行多少 draw、每个 draw 的 index range 是什么”。第三,raster pass 中的 primitive count、vertex shader invocation 和 early/late depth 统计,回答“这些 primitive 对后续阶段造成了多少工作”。

大规模几何优化需要守住一个边界:减少 primitive 数量只对相关瓶颈有效。若 frame 主要受 CPU 提交限制,multi-draw 和 indirect draw 的收益更直接;若主要受 vertex processing 限制,meshlet culling、LOD 和索引重排更直接;若主要受 fragment shading 或 overdraw 限制,primitive assembly 前的优化只能间接改善。正式改动前应先用 profiler 定位瓶颈,再选择组织方式。

回到本章主问题,primitive assembly 建立的是“顶点流如何变成图元流”的判断框架。小矩形能让你手工验证 index、topology 和 winding;大场景能让你判断 chunk、meshlet、multi-draw 和 GPU culling 如何控制进入管线的 primitive 数量。掌握这条路径后,后续讨论 clipping、culling 和 raster algorithms 时,输入对象会从抽象顶点转为已经装配好的 point、line、triangle 或 patch 流。

最小自检任务

给定一个矩形广告牌,顶点顺序为 v0 = left_bottomv1 = right_bottomv2 = left_topv3 = right_top。当前 draw 使用 indexed triangle list,index buffer 为 0, 1, 2, 2, 1, 3,cull mode 使用 backface culling,front face 约定为逆时针。请完成三个判断:这个 draw 会生成几个 triangle primitive;如果画面中广告牌消失,先按什么顺序排查;当场景中有 10000 个同材质广告牌时,primitive 组织应优先检查哪些优化方向。

答案要点

这个 draw 会生成 2 个 triangle primitive,因为 triangle list 每 3 个索引组成一个独立三角形,6 个索引对应 2 个三角形。排查广告牌消失时,先确认 pipeline topology 是 triangle list,再确认 index buffer 是否按 0, 1, 22, 1, 3 分组,然后检查两个三角形在当前坐标约定下的 winding 是否与 front face 设置一致,接着检查 Vertex Shader 输出的 clip position 是否落入可见范围,最后查看 cull mode、depth test 和材质 pass 是否屏蔽了绘制。10000 个同材质广告牌应先看 CPU draw 提交和 instance count,再判断是否使用 instancing、multi-draw 或 indirect draw;如果 vertex processing 仍然占用较高,再检查索引复用、顶点展开方式和 chunk / meshlet 级可见性筛选。

本章知识点总结

  • 基元装配:基元装配把有顺序的顶点输出归组为 point、line、triangle 或 patch,是光栅化前的几何边界形成阶段。
  • Topology:Topology 决定连续顶点如何组合成 primitive,同一份 index buffer 换成不同 topology 会产生不同图元流。
  • 索引顺序:Index buffer 同时决定顶点复用、primitive 分组和 winding,是排查缺面与缓存效率的第一批证据。
  • 三角形单位:实时渲染最稳定的光栅化单位是三角形,因为插值、覆盖和正反面判断都有明确规则。
  • Strip 边界:Triangle strip 适合规则连续几何,通用资产更常使用 indexed triangle list 配合索引重排。
  • Patch 输入:Patch list 是 tessellation 的控制点输入,最终 primitive 数量由 tess factor 和 domain shader 输出决定。
  • Adjacency:Adjacency topology 给 Geometry Shader 提供邻接顶点,适合轮廓和 shadow volume 等需要邻域信息的路径。
  • 非三角转换:曲线、n-gon、sprite 和 billboard 需要在导入、运行时或 shader 阶段转换成可处理的图元流。
  • Submesh 组织:Submesh 合并服务提交成本控制,material split 服务资源绑定、排序和 shader 分支边界。
  • 索引重排:索引重排通过提高 post-transform cache 命中率降低重复 Vertex Shader 工作,但需要复查 winding 和材质分段。
  • 大场景分块:Chunk 和 meshlet 把海量几何拆成可剔除、可调度、可压缩的小单位,控制进入装配阶段的 primitive 数量。
  • 批量提交:Multi-draw 和 indirect draw 改变命令提交路径,primitive assembly 仍按每个 draw 的 topology 和 index range 解释数据。
  • GPU culling:GPU culling 通过前置 compute 或 mesh 阶段筛选可见几何,减少无效 draw 和无效 primitive 进入后续阶段。
  • 优化顺序:Primitive 组织优化应先定位瓶颈,再选择索引重排、submesh 调整、instancing、multi-draw、meshlet 或 GPU culling。