☰
Cesium大规模树木渲染:用实例化模型实现千棵多样树高效加载
2026/10/19 21:27:33 网站建设 项目流程

简介:针对Cesium中大规模树木添加需求,这套资源提供了一套可直接复用的实例树模型包,面向WebGIS开发者、三维场景搭建人员,可用于解决树木批量放置、多细节层级显示与渲染性能平衡等问题。压缩包共53个文件,体积约1.33MB,包含10组obj/mtl/gltf/bin格式的树木模型,以及配套的png、jpg纹理和meta.json元数据;其中gltf/bin适合在Cesium中直接加载,obj/mtl便于在建模软件中二次修改,png/jpg作为不同LOD层贴图,覆盖从低精度到高精度的细节切换。已有140人学习下载。通过这套文件,使用者无需从零建模即可获得带纹理的树木资产,可结合Cesium API进行实例化分布测试,也能对照gltf/bin层级结构理解大规模模型瓦片化与细节切换的实现方式,适合作为学习示例或轻量场景中的基础素材。 最近在做一个三维数字园区项目,需要在Cesium里铺一片人工林,量级是上千棵起步,客户还要求树形不能千篇一律,得有点“其它树”来丰富场景,比如银杏、枫树、松树混着来。刚开始我想得挺简单,直接用Cesium的Entity满天撒点,结果场景一跑起来帧率直接掉到个位数,我才意识到这事情没那么简单。这篇就梳理一下我在Cesium中添加大量树木、尤其是“其它树”这套玩法的完整思路和落地代码,给后续踩坑的朋友省点时间。

1. 先搞清楚:为什么大批量加树会把画面拖垮

1.1 Entity虽然方便,但扛不住数量

Entity是Cesium里最友好的API,写起来很快,几行代码就能摆一棵树:

viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(lng, lat), model: { uri: './models/tree.glb', scale: 2.0 } });

这种写法最大的问题是:每一个Entity都是一个独立对象,有独立的状态、独立的绘制批次、独立的变换矩阵。一旦数量上升到几百上千,Cesium要为每个Entity创建图元、计算包围盒、提交渲染状态,CPU端的开销会先爆掉,帧率断崖式下跌就成了必然。

实测数据很直观:在普通办公笔记本上,加300棵独立的Entity树,视角旋转时帧率从60fps掉到20fps左右。加到1000棵以上基本就卡成幻灯片了。

1.2 为什么Primitive加树也没聪明到哪去

后来我试过用Primitive手动构造Geometry,把树摆成一批:

const instances = []; for (let i = 0; i < 1000; i++) { instances.push(new Cesium.GeometryInstance({ geometry: new Cesium.BoxGeometry({ dimensions: new Cesium.Cartesian3(2, 2, 10) }), modelMatrix: Cesium.Matrix4.fromTranslation(lngLatToCartesian(...)) })); } const primitive = new Cesium.Primitive({ geometryInstances: instances, appearance: new Cesium.MaterialAppearance({...}) });

这种方式确实把几何数据合并了,渲染批次减少了。但问题在于,用BoxGeometry建出来的树就是方块,不是模型的树。如果用GeometryInstance去引用glTF/glb模型,Cesium官方并不推荐这么做,因为坐标变换、材质、模型的骨骼动画支持都很别扭。

也就是说,想要“大量”和“树长成真实样子”两个条件同时满足,传统Primitive路线也不是最优解。

2. 方案选型:Cesium加大量树的几条路

2.1 路线对比

我实际调研和尝试下来,目前在Cesium中大规模加树,业界主流可行方案基本是这几条:

方案数量级渲染性能开发复杂度适用场景
Entity模型点百级差极低临时演示、少量装饰
Primitive几何体千级中中简化树模型、风场简单包装
实例化模型 InstanceModel万级高中高大面积植被、大片树林
ModelInstanceCollection万级高中替代方案,官方示例已存在
3D Tiles(Batched3DModel)十万级最高高(需离线切片)超大规模、GIS数据集成

