Skip to main content

Chapter 11: Mesh Topology and Data Structures

本章讨论一个三角网格从 DCC 工具进入实时引擎时,拓扑关系如何被检查、改写、压缩并交给 GPU 渲染。读完本章,读者应能定位一个网格资产中的邻接关系、边界、孔洞、非流形边、渲染顶点拆分和 submesh 切分,并能判断它们分别影响编辑算法、导入管线、GPU buffer 上传和 draw call 组织。

贯穿材料是一块名为 GuardRailPanel 的护栏板网格:主体是带螺栓孔的金属板,边缘有倒角,四个孔洞周围需要硬法线,表面使用一张金属材质,螺栓区域使用另一张材质。它在建模软件中看起来像一个连续物体,进入引擎后会拆成多个 vertex buffer、一个或多个 index buffer、若干 submesh 和实例化数据。这个拆分过程由拓扑关系和渲染状态共同决定。

网格数据有两层含义。几何层回答“哪些点、边、面构成一个曲面”,渲染层回答“GPU 每次顶点读取需要哪些连续字节”。Half-edge、邻接表、边面关系主要服务几何层;vertex buffer、index buffer、attribute layout、material split 主要服务渲染层。稳定的导入管线需要先在几何层识别拓扑问题,再把结果转成渲染层的连续数据。

下面这条路径贯穿本章。图中的拓扑审查属于 CPU 侧导入阶段,buffer 上传和 draw call 属于运行时渲染阶段。两者使用同一个资产,但服务的查询完全不同。

这条路径给本章一个可复用判断顺序:先检查网格是否形成可处理曲面,再判断顶点属性是否需要拆分,然后选择 CPU 编辑结构和 GPU 渲染结构,最后用画面接缝、法线错误、draw call 数量、顶点数量和上传成本验证结果。

11.1 多边形网格的拓扑基础

多边形网格的拓扑描述“元素之间如何相连”。一个最小网格由顶点、边和面构成。顶点提供位置或属性挂载点,边连接两个顶点,面引用一组有序顶点形成可渲染或可编辑的局部片面。三角网格把面固定为三个顶点引用,因此 GPU 管线能直接把 index buffer 每三个索引解释为一个 triangle primitive。

GuardRailPanel 的螺栓孔说明了拓扑和外观的关系。孔洞周围的边界环决定洞的轮廓,倒角面决定高光过渡,硬法线决定孔边缘是否显得锋利。渲染时 GPU 只看到三角形和属性,导入阶段必须先判断这些三角形是否共同组成符合预期的曲面。

邻接关系(adjacency)指顶点、边、面之间的相邻引用。例如,一个面有哪些相邻面,一条边被哪些面使用,一个顶点周围按顺序连接了哪些面。邻接关系通常不会直接存进最终的 vertex buffer,因为 GPU 顶点读取主要按索引顺序进行。导入器、简化器、法线生成、UV 展开和碰撞代理生成需要邻接关系,所以它们会在 CPU 侧临时构建边表或 half-edge 结构。

流形(manifold)在网格处理中表示局部曲面关系可被稳定解释。对三角网格而言,一条内部边通常连接两个面,一条边界边连接一个面;围绕一个顶点的面应形成一个连续扇区或少数可明确分离的扇区。这个定义服务编辑和几何算法:法线平滑、边折叠、细分曲面和碰撞网格都依赖局部邻域可遍历。

边界(boundary)是一组只被一个面使用的边形成的环或链。护栏板外轮廓是边界,螺栓孔内圈也是边界。孔洞(hole)是边界环围出的缺面区域;它可以是设计结果,也可以是导入损坏。判断孔洞时需要结合资产语义:护栏板的螺栓孔是有效孔洞,扫描模型上意外出现的裂口通常是缺面。

非流形边(non-manifold edge)是局部曲面无法按普通表面解释的边或顶点。最典型情况是一条边同时被三个或更多面使用,或者两个独立表面只在一个顶点相接。它会破坏许多几何操作:边折叠时无法确定折叠影响范围,法线平滑时邻域路径混乱,封闭体积计算时内外侧判断失效。

下面的 Python 片段展示一个导入器如何用索引三角形统计边使用次数。它只验证边的面数关系,目的是定位 boundary 和 non-manifold edge;它还没有处理顶点周围的扇区顺序。

