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

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

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

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

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

原理:

  1. 把矩形拆成 N×M 的网格;
  2. 对每一个网格中心 drillPick()(射线拾取);
  3. 把返回的 Entity/Primitive 收集起来。
ini 复制代码
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],遍历:

ini 复制代码
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 屏和画布缩放),避免分辨率偏差。

arduino 复制代码
// 屏幕坐标(窗口)→ 绘图缓冲区坐标
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:计算框选边界矩形

基于转换后的坐标,计算框选区域的最小 / 最大坐标和宽高:

ini 复制代码
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 空间范围:

ini 复制代码
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 范围内的对象,再验证其屏幕坐标是否在框选内,兼顾性能和精度:

kotlin 复制代码
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;
}

四、总结

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

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

相关推荐
gis_rc2 天前
python下shp转3dtiles
python·3d·cesium·3dtiles·数字孪生模型
grasperp3 天前
3DTiles数据切片工具,支持LAS、OBJ、FBX 3DTiles怎么切片?3DTiles切片
cesium·3dtiles·三维gis·3dtiles切片·数据切片
duansamve5 天前
Cesium中实现在地图上移动/旋转点、线、面
cesium
冥界摄政王6 天前
CesiumJS学习第四章 替换指定3D建筑模型
3d·vue·html·webgl·js·cesium
冥界摄政王8 天前
Cesium学习第二章 camera 相机
node.js·html·vue3·js·cesium
冥界摄政王9 天前
Cesium学习第一章 安装下载 基于vue3引入Cesium项目开发
vue·vue3·html5·webgl·cesium
你们瞎搞10 天前
Cesium加载20GB航测影像.tif
前端·cesium·gdal·地图切片
闲云一鹤12 天前
Cesium 使用 Turf 实现坐标点移动(偏移)
前端·gis·cesium
二狗哈12 天前
Cesium快速入门34:3dTile高级样式设置
前端·javascript·算法·3d·webgl·cesium·地图可视化
二狗哈13 天前
Cesium快速入门33:tile3d设置样式
3d·状态模式·webgl·cesium·地图可视化