技术博客

MultiMesh 的 GPU 实例化上传 —— 一次 draw call 画一万棵树

第 149 / 149 篇 · 2025-11-12 · godot · 渲染管线实例化性能优化

森林里一万棵树如果是一万个 MeshInstance3D,draw call 和 uniform 更新都是灭顶之灾。MultiMesh 的标准答案:每实例数据(变换、颜色、自定义参数)打包进一块大缓冲,一次 instanced draw 全部画完,CPU 每帧成本是 $O(\text{改动的实例数})$ 而不是 $O(\text{总数})$。这篇拆 4.x RenderingServer 的 multimesh 实现:缓冲布局、更新路径、裁剪粒度。

实例缓冲的内存布局

RenderingServer::multimesh_allocate() 按 instance_count × format 分配一块 buffer,RD 后端是 RenderingDevice::STORAGE_BUFFER(SSBO),布局由变换格式决定:

TRANSFORM_3D(默认):每实例 48 字节 = 3×vec4(3×4 矩阵,行主序,w 分量留给 shader 用)
TRANSFORM_2D:        每实例 24 字节 = 2×vec4(2×3 矩阵)
+ COLOR(16B) / CUSTOM_DATA(16B) 可选

选 3×4 而不是数学上的 4×4 是对齐与带宽的权衡:实例缓冲是全顶点着色器都要读的热点数据,少 16 字节/实例就是 1 万实例省 160KB 每帧的读取;最后一行 (0,0,0,1) 是常量,shader 里展开成 mat4 时手动补。custom_data 的 4 个 float 是给用户的任意通道(粒子大小、风速、随机种子……),shader 里 INSTANCE_CUSTOM 读取。

上传路径分两档:部分更新(multimesh_instance_set_transform 等单实例 API)走 RD::buffer_update 的 offset 写入,只碰该实例的 48 字节;全量重建(multimesh_set_buffer 一次性给 PackedByteArray)走 staging buffer 整块替换。前者是热路径——每帧移动几百个实例时成本是几百次 48 字节的 memcpy + 合并后的 DMA,和实例总数解耦。

顶点着色器侧:instancing 的零成本秘密

instance 数据进顶点着色器靠 Vulkan 的 gl_InstanceIndex(SV_InstanceID):vertex shader 里用 instance_index 从 SSBO 取该实例的矩阵行,VERTEX = (model_matrix * instance_matrix * vec4(vertex,1)).xyz。所有实例共享同一份顶点缓冲、同一份 index buffer、同一个 pipeline——GPU 内部把 draw 摊成硬件级别的实例循环,没有 per-instance 的 driver 开销。一万棵树就是 vkCmdDrawIndexed(count, 10000, 0, 0, 0) 这一条命令。

shader 里访问用 instance_data = multimesh.data[gl_InstanceIndex],instance_count 本身是 push constant。这套路径在 Godot 的 scene_forward_clustered.glsl 里是宏分支——USE_MULTIMESH 开时 instance 矩阵替代 instance uniform。

裁剪粒度:instance 不能逐个 cull

这是 MultiMesh 最常误解的一点:视锥剔除的粒度是整个 multimesh 的 AABB,不是逐实例。所有实例共享一个包围盒(所有实例变换的并集),相机看不到整个盒子就整批跳过——但盒子的 90% 在视锥外、10% 在内时,全部一万个实例都过顶点着色器。Godot 不帮你做 per-instance culling(visible_instance_count 只能截前 N 个)。

工程上的标准解法是把大场景切成多个中等 multimesh(每几百到一千实例),让每个的 AABB 足够紧——视锥剔除粒度变细,总 draw call 从 1 涨到几十,仍然远低于独立 MeshInstance。另一个思路是 GPU culling(compute shader 逐实例写可见性列表 + indirect draw),Godot 4.x 没内置,得自己写 RenderingDevice 扩展,适合十万实例以上的极端场景。

和粒子系统的关系

GPUParticles3D 的默认路径就是 MultiMesh:粒子模拟在 compute shader 里写出每个粒子的 transform,粒子渲染用 MultiMesh 实例化。所以 MultiMesh 的缓冲更新成本模型(部分更新 SSBO + instanced draw)正是粒子系统每帧做的事——理解了 multimesh 就理解了 GPUParticles 的渲染半侧(模拟半侧见粒子篇)。

一图流

MultiMesh:一个 SSBO + 一次 instanced draw 实例 SSBO(N×48B) #0 mat3x4 + color? #1 mat3x4 + color? …… 顶点着色器 gl_InstanceIndex 取矩阵 共享顶点/index 缓冲 同 pipeline 循环 N 次 1 次 draw call N 个实例 剔除粒度 = 整个 AABB;per-instance culling 需自行切分或 GPU indirect draw

实战推论

  • 实例数不是瓶颈,状态才是。一万实例 1 个 draw call 的前提是单 mesh + 单 material;多 surface 的 mesh 会退化成 surface 数 × 1 个 draw。
  • 每帧只调用 multimesh_instance_set_transform 改动过的实例;全量 set_buffer 适合一次性初始化,每帧用它等于放弃 MultiMesh 的全部意义。
  • AABB 紧密度决定实际 GPU 负载:移动实例后记得 multimesh_set_custom_aabb(如果实例活动范围已知且紧凑),比让引擎自动算并集更省。

本文基于 Godot 4.x 源码:servers/rendering/renderer_rd/storage_rd/mesh_storage_rd.cpp(multimesh_allocate / instance_set_transform / set_buffer)、servers/rendering/renderer_rd/shaders/scene_forward_clustered.glsl(USE_MULTIMESH 分支)、scene/resources/multimesh.cpp(用户 API 到 RS 的转发)。