news 2026/9/5 20:45:52

three.js BatchedMesh 深度指南:用多绘制批次渲染减少 Draw Call

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
three.js BatchedMesh 深度指南:用多绘制批次渲染减少 Draw Call

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 的正确用法,理解perObjectFrustumCulledsortObjectscustomSort等渲染属性背后的裁剪与排序机制,以及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 );

流程可以归纳为三条主线:

  1. 几何体线addGeometry返回geometryId,之后可用setGeometryAt换装、deleteGeometry删除;
  2. 实例线addInstance( geometryId )返回instanceId,之后用setMatrixAt/setColorAt/setVisibleAt/deleteInstance操作;
  3. 渲染线:把batchedMesh像普通Mesh一样加入场景即可,裁剪、排序、间接绘制都发生在渲染器的onBeforeRender钩子里,无需手动干预。

构造函数与容量参数

new BatchedMesh( maxInstanceCount, maxVertexCount, maxIndexCount, material )

参数说明默认值
maxInstanceCount计划添加并渲染的实例数量上限必填
maxVertexCount所有唯一几何体合计占用的顶点数上限必填
maxIndexCount所有唯一几何体合计占用的索引数上限maxVertexCount * 2
material网格材质,单个Material或材质数组必填

注意容量参数的语义:maxVertexCount/maxIndexCount约束的是去重后的几何体集合(相同几何体添加多份实例不会重复占用空间),而maxInstanceCount约束的是实例总数

从源码看,这些容量值在构造时就直接决定了三张内部DataTexture的布局(_initMatricesTexture、_initIndirectTexture、_initColorsTexture):

  • 矩阵纹理_matricesTextureRGBAFormat + FloatType,1 个Matrix4恰好占用 4 个像素(RGBA RGBA RGBA RGBA 对应矩阵的 4 列),纹理边长取ceil(sqrt(maxInstanceCount * 4) / 4) * 4且不小于 4。也就是说 16×16 的纹理最多容纳 64 个矩阵,64×64 容纳 1024 个;
  • 间接纹理_indirectTextureRedIntegerFormat + 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(见下文)调整。

属性总览

属性类型默认值说明
boundingBoxBox3null批次的包围盒,需通过computeBoundingBox()显式计算
boundingSphereSpherenull批次的包围球,需通过computeBoundingSphere()显式计算
customSortFunctionnull渲染前执行的自定义排序函数,接收待排序实例列表(每项含z深度字段)和相机
instanceCountnumber(只读)当前实例数量
isBatchedMeshboolean(只读)true类型测试标志
maxInstanceCountnumber(只读)批次可存储的最大实例数
perObjectFrustumCulledbooleantrue是否对批次内的单个对象做视锥剔除
sortObjectsbooleantrue是否对批次内的对象排序以改善 overdraw 相关瑕疵;材质标记为transparent时按远到近渲染,否则按近到远渲染
unusedIndexCountnumber(只读)未使用的索引数量
unusedVertexCountnumber(只读)未使用的顶点数量

其中perObjectFrustumCulledsortObjects直接对应渲染管线中的两个分支:两者都为true时,每帧渲染前都会遍历实例做视锥测试和深度排序;若可见性未变化且两者均为false,则整个onBeforeRender直接短路返回(见 onBeforeRender)。对于静态大批次,关闭这两项可以省掉每帧的遍历开销。

几何体管理:addGeometry / setGeometryAt / optimize

.addGeometry( geometry, reservedVertexCount, reservedIndexCount ) : number

把几何体加入批次并返回geometryId,供其他函数使用。

  • geometry:要添加的BufferGeometry
  • reservedVertexCount(可选,默认-1):为该几何体预留的顶点缓冲区空间。如果计划稍后用比原几何体更大的几何体替换这个槽位,就必须在这里预留足够的空间;默认取所给几何体顶点缓冲区的长度;
  • reservedIndexCount(可选,默认-1):索引缓冲区预留空间,规则同上;默认取所给几何体索引缓冲区的长度。