from collections import defaultdict

triangles = [
(0, 1, 2),
(2, 1, 3),
(1, 4, 3),
]

edge_faces = defaultdict(list)
for face_id, tri in enumerate(triangles):
for a, b in ((tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])):
edge = tuple(sorted((a, b)))
edge_faces[edge].append(face_id)

boundary_edges = [edge for edge, faces in edge_faces.items() if len(faces) == 1]
non_manifold_edges = [edge for edge, faces in edge_faces.items() if len(faces) > 2]

print(boundary_edges)
print(non_manifold_edges)

这段代码的关键点是把有向三角边归一化为无向边。渲染管线关心的是三角形顺序和 winding,拓扑审查先关心同一条几何边被多少面引用。对 GuardRailPanel,外边缘和孔洞边缘应该出现在 boundary_edges 中;如果某个孔边缘出现在 non_manifold_edges 中,导入器应把它标记为需要修复或拆分的拓扑错误。

一个稳定判断顺序是:先统计边引用次数,再追踪边界环是否闭合,然后检查顶点一环邻域是否连续,最后结合材质、法线和 UV 接缝判断拆分是否属于渲染需求。这样能把“几何损坏”和“渲染顶点拆分”分开处理。

现象拓扑解释渲染侧信号
孔洞边缘形成闭合边界环设计型 hole轮廓正确,边缘法线可能需要硬化
一条边被三个面引用non-manifold edge法线、碰撞、简化结果容易异常
相同位置出现多个顶点属性拆分或导出重复顶点数上升,接缝处属性可能分离
面的 winding 混乱局部面方向不一致背面剔除、法线方向、阴影出现翻转

拓扑基础最终服务一个结论:网格质量先由元素连接关系决定,再由顶点属性和渲染状态决定。渲染阶段可以把很多数据压成连续数组,但导入阶段仍要保留足够的拓扑信息来判断资产是否可处理。

11.2 顶点/边/面的数据组织方式

实时渲染中的网格数据通常会转换为 vertex buffer 和 index buffer。vertex buffer 存储顶点属性字节,index buffer 存储组成图元的顶点索引。Khronos 的 glTF 2.0 specification 将 mesh 定义为 primitive 数组,primitive 包含属性、索引、材质和 topology mode;这个定义接近实时引擎的 draw call 输入。

顶点在渲染层不是单纯的空间点。一个 render vertex 是一组同时被顶点着色器读取的属性:position、normal、tangent、UV、color、joint、weight 或自定义属性。glTF 规范中的 POSITIONNORMALTANGENTTEXCOORD_nCOLOR_nJOINTS_nWEIGHTS_n 正是这种属性语义的标准化表达。相同 position 在 UV 接缝、硬法线、材质边界或 skin weight 不同的位置会拆成多个 render vertex。

GuardRailPanel 的孔洞边缘提供了一个直接例子。几何上,孔边缘上的一点属于同一个曲面位置;渲染上,孔边缘的正面和倒角面可能需要不同 normal,不同 tangent,甚至不同 UV island。导入器会复制这个 position,生成多个 render vertex,并让不同三角形索引引用不同副本。这样做会提高顶点数量,但能让 shader 获得正确属性。

数据布局常见两类:AoS(Array of Structures)把一个顶点的所有属性连续放在一起,SoA(Structure of Arrays)把 position、normal、UV 等属性分别放在不同数组中。interleaved layout 是 AoS 的常见形式,separate streams 是 SoA 的常见运行时形式。选择布局时要看 shader 读取哪些属性、pass 是否复用 position、平台 vertex fetch 行为和上传更新频率。

布局数据形态适用判断
Interleaved vertexpos normal uv tangent 连续排列一个 pass 同时读取大部分属性,缓存局部性清晰
Separate streamsposition buffer、normal buffer、UV buffer 分离depth pass 只读取 position,动画或 morph 只更新部分属性
Index buffer三角形引用 render vertex顶点复用率高,post-transform cache 有收益
Non-indexed geometry属性按图元顺序线性展开小型临时几何或完全无复用的 procedural 输出

