Cesium 框选工具的 3 次演进:从卡顿到精准,我踩过这些坑
最近在开发一个Cesium的框选功能,因为性能问题头疼了很久。 经过多次尝试,终于找到了一套相对完善的解决方案,在此记录分享,也希望能为遇到类似挑战的朋友提供参考。
一、方案 1:传统射线拾取(drillPick)—— 能实现但卡到崩溃
最开始先想到的是 Cesium 自带的drillPick方法 —— 它能获取屏幕某点的所有空间对象。基于这个 API,很容易想到「密集采样 + 去重」的框选思路。
原理:
- 把矩形拆成 N×M 的网格;
- 对每一个网格中心
drillPick()(射线拾取); - 把返回的 Entity/Primitive 收集起来。
for (let x = xMin; x < xMax; x += 12) {
for (let y = yMin; y < yMax; y += 12) {
const picked = viewer.scene.drillPick(new Cesium.Cartesian2(x, y));
picked.forEach(p => resultSet.add(p.id || p.primitive));
}
}
从结果上说只要框的住,就一定能选中。
但是!!!!
性能极差!!!尤其是框稍微大一点,每个点drillPick都要遍历场景对象,导致页面卡的飞起。
二、方案 2:经纬度范围筛选 —— 性能好了但精度崩了
好吧,drillPick用不成了,那我换个思路计算范围怎么样。
首先想到的是把屏幕框选的对角点转成经纬度坐标,再判断每个对象的经纬度是否在这个范围内。这样不用密集采样,性能自然提升。
把矩形两个对角-> Cartographic -> 经纬度范围 [lngMin, lngMax, latMin, latMax],遍历:
entities.forEach(e => {
const carto = Cesium.Cartographic.fromCartesian(e.position);
if (lngMin < carto.longitude < lngMax && latMin < carto.latitude < latMax) {
selected.add(e);
}
});
这下终于快多了,但是没想到又有新的问题 —— 精度不匹配
视野倾斜角度过大,模型判断不准等问题导致拾取的结果大相径庭
三、方案 3:屏幕坐标 + 视锥剔除 —— 精准与性能的平衡
好吧,既然经纬度不好判断,那我都换成屏幕坐标呢?
3.1 核心思路
- 坐标转换:将屏幕框选的对角点转成 Cesium 的「绘图缓冲区坐标」(适配不同分辨率);
- 构建视锥:基于框选区域创建一个「局部视锥」(用PerspectiveOffCenterFrustum),这个视锥就是屏幕框选区域在 3D 空间的「视野范围」;
- 视锥判断:遍历对象时,先用视锥剔除判断对象是否在「框选视锥」内(快速排除);
- 屏幕坐标验证:对通过视锥判断的对象,再验证其屏幕坐标是否在框选范围内(确保精准)。
3.2 关键步骤拆解(附代码)
步骤 1:屏幕坐标转绘图缓冲区坐标
Cesium 的SceneTransforms.transformWindowToDrawingBuffer能将浏览器窗口坐标(像素)转成「绘图缓冲区坐标」(适配 Retina 屏和画布缩放),避免分辨率偏差。
// 屏幕坐标(窗口)→ 绘图缓冲区坐标
const startBufferPos = Cesium.SceneTransforms.transformWindowToDrawingBuffer(
this.viewer.scene, startPos, new Cesium.Cartesian2()
);
const endBufferPos = Cesium.SceneTransforms.transformWindowToDrawingBuffer(
this.viewer.scene, endPos, new Cesium.Cartesian2()
);
步骤 2:计算框选边界矩形
基于转换后的坐标,计算框选区域的最小 / 最大坐标和宽高:
calculateBoundingRectangle(start, end) {
const minX = Math.min(start.x, end.x);
const maxX = Math.max(start.x, end.x);
const minY = Math.min(start.y, end.y);
const maxY = Math.max(start.y, end.y);
return new Cesium.BoundingRectangle(minX, minY, maxX - minX, maxY - minY);
}
步骤 3:创建局部视锥剔除体(关键)
这是方案的核心 —— 用PerspectiveOffCenterFrustum创建一个「框选区域对应的视锥」,替代全局相机视锥。这个视锥能精准匹配屏幕框选的 3D 空间范围:
createCullingVolume(rectCenter, width, height) {
const scene = this.viewer.scene;
const camera = scene.camera;
const frustum = camera.frustum; // 相机原始视锥
// 1. 计算视锥偏移(基于框选中心)
const ndcX = 2.0 * rectCenter.x / scene.drawingBufferWidth - 1.0;
const ndcY = 2.0 * (scene.drawingBufferHeight - rectCenter.y) / scene.drawingBufferHeight - 1.0;
const rightOffset = ndcX * frustum.near * frustum.aspectRatio * Math.tan(frustum.fovy / 2);
const topOffset = ndcY * frustum.near * Math.tan(frustum.fovy / 2);
// 2. 计算框选区域的像素尺寸(3D空间中)
const pixelDimensions = frustum.getPixelDimensions(
scene.drawingBufferWidth,
scene.drawingBufferHeight,
frustum.near,
scene.pixelRatio,
new Cesium.Cartesian2()
);
const halfWidth = pixelDimensions.x * width / 2;
const halfHeight = pixelDimensions.y * height / 2;
// 3. 创建局部视锥(框选区域对应的视锥)
const offCenterFrustum = new Cesium.PerspectiveOffCenterFrustum();
offCenterFrustum.right = rightOffset + halfWidth;
offCenterFrustum.left = rightOffset - halfWidth;
offCenterFrustum.top = topOffset + halfHeight;
offCenterFrustum.bottom = topOffset - halfHeight;
offCenterFrustum.near = frustum.near;
offCenterFrustum.far = frustum.far;
// 4. 生成视锥剔除体
return offCenterFrustum.computeCullingVolume(
camera.positionWC, // 相机位置
camera.directionWC, // 相机方向
camera.upWC // 相机上方向
);
}
步骤 4:视锥 + 屏幕坐标双重过滤
遍历对象时,先通过视锥剔除快速排除不在 3D 范围内的对象,再验证其屏幕坐标是否在框选内,兼顾性能和精度:
scanVisibleEntities(cullingVolume, boundingRect) {
const results = { models: [], entities: [], point: [] };
const scene = this.viewer.scene;
// 1. 遍历Primitives(模型、点集合等)
scene.primitives._primitives.forEach(primitive => {
// 视锥剔除:快速排除不在框选视锥内的对象
if (!primitive.show || !this.isVisible(primitive, cullingVolume)) return;
// 屏幕坐标验证:确保对象在屏幕框选范围内
const worldPos = primitive.boundingSphere?.center || primitive.position;
const screenPos = Cesium.SceneTransforms.worldToWindowCoordinates(scene, worldPos);
if (!screenPos) return;
// 判断屏幕坐标是否在框选内
const inRect = screenPos.x >= boundingRect.x &&
screenPos.x <= boundingRect.x + boundingRect.width &&
screenPos.y >= boundingRect.y &&
screenPos.y <= boundingRect.y + boundingRect.height;
if (inRect) {
if (primitive instanceof Cesium.Model) results.models.push(primitive);
else if (primitive instanceof Cesium.PointPrimitive) results.point.push(primitive);
}
});
// 2. 遍历Entities(同上逻辑,省略)
this.viewer.entities._entities._array.forEach(entity => { /* ... */ });
return results;
}
// 辅助方法:判断对象是否在视锥内
isVisible(object, cullingVolume) {
const volume = object.boundingVolume || object.boundingSphere || object.position;
if (!volume) return false;
// 视锥相交判断:OUTSIDE表示完全不在视锥内
return cullingVolume.computeVisibility(volume) !== Cesium.Intersect.OUTSIDE;
}
四、总结
上面的方法应该能够应对大多数场景,但应该还能有优化的空间,减少遍历的次数。
如果你在使用过程中遇到问题,或者有更好的优化思路,欢迎在评论区交流讨论。