three.js BatchedMesh 深度指南:用多绘制批次渲染减少 Draw Call
【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js
本篇基于 three.js 官方 API 文档与源码实现,系统讲解BatchedMesh类的使用方式、容量参数、几何体与实例管理 API,以及其底层的间接绘制(multi-draw / indirect draw)渲染管线。读完之后,你将能够:在"同一材质、不同几何体或不同变换"的大规模场景中用BatchedMesh替代成百上千个独立Mesh,掌握addGeometry/addInstance/setMatrixAt等完整 API 的正确用法,理解perObjectFrustumCulled、sortObjects、customSort等渲染属性背后的裁剪与排序机制,以及optimize()的缓冲区重排原理。
BatchedMesh 是什么
BatchedMesh是一种带多绘制批次渲染(multi draw batch rendering)支持的特殊Mesh,继承链为EventDispatcher → Object3D → Mesh → BatchedMesh,实现位于 src/objects/BatchedMesh.js。它的官方定位是:
当你需要渲染大量使用相同材质、但拥有不同几何体或不同世界变换的对象时,请使用这个类。使用
BatchedMesh可以帮助减少 draw call 数量,从而提升应用的整体渲染性能。
它与InstancedMesh的关键区别在于:InstancedMesh要求所有实例共享同一个几何体,而BatchedMesh允许一个批次内混入多个不同的几何体(每个几何体可以有多份实例),并且每个实例还可以拥有独立的颜色(通过setColorAt)与可见性(通过setVisibleAt)。从源码结构看,BatchedMesh把所有子几何体的顶点/索引数据合并进一个大BufferGeometry,再为每个实例维护一份绘制区间(start/count)元数据,渲染时借助渲染器的间接绘制能力一次性提交。
官方在 examples/webgl_mesh_batch.html 和 examples/webgpu_mesh_batch.html 中提供了 WebGL 与 WebGPU 两个可运行的完整示例,可作为本文所有代码的参考实现。
快速上手:完整示例
以下是官方文档给出的标准用法,覆盖"初始化 → 添加几何体 → 创建实例 → 设置矩阵 → 加入场景"的完整流程:
const box = new THREE.BoxGeometry( 1, 1, 1 ); const sphere = new THREE.SphereGeometry( 1, 12, 12 ); const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } ); // 初始化 BatchedMesh 并添加几何体 const batchedMesh = new BatchedMesh( 10, 5000, 10000, material ); const boxGeometryId = batchedMesh.addGeometry( box ); const sphereGeometryId = batchedMesh.addGeometry( sphere ); // 为这些几何体创建实例 const boxInstancedId1 = batchedMesh.addInstance( boxGeometryId ); const boxInstancedId2 = batchedMesh.addInstance( boxGeometryId ); const sphereInstancedId1 = batchedMesh.addInstance( sphereGeometryId ); const sphereInstancedId2 = batchedMesh.addInstance( sphereGeometryId ); // 设置每个实例的局部变换 batchedMesh.setMatrixAt( boxInstancedId1, boxMatrix1 ); batchedMesh.setMatrixAt( boxInstancedId2, boxMatrix2 ); batchedMesh.setMatrixAt( sphereInstancedId1, sphereMatrix1 ); batchedMesh.setMatrixAt( sphereInstancedId2, sphereMatrix2 ); scene.add( batchedMesh );流程可以归纳为三条主线:
- 几何体线:
addGeometry返回geometryId,之后可用setGeometryAt换装、deleteGeometry删除; - 实例线:
addInstance( geometryId )返回instanceId,之后用setMatrixAt/setColorAt/setVisibleAt/deleteInstance操作; - 渲染线:把
batchedMesh像普通Mesh一样加入场景即可,裁剪、排序、间接绘制都发生在渲染器的onBeforeRender钩子里,无需手动干预。
构造函数与容量参数
new BatchedMesh( maxInstanceCount, maxVertexCount, maxIndexCount, material )
| 参数 | 说明 | 默认值 |
|---|---|---|
maxInstanceCount | 计划添加并渲染的实例数量上限 | 必填 |
maxVertexCount | 所有唯一几何体合计占用的顶点数上限 | 必填 |
maxIndexCount | 所有唯一几何体合计占用的索引数上限 | maxVertexCount * 2 |
material | 网格材质,单个Material或材质数组 | 必填 |
注意容量参数的语义:maxVertexCount/maxIndexCount约束的是去重后的几何体集合(相同几何体添加多份实例不会重复占用空间),而maxInstanceCount约束的是实例总数。
从源码看,这些容量值在构造时就直接决定了三张内部DataTexture的布局(_initMatricesTexture、_initIndirectTexture、_initColorsTexture):
- 矩阵纹理
_matricesTexture:RGBAFormat + FloatType,1 个Matrix4恰好占用 4 个像素(RGBA RGBA RGBA RGBA 对应矩阵的 4 列),纹理边长取ceil(sqrt(maxInstanceCount * 4) / 4) * 4且不小于 4。也就是说 16×16 的纹理最多容纳 64 个矩阵,64×64 容纳 1024 个; - 间接纹理
_indirectTexture:RedIntegerFormat + UnsignedIntType,边长ceil(sqrt(maxInstanceCount)),每个像素存一个实例索引,供渲染器把"第 i 次绘制使用第几个实例的矩阵"传入顶点着色器; - 颜色纹理
_colorsTexture:惰性创建,仅在首次调用setColorAt时才分配,初始全白,避免无需求时浪费显存。
maxIndexCount省略时的默认行为在构造函数签名中直接体现:constructor( maxInstanceCount, maxVertexCount, maxIndexCount = maxVertexCount * 2, material )(见 src/objects/BatchedMesh.js#L192)。
容量不足时,addInstance会抛出Maximum item count reached错误;预留空间超限的addGeometry会抛出Reserved space request exceeds the maximum buffer size错误。两者都可以通过事后扩容/缩容 API(见下文)调整。
属性总览
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
boundingBox | Box3 | null | 批次的包围盒,需通过computeBoundingBox()显式计算 |
boundingSphere | Sphere | null | 批次的包围球,需通过computeBoundingSphere()显式计算 |
customSort | Function | null | 渲染前执行的自定义排序函数,接收待排序实例列表(每项含z深度字段)和相机 |
instanceCount | number(只读) | — | 当前实例数量 |
isBatchedMesh | boolean(只读) | true | 类型测试标志 |
maxInstanceCount | number(只读) | — | 批次可存储的最大实例数 |
perObjectFrustumCulled | boolean | true | 是否对批次内的单个对象做视锥剔除 |
sortObjects | boolean | true | 是否对批次内的对象排序以改善 overdraw 相关瑕疵;材质标记为transparent时按远到近渲染,否则按近到远渲染 |
unusedIndexCount | number(只读) | — | 未使用的索引数量 |
unusedVertexCount | number(只读) | — | 未使用的顶点数量 |
其中perObjectFrustumCulled与sortObjects直接对应渲染管线中的两个分支:两者都为true时,每帧渲染前都会遍历实例做视锥测试和深度排序;若可见性未变化且两者均为false,则整个onBeforeRender直接短路返回(见 onBeforeRender)。对于静态大批次,关闭这两项可以省掉每帧的遍历开销。
几何体管理:addGeometry / setGeometryAt / optimize
.addGeometry( geometry, reservedVertexCount, reservedIndexCount ) : number
把几何体加入批次并返回geometryId,供其他函数使用。
- geometry:要添加的
BufferGeometry; - reservedVertexCount(可选,默认
-1):为该几何体预留的顶点缓冲区空间。如果计划稍后用比原几何体更大的几何体替换这个槽位,就必须在这里预留足够的空间;默认取所给几何体顶点缓冲区的长度; - reservedIndexCount(可选,默认
-1):索引缓冲区预留空间,规则同上;默认取所给几何体索引缓冲区的长度。
源码中的三条硬约束(_validateGeometry):
- 索引必须一致——批次内所有几何体要么都有
index,要么都没有,否则抛出All geometries must consistently have "index"; - 属性必须一致——后加入的几何体必须包含首批次几何体已有的全部属性,且各属性的
itemSize与normalized必须一致,否则抛出All attributes must have a consistent itemSize and normalized value; - 预留空间不能越界——
indexStart + reservedIndexCount > maxIndexCount或vertexStart + reservedVertexCount > maxVertexCount时抛出Reserved space request exceeds the maximum buffer size。
值得注意的实现细节:addGeometry内部会把索引值平移为该几何体在合并顶点缓冲区中的偏移量(vertexStart + srcIndex.getX(i),见 setGeometryAt),预留区中未被实际几何体使用的索引会填充为指向vertexStart的退化三角形,保证空闲区间渲染出来不产生任何可见像素。
.setGeometryAt( geometryId, geometry ) : number
用新几何体替换指定 ID 的几何体。若预留空间不足则抛出错误;调用它会改变所有正在渲染该几何体的实例——这是"几何体去重"设计的直接推论:同一个geometryId被多少份实例引用,就会有多少份实例的外观一起更新(例如可用来做动画帧序列)。
.deleteGeometry( geometryId ) : BatchedMesh
删除该几何体,引用它的所有实例也会被一并删除(副作用,见 deleteGeometry)。删除是"软删除":geometryId进入空闲池,之后再次addGeometry时会按 ID 升序优先复用最小的空闲 ID。
.optimize() : BatchedMesh
重排批次内的子几何体,回收已删除几何体留下的空隙,从而腾出空间添加新几何体。从 optimize 实现 看,它按vertexStart排序所有活跃的几何体区间,用array.copyWithin把顶点/索引数据向前压实,并同步修正索引指针的偏移量(elementDelta)。如果批次生命周期内会频繁增删几何体,建议定期调用optimize()防止"碎片化"导致unusedVertexCount偏小而实际空间不足。
扩容与缩容:setInstanceCount / setGeometrySize
.setInstanceCount( maxInstanceCount ):调整实例容量。会先压缩末尾已释放的实例槽位;若目标值小于仍有实例占用的 ID 范围,抛出Instance ids outside the range ... are being used. Cannot shrink instance count。调整后矩阵/间接/颜色三张纹理都会按新容量重建并拷贝旧数据(setInstanceCount)。仓库单元测试 test/unit/src/objects/BatchedMesh.tests.js 正是验证了这个"删除实例后收缩容量"的场景:4 个实例删 2 个再setInstanceCount( 2 ),断言instanceCount === 2;.setGeometrySize( maxVertexCount, maxIndexCount ):调整顶点/索引缓冲区容量。若存在活跃区间的vertexStart + reservedVertexCount(或索引对应值)超出新上限,抛出Cannot shrink further类错误。内部会 dispose 旧几何体、按旧几何体的属性布局重建并整体拷贝数据(setGeometrySize)。
实例管理:addInstance 与各类 set/get
.addInstance( geometryId ) : number
用已注册的几何体创建新实例,返回instanceId。实现上(addInstance):达到maxInstanceCount且没有可回收的已删除 ID 时抛出Maximum item count reached;有空闲 ID 时按升序优先复用。新建实例的矩阵在矩阵纹理中初始化为单位矩阵,颜色初始化为白色。
矩阵:.setMatrixAt( instanceId, matrix ) / .getMatrixAt( instanceId, matrix )
设置/读取单个实例的局部变换(相对batchedMesh本身)。注意文档明确声明:不支持负缩放的矩阵(negatively scaled matrices are not supported),因为这会翻转绕序导致背面剔除逻辑异常。实现上就是把 16 个 float 写入矩阵纹理对应 4 个像素并标记needsUpdate(setMatrixAt)。
颜色:.setColorAt( instanceId, color ) / .getColorAt( instanceId, color )
color既可以是Color(RGB),也可以是Vector4(RGBA,附带 alpha)。首次调用setColorAt时惰性创建颜色纹理;未设置过颜色时getColorAt返回全白(1,1,1[,1])。
可见性与几何绑定
.setVisibleAt( instanceId, visible )/.getVisibleAt( instanceId ):控制单个实例的可见性,等价于InstancedMesh中"隐藏某个实例"的能力,且不会把已删除实例误标为可见;.setGeometryIdAt( instanceId, geometryId )/.getGeometryIdAt( instanceId ):在实例级别动态换绑几何体,无需销毁重建实例;.validateInstanceId( instanceId )/.validateGeometryId( geometryId ):对越界或已删除的 ID 抛出明确错误,各get/set方法内部都会先调用它,帮助你在开发期尽早发现 ID 管理错误。
删除与区间查询
.deleteInstance( instanceId ) : BatchedMesh:软删除,ID 进入可复用池;.getGeometryRangeAt( geometryId, target ) : Object:返回该几何体在合并缓冲区中的区间数据,字段包括vertexStart / vertexCount / reservedVertexCount / indexStart / indexCount / reservedIndexCount / start / count(见 getGeometryRangeAt),可用于自定义渲染、LOD 或与其他系统交换元数据。
包围盒与包围球
.computeBoundingBox()/.computeBoundingSphere():默认均为null,需显式计算。实现是把每个活跃实例的几何体包围体经实例矩阵变换后求并集(computeBoundingBox),可用于整体视锥剔除或拾取粗筛;.getBoundingBoxAt( geometryId, target )/.getBoundingSphereAt( geometryId, target ):返回单个几何体的包围体,未找到对应 ID 时返回null;首次调用时会按该几何体的索引/顶点区间惰性计算并缓存,此后直接复用。
渲染管线深入:裁剪、排序与间接绘制
BatchedMesh的核心价值体现在每次绘制前的 onBeforeRender 钩子中,它完成了从"实例元数据"到"多绘制指令"的转换:
- 短路优化:若可见性自上次以来没有变化,且
perObjectFrustumCulled与sortObjects均为false,直接返回,不遍历任何实例; - 视锥剔除(可选):当
perObjectFrustumCulled为true时,在批次局部坐标系下构造视锥(数组相机会用FrustumArray处理 XR 多视图),对每个实例的世界空间包围球做intersectsSphere测试,剔除视野外的实例; - 深度排序(可选):当
sortObjects为true时,把通过剔除的实例压入带对象池的MultiDrawRenderList,每项记录(start, count, z, index),其中z是包围球中心沿相机前向的投影距离。默认排序函数是:- 不透明材质:
sortOpaque,a.z - b.z,近到远——提前写入深度,减少后续片元的 overdraw; - 透明材质:
sortTransparent,b.z - a.z,远到近——保证 alpha 混合的正确叠加顺序; - 若设置了
customSort,则由你的函数(list, camera) => ...接管排序,列表中每项的z字段可用于深度排序或自定义规则(如按材质批次分组);
- 不透明材质:
- 写出多绘制指令:把排序结果写入
_multiDrawStarts(Int32Array,索引偏移,索引模式下换算为字节偏移)与_multiDrawCounts(Int32Array,索引数量),同时把"第 i 次绘制对应第几个实例"写入间接纹理的indirectArray,供顶点着色器采样该实例的矩阵; - 线框兼容:当
material.wireframe为true时,渲染器会把三角形隐式转换为每三角形 3 条线段的线属性,因此multiDrawMultiplier = 2(顶点数×2 的索引空间换算),并按顶点数是否超过 65535 选择 2 字节或 4 字节的字节步长。
此外onBeforeShadow会直接委托给onBeforeRender(使用阴影相机),意味着投影中的阴影渲染同样享受批量化的裁剪与排序,而不会回退到逐实例绘制。
射线检测:raycast 的批量支持
BatchedMesh重写了 raycast:遍历所有可见且活跃的实例,用setDrawRange( geometryInfo.start, geometryInfo.count )让一个共享的临时Mesh逐实例复用父类的三角形求交逻辑,命中结果的intersect.object指向batchedMesh本身,并附带intersect.batchId字段标识命中了哪个实例。因此点击拾取的用法是:
raycaster.intersectObject( batchedMesh ); // 命中后 intersect.batchId 即为实例 ID注意这是 CPU 端逐实例检测,实例数量极大时可先利用computeBoundingSphere的结果做粗筛。
生命周期管理:copy 与 dispose
copy( source ):深拷贝几何体、包围体、几何/实例信息表、空闲 ID 池,并克隆矩阵/间接/颜色三张纹理且拷贝其像素数据(copy),可安全用于场景快照或对象克隆;dispose():释放几何体与三张内部纹理的 GPU 资源。文档明确要求"当该实例不再被应用使用时调用此方法"。若几何体与外部Mesh共享,需自行确认geometry.dispose()的副作用。
实战注意事项清单
结合源码与文档,以下是使用BatchedMesh时的关键约束与最佳实践:
- 容量按峰值规划:构造时按最大实例数、最大唯一几何体集合的顶点/索引总量预留;运行中不够用再
setInstanceCount/setGeometrySize扩容(会重建纹理并拷贝数据,有额外开销); - 几何体属性要统一:首批次加入的几何体定义了整批的属性布局,后续几何体必须提供相同的属性集合与
itemSize;索引有无必须全批一致; - 预留空间是换装的前提:计划运行时用
setGeometryAt换更大几何体(如骨骼动画的顶点膨胀、LOD 高模替换)时,addGeometry阶段就必须传入足够的reservedVertexCount/reservedIndexCount; - 不要使用负缩放:
setMatrixAt的文档明确声明不支持负缩放矩阵; - 删除后记得重排:
deleteGeometry只回收 ID 不回收缓冲区空间,unusedVertexCount统计的是尾部连续空闲量;长时间运行的场景建议周期性optimize(); - 静态场景关闭裁剪与排序:
perObjectFrustumCulled = false; sortObjects = false;且可见性不再变化时,onBeforeRender直接短路,零遍历开销; - 透明批次注意排序方向:
transparent材质自动改为远到近渲染,如需特殊混合策略可用setCustomSort接管; - 类型检测:用
object.isBatchedMesh做instanceof之外的轻量类型判断; - 与 InstancedMesh 的选型:几何体单一、实例海量且需要 GPU 端实例属性动画时优先
InstancedMesh;几何体种类多(每种几何体 2~N 份实例)、需要逐实例颜色/可见性/射线batchId拾取时优先BatchedMesh; - 完整示例参考:examples/webgl_mesh_batch.html 演示了带混合几何体与动态增删实例的完整场景,examples/webgpu_mesh_batch.html 展示 WebGPU 后端下的等价行为,另有 examples/webgl_batch_lod_bvh.html 展示与 LOD、BVH 加速结构配合的用法。
参考
- API 文档源文件:docs/pages/BatchedMesh.html.md
- 核心实现:src/objects/BatchedMesh.js
- 单元测试:test/unit/src/objects/BatchedMesh.tests.js
- 运行示例:examples/webgl_mesh_batch.html、examples/webgpu_mesh_batch.html
【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考