图形 API 会把 shader 输入和 buffer 字节布局绑定在一起。Vulkan 规范的 Fixed-Function Vertex Processing 描述了 vertex shader input attribute、vertex input binding 和 vkCmdBindVertexBuffers 之间的关系:attribute 指向 binding,binding 在 draw 时关联具体 buffer,格式信息决定如何从内存取出并转换数据。这个规则说明 vertex buffer layout 是 shader 契约和 API 状态共同定义的结果。

下面的 C++ 结构只展示一种 interleaved layout。它把 GuardRailPanel 的主要静态属性放在一个顶点结构里,适合普通 forward 或 deferred geometry pass。

struct MeshVertex {
float position[3];
float normal[3];
float tangent[4];
float texcoord0[2];
};

struct MeshBuffers {
MeshVertex* vertices;
uint32_t vertex_count;
uint32_t* indices;
uint32_t index_count;
};

这段代码表达的是 CPU 侧资产构建结果,真实引擎还会把数据上传到 GPU buffer,并用 pipeline state 描述 attribute offset、format 和 stride。tangent[4] 中的第四个分量常用于记录 bitangent 方向符号。选择 uint16_tuint32_t index 要看 render vertex 数量;顶点数量低于 65536 时,16-bit index 能降低 index buffer 带宽。

边和面在最终渲染结构中通常以索引隐含存在。一个 triangle list 的 index buffer 每三个索引构成一个面;边则由三角形内的三对索引推导出来。这个表示对 GPU 高效,但邻接查询成本高。导入器如果要做法线生成、切线生成、简化和拓扑审查,应在 CPU 侧建立额外结构,然后在导出 runtime mesh 时丢弃或压缩这些辅助结构。

一个实用判断是把网格数据分成三份:authoring data、processing data 和 runtime data。authoring data 保留 DCC 语义,processing data 保留拓扑和邻接,runtime data 只保留渲染、碰撞、光照和 streaming 需要的最小数据。混淆这三份数据会让运行时背负导入期结构,也会让导入器缺少几何处理所需的邻接信息。

11.3 半边结构(Half-Edge)与环状数据结构

半边结构(Half-Edge)把一条无向边拆成两条方向相反的 halfedge,并在每条 halfedge 上保存 nexttwinfacevertex 等引用。它解决的问题是稳定遍历网格邻域:沿着一个面的边界前进,跳到相邻面,围绕一个顶点访问一圈面,沿边界环找到孔洞。

CGAL 的 Halfedge Data Structures manual 将 halfedge 描述为组合数据结构,并说明 face 和 vertex 可存储一个 incident halfedge,halfedge 至少提供 next 和 opposite 引用。这类结构的重点是关系查询;几何坐标、法线、材质和自定义属性由上层结构挂载。

GuardRailPanel 的孔洞边缘可以用 half-edge 直接遍历。选中孔边缘上的一条 halfedge,沿 next 前进可以得到该面的边界,沿 twin 可以跳到相邻面;如果某条边没有相邻内部面,导入器可以为它创建 boundary halfedge 或使用 sentinel 标记。这样,孔洞边界环、外轮廓边界环和裂缝都能用同一套遍历逻辑处理。

下面是一个简化 half-edge 结构。它省略内存池和句柄系统,只保留核心引用,适合说明关系。

struct HalfEdge {
uint32_t vertex; // 当前 halfedge 指向的终点
uint32_t face; // 所属面
uint32_t next; // 同一面内下一条 halfedge
uint32_t twin; // 反方向 halfedge
};

struct Face {
uint32_t halfedge;
};

struct VertexTopo {
uint32_t halfedge;
};

这组结构的遍历方式和 index buffer 不同。index buffer 擅长按三角形顺序提交给 GPU;half-edge 擅长回答“从这个元素能到达哪些邻居”。围绕顶点的一环遍历通常从一个 incident halfedge 出发,反复执行 twin 后接 next,直到回到起点或遇到边界。这个环状遍历能支持平滑法线、平均曲率、顶点选择扩展和局部编辑。

边折叠(edge collapse)会把一条边的两个端点合并,并删除或改写周围面。执行前要检查折叠是否产生翻面、退化三角形、材质边界破坏或非流形连接。half-edge 让导入器能在局部邻域内枚举受影响面,更新 nexttwin 和 incident 引用。LOD 简化、碰撞代理生成和自动修复都依赖这种局部更新能力。