结合“既要数量大、又想自由控制单树位置、又扩展成其它树”,我最终选择了实例化模型这条路。它既能保持glTF模型本身的完整渲染效果,又能把上千棵树的变换矩阵一次性提交给GPU,减少CPU压力和状态切换。

2.2 为什么不是3D Tiles

我知道有人会说:既然要成片加树,直接上3D Tiles切片不香吗?

香,但分场景。3D Tiles的优势在于静态场景、预烘焙、按需加载,适合那种地形底图式的大规模植被覆盖,比如整片山区林地的数据集成。但如果你是业务系统里动态生成树林、需要单树拾取、需要程序化调整树的数量和位置,3D Tiles的灵活性就差很多,而且还要自己搭切片工具链,开发周期长。

我们的项目场景是园区设计展示,树的位置会随设计方案调来调去,用3D Tiles的话,每次调整都要重新跑切片,完全不现实。

3. 核心实操:用实例化模型在Cesium里种树

3.1 准备树模型

第一种方法是准备普通glTF/glb模型。我项目里的树模型是从建模同事那边拿的,格式是glb,因为glb是二进制,加载速度快,体积小。

这里有一个关键点我发现很多教程没提:树的glb模型,坐标轴原点必须在树底部中心。如果你的模型原点在树冠中心或者树根底部边角,后面摆放实例时,位置偏移会特别难受,你做俯视视角对齐时,树在地面上晃晃悠悠站不稳,就得靠代码去补偏移量,很蛋疼。

如果模型原点有问题,先用Blender或者在线glTF编辑器把模型移到原点,再导出glb。

3.2 实例化模型核心代码

Cesium 1.95以上已经支持ModelInstance和ModelInstanceCollection。我的做法是建立一棵树的实例,然后批量add实例:

const trees = []; async function addManyTrees(viewer, positions, glbUrl) { const modelInstanceCollection = await Cesium.ModelInstanceCollection.fromUrl(glbUrl, { instances: positions.map((pos) => ({ modelMatrix: getTreeMatrix(pos.lng, pos.lat, pos.height || 0, pos.rotation || 0, pos.scale || 1) })) }); viewer.scene.primitives.add(modelInstanceCollection); trees.push({ collection: modelInstanceCollection, positions: positions }); return modelInstanceCollection; } function getTreeMatrix(lng, lat, height, rotation, scale) { const cartesian = Cesium.Cartesian3.fromDegrees(lng, lat, height); // 构建一个局部坐标系的ENU矩阵 const enu = Cesium.Transforms.eastNorthUpToFixedFrame(cartesian); // 绕Z轴旋转,让树随机朝向,避免整片树林看起来像复制粘贴 const rotationMatrix = Cesium.Matrix3.fromRotationZ(Cesium.Math.toRadians(rotation)); const scaleMatrix = Cesium.Matrix4.fromUniformScale(scale); // 最终矩阵:局部旋转 + 缩放 + ENU平移 const m = new Cesium.Matrix4(); Cesium.Matrix4.multiply(enu, Cesium.Matrix4.fromRotationTranslation(rotationMatrix), m); Cesium.Matrix4.multiply(m, scaleMatrix, m); return m; }

注意,我这里是先旋转后缩放,顺序不能反。如果先缩放再旋转,遇到非均匀缩放时(xy不等比那种),绕Z旋转会导致树被拉伸扭曲。树的scale基本均匀,但养成这个习惯,以后加灌木、路灯、石头都通用。

3.3 一次种多种“其它树”

标题里说的“其它树”,在我这个项目里就是指不止一种树:银杏、枫树、松树、还有一些低矮灌木。

模型实例集合一个collection只能对应一种模型,这是一个硬限制。你要加多种树,就得建多个ModelInstanceCollection,每个collection对应一种树模型:

// 银杏 await addManyTrees(viewer, ginkgoPositions, './models/ginkgo.glb'); // 枫树 await addManyTrees(viewer, maplePositions, './models/maple.glb'); // 松树 await addManyTrees(viewer, pinePositions, './models/pine.glb');

实测下来,三个collection同时存在,每类各400棵左右,总共1200棵,帧率稳定在60fps,没有明显卡顿。同一时间只有一个collection在交互操作(拾取)时,性能依然流畅。

如果树种太多(比如超过5种),建议按视觉重要性分类:近处重点树用独立模型实例,远处林带用LowPoly模型。不要把所有树种都堆成集合,性能开销和拾取逻辑会更复杂。

3.4 随机分布算法:种树也要讲审美

树的位置如果完全随机,视觉效果一塌糊涂,有的树重叠,有的树像排队站岗。我实践下来,更合理的分布思路是“泊松圆盘采样”,先保证任意两棵树之间距离不小于某阈值,再在这个基础上做扰动,得到自然但不重叠的分布。

简单实现可以用网格抖动近似:

function generateTreePositions(area, count, minSpacing) { // area: {xmin, ymax, xmax, ymin} 表示平面范围 // minSpacing: 最小间距 const positions = []; const gridSize = minSpacing * 0.8; // 略小于最小间距 const cols = Math.floor((area.xmax - area.xmin) / gridSize); const rows = Math.floor((area.ymax - area.ymin) / gridSize); for (let i = 0; i < count; i++) { let placed = false; let attempt = 0; while (!placed && attempt < 30) { const x = area.xmin + Math.random() * (area.xmax - area.xmin); const y = area.ymin + Math.random() * (area.ymax - area.ymin); let closest = Infinity; for (const p of positions) { const d = Math.hypot(p.x - x, p.y - y); if (d < closest) closest = d; } if (closest >= minSpacing) { positions.push({ x, y, rotation: Math.random() * 360 }); placed = true; } attempt++; } } return positions; }

这段代码不是严格泊松圆盘,但实用主义角度够用了,速度也快。生成的树林既有疏密变化,又不会互相穿插。

4. 表现层优化:让一大片树林好看又不卡

4.1 动态光照与阴影

大面积树最怕阴影。Cesium的默认阴影计算范围是全局的,几千棵树同时投影阴影,GPU提交量大得吓人。

实测:开着viewer.shadows = true加载1200棵树,帧率掉到30fps左右,整体画面发闷。关掉阴影后恢复到60fps。

折中方案是只给近处若干棵树开阴影,远处的树实例关闭阴影。Cesium的ModelInstanceCollection支持给每个实例设置skipShadow或者通过模型本身控制。我把阴影选择逻辑和相机距离绑定,距离小于200米才开启,超过就直接关闭,画质和性能都能兼顾。

4.2 LOD与简化模型

如果树的数量奔着几千去,即使开了实例化,单个树模型顶点数也不能太高。我用的树模型单棵控制在2000~4000个三角形,视觉上足够丰富,同时又不会让GPU崩溃。

如果你手里有高精度模型,建议用工具做一次减面,得到低模版本,远处显示时切换低模。ModelInstanceCollection加载的是同一个模型,不同LOD,可以建两个collection(高模近、低模远),通过相机距离动态切换显示。

4.3 树的风动效果

网上不少特效库卖“动态树”效果,本质都是顶点着色器里加正弦扰动。自己做的话,核心思路是在glTF模型的顶点着色器里加风场参数,让树冠顶点随时间和风向偏移。

但ModelInstanceCollection内部用的是私有渲染管线,你很难直接给实例模型挂自定义Shader。我试了两个绕路方案:

  • 把树的风力动画做成glTF自带的骨骼动画或顶点动画,加载后播放动画。这种方式适合少量特写树。
  • 大面积树直接放弃动态风,用静态树+远处粒子落叶特效来补偿视觉单调。

考虑到性能和开发成本,我最后选了后者,在树林周围少量落英特效,视觉上完全够用。

4.4 拾取与交互

实例化模型也支持拾取,但拾取到的对象是ModelInstance,不是独立的树。需要给每个实例设置instanceId或者编码到transform里,然后根据ID反查位置和类型。

const pickedFeature = viewer.scene.pick(mousePosition); if (pickedFeature && pickedFeature.primitive instanceof Cesium.ModelInstanceCollection) { const instanceId = pickedFeature.instanceId; const treeInfo = treeDataMap[instanceId]; console.log('选中了', treeInfo.type, treeInfo.position); }

注意,Cesium.ModelInstanceCollection在不同版本里API有变动,老版本叫instanceId,新版本是nodeId或modelMatrix相关对象。建议开发前先确认自己的Cesium版本,在1.9x和1.10x之间踩坑概率很大。

5. 常见问题与排查技巧实录

5.1 树是横躺在地上的

几乎所有人第一次跑都会遇到这个问题。原因是模型自身坐标系和Cesium的Z轴方向不一致,或者ENU矩阵乘反了。

解决办法:把模型的局部坐标系调整为Z轴朝上(模型在地上站直),然后用我前面写的getTreeMatrix,先ENU矩阵,再做旋转。不要在Matrix4上随便叠加旋转,除非你清楚局部轴系。

5.2 树闪烁或消失,屏幕边缘有条纹

这个一般是模型近裁剪面或包围盒问题。glb模型如果没有正确计算包围盒,实例化后Cesium用原始模型尺寸做剔除,会出现“树到眼前才出现”的怪现象。

我用blender导出时,勾选“Recalculate Bounding Box”或者在gltf-transform命令行里跑一次:

gltf-transform resize in.glb out.glb --size keep

这样能重新计算包围盒,问题基本消失。

5.3 加载1000棵树后内存暴涨

大部分原因是模型纹理太大。glb内的贴图如果是4K、8K的单张图,加载1000个实例,GPU显存占用直接爆掉。解决办法是模型统一压缩到1K贴图,甚至512都够用。树冠用带透明通道的PNG纹理,压缩率极高。

5.4 多棵树种分开加载后,渲染顺序错乱

不同树模型之间如果都有透明纹理(树叶通常都带Alpha),Cesium深度排序会出现问题。表现是近处树叶遮挡远处树干,或者透明区域变成黑色块。

解决方案有两步:

  • glb模型材质里把Alpha模式设置为MASK(而不是BLEND),透明度低于某个阈值直接丢弃像素,不参与混合。
  • 如果非得用BLEND模式,把材质里的alphaCutoff调高一点,并确保模型UV不重叠。

在Blender里导出glb时,材质面板把Blend Mode设为Alpha Clip,对应到Cesium就是MASK模式,实操效果最好。

5.5 坐标偏移,树没有种在预期位置

排查顺序:

  1. 先确认lng/lat正确,转成Cartesian3后看位置是否在地面附近。
  2. 再确认高度值,树根必须贴地,如果地形起伏大,需要采样地形高度。
  3. 最后看模型原点,用Cesium的debug命令把模型的boundingSphere输出出来,对比位置偏移量。

6. 几种加树方案的量级对比和个人体会

就我目前的项目情况,最后定格在方案组合上:

  • 主干道、建筑物门口、广场这些视觉焦点,用个位数的高精度独立模型Entity摆放,单棵最高2万面,精致好看。
  • 大面积成片林地,用ModelInstanceCollection批量添加,每棵树3000面以内,总共1500棵左右,性能稳定。
  • 需要交互点击单棵树(比如查询树种、胸径、归属),通过instanceId映射业务数据。

我实测下来,一个ModelInstanceCollection塞2000个树实例,显存占用大约在300MB以内(1K贴图),一帧的drawcall基本维持在几十次,非常友好。

如果你要上十万级树木,我建议老老实实走3D Tiles切片路线。当前版本的Cesium实例化API虽然优秀,但也不是无限扩展,数量级上到五万以上,万级模型矩阵计算和高频更新还是会有压力。

最后提醒一句:不管用哪种方案,务必先确认Cesium版本,近一年官方API变化不小,网上很多老教程用的是Cesium.ModelInstanceCollection这个类,有的版本叫法不一样。写代码前先用Cesium.ModelInstanceCollection去控制台打印一下,确认存在再往下写,能省掉一大半排查时间。

我自己的踩坑经验就是,一开始冲着官方文档里的ModelInstance去的,结果查了一大圈资料,最后发现直接用ModelInstanceCollection.fromUrl一次性传入全部实例矩阵,代码最简洁、效果最稳定。把这条经验分享给各位,希望你们少走弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询