用Three.js打造高性能3D地图的实战经验与优化技巧
3D地图上点不准?我差点把鼠标摔了
上周做个项目,要在 3D 地图上点选某个区域高亮,结果手指(或者鼠标)点哪儿都不对。明明点在建筑上,结果触发的是隔壁街区的事件。调试一整天,越调越懵,最后发现是坐标转换的问题——但过程真是折腾得够呛。
用的是 CesiumJS,一个挺主流的 3D 地球库。本来以为直接监听 screenSpaceEventHandler 的 LEFT_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 = true是pickPosition能用的前提 - 移动端注意 viewport 和缩放:别让 CSS 或 meta 标签搞乱了触摸坐标
- 做好 fallback:
pickPosition失败时回退到地表,避免完全无响应
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有人用过 Scene.drillPick 来穿透多个图层?我试过但性能不太好,就没上。这个技巧的拓展用法还有很多,后续会继续分享这类博客。

暂无评论