边分裂(edge split)会在一条边上插入新顶点,并把相关面拆成更多面。对护栏板孔洞,如果倒角需要更圆滑的轮廓,导入器或建模工具会沿边界插入新顶点。half-edge 能明确找到边两侧的面和边界状态,因此能把新增 halfedge 与旧环正确连接。

边界处理是 half-edge 实现中的核心细节。常见做法有两种:一种为边界创建虚拟 boundary face,让边界也形成可遍历环;另一种使用无效 twin 或特殊标记。虚拟 boundary face 使“沿环遍历”逻辑统一,但会增加结构管理成本;特殊标记更轻量,但遍历代码要显式处理边界分支。

半边结构和 GPU 运行时结构的边界要清楚。half-edge 对编辑、导入、简化和拓扑验证很有价值,直接作为每帧渲染输入会增加随机访问和指针跳转成本。运行时通常把 half-edge 的处理结果烘焙为连续的 vertex/index buffer、cluster、meshlet 或碰撞结构。CPU 编辑结构回答“邻居是谁”,GPU 渲染结构回答“下一批连续字节在哪里”。

11.4 网格加载与存储优化

网格加载的目标是把作者数据转换成运行时可直接使用的数据。对 GuardRailPanel,导入器要完成单位和坐标转换、属性校验、顶点去重、索引生成、法线与切线处理、材质切分、量化压缩、索引重排和 streaming 分块。每一步都应保留可追踪证据,例如输入顶点数、输出 render vertex 数、submesh 数量、index 类型、压缩误差和缓存优化前后指标。

去重(deduplication)先要明确“相同”的定义。几何 position 相同只说明两个点处在同一空间位置;render vertex 去重需要 position、normal、tangent、UV、color、joint、weight 和自定义属性全部满足合并条件。孔洞边缘的硬法线会阻止合并,UV seam 会阻止合并,材质边界通常会把三角形拆到不同 submesh 中。

索引重排(index reorder)服务 post-transform cache。GPU 顶点着色器处理一个 render vertex 后,硬件通常能在短时间内复用变换结果;三角形顺序越能重用相邻顶点,重复 vertex shader 执行越少。meshoptimizer 文档中的 vertex cache optimization 描述了通过重排三角形序列提升局部性的方法,并指出不同 GPU 架构的复用细节存在差异。

切线生成(tangent generation)依赖 position、normal 和 UV。法线贴图在 fragment shader 中通过 tangent space 解释扰动方向,导入器必须让 tangent 与 UV island 和 normal split 对齐。对护栏板孔洞,倒角面和正面如果使用不同 UV island,切线也应随 render vertex 拆分。切线错误的画面信号通常是法线贴图在接缝处翻转或高光方向突变。

量化压缩(quantization)把 float 属性转换为更小的整数表示。例如 position 可以按 mesh bounds 转成 16-bit 或 10-bit 分量,normal 和 tangent 可以用归一化整数或专用编码。量化的判断依据是屏幕误差和 shader 解码成本:静态远景物体更适合压缩,近景硬表面需要控制 position 和 normal 误差。压缩后应记录最大误差和包围盒,方便资产审查。

cache reorder 与 overdraw reorder 要分开判断。前者面向顶点复用,后者面向像素遮挡。meshoptimizer 文档也说明 overdraw 优化可能牺牲部分 vertex cache 效率,并且在某些 tile-based GPU 上收益受限。导入器应把这类优化作为可测量策略:在目标场景里观察 vertex shader 时间、fragment shader 时间、overdraw heatmap 或 GPU counter,再决定是否启用。

Streaming 关注大资产的加载粒度。一个巨型网格如果一次性上传,会增加启动等待和显存峰值;按 chunk、LOD、material 或空间 cluster 切分后,可以按相机距离和可见性逐步加载。切分时要保留边界属性一致性,尤其是法线、切线、lightmap UV 和碰撞代理。否则 chunk 接缝会在光照或阴影中显现。

一个可执行加载顺序如下:先解析格式并建立原始属性视图,再执行拓扑审查,然后生成 render vertex 和 index buffer,随后做 tangent、bounds、LOD 和压缩,最后按 material、LOD、streaming chunk 写入运行时资产。glTF 的 buffer、bufferView 和 accessor 分层也体现了这种数据视图思想:buffer 是二进制 blob,bufferView 是连续片段,accessor 定义类型化读取方式;规范还要求 vertex indices 和 vertex attributes 使用的 buffer view 只包含同一种数据。

