网格数据与GPU访问
全库目录221
本文目录16
网格不是一组孤立的三角形。它是一套拓扑、顶点属性和索引数据。DCC 里的一个“点”进入引擎后,可能因为 UV 缝、硬边或材质边界拆成多个 GPU 顶点。
Point、Vertex 与 Primitive
不同软件对这些词的定义不完全相同。理解时先看属性属于哪一层:
- Point 表示共享空间位置,Houdini 常把位置
P和可共享属性放在这里; - Vertex 是一个 Primitive 对 Point 的引用,适合存每个面角不同的 UV、法线;
- Primitive 表示三角形、多边形或曲线段,可保存材质 ID、组和面属性;
- Detail 是整份几何只有一份的数据,例如导出设置或管线元数据。
GPU Vertex Buffer 通常没有 Houdini 这种分层。导出器要把所需属性摊平成顶点记录。在传统单索引顶点输入中,同一位置对应的法线、切线、UV或颜色不同,就需要不同顶点记录。Vertex Pulling可分别索引属性,属于另一种数据布局。材质边界若只拆Draw且顶点属性相同,并不必然复制顶点。
因此 DCC 显示的点数不等于引擎顶点数。检查资源预算时应看导入后的 Vertex Count,而不是只看建模软件里的面数。
面角属性展开
导出器按图元的每个面角读取最终属性,构造完整键 (position, normal, tangent, uv, color, skin...)。哈希表中已有相同键时复用索引,否则追加顶点记录。只以位置作为键会把UV缝和硬边错误焊接;对浮点使用容差合并则可能改变遮罩、蒙皮或边界,应先定义允许误差再量化。
vertices = []; indices = []; lookup = {}
for corner in triangleCorners:
key = 完整的已转换、已编码顶点属性
if key not in lookup:
lookup[key] = len(vertices)
vertices.append(key)
indices.append(lookup[key])空间转换和编码应先统一。例如导出后UV不同的两个角,即使位置相同也保留两条记录;同一顶点被重复引用则共享索引。CPU实现flatten_corners以精确属性键处理这两种情况,工程中还需保留材质分组和拓扑映射。
Vertex Buffer 与 Index Buffer
Vertex Buffer 以固定步长保存 Position、Normal、Tangent、UV、Color、Bone Index/Weight 等属性。Index Buffer 用索引组合三角形,使相邻三角形复用相同顶点。
没有索引时,两个共享边的三角形需要提交 6 个顶点。使用索引后,矩形通常只需要 4 个顶点和 6 个索引。复用能减少顶点数据和 Vertex Shader 重复执行,但前提是两个面角的全部顶点属性一致。
16位索引有65536个编码值;启用使用固定最大值的Primitive Restart时,保留编码不能再作为普通顶点索引。实际可访问范围还取决于Base Vertex及API约束。16位存储通常占用更小。更大的网格需要 32 位索引或拆成多个 Section。拆分也会影响 Draw Call、剔除粒度和材质批次。
顶点布局
交错布局把同一顶点的属性连续存放,适合一次绘制读取完整属性。分离布局把位置、法线、UV 放入独立 Buffer,适合只读取部分属性的 Pass,例如仅位置的 Depth Prepass。
选择取决于使用方式:
- 主材质 Pass 通常需要大部分属性,交错布局读取直接;
- Shadow、Depth 或计算任务可能只需要 Position;
- 动态更新位置时,分离静态属性可减少上传量;
- 压缩 Normal、Tangent、UV 能省带宽,但 Shader 需要解码。
布局变化也会改变 Pipeline 输入状态。不要为了理论上的字节节省制造大量难以合批的格式。
布局偏移检查
下面的交错结构把位置、压缩法线和半精度 UV 放在一个 20 字节记录中。Input Layout 的偏移应分别为 0、12、16:
struct VertexGPU
{
float position[3];
uint32_t normalR10G10B10A2;
uint16_t uvHalf[2];
};
static_assert(offsetof(VertexGPU, position) == 0);
static_assert(offsetof(VertexGPU, normalR10G10B10A2) == 12);
static_assert(offsetof(VertexGPU, uvHalf) == 16);
static_assert(sizeof(VertexGPU) == 20);这段C++需要<cstdint>和<cstddef>;布局断言通过仍要由API配置相同格式。以R10G10B10A2_UNORM为例,xyz读取为[0,1]后再映射到[-1,1]并归一化,不能当成直接存储的有符号法线;UV应按R16G16_FLOAT读取。API侧还要使用与压缩数据匹配的顶点格式。只让结构体大小正确,不能保证 Shader 对每个字段的格式解释正确。
Buffer 更新、映射与复制
静态 Buffer 适合创建后长期只读;每帧变化的数据应按更新范围和是否与 GPU 重叠选择路径:
glBufferSubData更新明确子区,适合少量、位置稳定的修改;glMapBufferRange把一段存储映射给 CPU,适合批量写入或环形分配;glCopyBufferSubData在 GPU Buffer 之间复制,避免把数据读回 CPU;- 重新提交整个存储可以让驱动分配新后备区,避免等待仍在使用旧数据的 Draw。
下面的环形写入只说明范围与同步契约。writeOffset 指向当前帧拥有的区间,写入字节数不能跨过分配边界:
void* dst = glMapBufferRange(
GL_ARRAY_BUFFER, writeOffset, byteCount,
GL_MAP_WRITE_BIT |
GL_MAP_INVALIDATE_RANGE_BIT |
GL_MAP_UNSYNCHRONIZED_BIT);
if (dst == nullptr) { /* 记录映射失败并终止本次上传 */ return; }
std::memcpy(dst, source, byteCount);
if (glUnmapBuffer(GL_ARRAY_BUFFER) == GL_FALSE) {
/* 数据已损坏,重新建立有效内容后再提交绘制 */
return;
}GL_MAP_UNSYNCHRONIZED_BIT 表示应用保证这段存储不被 GPU 使用;它不会替应用解决冲突。安全做法是为飞行帧划分独立区间,并用 Fence 确认区间可以复用。在采用隐式同步的更新路径中,映射长时间等待可能来自CPU申请的区域仍被GPU占用;本片段请求不做这项同步,重叠使用时可能直接损坏结果,不能用“没有等待”证明安全。使用模式提示描述预期访问,真正的分配位置和重命名策略由实现决定,不能仅凭 STATIC 或 DYNAMIC 推断性能。
Post-transform Vertex Cache
GPU 处理一个索引后,会短暂缓存 Vertex Shader 输出。如果后续三角形很快再次引用这个索引,就能复用结果。
三角形重排保留拓扑,却会影响缓存命中。对透明混合、Primitive ID或原子副作用敏感的路径,还必须保留相应语义。网格优化工具会重排三角形和顶点,使空间相邻的三角形在索引序列中也尽量相邻。常见观察指标是ACMR,即每个三角形的新顶点变换次数。用容量3的LRU教学缓存处理索引[0,1,2,2,1,3]时有4次未命中、2个三角形,ACMR=2。真实GPU的复用机制与容量由硬件决定,软件LRU只能比较局部性,不能冒充计数器。
顶点缓存优化与像素 Overdraw 的目标可能冲突:
- 按局部拓扑排序有利于顶点复用;
- 按 Front-to-back 或 Cluster 排序可能更有利于 Early-Z;
- 大场景还要考虑 Meshlet、剔除和流送粒度。
应按实际瓶颈选择,不存在对所有 Pass 都最优的唯一顺序。
法线、硬边与 UV 缝
平滑法线会在相邻面之间共享或平均方向。硬边需要同一位置保存不同法线,因此会拆顶点。UV 缝同理。
切线空间还依赖 UV 方向和手性。引擎与烘焙器应采用兼容的切线基,例如 MikkTSpace。否则即使 Normal Map 相同,也可能在接缝处出现明暗断裂。
硬边通常应配合 UV 缝。若一个顶点在几何上是硬边,却让两侧共享同一套烘焙纹理插值,Normal Map 很难稳定补偿突变。
简化网格的误差与数据约束
简化不是随机删三角形。目标是在减少几何的同时控制形状误差、轮廓、UV、法线和蒙皮误差。
Quadric Error Metrics(QEM)会为平面约束建立二次误差。边折叠时,把两个顶点合并到新位置,并估计新位置到相关平面的累计误差:
其中齐次位置 ,, 是相邻平面的归一化平面方程。优先折叠误差较小的边,可以逐步得到低模。
折叠点求解
将合并后的4×4对称矩阵Q分成左上3×3矩阵A、末列前三项b和末项c,误差可写成 E(x)=xᵀAx+2bᵀx+c。梯度为 2Ax+2b,因此可逆时求解 Ax=-b。矩阵奇异或病态时,可分别计算边的两个端点及中点误差,选择最低者;这只是候选退化方案,不能保证连续域最优。
例如只有平面x=1与z=2,点(2,3,4)的误差为1²+2²=5,y没有约束,因此A必然奇异。实现应识别这种平坦方向,避免把不稳定逆矩阵产生的远点写入网格。平面法线归一化后误差单位是长度平方;面积加权时还要明确额外权重。
每次折叠更新邻接面和边候选,并拒绝面翻转、退化、非流形或被保护边界破坏。优先队列中的旧候选需要版本验证,不能在邻域变化后直接复用旧误差。
实际工具还要保护:
- 外轮廓和明显折角;
- UV 岛边界与材质边界;
- 骨骼权重和关节附近形变;
- 需要保留的 Socket、Pivot 和碰撞关系;
- 植被枝干等影响剪影的结构。
重拓扑与烘焙输入
Retopology 解决的是可变形拓扑、面密度和运行时预算。高模细节通常通过 Normal、Height、AO 或 ID Map 烘焙到低模。
Baking 本质是从低模表面沿 Cage 或投射方向查找高模表面,再把高模属性写入低模纹理。常见错误包括:
- Cage 穿入高模,投射到错误表面;
- UV 岛 Padding 不足,Mipmap 后串色;
- 高低模命名或分组错误,互相投射;
- 切线空间设置与引擎不一致;
- 镜像 UV 的切线手性处理错误。
渲染数据与场景查询的契约
Bounds 是剔除、LOD、流送和空间查询的基础。静态网格通常可以离线计算紧致 AABB;蒙皮、顶点位移和 GPU 程序化动画若超出导入时 Bounds,物体会在仍可见时被错误剔除。
解决方法不是无限放大 Bounds。过大的范围会降低视锥和遮挡剔除效率。应根据动画极值、风动幅度或位移参数计算保守但有限的范围,并用运行时可视化检查。
一个网格按材质拆成多个 Submesh,通常意味着多个 Draw。把材质 ID 合并进同一 Shader 可以减少 Submesh,但会增加纹理数组、分支或实例数据复杂度。应同时看 Draw、材质变体和像素成本。
渲染网格也不应直接等同于碰撞网格:
- Hero Asset 可以使用手工低模碰撞体;
- 静态环境可使用简化 Mesh Collider;
- 动态刚体优先使用少量凸包组合;
- 复杂的逐三角形碰撞会增加构建、内存和查询成本。
渲染数据能画对以后,还要检查它与碰撞、挂点怎样对应。Pivot 决定放置基准,Bounds 决定剔除,Socket 提供挂接变换。它们一旦使用了不同单位或空间,画面可能正常,挂接和查询却会偏移。因此导入器要把这些数据一起转换,再用一个带挂点和碰撞体的简单资产核对。
程序化输入的属性契约
Houdini节点网络以几何与属性构成可重复的数据流。可交付的HDA应明确:
- 输入几何、曲线和参数的单位与坐标系;
- 必需属性及其 Point/Vertex/Primitive/Detail 归属;
- 输出组、材质路径、碰撞和 LOD 命名;
- 随机种子和确定性;
- 非法输入的报错方式。
尽量用 Group、Attribute 和参数表达选择,避免依赖一次手工 Edit 的硬编码点号。拓扑变化后,点号会改变,后续节点就可能选错对象。
检查方法
- 在引擎导入器查看实际 Vertex/Index Count、Submesh 和 Index Format。
- 用 Frame Debugger 或 RenderDoc 检查绑定的 Buffer、Stride 和 Vertex Input。
- 对比 DCC 点数与引擎顶点数,定位硬边、UV 缝和材质边界产生的拆分。
- 检查简化前后的剪影、法线、UV、蒙皮和碰撞,不只比较面数。
- 用固定摄像机捕获不同 LOD,做差分图和 GPU 时间对照。
CPU参考flatten_corners、cache_misses和quadric_error分别检查UV缝拆分、LRU计数与平面平方误差,见 reference.py,运行 python -B 知识库/examples/revision_06_08/test_reference.py。这些模型未执行网格简化器、C++布局编译、OpenGL映射或GPU捕获。
相关主题
参考资料
- SideFX,Geometry attributes:2026-10-22核读Houdini22.0的Geometry components和Attribute precedence。
- Garland与Heckbert,Surface Simplification Using Quadric Error Metrics:2026-10-22核读1997论文第4节、公式(1)及奇异矩阵回退;本文两平面反例独立复算。
- Khronos glMapBufferRange:写入、Invalidate与Unsynchronized的调用约束;应用负责避免重叠GPU使用。
- MikkTSpace源代码:切线空间生成实现入口,具体导出器应固定采用版本。