源码中的三条硬约束(_validateGeometry):

  1. 索引必须一致——批次内所有几何体要么都有index,要么都没有,否则抛出All geometries must consistently have "index"
  2. 属性必须一致——后加入的几何体必须包含首批次几何体已有的全部属性,且各属性的itemSizenormalized必须一致,否则抛出All attributes must have a consistent itemSize and normalized value
  3. 预留空间不能越界——indexStart + reservedIndexCount > maxIndexCountvertexStart + 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 钩子中,它完成了从"实例元数据"到"多绘制指令"的转换:

  1. 短路优化:若可见性自上次以来没有变化,且perObjectFrustumCulledsortObjects均为false,直接返回,不遍历任何实例;
  2. 视锥剔除(可选):当perObjectFrustumCulledtrue时,在批次局部坐标系下构造视锥(数组相机会用FrustumArray处理 XR 多视图),对每个实例的世界空间包围球做intersectsSphere测试,剔除视野外的实例;
  3. 深度排序(可选):当sortObjectstrue时,把通过剔除的实例压入带对象池的MultiDrawRenderList,每项记录(start, count, z, index),其中z是包围球中心沿相机前向的投影距离。默认排序函数是:
    • 不透明材质:sortOpaquea.z - b.z近到远——提前写入深度,减少后续片元的 overdraw;
    • 透明材质:sortTransparentb.z - a.z远到近——保证 alpha 混合的正确叠加顺序;
    • 若设置了customSort,则由你的函数(list, camera) => ...接管排序,列表中每项的z字段可用于深度排序或自定义规则(如按材质批次分组);
  4. 写出多绘制指令:把排序结果写入_multiDrawStarts(Int32Array,索引偏移,索引模式下换算为字节偏移)与_multiDrawCounts(Int32Array,索引数量),同时把"第 i 次绘制对应第几个实例"写入间接纹理的indirectArray,供顶点着色器采样该实例的矩阵;
  5. 线框兼容:当material.wireframetrue时,渲染器会把三角形隐式转换为每三角形 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时的关键约束与最佳实践:

  1. 容量按峰值规划:构造时按最大实例数、最大唯一几何体集合的顶点/索引总量预留;运行中不够用再setInstanceCount/setGeometrySize扩容(会重建纹理并拷贝数据,有额外开销);
  2. 几何体属性要统一:首批次加入的几何体定义了整批的属性布局,后续几何体必须提供相同的属性集合与itemSize;索引有无必须全批一致;
  3. 预留空间是换装的前提:计划运行时用setGeometryAt换更大几何体(如骨骼动画的顶点膨胀、LOD 高模替换)时,addGeometry阶段就必须传入足够的reservedVertexCount/reservedIndexCount
  4. 不要使用负缩放setMatrixAt的文档明确声明不支持负缩放矩阵;
  5. 删除后记得重排deleteGeometry只回收 ID 不回收缓冲区空间,unusedVertexCount统计的是尾部连续空闲量;长时间运行的场景建议周期性optimize()
  6. 静态场景关闭裁剪与排序perObjectFrustumCulled = false; sortObjects = false;且可见性不再变化时,onBeforeRender直接短路,零遍历开销;
  7. 透明批次注意排序方向transparent材质自动改为远到近渲染,如需特殊混合策略可用setCustomSort接管;
  8. 类型检测:用object.isBatchedMeshinstanceof之外的轻量类型判断;
  9. 与 InstancedMesh 的选型:几何体单一、实例海量且需要 GPU 端实例属性动画时优先InstancedMesh;几何体种类多(每种几何体 2~N 份实例)、需要逐实例颜色/可见性/射线batchId拾取时优先BatchedMesh
  10. 完整示例参考: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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/5 20:45:20

技术分享课如何做到学员可复现:最小闭环与环境自检

评价一次技术讲师授课分享的质量,不能只看老师讲得多顺,还要看现场学员在课程结束后能不能独立还原课堂步骤。常见的情况是:老师在自己的电脑里跑通了三遍示例,学员打开命令行之后第一行命令就报错;老师切到示例代码很…

作者头像 李华
网站建设 2026/9/5 20:43:06

Apktool 安装教程:从零到解包第一条命令

Apktool 安装教程:从零到解包第一条命令 【免费下载链接】Apktool A tool for reverse engineering Android apk files 项目地址: https://gitcode.com/GitHub_Trending/ap/Apktool Apktool 是一款把 Android APK 拆成可编辑项目、改完再重新打包的逆向工具。…

作者头像 李华
网站建设 2026/9/5 20:40:14

基于RT-Thread与Ymodem协议实现STM32L4串口OTA固件升级

简介:本资源是一套基于RT-Thread操作系统的STM32L4系列单片机OTA固件升级完整工程,面向嵌入式开发工程师及RTOS进阶学习者,解决低功耗物联网设备在无调试器条件下通过串口安全远程更新固件的核心需求。工程以STM32L496为硬件平台,…

作者头像 李华
网站建设 2026/9/5 20:39:52

用Python复现“谷歌翻译20次”实验:从语义漂移分析到TTS演唱

用谷歌翻译把一句话来回翻译 20 次,再把最后生成的文字当作歌词唱出来,是最近短视频平台上很常见的创意挑战。外行看是恶搞,程序员的视角里却藏着一个很有意思的工程问题:机器翻译输出是稳定的吗?语义是怎么在多次往返…

作者头像 李华
网站建设 2026/9/5 20:39:46

Python实现协同过滤推荐系统:从原理到源码实战

简介:这是一份面向Python开发者与推荐系统初学者的实战型学习资源,聚焦推荐算法原理理解与工程实现,覆盖协同过滤、矩阵分解、图模型、深度学习等主流方法。资源包含70个文件,以21个Python源码(含ItemCF/UserCF/LFM/Gr…

作者头像 李华