下面的伪代码展示导入器的主路径。它强调阶段顺序,省略具体格式解析和 API 上传细节。

ImportedMesh import_mesh(const SourceMesh& source) {
TopologyGraph topo = build_topology_graph(source.positions, source.polygons);
TopologyReport report = audit_topology(topo);

AttributeSet attributes = split_render_vertices(source, report);
TangentSet tangents = generate_tangents(attributes);
IndexBuffer indices = build_indices(attributes);

reorder_for_vertex_cache(indices, attributes.positions);
QuantizedMesh packed = quantize_attributes(attributes, tangents, indices);
return build_runtime_mesh(packed, source.material_slots);
}

这段伪代码的关键判断是先拓扑审查,再渲染顶点拆分。顺序反过来会让导入器把 UV seam、硬法线 split 和非流形问题混在一起,后续很难判断顶点数量上升来自有效属性拆分还是来自脏数据。

11.5 实时引擎中的网格数据组织策略

实时引擎不会用一种结构覆盖所有网格。static mesh、skinned mesh、submesh、material split 和 instance data 分别服务不同运行时约束。组织策略的核心是把更新频率、渲染状态、内存布局和 draw call 边界对齐。

Static mesh 的几何属性在运行时基本固定。GuardRailPanel 的主体适合做 static mesh:position、normal、tangent 和 UV 一次上传到 GPU,帧间只更新 object transform 或 instance data。它的优化重点是压缩、索引重排、LOD、bounds、streaming 和 material split。

Skinned mesh 的顶点位置会随骨骼或形变变化。它需要 joint index、joint weight、bind pose、animation buffer 或 compute skinning 输出。skinned mesh 的拓扑在大多数运行时保持固定,但 vertex shader 或 compute pass 会根据骨骼矩阵计算变形后位置和法线。它的瓶颈经常出现在属性带宽、骨骼矩阵读取和重复 skinning。

Submesh 是一次 mesh 内部按材质、pipeline state、LOD 或索引范围划分出的渲染单元。glTF primitive 与引擎 submesh 很接近:每个 primitive 绑定一组属性、可选索引、材质和 topology mode。护栏板主体和螺栓区域使用不同材质时,应拆成两个 submesh;这样会增加 draw call 或 multi-draw 项,但能让材质状态清晰。

Material split 的代价来自状态切换和批次碎片化。一个 mesh 如果被过度切成很多材质区域,CPU 提交、descriptor 绑定和 GPU pipeline 切换成本会升高。合并材质可以减少提交,但可能增加纹理 atlas、shader 分支或无效像素成本。判断时要看材质数量、可见实例数、pass 数量和平台提交开销。

Instance data 让多个对象共享同一份 mesh buffer,只为每个实例提供 transform、颜色、材质索引或自定义参数。护栏板在道路场景中可能重复出现数百次,实例化能把几何数据复用到多个 draw。instance data 通常放在单独 buffer,更新频率高于静态 vertex buffer;shader 根据 instance id 读取每个实例的 transform 和参数。

下面的结构展示一个简化运行时 mesh。它把几何 buffer、submesh 范围和 instance buffer 分开,便于不同更新频率的数据走不同上传路径。

struct SubmeshRange {
uint32_t first_index;
uint32_t index_count;
uint32_t material_id;
};

struct RuntimeMesh {
GpuBuffer vertex_buffer;
GpuBuffer index_buffer;
SubmeshRange* submeshes;
uint32_t submesh_count;
Bounds local_bounds;
};

struct InstanceData {
float object_to_world[16];
uint32_t material_variant;
};

这个结构把 RuntimeMesh 作为共享资源,把 InstanceData 作为每帧或按需更新的数据。渲染器构建 draw list 时,先按 mesh、material、pipeline 和 pass 组织,再填充 instance range。这样能减少重复上传,保持可见性剔除、LOD 和材质排序的操作空间。

实时引擎的网格组织可以按以下顺序判断。先看顶点是否随帧变化,决定 static mesh 或 skinned mesh。再看材质和 pipeline state,决定 submesh 切分。然后看对象是否大量重复,决定 instance data。最后看资产规模和场景可见性,决定 streaming chunk、LOD 和 cluster。这个顺序从更新频率开始,因为更新频率决定 buffer 生命周期和同步成本。

