Cesium大量树渲染不卡顿:实例化、3D Tiles与点云方案
简介面向Cesium三维场景开发者的多细节树木模型包聚焦大规模树木添加与渲染的效率问题利用Cesium 3D Tiles与LOD分层技术组织数据适合高密集植被场景下的视觉呈现与性能优化。压缩包内共53个文件大小约1.33MB包含10个OBJ模型、10个MTL材质、10套glTF与bin瓦片、10张PNG纹理、2张JPG贴图及1个JSON元数据覆盖2到1024多档精度层级纹理贴合树干与树冠可服务城市绿化、生态模拟、三维游戏等场景搭建。已有141位开发者学习参考。素材的模型层级划分与纹理命名规范清晰既可直接导入工程作为树木要素复用也可作为理解Cesium瓦片化、LOD切换与实例化渲染机制的样例通过meta.json元数据能更清楚瓦片组织方式便于迁移到自有项目中。对正在做Cesium场景优化、需要批量添加植被的开发者而言这套内容兼具工程实用与学习研究价值。1. 大量树的“其它”解法到底是什么在 Cesium 里“种树”最容易翻车的方式就是教科书式做法拿一棵 glTF 树模型new一个Entity往场景里加再来一棵再加。二十棵没问题两百棵勉强两千棵时帧率直接崩到个位数页面像在放幻灯片。这个标题里的“其它树”指的不是松树杨树那类树种而是另一套表达树的办法用盒体、点云、实例化瓦片去“扮演”一棵树。核心思路就一句话你要的是视觉上的一片林而不是几何上的一万份模型。这套思路适合正在做智慧园区、林业可视化、城市级大场景的从业者换一种批量表达帧率能从个位数拉回流畅还能扛住几十万棵树的体量。下面从选型逻辑开始一路讲到可复现的代码和坑位。2. 树怎么“假装”成树四种表达与选型逻辑2.1 为什么一棵棵添加树会卡从 draw call 说起GPU 渲染一帧CPU 要先告诉显卡“这一批东西按什么方式画”这个“一次提交”就是 draw call。每棵独立的Entity树模型至少要贡献一个到几个 draw call一万棵树就是上万次提交CPU 会先被拖死GPU 反而闲得很。这也是为什么场景里树一多帧率骤降但显卡占用率往往不到一半瓶颈在 CPU 侧的对象管理和绘制提交不在显卡算力。实例化instancing的思路恰好相反把同一份树网格只上传一次之后每棵树只传“位置、朝向、缩放”这几个矩阵数据显卡拿着同一份模型批量画。一次提交画几百上千棵树代价只是几十个矩阵。这就是“其它树”性能好的根本原因也是后面所有方案共同的理论基础。2.2 四种“树”的表达方式与适用边界我把 Cesium 里常用的树表达方式分成四档每一档都有明确的推荐上限和适用场景表达方式单批开销推荐上限加载方式适合场景Entity glTF 模型高200 棵模型随场景加载近景点缀、交互选中Primitive GeometryInstance低2 万~5 万棵内存直接构造中远景占位、大范围林相3D Tiles 实例化瓦片i3dm低几十万棵瓦片按需加载全场景真实树模型PointPrimitiveCollection 点云极低百万级内存直接构造超远景树海、林冠覆盖选型时我一般按这四条走如果只是近景几棵树直接用Entity最省事如果是园区级、区县级且以中远景为主上Primitive实例化代码量小、当天能跑如果项目交付要求真实树种模型且要跨远近视角连续浏览直接走 3D Tiles 实例化瓦片如果只表达林相边界、树冠高度这些宏观信息点云一把梭。2.3 树模型的预处理glb 导出时的三个关键参数无论走哪条路树模型最终都要统一成 glb 格式。常见做法是在 3D 建模工具里导出 glb导出时有三个参数直接影响 Cesium 里的表现一是勾选“应用变换”否则模型的坐标轴偏移会让树悬浮或钻地二是开启几何压缩常见的是 Draco树这种低模重复量大的场景压缩收益非常明显三是把材质通道收敛为标准 PBR 的金属粗糙度流程Cesium 对这种材质支持最稳定贴图尺寸尽量压到64x64或128x128。# 示例建模工具导出或转换链调用的通用参数 # --binary输出单文件 glb # --draco对几何做压缩树这种大量重复模型收益很大 # --texture-size 64限制所有贴图边长减少显存占用 model-convert --input trees.fbx --output trees.glb --binary --draco --texture-size 64参数说明--binary强制输出二进制 glbCesium 加载单文件比 gltf bin 外链更稳--draco压缩的是几何顶点数据树冠这类曲率变化不大的模型用 64 或 128 贴图完全够。这一步没做好后面加载阶段会遇到模型不显示、位置偏移这类玄学问题排查起来特别浪费工时所以我习惯把预处理固定成标准流程。3. 实例化种树一万棵树的代码与参数3.1 生成一万个树坐标随机点与真实点源的区别先解决“树长在哪”。测试阶段可以用随机点覆盖一片矩形范围就行真实项目通常给的是 CSV 或 GeoJSON 里的树点包括经纬度和树高字段。下面这段代码生成一万个随机坐标并尝试采样地形高度避免树悬空或陷进地里。async function generateTreePositions(viewer, count) { const positions []; // 示例一片约 2km x 2km 的范围换成你的项目实际范围 const lonMin 120.0, lonMax 120.02; const latMin 30.0, latMax 30.02; for (let i 0; i count; i) { positions.push(Cesium.Cartographic.fromDegrees( lonMin Math.random() * (lonMax - lonMin), latMin Math.random() * (latMax - latMin), 0 )); } // 只在地形可用时采样否则返回原始高度 if (viewer.terrainProvider viewer.terrainProvider.availability) { await Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, positions); } return positions; }逻辑说明Cesium.Cartographic.fromDegrees生成的是经纬度对象初始高度填 0sampleTerrainMostDetailed会原地修改数组里每个对象的高度值不需要接收返回值。随机点适合验证性能但真实项目的树不会均匀分布建议直接用林业调查的点数据生成后按地块抽稀避免同一条路线上挤成一堆。3.2 用 Primitive GeometryInstance 把树“铺”出去最小可复现代码这一步是全文最值得抄的代码。它不加载任何外部模型而是用内置的盒体当树冠用GeometryInstance把一万个盒体合并进同一个Primitive绘制压力极小。function buildTreePrimitive(positions) { const instances []; for (const carto of positions) { // 每棵树随机缩放和转向消除重复感 const scale 0.8 Math.random() * 0.6; const randomColor Cesium.Color.fromHsl( 0.25 Math.random() * 0.1, // 色相在绿色附近浮动 0.6, 0.35 ); instances.push(new Cesium.GeometryInstance({ geometry: Cesium.BoxGeometry.fromDimensions({ dimensions: new Cesium.Cartesian3(1.2, 1.2, 3.0), // 单位米 vertexFormat: Cesium.VertexFormat.POSITION_ONLY, }), modelMatrix: Cesium.Matrix4.fromTranslationQuaternionRotationScale( Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, carto.height), Cesium.Quaternion.fromHeadingPitchRoll( Cesium.HeadingPitchRoll.fromDegrees(Math.random() * 360, 0, 0) ), new Cesium.Cartesian3(scale, scale, scale) ), attributes: { color: Cesium.ColorGeometryInstanceAttribute.fromColor(randomColor), }, })); } return new Cesium.Primitive({ geometryInstances: instances, appearance: new Cesium.PerInstanceColorAppearance({ closed: true }), asynchronous: false, releaseGeometryInstances: false, }); } // 使用示例 const positions await generateTreePositions(viewer, 10000); const treePrimitive buildTreePrimitive(positions); viewer.scene.primitives.add(treePrimitive);逻辑说明每个GeometryInstance里的modelMatrix决定了这棵树的位置、朝向和缩放PerInstanceColorAppearance允许每个实例单独染色这也是它比普通MaterialAppearance适合做树的点。asynchronous: false让 Primitive 在当前帧直接构建测试时能立刻看到结果生产环境改成true可以避免长任务卡 UI。releaseGeometryInstances: false保证调试时还能拿到原始实例数据正式上线后可以设回true释放内存。参数说明BoxGeometry.fromDimensions的dimensions单位是米1.2 x 1.2 x 3.0相当于一棵 3 米高的阔叶树轮廓vertexFormat只保留POSITION_ONLY因为这里不需要法线和 UV能省不少内存。randomColor用 HSL 生成色相控制在 0.25~0.35绿色区间明度给到 0.35 左右能模拟树冠受光后的深浅差别。如果觉得盒体太方可以把BoxGeometry换成CylinderGeometry视觉上更像针叶树代码结构完全一样。3.3 为什么 Primitive 比 Entity 快批次合并与内存差异同样是这一万棵树用Entity写场景里会多出一万个Entity对象每个对象都带自己的属性监听、状态更新和渲染状态管理而Primitive GeometryInstance只有“一个 Primitive 一万个实例矩阵”CPU 每次提交只需要遍历这一批矩阵而不是遍历一万个完整对象。两套方案在源码层面的差距就是“数据”和“对象”的差距数据可以批量拷贝进显存对象必须逐个管理生命周期。我实际测过同一台机器上Entity方案到三千棵左右开始明显掉帧切Primitive方案后同样三千棵几乎无感到两万棵才开始有压力。如果你需要的是“真实 glTF 树模型的实例化”Primitive 这套装不了模型那就要走下一章的 3D Tiles 实例化瓦片。这也是很多初学者绕弯的地方先花两天找 glTF 实例化 API不如先想清楚自己的树到底需要多真。4. “其它树”的两条路3D Tiles 与粒子点云4.1 用实例化瓦片i3dm装下一整片林结构与加载参数如果项目要求近看有真实树冠造型Primitive 盒体就不够用了。常见做法是把一棵 glTF 树模型作为“模板”把成千上万棵树的经纬度、朝向、缩放打成实例列表交给 3D Tiles 工具链生成实例化瓦片也就是 i3dm。生成的目录结构一般长这样tiles/ └── trees/ ├── tileset.json ├── content/ │ └── instances.i3dm └── textures/ └── leaf.png逻辑说明tileset.json是瓦片入口文件instances.i3dm里存着树模板和每个实例的变换矩阵贴图单独放在textures下供所有实例共享。相比一万个独立模型文件这种方式把重复网格压缩成一份显存占用能少一个数量级。如果你手头只有一棵树的 glb 和一份 CSV 坐标表最务实的路径是先用第 3 章的 Primitive 方案验证整体效果确认树点分布没问题再投入时间做 i3dm 切片。// 加载整片树海瓦片 const tileset await Cesium.Cesium3DTileset.fromUrl( /data/trees/tileset.json, { maximumScreenSpaceError: 16, maximumMemoryUsage: 512, cullWithChildrenBounds: false, } ); viewer.scene.primitives.add(tileset);参数说明maximumScreenSpaceError是屏幕空间误差值越大瓦片加载越粗糙、切换越少树海这种远景内容我一般给 16近景才降到 4maximumMemoryUsage限制瓦片缓存内存单位是 MB给 512 是避免远景浏览时把整片林全部塞进内存cullWithChildrenBounds如果切包时包围体算得不准会出现树被整体剔除的情况先关掉能减少一个排查变量。4.2 用点云做百万粒子的树海PointPrimitiveCollection 的参数再往远景走i3dm 也扛不住的时候就用点云。这里不是 LiDAR 那种真点云而是“一棵树一个点”在树冠顶部位置画一个带颜色和透明度的点几十万、上百万个点对 Cesium 来说毫无压力视觉上就是一片起伏的林冠。const pointCollection new Cesium.PointPrimitiveCollection(); for (const carto of positions) { pointCollection.add({ position: Cesium.Cartesian3.fromRadians( carto.longitude, carto.latitude, carto.height ), color: Cesium.Color.fromCssColorString(#4a7c59).withAlpha(0.85), pixelSize: 6, scaleByDistance: new Cesium.NearFarScalar(1000, 1.0, 8000, 0.2), disableDepthTestDistance: 0, }); } viewer.scene.primitives.add(pointCollection);逻辑说明PointPrimitiveCollection内部同样走批量渲染它比 Primitive 实例化更轻因为它连“盒体”都不用画只画点精灵。pixelSize的单位是像素而不是米所以同一套点在不同观察距离下屏幕大小不变远景时反而更像树冠斑块。scaleByDistance用NearFarScalar控制1000 米外点缩到原始大小8000 米外缩到 0.2 倍避免远景点过大糊成一团。disableDepthTestDistance这个参数值得多说一句设成 0 表示不关闭深度测试点在山上时会被山体正确遮挡如果设成无穷大点会永远显示在最前面适合做“上帝视角叠加层”但视角一转就会出现点穿透地形的违和感。树海点云我建议保持默认 0让地形裁掉山背后的点。4.3 真实项目里我一般怎么组合这三个方案一个常见误区是想只用一个方案解决所有距离。实际项目里我习惯把三种方案当成三个独立图层同时加载按视角距离切换显示近景 0~500 米上 i3dm 或少量精模 Entity树的造型要经得起看。中景 500~3000 米用 Primitive 盒体实例树冠颜色随机化后观感不差。远景 3000 米以上直接用点云表达林相分布和地形起伏。切换逻辑会放到最后一章这里先强调一点三个图层要分开管理各自放进独立的Primitive容器并保存好引用。很多人把三种树混进同一个entities或同一个primitives里后面想分层切换时只能全删重建这就是典型的没留后路重构成本比写三行show大得多。5. 大量树的常见坑帧率、闪烁、内存与遮挡5.1 帧率骤降但 GPU 占用不高瓶颈在 CPU 提交现象树加到几千棵后帧率从 60 掉到 20 左右打开显卡占用率一看还不到 50%。原因draw call 和对象管理都在 CPU 侧Entity越多CPU 每帧要处理的对象状态越多。显卡根本没吃满CPU 先排队排死了。解决把零散的Entity合并成单个Primitive用GeometryInstance传实例矩阵如果必须用Entity就接受它不适合大规模的现实只把它用在近景点缀。合并后如果还是卡打开viewer.scene.debugShowStatistics看 draw call 数目标是把树海相关的 draw call 压到几十以内。5.2 远景树闪烁得像水面波纹LOD 切换与贴图 mipmap现象相机平移或缩放时远处的树冠一会儿出现一会儿消失伴随白色或绿色闪烁。原因maximumScreenSpaceError设得太小瓦片 LOD 切换过于频繁或者贴图没有生成 mipmap纹理在远距离采样时出现摩尔纹和闪烁。解决远景树海的maximumScreenSpaceError提到 16 以上减少 LOD 抖动导出贴图时强制生成完整 mipmap 链不要在 3D 建模软件里关掉它如果用的 i3dm 切片工具检查切包时是否对纹理做了重压缩重压缩常把 mipmap 丢掉。5.3 树消失了一半或整片消失遮挡剔除误杀现象某个视角下树海只有一半显示转动相机到另一侧消失的那片又回来了。原因瓦片切包时生成的包围体没有覆盖全部实例cullWithChildrenBounds根据包围体做遮挡剔除把明明可见的内容误判成不可见。解决Cesium3DTileset加载参数里把cullWithChildrenBounds关掉先验证是不是它的锅如果是回到切片工具里检查包围体通常要手动给树海区域加一圈冗余量。Primitive 方案下类似的坑是boundingSphere没手动指定默认包围体可能只覆盖了第一个实例导致远处的树全部被裁掉手动传入一个覆盖全部点的BoundingSphere能根治。5.4 内存只涨不降重复创建与引用未释放现象页面越跑越卡任务管理器里浏览器内存曲线一路向上最终崩溃。原因每次刷新树数据时直接new一个 Primitive 加到primitives里旧引用没有移除生成坐标的异步Promise在组件销毁后仍旧执行回调里还在往场景里塞数据。解决替换树之前先移除旧对象把引用清空// 移除上一次的实例化树 if (treePrimitive) { viewer.scene.primitives.remove(treePrimitive); treePrimitive null; }逻辑说明scene.primitives.remove会从渲染循环里摘掉这个 Primitive并把它的 GPU 资源标记为可回收。如果旧 Primitive 只是从数组里丢掉引用但还挂在primitives上Cesium 会继续每帧渲染它内存自然只涨不降。异步生成坐标的函数里要加一个“取消令牌”或版本号只有最新一次请求的结果允许写入场景否则连续操作两次后旧回调晚到会把树重复叠加一遍。6. 视距分档把三套“树”做成一段接力渲染6.1 距离阈值与 show 切换的最小实现三套方案不是永远同时渲染而是按相机到林区中心的距离接力。切换的核心是控制每个图层的show属性保留对象引用不做重建。function updateTreeLayers(cameraPosition, tileset, treePrimitive, pointCloud) { // 林区中心点切片或生成坐标时顺手算好 const dist Cesium.Cartesian3.distance(cameraPosition, treeCenter); if (dist 6000) { // 远景只看点云 tileset.show false; treePrimitive.show false; pointCloud.show true; } else if (dist 2000) { // 中景盒子实例 点云过渡 tileset.show false; treePrimitive.show true; pointCloud.show true; } else { // 近景真实模型展示点云关掉 tileset.show true; treePrimitive.show false; pointCloud.show false; } } viewer.camera.changed.addEventListener(() { updateTreeLayers( viewer.camera.positionWC, tileset, treePrimitive, pointCloud ); });逻辑说明camera.changed在相机停止或移动时都会触发比每帧监听更省。距离阈值要根据场景尺度调整如果是几百亩的园区阈值可以缩到800/1500如果是地级市范围3000/10000更合理。切换瞬间如果看到明显跳变说明两层之间的视觉差异太大点云的pixelSize要调大一些让中景和远景在过渡带上的树冠斑块尺寸接近。距离段启用的树图层预期收益0~2000mi3dm 近景模型近看有真实树形2000~6000mPrimitive 盒子实例 点云中景有体积感draw call 可控6000m 以上点云百万级树海不掉帧结尾再说一个习惯我现在接大场景单子拿到树点数据第一件事不是加载一整套模型而是先按 0~2000、2000~6000、6000 以上拉三条距离线把每段该用什么表达方式定下来再写渲染代码。第一次做的时候我直接拿一万个模型 Entity 硬塞进场景结果演示时一拉远帧率掉到个位数当场被问“是不是显卡不行”。后来养成了先分档再渲染的习惯同样的数据量下帧率基本稳定在 40 以上。这套“其它树”的写法希望帮到你。本文还有配套的精品资源点击获取