Cesium框选工具的3次进化:从卡顿到精准,我踩过这些坑

38 阅读4分钟

Cesium 框选工具的 3 次演进:从卡顿到精准,我踩过这些坑

最近在开发一个Cesium的框选功能,因为性能问题头疼了很久。 经过多次尝试,终于找到了一套相对完善的解决方案,在此记录分享,也希望能为遇到类似挑战的朋友提供参考。

一、方案 1:传统射线拾取(drillPick)—— 能实现但卡到崩溃

最开始先想到的是 Cesium 自带的drillPick方法 —— 它能获取屏幕某点的所有空间对象。基于这个 API,很容易想到「密集采样 + 去重」的框选思路。

原理:

  1. 把矩形拆成 N×M 的网格;
  2. 对每一个网格中心 drillPick()(射线拾取);
  3. 把返回的 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 核心思路

  1. 坐标转换:将屏幕框选的对角点转成 Cesium 的「绘图缓冲区坐标」(适配不同分辨率);
  1. 构建视锥:基于框选区域创建一个「局部视锥」(用PerspectiveOffCenterFrustum),这个视锥就是屏幕框选区域在 3D 空间的「视野范围」;
  1. 视锥判断:遍历对象时,先用视锥剔除判断对象是否在「框选视锥」内(快速排除);
  1. 屏幕坐标验证:对通过视锥判断的对象,再验证其屏幕坐标是否在框选范围内(确保精准)。

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;
}

四、总结

上面的方法应该能够应对大多数场景,但应该还能有优化的空间,减少遍历的次数。

如果你在使用过程中遇到问题,或者有更好的优化思路,欢迎在评论区交流讨论。