决策点观察证据常见组织方式
顶点是否变形骨骼、morph、布料、procedural updateskinned mesh 或 dynamic vertex stream
材质是否不同shader、texture、blend、double-sided 状态submesh 或 material range
对象是否重复同一 mesh 多次出现instance buffer
是否需要分批加载资产大、远景多、开放世界chunk、LOD、streaming page
是否需要 GPU-drivendraw 数量大、剔除复杂cluster、meshlet、indirect draw

本章的最终结论是:网格数据结构要按问题分层。拓扑结构服务编辑、验证和转换;连续 buffer 服务 GPU 读取;submesh 和 instance data 服务渲染状态与批处理;LOD、chunk 和压缩服务规模控制。把这些层次拆清楚,才能解释同一个模型为什么在 DCC 工具里是一个物体,在引擎里会变成多份 buffer、多个 submesh 和多条 draw 命令。

最小自检任务

给定一个护栏板网格:它有一个矩形外轮廓、四个螺栓孔、主体金属材质和螺栓边缘材质。导入后发现 render vertex 数量远大于几何 position 数量,并且有一条孔边缘边被三个三角形引用。请判断导入器应如何区分有效属性拆分和拓扑错误,并说明最终运行时应保留哪些结构。

答案要点

先用几何 position 和 polygon index 构建边表或 half-edge,统计每条无向边的 incident face 数量。外轮廓和螺栓孔边缘形成只被一个面使用的 boundary loop,属于可解释边界;一条孔边缘边被三个三角形引用时,应标记为 non-manifold edge,并在导入阶段修复、拆分或拒绝该资产。

再检查 render vertex 拆分来源。硬法线、UV island、tangent split、skin weight 差异和材质边界会让相同 position 生成多个 render vertex,这属于有效属性拆分;它们应被记录为 vertex count 增长原因。非流形边产生的重复点或异常三角形属于拓扑错误,处理前不应直接进入压缩和 cache reorder。

最终运行时通常保留 vertex buffer、index buffer、submesh range、material id、bounds、LOD 或 streaming chunk。half-edge、边表和拓扑报告主要保留在导入产物、调试数据或编辑器缓存中。运行时渲染路径需要连续读取和稳定 draw list;拓扑结构用于资产处理和诊断。

本章知识点总结

  • 拓扑关系:网格拓扑描述顶点、边和面如何相连,决定导入器能否稳定执行法线、切线、简化和修复。
  • 邻接查询:邻接关系服务 CPU 侧几何处理,最终 vertex buffer 通常用连续字节和索引隐含这些关系。
  • 流形边界:内部边通常连接两个面,边界边连接一个面,孔洞由闭合边界环表达。
  • 非流形边:一条边被三个或更多面使用会破坏局部曲面遍历,并影响简化、碰撞和法线生成。
  • 渲染顶点:render vertex 是同一组 shader 输入属性的集合,相同 position 会因法线、UV、切线或材质边界拆分。
  • Buffer 布局:interleaved layout 适合同时读取多数属性的 pass,separate streams 适合不同更新频率或不同 pass 读取需求。
  • Half-edge:半边结构通过 nexttwinfacevertex 引用支持面环、顶点一环和边界环遍历。
  • 编辑结构:half-edge 适合导入、编辑、简化和拓扑验证,运行时渲染通常使用压缩后的连续 buffer。
  • 去重条件:顶点去重应比较完整属性集合,position 相同不足以推出 render vertex 可以合并。
  • 索引重排:index reorder 通过改善顶点复用局部性降低重复 vertex shader 执行。
  • 切线生成:tangent 必须和 UV island、normal split 以及 render vertex 拆分保持一致,接缝错误会表现为高光或法线贴图异常。
  • 量化压缩:属性量化用可接受屏幕误差换取更低带宽和存储成本,近景硬表面需要更严格误差控制。
  • Submesh:submesh 按材质、pipeline state 或索引范围组织一次 mesh 内部的 draw 单元。
  • 实例数据:instance data 让多个对象共享同一 mesh buffer,并把 transform 和变体参数放入独立更新路径。
  • 组织顺序:实时引擎先按顶点更新频率区分 static 和 skinned,再按材质、重复实例和规模控制选择 submesh、instance、LOD 和 streaming。