用Three.js打造高性能3D地图的实战经验与优化技巧

Tr° 文君 交互 阅读 1,199
赞 35 收藏
二维码
手机扫码查看
反馈

3D地图上点不准?我差点把鼠标摔了

上周做个项目,要在 3D 地图上点选某个区域高亮,结果手指(或者鼠标)点哪儿都不对。明明点在建筑上,结果触发的是隔壁街区的事件。调试一整天,越调越懵,最后发现是坐标转换的问题——但过程真是折腾得够呛。

用Three.js打造高性能3D地图的实战经验与优化技巧

用的是 CesiumJS,一个挺主流的 3D 地球库。本来以为直接监听 screenSpaceEventHandlerLEFT_CLICK 就行,拿到笛卡尔坐标再转成经纬度,不就完事了?结果实际点的时候,偏差能有几百米,尤其是在倾斜视角下,简直离谱。

试了三种方案,前两种都翻车了

一开始我以为是精度问题,是不是地球曲率没算好?于是去查 Cesium 的文档,看到 pick 方法可以获取场景中被点击的对象。我就试了:

const handler = new Cesium.ScreenSpaceEventHandler(viewer.canvas);
handler.setInputAction(function(movement) {
  const pickedObject = viewer.scene.pick(movement.position);
  if (Cesium.defined(pickedObject)) {
    console.log('点到了实体');
  }
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);

但问题是,我压根没往场景里加实体(Entity),只是用 GeoJsonDataSource 加载了一些面数据。这些面在渲染时是 primitives,不是 Entity,所以 pick 根本拿不到。白忙活。

后来我又想,那直接用 camera.pickEllipsoid 吧,这个方法能把屏幕坐标转成地球表面的笛卡尔坐标,再转经纬度:

const ray = viewer.camera.getPickRay(movement.position);
const cartesian = viewer.scene.globe.pick(ray, viewer.scene);
if (cartesian) {
  const cartographic = Cesium.Cartographic.fromCartesian(cartesian);
  const lon = Cesium.Math.toDegrees(cartographic.longitude);
  const lat = Cesium.Math.toDegrees(cartographic.latitude);
}

看起来没问题,但一测试发现:在 3D 倾斜视角下,点的位置会“漂移”——你点的是楼顶,结果返回的是地面投影点。因为 globe.pick 默认是打到 WGS84 椭球面上,而我的建筑模型是浮在空中的,根本不在地表。所以这个方法只适用于地表点击,不适用于 3D 建筑或地形之上的对象。

这里我踩了个大坑:以为所有 3D 地图点击都是打到地表,其实用户看到的“点击位置”和实际射线交点可能差很远,尤其在俯视角度小的时候。

最终方案:用 scene.pickPosition + 高度补偿

折腾到快下班,我突然想到:Cesium 其实有个 scene.pickPosition 方法,它能根据深度缓冲(depth buffer)还原出当前像素对应的 3D 世界坐标,包括高度!这不就是我要的吗?

赶紧试了下:

const handler = new Cesium.ScreenSpaceEventHandler(viewer.canvas);
handler.setInputAction(function(movement) {
  const position = viewer.scene.pickPosition(movement.position);
  if (Cesium.defined(position)) {
    const cartographic = Cesium.Cartographic.fromCartesian(position);
    const lon = Cesium.Math.toDegrees(cartographic.longitude);
    const lat = Cesium.Math.toDegrees(cartographic.latitude);
    const height = cartographic.height;
    console.log(点击位置:${lon}, ${lat}, 高度:${height});
  }
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);

结果一跑,坐标准了!点楼顶就是楼顶,点街道就是街道。但有个前提:必须开启深度测试,而且场景里要有足够的几何信息供深度缓冲使用。默认情况下 Cesium 是开的,但如果用了某些自定义着色器或者关闭了 depthTest,pickPosition 就会返回 undefined。

为了保险起见,我在初始化 viewer 时加了这行:

viewer.scene.globe.depthTestAgainstTerrain = true;

这样即使点击的是地形上的建筑,也能正确拾取到高度。不过要注意,如果点击的是天空或者没有几何体的区域,pickPosition 依然会返回 undefined,所以得做判断。

但还有个小问题:移动端 touch 事件偏移

PC 上搞定了,结果一上手机,又出问题了。手指点的位置和实际触发的位置有偏移,大概偏右下 20px 左右。我一开始以为是 viewport 问题,检查了 canvas 的 CSS,发现没设 padding 或 border,应该没问题。

后来才想起来:Cesium 的 movement.position 是基于 canvas 的 clientX/clientY,但在移动端,如果页面有缩放(比如 viewport 设置不当),或者用了 transform: scale(),坐标就会错位。我们项目里恰好用了 meta viewport 但没处理好,导致触摸坐标没对齐。

解决方法很简单:确保 canvas 的 CSS 是 1:1 显示,不要用 transform 缩放,同时设置:

<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">

另外,在获取位置时,最好用 getBoundingClientRect 做一次校正(虽然 Cesium 内部应该已经处理了,但有些旧版本可能有 bug)。不过这次我们升级了 Cesium 到 1.105,这个问题就没了。

完整可用的点击拾取代码

最后整理了一份稳定可用的代码,支持 PC 和移动端,带 fallback 逻辑:

// 初始化 viewer 时开启深度测试
viewer.scene.globe.depthTestAgainstTerrain = true;

const clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.canvas);
clickHandler.setInputAction(function(movement) {
  // 优先使用 pickPosition(带高度)
  let cartesian = viewer.scene.pickPosition(movement.position);
  
  // 如果 pickPosition 失败(比如点到天空),回退到地表
  if (!Cesium.defined(cartesian)) {
    const ray = viewer.camera.getPickRay(movement.position);
    cartesian = viewer.scene.globe.pick(ray, viewer.scene);
  }

  if (Cesium.defined(cartesian)) {
    const cartographic = Cesium.Cartographic.fromCartesian(cartesian);
    const lon = Cesium.Math.toDegrees(cartographic.longitude);
    const lat = Cesium.Math.toDegrees(cartographic.latitude);
    const height = cartographic.height || 0;
    
    // 这里可以做你的业务逻辑,比如高亮、弹窗等
    console.log(点击位置:${lon.toFixed(6)}, ${lat.toFixed(6)}, 高度:${height.toFixed(2)});
    
    // 示例:发送到后端
    // fetch('https://jztheme.com/api/click-location', {
    //   method: 'POST',
    //   body: JSON.stringify({ lon, lat, height })
    // });
  } else {
    console.warn('未拾取到有效位置');
  }
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);

这段代码亲测有效,目前线上跑着没啥大问题。唯一的小瑕疵是:在极端低帧率下(比如低端手机加载大量 3D 模型时),pickPosition 可能因为深度缓冲还没更新而返回旧值。但这属于性能问题,不是逻辑 bug,暂时没动它——毕竟用户也不会在这种卡顿场景下精确点击。

总结一下踩坑点

  • 别假设点击一定落在地表:3D 场景里高度很重要,用 pickPosition 而不是 globe.pick
  • 深度测试必须开depthTestAgainstTerrain = truepickPosition 能用的前提
  • 移动端注意 viewport 和缩放:别让 CSS 或 meta 标签搞乱了触摸坐标
  • 做好 fallbackpickPosition 失败时回退到地表,避免完全无响应

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有人用过 Scene.drillPick 来穿透多个图层?我试过但性能不太好,就没上。这个技巧的拓展用法还有很多,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论