Cesium POI点聚合实战:从EntityCluster到Primitive性能优化
简介面向Cesium三维开发者的POI点聚合实用代码包重点解决primitive加载海量兴趣点时缺少直接聚合机制、密集点阵影响渲染性能的问题。方案绕开修改Cesium内部源码的常规做法利用DistanceDisplayCondition属性依据POI点typename字段的层级关系动态设置显隐距离按远、中、近三个视距范围实现分级显示。代码中通过_clustering方法计算视距并在_add方法中应用distanceDisplayCondition分层逻辑清晰该方案不依赖修改Cesium内核兼容性与可维护性较好便于开发者直接迁移到实际WebGIS项目。压缩包共2个文件包含一个HTML示例页面和一个inscode文件整体仅4KB结构小巧、易查阅。目前已有78人学习/下载适合需要快速实现Cesium聚合效果的中高级前端或GIS开发者无论是城市级POI展示还是局部精细标注该分级显隐思路都能有效提升渲染效率。 做三维地图的项目POI 点聚合是个绕不开的话题Cesium 项目尤其如此。网上关于点聚合的示例代码很多但真正能落地到业务里的其实不多。Cesium 本身是重引擎但即便这样把几万个标注点直接塞进场景也会出现明显的掉帧跳帧尤其在低端笔记本和移动端浏览器上情况只会更糟。POI 点聚合的核心价值就是把屏幕上密密麻麻的兴趣点先合并成一组再用一个聚合点表达“这一片有多少个 POI”既保住了信息量又保住了画面整洁度。这篇文章直接面向 Cesium 开发者从最常用的 EntityCluster 开始讲给出可以马上复制的代码再往后深挖到原生 Primitive 级别的优化思路以及我在真实项目里踩过的聚合参数坑。适合刚接触 Cesium 点聚合、被海量 POI 卡到怀疑人生的朋友。1. 为什么 POI 聚合在 Cesium 里几乎是绕不开的一步1.1 海量 POI 的渲染瓶颈很多人一开始做 POI 显示都是直接从接口拿到数据、循环 add 到场景里几百个点没问题等数据量涨到几千甚至两三万问题就来了。Cesium 对单个 Entity 的渲染做了合并批处理但 Entity 层本身的创建、销毁、属性追踪开销并没有消失。相机每拖动一帧场景里所有点都要重新计算屏幕位置数量一多CPU 占用直接拉满掉帧就成了常态。另一个被忽略的问题是视觉遮挡。十个 POI 挤在同一个屏幕位置画面上只剩一堆重叠的图标用户根本点不中目标。聚合不是单纯为了性能它也是在帮用户做信息降噪把“几十个点挤在一起”变成“一个带数字的聚合点”用户点一下才能展开明细这个交互模型比直接铺开更符合地图产品的直觉。这里有个生活化类比一张桌面堆一千张纸片你还能翻一翻找目标堆十万张就只剩视觉噪音了。点聚合相当于先把同一块区域的纸片收进一个文件夹上面写着“此处有 36 个文件”既保住了信息量又保住了桌面整洁度。1.2 聚合的本质是“按像素范围划分屏幕”Cesium 的前端聚合算法并不是在数据库或后端做空间索引而是在渲染层实时计算。它会围绕每个点扫描周围一定像素半径内的其他点距离小于像素阈值的点就被归为同一个簇簇的位置默认取这些点经纬度的几何平均数或重心。也就是说聚合的粒度不是固定不变的它跟相机高度和缩放级别直接相关。缩得越远簇越大拉近以后簇会不断分裂成更细的子簇最终回到单个点。这种“视口相关”的聚合策略天然适合数字孪生、城市治理、商业网点这类项目因为用户从城市宏观到街道微观的切换过程中画面上的标注总数始终被控制在一个可接受的范围。2. EntityCluster CustomDataSource最省心的聚合代码2.1 基础代码把数据源加进 CesiumCesium 官方最普遍的点聚合方案是EntityCluster搭配CustomDataSource一行dataSource.clustering.enabled true就能起聚合作用。下面是可以直接跑的代码骨架。const viewer new Cesium.Viewer(cesiumContainer, { baseLayerPicker: false, geocoder: false, sceneMode: Cesium.SceneMode.SCENE3D, }); const poiDataSource new Cesium.CustomDataSource(poiLayer); viewer.dataSources.add(poiDataSource); // 开启聚合 poiDataSource.clustering.enabled true; poiDataSource.clustering.pixelRange 40; // 聚合判定像素范围 poiDataSource.clustering.minimumClusterSize 2; // 最少多少个点聚合成簇 // 模拟一批 POI 数据 const poiList [ { id: 1, lon: 116.397, lat: 39.908, name: 地标A, category: 餐饮 }, { id: 2, lon: 116.398, lat: 39.909, name: 地标B, category: 酒店 }, // 实际项目一般从接口动态加载 ]; poiList.forEach((poi) { poiDataSource.entities.add({ position: Cesium.Cartesian3.fromDegrees(poi.lon, poi.lat), point: { pixelSize: 10, color: Cesium.Color.fromCssColorString(#3B82F6), outlineColor: Cesium.Color.WHITE, outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY, }, properties: poi, }); });这段代码里有两个容易被忽略的细节。disableDepthTestDistance: Number.POSITIVE_INFINITY是让点标不被地形和建筑遮挡否则 POI 贴地时镜头转到山体后面会被直接吞掉。properties是我手动挂上的原始业务数据后续点击聚合点要展开列表就靠它取回原始 POI 信息。2.2 聚合参数怎么调官方默认值并不是“最佳实践”需要按数据量级微调。我常用的参数如下参数名默认值经验值说明pixelRange3530-60聚合的像素半径越大越容易聚合成簇minimumClusterSize22-3低于该数量的点不聚合clusterEvent无必须监听每个聚合簇的外观在回调里定制enabledfalsetrue数据源级聚合开关pixelRange不建议超过 80拉远视角时整座城市都可能变成一个点用户想看某个区县就点不到了也不建议小于 20否则聚合效果不明显。minimumClusterSize设 2 通常最稳妥设 3 会漏掉两个相邻 POI 的情况用户会困惑“明明两个点靠在一起怎么没合并”。2.3 自定义聚合样式只开启聚合还不够必须通过clusterEvent调整成自己业务的视觉样式。系统默认的聚合图标就是一个普通的圆点一般要换成带数字角标的图片。poiDataSource.clustering.clusterEvent.addEventListener( (clusteredEntities, cluster) { cluster.label.show true; cluster.label.text clusteredEntities.length.toString(); cluster.label.font bold 16px sans-serif; cluster.label.style Cesium.LabelStyle.FILL_AND_OUTLINE; cluster.label.fillColor Cesium.Color.WHITE; cluster.label.outlineColor Cesium.Color.BLACK; cluster.label.outlineWidth 3; cluster.label.horizontalOrigin Cesium.HorizontalOrigin.CENTER; cluster.label.verticalOrigin Cesium.VerticalOrigin.CENTER; cluster.billboard.show true; cluster.billboard.image /image/cluster_marker.png; cluster.billboard.width 48; cluster.billboard.height 48; } );这里有个关键点cluster本身也是一个 Entity每一帧都可能被重建或复用。如果你在回调里只设置了label没有设置billboard.show某些版本下会出现“只有数字没有底图”的残影如果你之前设置了图片后续想恢复成原始单点也需要手动把cluster.billboard.show置为false。最好的做法是把聚合点的样式统一收敛在clusterEvent回调里不要在外面单独修改 cluster Entity 的属性否则不同帧之间容易互相覆盖。3. 聚合点交互点击簇展开明细、飞行定位3.1 用 clusterEvent 拿回原始 POI聚合后的点本质上是一个“壳”真正的业务数据还在clusteredEntities数组里。通过监听clusterEvent我们能拿到这个数组完成两个最常用的操作点击聚合点弹出明细或者把相机飞到这一簇所覆盖的地理范围。不少开发者在这里会卡住因为ScreenSpaceEventHandler的拾取结果只有聚合 Entity看不到原始 POI。Cesium 内部会把簇内实体缓存在聚合 Entity 的私有字段上常见的是entity._cluster但这个内部字段不稳定不同版本可能有差异。我更推荐自己用 Map 存一份一劳永逸const clusterMap new Map(); poiDataSource.clustering.clusterEvent.addEventListener( (clusteredEntities, cluster) { clusterMap.set(cluster.id, clusteredEntities); // 样式设置略 } ); const handler new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction((click) { const picked viewer.scene.pick(click.position); if (!Cesium.defined(picked) || !Cesium.defined(picked.id)) return; const entity picked.id; const clusterEntities clusterMap.get(entity.id); if (clusterEntities) { const poiDataList clusterEntities.map((item) item.properties); console.log(聚合簇内 POI 数量, poiDataList.length, poiDataList); // 在这里打开你自己的弹窗组件 } }, Cesium.ScreenSpaceEventType.LEFT_CLICK);用 Map 的另一个好处是不管 Cesium 内部字段怎么变我们自己在clusterEvent里存的数据始终是稳定的。还可以顺便在 Map 里记录簇的实体 ID后续要删除或更新聚合簇时操作路径也清晰。3.2 点击聚合点弹出明细的交互细节弹窗该展示哪些信息取决于业务。如果项目只需要“本簇有 36 个 POI”那就直接把clusteredEntities.length丢给前端组件。如果要把簇里的 POI 名称逐条列出来需要遍历clusteredEntities取properties。这里提醒一句当clusteredEntities.length很大时不要一次性把全部 DOM 渲染出来几百行列表同时插入会让页面卡顿。最好先展示前 10 条加一个“查看更多”按钮后面的数据按需加载。聚合点是否可点也要有视觉反馈。我的习惯是在样式回调里给cluster.billboard加合适的悬停反馈或者直接用事件监听viewer.canvas.style.cursor pointer让用户知道这个数字泡泡能点。否则地图上突然出现的圆形图标用户很难理解它的含义。3.3 相机飞到簇范围内的完整代码部分场景希望点击聚合点后镜头自动拉近并展开覆盖区域。核心是先算出簇内点的包围球再调用viewer.camera.flyToBoundingSphere飞过去。function flyToCluster(entities) { if (!entities || entities.length 0) return; const positions entities.map((entity) entity.position._value); const boundingSphere Cesium.BoundingSphere.fromPoints(positions); viewer.camera.flyToBoundingSphere(boundingSphere, { offset: new Cesium.HeadingPitchRange( 0, Cesium.Math.toRadians(-60), Math.max(boundingSphere.radius * 4, 800) ), duration: 1.2, }); }这里有个算法细节BoundingSphere.fromPoints会求点集的最小包围圆。如果簇内点非常密集半径会很小飞过去后视角会贴得很近如果簇内点分散在很大一片区域半径会很大飞到目标高度就很高。所以我给offset.range加了一个 800 米的下限避免飞行过度拉近视觉跳变太突兀。4. 十万级数据把 Entity 换成原生 Primitive 聚合4.1 Entity 方案的性能边界在哪里EntityCluster最大的优点是好写、好理解实体对象自带拾取和属性管理。但它的性能天花板也很明显Cesium 的 Entity 层要为每个实体维护一个可回收对象即便底层渲染已经走了批量绘制实体的创建、销毁、事件追踪依然有额外开销。几千个点完全没问题当我压到两三万个点并且频繁拖动相机时帧率下降就很明显了尤其在连续拖动过程中聚合计算每帧都要执行CPU 开销叠加起来非常可观。如果你面对的数据量到了十万级业务上又有高频更新需求那就不能再把宝全押在 Entity 层了需要切换到基于 Primitive 的渲染方案。4.2 BillboardCollection 的原生聚合代码骨架如果你的 POI 渲染已经用了BillboardCollection通常是为了十万级点位必须走批量渲染就不能继续依赖EntityCluster了需要走到原生 Primitive 层的聚合接口。Cesium 提供了一对底层类Cesium.ClusterBillboards和Cesium.ClusterBillboard。基本思路是先定义聚合后那个 billboard 长什么样再把它挂到集合的clusterBillboards上。const billboards viewer.scene.primitives.add( new Cesium.BillboardCollection() ); const clusterBillboard new Cesium.ClusterBillboard(); clusterBillboard.image /image/cluster_marker.png; clusterBillboard.width 48; clusterBillboard.height 48; clusterBillboard.label { text: 0, font: bold 14px sans-serif, fillColor: Cesium.Color.WHITE, horizontalOrigin: Cesium.HorizontalOrigin.CENTER, verticalOrigin: Cesium.VerticalOrigin.CENTER, }; const clustering new Cesium.ClusterBillboards(); clustering.clusterBillboard clusterBillboard; billboards.clusterBillboards clustering; for (const poi of poiList) { billboards.add({ position: Cesium.Cartesian3.fromDegrees(poi.lon, poi.lat), image: /image/poi_dot.png, width: 24, height: 24, // 部分版本要求给单个 billboard 指定 clusterBillboards clusterBillboards: clustering, }); }这里要特别说明ClusterBillboards这套原生接口在文档里属于相对底层的功能不同 Cesium 版本的字段名、默认行为差异不小。如果你项目里用的版本较新建议先翻一下当前版本的 TypeScript 定义或官方示例再确认label是直接挂在clusterBillboard上还是用其他方式配置。这套接口不像实体聚合那样有统一的clusterEvent交互层需要自己维护复杂度会高不少。4.3 Entity 与 Primitive 聚合怎么选我通常按数据量划分选型方案适用数据量开发成本交互能力性能表现EntityCluster几百到几千点低几行配置强直接拿实体够用BillboardCollection ClusterBillboards万级到十万级高需自行维护拾取和样式弱需自己扩展更稳后端预聚合 分级瓦片百万级以上最高强但需要服务端支持最高我的观点是只有点规模真的到了动不动渲染不过来的时候才值得走 Primitive 路线。否则用 EntityCluster 把业务先跑通永远是性价比最高的选择。过度优化是很多 Cesium 新人的通病我见过有人三千个点就开始手写底层聚合最后项目还没上线代码已经是天书了。5. 实际项目里避坑清单5.1 相机缩放和聚合粒度联动Cesium 的聚合并不能把“聚合级别”固定在某一个缩放层级。拉远之后多个 POI 自动合并拉近之后又自动分裂。这既是特性也是坑。如果业务要求“缩放级别 10 以下按区域聚合级别 10 以上显示全部点”单纯靠参数调不出来得自己监听scene.camera.moveEnd在特定 zoom 级别切换poiDataSource.clustering.enabled或者动态更换pixelRange。这种联动还有一个经典场景城市远视角看全局时聚合点要少而清晰拉近到街道时聚合点要快速分裂成具体的 POI。此时建议把相机的distance和pixelRange做映射距离越远pixelRange越大距离越近pixelRange越小。我一般让pixelRange在 30 到 55 之间浮动而不是一直保持不变。5.2 高DPI屏幕上的 pixelRange 换算pixelRange的“像素”更接近 CSS 像素而不是物理像素。在高 DPI 屏幕上一个 CSS 像素对应 2 到 3 个物理像素所以同样设成 40在 2x 屏上看起来比 1x 屏上更“局促”聚合更容易发生。如果你在普通显示器上调好了聚合效果放到 Retina 屏上会明显感觉聚合点变多、变密需要做一次类似pixelRange * (window.devicePixelRatio 1 ? 1.2 : 1)的修正。这个问题通常在项目上线后用户反馈“两边看到的聚合数量不一样”时才暴露。我建议在初始化代码里就根据window.devicePixelRatio做一次全局配置避免后续反复调试。5.3 聚合点的抖动与闪烁处理簇的位置默认取簇内点几何平均当相机角度变化时原本在屏幕空间里“靠近”的点会因透视变换产生微小位移导致簇的中心点跟着轻微抖动。解决思路有两个一是不要依赖默认位置在clusterEvent里把第一个加入数组的原始 POI 坐标作为聚合点位置减少变化频率二是给聚合参数变化加一点缓冲比如在移动端对pixelRange做防抖延迟 100ms 再做更新视觉上会平稳很多。另一个闪烁来源是clusterEvent里对label.background或billboard.image做了异步加载。图片还没加载完标签先显示出来就会出现一个尴尬的数字悬空。要么把聚合底图预加载到页面要么用 Cesium 自带的ImageMaterialProperty方式统一管理。5.4 多类别 POI 的聚合分层如果业务有“餐饮、酒店、交通、景点”等分类直接放在一个数据源里聚合点会混在一起用户点开一看全是混合类别体验很差。我的做法是每个分类建一个独立的CustomDataSource各自聚合。这样聚合点和颜色天然分层交互也清晰。代价是屏幕上的聚合点数量会更多因为每个分类都有自己的一套簇需要更精细地控制pixelRange。合理做法是远景时只加载大类别聚合近景时才加载子类别而不是一开始就把七八个数据源全开。我在城市运行项目里远景只加载“停车场”和“公厕”两个应急类别拉近后再加载“商铺”“酒店”等二十多个子类别。这样远景画面干净近景信息丰富性能也稳得住。最后再说一个和聚合无关但很实用的细节如果 POI 数量很大又需要频繁局部更新不要把整个数据源clear()后重新加优先用dataSource.entities.removeById(id)做增量修改既能减少聚合重算压力也能避免用户看到整屏点“闪一下”。做聚合类功能我最深的体会就是先跑通方案再根据真实数据量和用户交互反推优化而不是一开始就堆最复杂的架构。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →