☰
Cesium POI点聚合实战:EntityCluster配置、避坑与海量性能优化
2026/10/18 18:22:22 网站建设 项目流程

简介:面向Cesium三维GIS开发者的POI点聚合代码示例,重点解决Cesium原生primitive不支持点聚合的局限。该方案借助DistanceDisplayCondition属性,根据POI的typename字段层级关系动态返回视距范围:如“汽车服务;加油站;加油站”对应最远视距,“汽车服务;加油站”对应中视距,“汽车服务”对应最近视距,从而在_clustering方法中计算显示距离,并在_add方法中应用条件,最终实现远、中、近视距下的POI分级显示。压缩包内含1个HTML演示页面与1个inscode项目文件,整体大小仅4KB,目前已有78人学习下载。通过这份代码,开发者可以快速掌握基于视距条件实现聚合的思路,无需修改Cesium内部源码即可集成进现有项目,相较修改EntityCluster.js的传统方案更轻量、易维护。代码结构清晰,适合需要高效渲染海量POI数据的WebGIS场景,可灵活调整视距参数以适配不同业务层级,有效缓解高密度POI渲染时的视觉拥挤,提升地图交互流畅度。

1. Cesium POI 点聚合是干什么的:从满屏图标到一眼看懂密度

把 1 万个 POI 一次性丢进 Cesium,俯视状态下你看到的不是地图,而是一团黑色马赛克——图标互相压叠,帧率掉到个位数,用户根本看不出哪里密哪里稀。Cesium POI 点聚合解决的就是这个问题:按照屏幕像素距离把靠近的 POI 合并成一个聚合节点,节点上显示数量,视角拉近后节点自动拆回单个 POI。这套机制对门店分布、安防点位、设备台账这类以密度展示为主的应用几乎是标配。下面从 Cesium 内置的 EntityCluster 讲起,给一段能直接跑通的最小代码,再把我踩过的 5 个坑和十万级 POI 的调优路径一次讲透。

2. 聚合原理与方案选型:EntityCluster 和自研格网怎么选

2.1 EntityCluster 的工作机制

Cesium 的聚合能力不是单独一个插件,而是挂在 DataSource 上的一个属性:任何 DataSource 子类都有 clustering 对象,CustomDataSource、GeoJsonDataSource、CzmlDataSource 一视同仁。你往数据源里 add 实体,Cesium 会在相机变化后把这些实体的世界坐标投影到屏幕,再以像素距离为尺子做合并:两个图标在屏幕上的距离小于 pixelRange,就会被归到同一个聚合组。

聚合组不是一个抽象概念,它在场景里是一个真正的 Entity,位置是成员实体位置的加权中心。所以你能像操作普通实体一样给它设置 billboard、label、point,甚至 ellipse。相机移动时聚合组会实时分裂和重组,这个变化通过 clustering.clusterEvent 事件对外通知,事件的回调参数是 (clusteredEntities, cluster),前者是聚合组里的实体数组,后者是聚合节点实体本身。

理解两个关键点对调参很重要。第一,聚合发生在屏幕空间而不是地理空间,同样一批 POI,500 米高空视角是散的,20 公里高空视角就合并了,因为投影后的像素距离变了。第二,聚合节点是复用对象,不是在每个事件里新建,你的 clusterEvent 回调只是改变它的外观,搞反了这个方向就会写出性能很差的代码,后面避坑章专门讲。

2.2 选型依据:内置聚合和自研聚合的边界在哪

自己写聚合方案,最常见的是格网聚合:把经纬度按固定单元格切分,一个单元格内的 POI 合并成一个聚合点。这个方案和 EntityCluster 不是替代关系,而是互补关系。我遇到的大多数项目,第一阶段用内置 EntityCluster 就够,第二阶段才被数据量逼着做格网。

对比项内置 EntityCluster自研格网聚合
实现成本配置属性即可,零合并逻辑需要自己分桶、合并、更新样式
动态性随相机实时拆散/合并固定单元格,缩放到一定程度要重建
实体数量上限5000 以内流畅,5 万开始吃力配合预聚合可以撑到几十万
交互区分需要自己标记聚合节点和普通 POI聚合点自带 cellKey,容易回溯成员
地图语义聚合范围跟屏幕密度走,符合直觉聚合范围跟地理格子走,边界偏生硬

我的取舍标准是这样:POI 数量小于 5000,无脑用内置 EntityCluster;5000 到 5 万,内置加性能优化,重点是减少实体数量和层级切换;超过 5 万,用格网预聚合把实体数量砍到几千再交给 EntityCluster。注意这是递进关系,不是二选一,第 5 章的调优还会回到这个组合。

2.3 数据准备:POI 数据入库前的清洗与字段约定

聚合对脏数据的容忍度比普通标注高,但脏数据会让聚合数量失真。我一般在进入 Cesium 前做三道清洗。第一道查坐标合法性,lng 在 -180 到 180,lat 在 -90 到 90,纯数字、非 NaN,能滤掉接口返回 0 和空字符串的坏数据。第二道去重,同一经纬度加上同一 name 的记录只保留一条,重复 POI 会让聚合数虚高,用户放大后看到两个一模一样的图标也会觉得是 bug。第三道约定字段,id、lng、lat、name、type 必须有,type 是业务分类,后面按字段分组聚合和图标配色都要用它。

function cleanPoi(rawList) { const seen = new Set(); return rawList.filter(p => { if (!Number.isFinite(p.lng) || !Number.isFinite(p.lat)) return false; if (p.lng < -180 || p.lng > 180 || p.lat < -90 || p.lat > 90) return false; const key = `${p.lng.toFixed(6)},${p.lat.toFixed(6)},${p.name}`; if (seen.has(key)) return false; seen.add(key); return true; }); }

这段代码在数据源创建之前执行,把 rawList 清洗成合法数组。Number.isFinite 比 typeof 判断更严格,能过滤掉 NaN 和 Infinity。toFixed(6) 是去重的关键,经纬度保留 6 位小数约等于 0.1 米精度,同一个点位重复上报基本能命中。清洗之后数据量如果还是超过 5 万,先别急着连 Cesium,跳到第 5 章看格网预聚合。

3. 用 EntityCluster 跑通 POI 聚合:最小代码与参数说明

3.1 最小可运行代码:创建数据源、开聚合、加 POI

下面这段是把聚合跑起来的最小骨架,把坐标数组换成你的真实数据就能用。

const viewer = new Cesium.Viewer('cesiumContainer'); const poiSource = new Cesium.CustomDataSource('poi-layer'); viewer.dataSources.add(poiSource); // 开启数据源的聚合能力 poiSource.clustering.enabled = true; poiSource.clustering.pixelRange = 50; // 屏幕像素半径 poiSource.clustering.minimumClusterSize = 2; // 至少 2 个 POI 才聚合 poiSource.clustering.clusterBillboards = true; // billboard 图元参与聚合 poiSource.clustering.clusterPoints = true; // point 图元参与聚合 // 模拟 200 个 POI,坐标随机偏移 const mockPoi = []; for (let i = 0; i < 200; i++) { mockPoi.push({ lng: 116.39 + (Math.random() - 0.5) * 0.2, lat: 39.9 + (Math.random() - 0.5) * 0.2, name: `POI_${i}`, type: i % 3 === 0 ? 'hospital' : i % 3 === 1 ? 'school' : 'shop' }); } mockPoi.forEach(p => { poiSource.entities.add({ position: Cesium.Cartesian3.fromDegrees(p.lng, p.lat), billboard: { image: '/assets/img/poi_marker.png', width: 28, height: 28, verticalOrigin: Cesium.VerticalOrigin.BOTTOM }, properties: { poiName: p.name, poiType: p.type } }); });

注意:clustering 是数据源级别的配置,换数据源要重新设置,不会跟着实体迁移。

这段代码的核心是把聚合配置和数据添加彻底分离:clustering 只管开关,实体只管加,Cesium 自动完成合并判定。pixelRange 是屏幕像素半径,两个 POI 投影后距离小于 50 像素就会进同一个聚合组,值越大聚合越激进,城市全景下 50 到 80 是常用区间。minimumClusterSize 设成 2 表示两个 POI 也得聚,设成 5 可以让孤立 POI 保持散点,避免出现一个聚合节点只包两三个点的尴尬。clusterBillboards 和 clusterPoints 分别控制 billboard 和 point 两类图元是否参与聚合,混用图元时哪个开关没打开,那一类图元就不会被聚合。

3.2 在 clusterEvent 中定制聚合节点样式

聚合开启后的默认节点是个蓝色圆点,不显示数量,看不出密度。定制样式要在 clusterEvent 里做,这是聚合方案里最核心的回调。

poiSource.clustering.clusterEvent.addEventListener((clusteredEntities, cluster) => { const count = clusteredEntities.length; cluster.billboard = { image: '/assets/img/cluster_marker.png', width: 46, height: 46, verticalOrigin: Cesium.VerticalOrigin.CENTER, disableDepthTestDistance: 2000 }; cluster.label = { text: count > 99 ? '99+' : String(count), font: 'bold 14px Arial', fillColor: Cesium.Color.WHITE, pixelOffset: new Cesium.Cartesian2(0, -18), showBackground: true, backgroundColor: new Cesium.Color(0, 0, 0, 0.45) }; });

回调参数是固定的:clusteredEntities 是聚合组内的实体数组,cluster 是聚合节点实体。回调做的事其实就是给 cluster 设置外观,Cesium 负责把它的位置放到成员中心。count 超过 99 显示 99+,避免三位数字把图标撑爆。disableDepthTestDistance 设成 2000,表示 2000 米观察距离内聚合图标不被地形和建筑遮挡,这个是经验值,第 4 章展开讲。

一个性能提醒:这个回调在相机移动时会被高频触发,回调里尽量只做属性赋值,不要 new Image、不要发请求、不要做复杂遍历。如果聚合成员的数组需要保存,slice 一份存到 Map 里,别直接持引用,原因后面讲。

3.3 点击聚合节点与普通 POI 的区分

聚合节点也是 Entity,点击拾取能拿到它,但拿到的对象和普通 POI 没有天然区别,需要自己标记。我的做法是维护一个 Map,把 cluster 实体映射到它的成员列表。

const clusterMap = new Map(); poiSource.clustering.clusterEvent.addEventListener((clusteredEntities, cluster) => { clusterMap.set(cluster, clusteredEntities.slice()); // 样式设置代码同上一节,省略 }); const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction(movement => { const picked = viewer.scene.pick(movement.position); if (!Cesium.defined(picked) || !Cesium.defined(picked.id)) return; const members = clusterMap.get(picked.id); if (members) { // 点到聚合节点:飞行到该区域并放大 const carto = Cesium.Cartographic.fromCartesian( picked.id.position.getValue(Cesium.JulianDate.now()) ); viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees( Cesium.Math.toDegrees(carto.longitude), Cesium.Math.toDegrees(carto.latitude), viewer.camera.positionCartographic.height * 0.4 ) }); } else { // 点到普通 POI:这里做弹窗或高亮 const name = picked.id.properties.poiName.getValue(undefined); console.log('POI clicked:', name); } }, Cesium.ScreenSpaceEventType.LEFT_CLICK);

scene.pick 返回的对象带 id 属性,对实体图元来说 id 就是 Entity。clusterMap.get(picked.id) 命中就说明点击的是聚合节点,否则是普通 POI。飞行高度用当前相机高度的 40%,一次点击大约放大 2.5 倍,两三次点击能把聚合完全拆开。注意 properties 里的值要 getValue(undefined) 取出,这是 Cesium Property 体系的标准用法,直接用 picked.id.properties.poiName 拿到的是属性对象而不是原始值。

4. 聚合避坑手册:5 个高频翻车点与排查路径

4.1 聚合图标点不中,点击事件总落空

现象:聚合节点显示正常,但鼠标点上去没反应,用 drillPick 却发现点到了别的实体。

原因:最常见的是 label 的像素尺寸大于 billboard,pick 射线最先命中 label 或它的背景板,命中区域落在 label 边缘时,某些 Cesium 版本里 picked 会是 undefined。其次是聚合节点没有显式设置 width 和 height,命中区域只有图标本身那么小。

解决:给 cluster.label 设置向左或向右的 pixelOffset,让文字离开图标主体;把 billboard 的 width 和 height 放大到 48 像素以上,扩大命中区域。拾取需求复杂的项目,直接用 viewer.scene.drillPick 替代 pick,它返回按深度排序的命中数组,遍历找第一个带 id 的对象,能绕开大部分层级遮挡问题。

4.2 缩放过程中聚合图标闪烁,像灯坏了一样

现象:连续缩放时聚合节点一闪一闪,或者相机没怎么动,聚合节点在聚合和拆分两个状态之间反复横跳。

原因:两个层面。第一,clusterEvent 在相机停止移动前会高频触发,回调里如果每次创建新样式对象或操作 DOM,浏览器 GC 一介入就是肉眼可见的卡顿和闪烁。第二,聚合判定本身有像素阈值,相机缓慢移动时部分 POI 恰好处于 pixelRange 边界,一会儿并一会儿拆,这是临界抖动,无法完全消除,只能缓解。

解决:clusterEvent 回调里只做赋值,图标图片在页面初始化时就加载成 Image 对象再传入,不要在回调里 new。对临界抖动,把 minimumClusterSize 提到 3 以上,让边界上的单点 POI 不参与聚合,抖动会明显减少。老版本 Cesium 上还可以给 clusterEvent 做 200 毫秒节流,用 requestAnimationFrame 包一层,虽然不算优雅,但确实有效。

4.3 POI 一多帧率就崩,5 万条直接变幻灯片

现象:实体数量超过 5 万,相机一移动帧率掉到个位数,CPU 占用拉满。

原因:EntityCluster 的聚合判定需要把每个实体投影到屏幕再算距离,这是 O(n) 遍历,5 万实体的遍历不至于要命,真正要命的是 5 万个 billboard 实体的渲染开销——每个实体一次独立 draw call,加上聚合过程要频繁重算成员关系,双重压力下帧率必然崩。实体数量是主要瓶颈,不是聚合算法本身。

解决:两条路同时走。第一,用第 5 章的格网预聚合把 5 万砍到 5000。第二,让非聚合层走 Primitive 体系,把 billboard 实体换成 PointPrimitiveCollection,draw call 从 5 万降到个位数。这里补一句血泪经验:别指望只调 pixelRange 扛性能,聚合计算量反而会变大,正确顺序永远是先减实体、再调渲染。

4.4 聚合节点被地形和高楼压住看不见

现象:项目加了 3D 地形后,山体背面的聚合节点完全看不见,城市里的聚合节点被高层模型挡住。

原因:聚合节点的位置是成员地理坐标的加权平均,这个平均点可能落在起伏地形之下,也可能落在楼模背后。默认情况下 billboard 的深度测试是开启的,聚合节点被遮挡是正确渲染行为,但在地图业务里通常不是你要的效果。

解决:在 clusterEvent 里给聚合节点 billboard 设置 disableDepthTestDistance,用有限值比无限大合理。我的习惯是 2000,意思是 2000 米观察距离内聚合图标永远在最上层,拉远后回归深度测试,保留山体遮挡的立体感。也可以把 heightReference 设为 CLAMP_TO_GROUND 让节点贴地,但地形起伏大的区域,贴地后节点可能和成员 POI 有肉眼可见的错位。这个问题属于地图玄学里典型的取舍,没有标准答案。

4.5 清除数据后聚合图标残留,Map 引用泄漏

现象:调用 poiSource.entities.removeAll() 清掉数据,地图上聚合节点还在,或者重构图层后旧聚合样式跑到新图层上。

原因:正常情况下 removeAll 会触发聚合重算,把聚合节点一并移除。残留通常出现在两个地方——一是 clusterMap 还持有 cluster 实体引用,Map 不清、引用不释放,打开内存面板能看到实体对象只增不减;二是切换图层时 new 了新数据源,但 clusterEvent 回调还挂在旧数据源上,旧聚合节点的样式被新数据源的聚合事件重复触发,看起来像幽灵图标。

解决:清除数据按固定顺序操作:先 poiSource.clustering.enabled = false 暂停聚合,再 entities.removeAll(),最后 clusterMap.clear()。切换图层时直接 viewer.dataSources.remove(poiSource),不要复用同一个数据源实例装不同业务数据。排查这类问题最快的路径是打开浏览器内存面板做一次 heap snapshot,对比清数据前后的对象数量,能精确看出引用没释放的位置。

5. 海量 POI 性能调优:从实体聚合升级到格网聚合

5.1 分层显示:远景聚合、近景明细的切换时机

数据量大的项目第一步不是优化渲染,而是想清楚什么时候给用户看什么。我的做法是分层:相机高度大于 3 公里显示聚合层,小于 3 公里显示明细层。聚合层用格网预聚合后的点位,数量控制在几千;明细层用原始 POI,只渲染当前视野范围。

// aggregateLayer 和 detailLayer 是事先创建好的两个 CustomDataSource const LAYER_SWITCH_HEIGHT = 3000; // 米 viewer.camera.changed.addEventListener(() => { const height = viewer.camera.positionCartographic.height; aggregateLayer.show = height >= LAYER_SWITCH_HEIGHT; detailLayer.show = height < LAYER_SWITCH_HEIGHT; });

camera.changed 在相机参数变化时触发,这里只看高度,简单可靠。这个事件触发很频繁,回调里只做 show/hide,不要做其他计算。为了消除切换瞬间的空白帧,我习惯让切换高度有重叠区间——聚合层到 2500 米才隐藏,明细层在 3000 米就显示,中间两层共存,视觉上无缝。本质是 LOD 思想,只是把模型 LOD 搬到了 POI 数据上。

5.2 格网预聚合:按经纬度分桶生成聚合点

格网预聚合的核心是把地理空间切成单元格,一个单元格的 POI 合成一个点。单元格大小跟相机高度挂钩,高度越高格子越大。

function buildGridAggregates(pois, cellDeg) { const buckets = new Map(); for (const p of pois) { const cellX = Math.floor(p.lng / cellDeg); const cellY = Math.floor(p.lat / cellDeg); const key = `${cellX},${cellY}`; if (!buckets.has(key)) buckets.set(key, []); buckets.get(key).push(p); } const result = []; buckets.forEach(list => { let sumLng = 0, sumLat = 0; list.forEach(p => { sumLng += p.lng; sumLat += p.lat; }); result.push({ lng: sumLng / list.length, lat: sumLat / list.length, count: list.length, items: list }); }); return result; }

提示:cellDeg 要随相机高度动态调整,别写死,否则缩放到大范围时聚合点过密、放大时又过稀。

输入是清洗后的 POI 数组和单元格大小,输出是聚合点数组。cellDeg 单位是度,纬度上 1 度约等于 111 公里,要做到几百米聚合粒度,cellDeg 在 0.01 到 0.005 量级。我用过一个经验公式:cellDeg = cameraHeight / 400000,相机高度 10 公里时算出 0.025 度,大概对应 2.7 公里一格,可以当起始值再微调。分桶用 Map 而不是对象,是因为拼接 key 在 Map 里哈希查找更快,几万条数据差异不大,但这是习惯。

聚合点生成后装进单独的 CustomDataSource,每个点带 count 属性,用 clusterEvent 显示数量。注意这时候的聚合点是地理格子级别的固定节点,不会随相机自行拆分,所以要么把该数据源的 clustering.enabled 设为 false,要么把 pixelRange 设得极大让它永不合并。

5.3 图标合并与渲染开销控制

实体数量降下来后,draw call 优化也不可忽视。一个 billboard 一个 draw call,几千个聚合点就是几千次调用,图集是标准解法。把 POI 分类图标合成到一张 PNG,用 imageSubRegion 切出子图标。

// 假设 atlas 是 4x4 的图标网格,每个 32x32 const ATLAS_GRID = 4; const ICON_SIZE = 32; function iconRegion(index) { const col = index % ATLAS_GRID; const row = Math.floor(index / ATLAS_GRID); return new Cesium.BoundingRectangle(col * ICON_SIZE, row * ICON_SIZE, ICON_SIZE, ICON_SIZE); } // 添加聚合点时 dataSource.entities.add({ position: Cesium.Cartesian3.fromDegrees(agg.lng, agg.lat), billboard: { image: '/assets/img/poi_atlas.png', imageSubRegion: iconRegion(agg.typeIndex), width: 32, height: 32 } });

imageSubRegion 让 Cesium 只取图集里对应的小块,多次 draw call 变成一次纹理绑定。合成图集时每个子图标四周留 2 像素透明边距,避免线性过滤采样串色,这是图集方案最容易翻车的细节。渲染开销控制到这一步,配合 5000 聚合点上限,二十万级 POI 的全景浏览基本能稳定在 40 帧以上。

6. 聚合进阶:按字段分组、飞行钻取与正确性验证

6.1 按业务字段分组聚合,让颜色会说话

聚合不只是合并数量,还能带业务语义。在 clusterEvent 里统计成员实体的 type 占比,取最高的类型给聚合节点染色。

poiSource.clustering.clusterEvent.addEventListener((clusteredEntities, cluster) => { const typeCount = {}; clusteredEntities.forEach(e => { const t = e.properties.poiType.getValue(undefined); typeCount[t] = (typeCount[t] || 0) + 1; }); const mainType = Object.entries(typeCount).sort((a, b) => b[1] - a[1])[0][0]; cluster.billboard = { image: '/assets/img/cluster_marker.png', width: 46, height: 46, color: colorMap[mainType] || Cesium.Color.GRAY }; cluster.label = { text: String(clusteredEntities.length), font: 'bold 14px Arial' }; });

一个前提:给 billboard 染色要求贴图是白色或浅色底,color 是乘法运算,深色贴图染出来会发黑。素材统一用灰度底图,颜色全部交给代码控制,这是我被配色坑过之后固定下来的习惯。

6.2 点击聚合后飞行展开:高度节奏

拆开聚合的条件是成员间屏幕距离超过 pixelRange。相机高度减半,地面距离在屏幕上约放大一倍,所以点击聚合节点后飞到当前高度的 40%,一次点击就能让聚合组裂成两三个小组,两三次点击完全拆开。这个渐进节奏比一步到位更符合空间认知,用户能看到聚合逐步裂开、密度区域逐步显现的过程。

6.3 验证聚合正确性的三个检查点

上线前固定检查三件事。数量守恒:全图缩小后所有聚合 count 加散点总数等于 POI 总数。等价拆解:任意聚合节点放大后拆出的 POI,和原始数据在该范围内的记录一致。状态一致:同一视角反复进出聚合结果稳定,不稳定多半是 clusterEvent 里混入了异步操作。

聚合的事做完别急着上线,先跑一遍这三项检查。这些年我的习惯是先用内置聚合到线上跑量,顶不住再换格网,一上来就自研聚合的返工成本远高于想象。希望帮到你。

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

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

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

立即咨询