如果你在 Cesium 里画过一个椭圆,大概率经历过下面这类怪事:直接写死经纬度坐标,调用viewer.entities.add加一个ellipse,图形显示完全正常;可一旦把它升级成“鼠标点一下、移动预览、再点一下确定”的绘图工具,中心点就开始飘,画出来的圆形像被压扁过,甚至图形直接跑到地球另一侧。还有一批更隐蔽的问题:预览的时候圆是贴着地面的,松开鼠标后图形却陷进地形里;又或者点击位置明明在屏幕上,可落点就是和鼠标对不上。
很多人第一反应是 API 用错了,于是反复改semiMajorAxis、semiMinorAxis、rotation这些参数。但真正的问题往往出在别处。这篇文章想先给一个明确判断:Cesium 绘制 Ellipse 的技术难点,从来不在ellipse这个 API 本身,而在从鼠标屏幕坐标到空间米制半径之间的坐标链路,以及绘图过程的会话状态设计。这条链路理清楚以后,不管你画的是雷达扫描圆、施工影响范围、可视域缓冲区,还是普通的圆形覆盖物,底层原理都是同一套。
本文会从 Cesium 绘图架构讲起,然后拆解“屏幕坐标 → 椭球坐标 → 半径长度”的转换流程,再给出一份可直接运行的 JavaScript 示例代码,最后补充地形、贴地、动态预览、常见排错和工程化建议。适合正在做 Cesium 绘图工具、业务编辑器,或者需要自己实现“点选绘制”交互的开发者阅读。
1. 这篇文章真正要解决的问题
Cesium 作为一个三维地球引擎,提供的“绘制能力”和传统 GIS 桌面软件完全是两种形态。传统桌面 GIS 里,画一个椭圆通常是一次性把几何对象交给绘制引擎,后续再做编辑。而 Web 端 Cesium 绘图工具要求你在每一帧的鼠标移动事件里,把临时几何状态同步到三维场景中,用户松开鼠标只是一次会话的结束,而不是绘制的结束。
围绕 Ellipse 这个图元,开发者实际踩坑的点非常集中,主要有四类:
- 画出来的不是预期的椭圆:半轴用像素算、用经纬度差值算、用度直接当米,最终图形变形或大小离谱。
- 预览动态效果与最终结果不一致:鼠标移动时是圆形,点下去以后又变成了另一个形状,通常是临时 Entity 和最终 Entity 的参数不一致。
- 图形与地形/底图贴合不上:没有正确处理
height、heightReference、深度测试,或者拾取的是椭球面而不是真实地形面。 - 交互状态混乱:用户连续点击、右键取消、按 Esc 撤销时,事件监听没有正确清理,导致重复添加图元、事件泄漏,甚至把别的绘图工具的事件一起触发。
所以本文不会只贴一段简单的add ellipse示例就结束,而是会把一个真实可用的“最小交互式 Ellipse 绘图工具”拆开来讲。重点是让读者理解每一个环节背后的原因,而不是背参数。
2. Cesium 里椭圆图形的三层表达
2.1 Entity 是“一句话描述”层
在 Cesium 里画椭圆,最简单的写法是这样:
const entity = viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(116.4, 39.9), ellipse: { semiMajorAxis: 1000, semiMinorAxis: 600, material: Cesium.Color.RED.withAlpha(0.4), outline: true, outlineColor: Cesium.Color.RED } });这里的position是椭圆中心的 ECEF 笛卡尔坐标,semiMajorAxis和semiMinorAxis的单位都是米。material控制填充样式,outline控制边界线。
这种写法最适合静态业务图元,比如事故影响范围、飞行禁入区、一次性的标绘结果。缺点是,一旦要实时交互,很难用这种硬编码entity方式自动更新所有参数。你必须自己维护一套状态,再把状态写入 Entity,或者改用CallbackProperty。
2.2 Geometry 与 Primitive 是“底层几何”层
如果再往下走一层,Cesium 的底层绘图是通过EllipseGeometry生成几何体,再交给Primitive渲染:
const geometry = new Cesium.EllipseGeometry({ center: Cesium.Cartesian3.fromDegrees(116.4, 39.9), semiMajorAxis: 1000, semiMinorAxis: 600, vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT }); const instance = new Cesium.GeometryInstance({ geometry }); const primitive = new Cesium.Primitive({ geometryInstances: instance, appearance: new Cesium.PerInstanceColorAppearance() }); viewer.scene.primitives.add(primitive);Entity API 是面向业务的高级封装,底层会自动帮你创建 Primitive。绝大多数绘图工具场景,使用 Entity API +CallbackProperty就能满足需求,不需要走到 Primitive 层面。
但从理解角度,要记住一点:EllipseGeometry 的半径参数单位永远是米。这和你在 2D 地图上画圆的缩放比例完全不同,也是接下来坐标链路里最容易出问题的点。
2.3 绘图工具里的“会话状态”层
绘图工具比“添加静态图元”多出来的核心东西,是一个绘图会话状态机。
举例来说,一次完整的 Ellipse 绘制过程,通常包含这样几个阶段:
| 阶段 | 状态 | 含义 | 用户操作 |
|---|---|---|---|
| 1 | IDLE | 空闲 | 啥也没发生 |
| 2 | WAIT_CENTER | 等待点击中心点 | 鼠标左键点击确定中心 |
| 3 | ADJUST_RADIUS | 移动鼠标调整半径 | 鼠标移动形成预览 |
| 4 | DONE | 完成绘制 | 鼠标左键再次点击确认 |
| 5 | CANCEL | 取消本次绘制 | 右键点击或按 Esc |
代码实现上的难点在于:第 2 阶段点击中心之前,你的鼠标事件处理器不应该做任何几何计算;第 3 阶段必须区分“移动预览”和“确认落点”;第 4 阶段之后要自动移除临时预览图形,并把最终结果回调给业务层。
很多项目画椭圆变形,正是因为事件逻辑没有按状态机区分,把每一次鼠标移动都当成了“最终半径”,或者把第一次点击既当中心又当边缘。
所以本文第 5 节给出的核心代码,不会用散乱的viewer.entities.add到处画图,而是把一次绘图封装成startDrawEllipse函数,内部维护状态机,结束后返回中心坐标、半轴长度、旋转角等结构化数据。
3. Ellipse 的坐标链路:从屏幕点击到米制图形
3.1 两种坐标拾取方式不能混用
在 Cesium 绘图工具里,你拿到的用户输入,本质是屏幕上的一个像素坐标,例如{x: 500, y: 300}。而 Ellipse 需要的是世界坐标 Cartesian3,比如{x: 2532977.5, y: 4692103.8, z: 4078035.2}。
从像素坐标到 Cartesian3,最常用的有两种方式:
方式一:拾取椭球面坐标
const cartesian = viewer.camera.pickEllipsoid( windowPosition, viewer.scene.globe.ellipsoid );这种方式不管场景里有没有真实地形,都只会把射线与数学椭球体的交点返回来。意思就是:你点击屏幕上一个位置,引擎会算出这条视线与地球椭球面的交点。如果没有加载地形,这是最常见的方案。
方式二:拾取场景深度坐标
const cartesian = viewer.scene.pickPosition(windowPosition);这种方式会读取当前渲染场景的深度缓冲,返回“屏幕上这个像素对应的场景三维坐标”。在有地形、3D Tiles、模型的情况下,它能拿到更真实的地表位置。
两种方式各有适用场景,很多椭圆画出来对不上鼠标,就是因为混用了。比如地形起伏很大的山区,你用pickEllipsoid拿到的是海平面位置,再把图形贴到实际地表,圆心和鼠标自然就对不上。反过来,如果你只是要画一个水平面上的圆形业务范围,却用了地形上的pickPosition,得到的Cartesian3会带着地表起伏,最终用这个三维点算半径时,半径可能变成一条空间斜线,而不是水平面的投影半径。
绘图工具这里更常见的正确处理是:先用scene.pickPosition尝试拾取,如果场景不支持深度拾取,再回退到camera.pickEllipsoid。然后用拾取到的 Cartesian3 统一参与后续计算。
3.2 半轴长度到底怎么算
假定你已经拿到了中心点centerCartesian3,也拿到了鼠标移动时产生的点movingCartesian3。用户经验里的“半径”,到底是这两点的空间直线距离,还是这两点在地表上的水平距离?
这里有一个容易忽略的细节:如果 terrain 已经开启,movingCartesian3可能来自地形表面。一个在山谷、一个在山顶,两个点的空间直线距离,和投影到水平面上的距离差别很大。
对于椭圆绘制工具,绝大多数业务语义希望半径是“地表水平距离”,或者“以中心点为基准的水平面半径”。在这个前提下,最稳妥的做法是:不要让半径跟随地形起伏,而是把中心点和移动点分别转换到经纬度,再把移动点的高度强制与中心点一致,然后计算两者在椭球表面上的距离。
简化代码可以是:
// 将两个 Cartesian3 转成 Cartographic const centerCarto = Cesium.Cartographic.fromCartesian(centerCartesian3); const movingCarto = Cesium.Cartographic.fromCartesian(movingCartesian3); // 统一高度,避免把地形起伏带入半径 movingCarto.height = centerCarto.height; // 使用 EllipsoidGeodesic 计算椭球面上的曲面距离,更符合“地表距离”语义 const geodesic = new Cesium.EllipsoidGeodesic( centerCarto, movingCarto ); const radius = geodesic.surfaceDistance;这里的surfaceDistance返回的是椭球面上两个经纬度点之间的曲面距离,单位是米。它的计算结果比简单的Cartesian3.distance更接近 GIS 使用者理解的“地面上量出来的距离”。
如果你处理的是非常小的范围,视觉上差别不大,直接用Cesium.Cartesian3.distance(centerCartesian3, movingCartesian3)也能接受。但在一个绘图工具里,建议从一开始就保持统一的半径计算口径,否则后续做存储、测量、长度校验时会非常被动。
3.3 旋转角度的理解误区
Ellipse 不一定是正圆,semiMajorAxis和semiMinorAxis不一样时,就需要一个旋转角告诉 Cesium“椭圆的长半轴朝向哪里”。
rotation参数的单位是弧度,默认值是 0。容易困惑的地方在于,这个角度是相对什么方向计算的。很多开发者会直接用中心点和移动点的坐标差算一个反正切角度,比如:
const angle = Math.atan2( movingCartesian3.y - centerCartesian3.y, movingCartesian3.x - centerCartesian3.x );这个算法在局部小范围、离极点很远的时候可能“碰巧”看着对。但它本质是 ECEF 坐标下的平面角度,不是基于当地北方向的方位角。到了高纬度地区,或区域跨越较大时,图形方向会明显偏转。
一个相对稳妥的思路是:先通过Cesium.Transforms.eastNorthUpToFixedFrame建立中心点的局部东北上坐标系,再把中心点到移动点的方向向量转换到该坐标系里,最后用atan2算出相对正北或正东的角度。这样更接近 GIS 里“以正北为 0 度,顺时针旋转”的直觉。
不过在这一步需要注意的是,Cesium 底层的几何生成方式和 2D 地图 API 的正负角方向可能并不完全一致。实际项目里不要想当然,建议先用一个已知方向的椭圆(比如长半轴朝正东或正北)做一遍测试,确认角度正负号,再把它固化到工具里。
3.4 CallbackProperty 让预览动态化
用户移动鼠标时,你不可能每次移动都销毁旧 Entity、新建新 Entity,那样性能很浪费,而且会出现闪烁。Cesium 提供了CallbackProperty,可以在每一帧渲染时动态计算参数。
典型用法是把需要动态变化的值包成一个函数:
const dynamicEllipse = viewer.entities.add({ position: new Cesium.CallbackProperty(() => { return drawState.centerCartesian; }, false), ellipse: { semiMajorAxis: new Cesium.CallbackProperty(() => { return drawState.currentRadius; }, false), semiMinorAxis: new Cesium.CallbackProperty(() => { return drawState.currentMinorRadius; }, false), material: Cesium.Color.RED.withAlpha(0.3), outline: true } });第二个参数传false是告诉 Cesium:这个属性不是常量,需要在每一帧重新求值。
但要注意:CallbackProperty里不要写太重的地形拾取、空间查询、网络请求。每一帧都会调用,一旦里面有昂贵计算,帧率会明显下降。
4. 环境准备与测试页面
这一节开始进入实操。为了降低环境门槛,先用一个最简单的静态 HTML 页面演示。如果你的项目基于 Vue3、React 或 Webpack,核心 API 完全一致,只是引入方式不同。
4.1 基础页面
创建一个index.html:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Cesium Ellipse 绘图工具示例</title> <link href="node_modules/cesium/Build/Cesium/Widgets/widgets.css" rel="stylesheet" /> <style> html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; font-family: "Microsoft YaHei", sans-serif; } </style> </head> <body> <div id="cesiumContainer"></div> <script src="node_modules/cesium/Build/Cesium/Cesium.js"></script> <script src="ellipse-draw.js"></script> </body> </html>如果你是在线 CDN 环境,把node_modules相关路径替换成你使用的 CDN 地址即可。核心点是Cesium.js和widgets.css必须配套,否则控件样式会异常。
4.2 初始化 Viewer
创建一个ellipse-draw.js,初始化Viewer:
const viewer = new Cesium.Viewer("cesiumContainer", { baseLayerPicker: false, animation: false, timeline: false, infoBox: false, selectionIndicator: false, sceneModePicker: false, navigationHelpButton: false }); // 为了让拾取结果能对应真实地形,开启深度测试是关键选项之一 viewer.scene.globe.depthTestAgainstTerrain = true; // 默认飞到北京附近 viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(116.4, 39.9, 2000) });这里需要特别解释一下depthTestAgainstTerrain。很多教程里没开这个选项,如果只加载影像底图,是否开启视觉差异不明显。但如果你加载了地形,没开深度测试时,贴地图形可能被地形“盖住”,或者拾取结果不准确。打开它能让后续的pickPosition拿到更可靠的地形坐标。
不过这个属性是全局的,开启后会影响一些地下、隧道、室内场景的渲染逻辑。如果你在做的项目本身就是复杂场景,不一定盲开,需要针对场景做取舍。
4.3 常用权限与 token 注意
使用 Cesium Ion 默认影像资源时会涉及 token。如果在国内网络环境不方便,或项目要求私有化部署,可以替换为本地发布的影像服务、天地图或其它 OGC 服务。核心代码并不依赖具体底图,Ellipse 的绘制逻辑只和坐标系、交互、渲染相关。
5. 核心交互实现:Ellipse 绘图工具完整示例
下面给出一份完整可运行的交互式椭圆绘图代码。它的行为设计如下:
- 调用
startDrawEllipse(viewer)后进入绘图模式。 - 第一次鼠标左键点击,确定椭圆中心。
- 鼠标移动过程中,实时预览椭圆。
- 第二次点击鼠标左键,确定半径并完成绘制。
- 点击鼠标右键或按 Esc,取消绘制。
- 完成后通过回调返回结构化数据。
这份代码没有依赖任何第三方库,只使用纯 Cesium API。
5.1 主函数结构
function startDrawEllipse(viewer, onFinished) { // 绘图会话状态 const drawState = { phase: "WAIT_CENTER", // WAIT_CENTER | ADJUST_RADIUS | DONE | CANCEL centerCartesian: null, centerCartographic: null, currentRadius: 0, currentMinorRadius: 0, currentRotation: 0, previewEntity: null, resultEntity: null }; // 临时显示“请点击中心点” showToast("请在地图上点击椭圆中心点"); // 使用 ScreenSpaceEventHandler 统一管理鼠标事件 const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); // 鼠标左键 handler.setInputAction((click) => { handleLeftClick(click); }, Cesium.ScreenSpaceEventType.LEFT_CLICK); // 鼠标移动 handler.setInputAction((movement) => { handleMouseMove(movement); }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); // 鼠标右键取消 handler.setInputAction(() => { cancelDrawing(); }, Cesium.ScreenSpaceEventType.RIGHT_CLICK); // Esc 键取消 const escKeyHandler = (e) => { if (e.key === "Escape") { cancelDrawing(); } }; document.addEventListener("keydown", escKeyHandler); function handleLeftClick(click) { if (drawState.phase === "CANCEL" || drawState.phase === "DONE") { return; } if (drawState.phase === "WAIT_CENTER") { const pickedPosition = pickPositionFromScreen(viewer, click.position); if (!pickedPosition) { showToast("未拾取到有效位置,请点击地球表面"); return; } drawState.centerCartesian = pickedPosition; drawState.centerCartographic = Cesium.Cartographic.fromCartesian(pickedPosition); drawState.phase = "ADJUST_RADIUS"; // 第一次点击就创建一个预览 Entity,后续只需动态更新半径 drawState.previewEntity = createPreviewEllipse(); showToast("请移动鼠标确定半径,然后再次点击完成"); return; } if (drawState.phase === "ADJUST_RADIUS") { const movingPosition = pickPositionFromScreen(viewer, click.position); if (!movingPosition) { showToast("未拾取到有效位置"); return; } // 根据移动点更新最终的半轴和旋转角 updateDrawStateWithPosition(movingPosition); // 移除预览,创建正式 Entity const result = createFinalEllipse(); // 清理事件 cleanup(); // 返回结构化结果 if (typeof onFinished === "function") { onFinished(result); } } } function handleMouseMove(movement) { if (drawState.phase !== "ADJUST_RADIUS") { return; } if (!drawState.centerCartesian) { return; } const movingPosition = pickPositionFromScreen(viewer, movement.endPosition); if (!movingPosition) { return; } updateDrawStateWithPosition(movingPosition); } function createPreviewEllipse() { const entity = viewer.entities.add({ position: new Cesium.CallbackProperty(() => drawState.centerCartesian, false), ellipse: { semiMajorAxis: new Cesium.CallbackProperty(() => drawState.currentRadius, false), semiMinorAxis: new Cesium.CallbackProperty(() => drawState.currentMinorRadius, false), rotation: new Cesium.CallbackProperty(() => drawState.currentRotation, false), material: Cesium.Color.RED.withAlpha(0.3), outline: true, outlineColor: Cesium.Color.RED, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND } }); return entity; } function createFinalEllipse() { const entity = viewer.entities.add({ position: drawState.centerCartesian, ellipse: { semiMajorAxis: drawState.currentRadius, semiMinorAxis: drawState.currentMinorRadius, rotation: drawState.currentRotation, material: Cesium.Color.RED.withAlpha(0.5), outline: true, outlineColor: Cesium.Color.RED, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND } }); drawState.resultEntity = entity; return entity; } function updateDrawStateWithPosition(movingPosition) { const movingCarto = Cesium.Cartographic.fromCartesian(movingPosition); const centerCarto = drawState.centerCartographic; // 为了半径语义稳定,把移动点高度和中心点统一 const centerCopy = Cesium.Cartographic.clone(centerCarto); movingCarto.height = centerCarto.height; const geodesic = new Cesium.EllipsoidGeodesic(centerCopy, movingCarto); const surfaceDistance = geodesic.surfaceDistance; drawState.currentRadius = surfaceDistance; // 默认绘制正圆,所以长短半轴一致;后续需要椭圆时可再修改 drawState.currentMinorRadius = surfaceDistance; // 角度可根据业务扩展。这里是默认不做旋转,保持 0 drawState.currentRotation = 0; } function cancelDrawing() { if (drawState.phase === "DONE" || drawState.phase === "CANCEL") { return; } if (drawState.previewEntity) { viewer.entities.remove(drawState.previewEntity); drawState.previewEntity = null; } drawState.phase = "CANCEL"; cleanup(); showToast("已取消绘制"); } function cleanup() { if (drawState.previewEntity) { viewer.entities.remove(drawState.previewEntity); drawState.previewEntity = null; } if (!handler.isDestroyed()) { handler.destroy(); } document.removeEventListener("keydown", escKeyHandler); drawState.phase = drawState.phase === "DONE" ? "DONE" : "CANCEL"; } // 返回取消当前会话的方法,供外部按钮调用 return { cancel: cancelDrawing }; } function pickPositionFromScreen(viewer, screenPosition) { let cartesian; if (viewer.scene.pickPositionSupported) { cartesian = viewer.scene.pickPosition(screenPosition); } if (!cartesian) { cartesian = viewer.camera.pickEllipsoid( screenPosition, viewer.scene.globe.ellipsoid ); } return cartesian; }5.2 辅助函数
上面的代码里用到showToast,实际项目中可以由 UI 框架的message组件替代。这里提供一个最简单的实现:
let toastElement = null; function showToast(message) { if (!toastElement) { toastElement = document.createElement("div"); toastElement.style.position = "fixed"; toastElement.style.left = "50%"; toastElement.style.top = "20px"; toastElement.style.transform = "translateX(-50%)"; toastElement.style.background = "rgba(0,0,0,0.8)"; toastElement.style.color = "#fff"; toastElement.style.padding = "8px 16px"; toastElement.style.borderRadius = "4px"; toastElement.style.zIndex = "9999"; toastElement.style.fontSize = "14px"; document.body.appendChild(toastElement); } toastElement.textContent = message; }调用入口:
// 在页面初始化后,开启一次椭圆绘制 setTimeout(() => { startDrawEllipse(viewer, (result) => { console.log("绘制完成", result); }); }, 500);这段代码的核心思路是:把绘图会话内部状态封装在drawState中,不对外暴露过多的临时变量。当用户完成绘制或主动取消时,统一通过cleanup释放事件监听和预览对象,避免内存泄漏和事件重复触发。
5.3 椭圆与正圆的切换设计
如果你要画的不是正圆,而是长短半轴不同的椭圆,updateDrawStateWithPosition需要改成两段式交互:
- 第一次点击确定中心。
- 第二次点击确定长半轴方向和长度。
- 第三次点击确定短半轴长度。
这种情况下,状态机要增加一个ADJUST_MINOR阶段。第二次点击后保留长半轴数据,切换为等待短半轴状态;第三次点击后完成绘制。虽然交互步骤变多,但绘图状态机的骨架完全复用,只需要在handleLeftClick里增加一个阶段判断。
核心变化如下:
if (drawState.phase === "ADJUST_RADIUS") { // 第二次点击:锁定长半轴 drawState.currentRotation = computeRotation(centerCarto, movingPosition); drawState.phase = "ADJUST_MINOR"; showToast("请点击确定短半轴长度"); return; } if (drawState.phase === "ADJUST_MINOR") { // 第三次点击:计算短半轴并完成 const minorMoving = pickPositionFromScreen(viewer, click.position); drawState.currentMinorRadius = computeSurfaceDistance(centerCarto, minorMoving); const result = createFinalEllipse(); cleanup(); if (typeof onFinished === "function") { onFinished(result); } }这一段说明了一个通用原则:绘图工具的复杂度主要来自状态机的阶段切换,而不是几何函数。把阶段切换逻辑做清晰,后面增加“长方形、多边形、箭头”都只是换一组参数计算函数而已。
6. 运行结果与验证
6.1 运行步骤
把index.html和ellipse-draw.js放到项目目录后,启动本地静态服务:
npx serve .如果使用 Vite 或 Webpack,直接启动对应开发服务。打开页面后,预期过程是:
- 页面加载完成,出现 Cesium 地球,视角飞到北京附近。
- 500ms 后页面顶部提示“请在地图上点击椭圆中心点”。
- 鼠标左键点击一个位置,出现一个半透明的红色圆形预览。
- 继续移动鼠标,圆形半径实时变大变小。
- 再次左键点击,红色预览消失,一个颜色更深的正式椭圆图形生成。
- 控制台打印
绘制完成的结果对象。
6.2 如何判断结果是否正确
一个最基本的判断标准是:正式生成的圆形边缘应该正好通过你第二次点击的那个位置。如果你第二次点的是距离中心 500 米处的建筑物,图形的边界应该压在该建筑物附近。
建议在浏览器控制台里做一个手动验证:
const center = Cesium.Cartesian3.fromDegrees(116.4, 39.9); const pointOnEdge = Cesium.Cartesian3.fromDegrees(116.4 + 0.01, 39.9); const distance = Cesium.Cartesian3.distance(center, pointOnEdge); console.log(distance); // 大约是1100米左右,具体取决于起始经度用这个简单的距离值去对比绘图结果回调中的currentRadius,就可以快速判断单位是否错了。
6.3 失败时的第一步排查方向
很多人绘图失败时第一反应是反复看ellipse的 material、outline、颜色选项,但问题往往不出在这。建议按以下顺序排查:
- 看控制台有没有报错,比如
pickPosition返回 undefined。 - 在
handleLeftClick里打印centerCartesiang和movingPosition,确认拾取到了 Cartesian3。 - 打印
surfaceDistance,确认半径量级是否正确。如果发现半径是几千甚至几十万,说明拾取坐标异常,多半是射线穿过了地球,拾取结果跑到背面去了。 - 确认绘图结束后没有残留监听事件。如果你连续点了几次,鼠标一动会创建多个预览 Entity,说明上一次会话没有正确销毁。
7. 地形、贴地与“悬浮”问题专项处理
前面代码里用了heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,代码本身在开启地形时也能工作。但实际项目中,下面几个问题出现频率很高,单列一节说明。
7.1 拾取的是椭球面,不是地形面
如果你的场景没有开启地形,pickEllipsoid是足够的。一旦加载了地形数据,屏幕上一个像素对应的地表高度可能是 2000 米,也可能是 -30 米。此时如果用pickEllipsoid获取中心点,再用该中心点去贴地,就会出现“图形中心与鼠标点击位置看起来不一样”的情况。
所以在有地形的场景里,优先使用scene.pickPosition。但pickPosition依赖深度缓冲,它不一定总能成功。代码里pickPositionFromScreen已经加了回退逻辑,如果pickPosition返回 undefined,会退回pickEllipsoid。
如果项目依赖真实地形坐标,需要进一步判断:viewer.scene.pickPositionSupported为false时,说明当前 Viewer 配置或硬件环境不支持深度拾取。这时候有三个选择:
- 开启 WebGL 深度相关配置,或检查是否在离屏渲染环境。
- 不使用
pickPosition,而是自己根据已知地形服务查询高程,再将水平坐标叠加高程。 - 使用
scene.globe.pick(ray, scene)获取射线和地形的交点。
7.2 CLAMP_TO_GROUND 的边界条件
CLAMP_TO_GROUND看起来很省事,但并不是所有环境下都能保证效果。它需要把图形提交到 GroundPrimitive 体系中进行地形裁切。如果当前场景的depthTestAgainstTerrain和地形数据状态有问题,贴地图形可能被遮挡、闪烁或干脆不显示。
如果你的椭圆主要用于“业务范围示意”,并不需要严格贴在实际地形表面,更稳定的做法是拍平高度。比如始终在height: 0的海平面高度绘制,视觉上看起来像贴地,实际是在椭球面上,不受局部地形起伏影响。
还有一种情况是雷达威力范围、管线影响范围这类业务图元,它们需要的是“某个高度面上的水平圆”而不是“贴合地形的覆盖物”。这时候不要用CLAMP_TO_GROUND,而应该显式指定height。
比如要画一个中心点海拔 100 米、半径 800 米的水平圆:
viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(116.4, 39.9, 100), ellipse: { semiMajorAxis: 800, semiMinorAxis: 800, height: 100, heightReference: Cesium.HeightReference.NONE, material: Cesium.Color.BLUE.withAlpha(0.3) } });这种情况下,中心点的 z 值本身就是 100 米海拔,Ellipse 的height也设置为 100,图形的平面才和中心点高度一致。如果中心点高度与图形的height不一致,图形可能看起来是“飘”的,或者切进地形。
7.3 地形起伏大时半径怎么选
前面用EllipsoidGeodesic计算的是椭球面的曲面距离。当地形起伏大时,这个半径仍然代表“中心点高度面上的水平半径”,不会受地形上下波动影响。这对于很多业务标绘场景是合理的。
但如果你要表达的是“中心点到山上某个点的斜距”,那就不能用椭球面距离,应该直接使用Cesium.Cartesian3.distance(centerCartesian3, movingCartesian3),因为斜距本身就是三维空间直线距离。
两种口径没有绝对对错,关键是要在工具层固定下来。最怕的情况是:预览时用Cartesian3.distance,正式生成时又用EllipsoidGeodesic.surfaceDistance,导致图形在最后一次点击后突然变小或变化。
8. Cesium 绘图工具常见问题与排查方法
下面把 Ellipse 绘图工具的典型问题整理成一张排查表,方便收藏和使用。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 图形在鼠标移动时变形 | 半轴计算使用了像素或经纬度差值,而不是米制距离 | 打印surfaceDistance和movingCartesian3日志 | 统一用EllipsoidGeodesic.surfaceDistance或Cartesian3.distance |
| 第一次点击中心后图形不出现 | pickPosition返回 undefined,没有创建预览 Entity | 在handleLeftClick内打印pickedPosition | 开启深度拾取或回退到pickEllipsoid |
| 预览是圆形,点击完成后变形 | 临时 Entity 与正式 Entity 的长短半轴口径不一致 | 对比drawState中参数与最终实体参数 | 保证两者通过同一套updateDrawStateWithPosition更新 |
| 完成绘制后,再次移动鼠标会出现几个残留图形 | 上一次绘图会话的事件监听没有清理 | 查看内存中 Entity 数量和事件处理器状态 | 调用handler.destroy()和移除实体 |
| 右键取消时双击事件干扰 | 右键点击同时触发了其它逻辑 | 查看页面是否还有第二个ScreenSpaceEventHandler | 收敛到统一的绘图事件管理器 |
| 开启了地形后图形贴地不准确 | 拾取中心和半径计算未考虑depthTestAgainstTerrain | 切换该属性观察效果变化 | 使用scene.pickPosition或单独查询高程 |
| 半轴值巨大,图形跑到地球背面 | 射线拾取到了地球另一侧的坐标,或 move 事件位置错误 | 打印半径数量级;拖动时观察中心坐标变化 | 增加距离上限判断,并检查movement.endPosition |
| 图形高亮时闪烁、被地形掩盖 | GroundPrimitive 与地形深度冲突 | 检查是否同时开启多个贴地 Primitive | 尝试改成非贴地模式,显式指定height |
| 在高纬度地区方向偏转 | 旋转角使用了简单 ECEFatan2,而不是局部东北坐标系 | 在北极附近画正东方向的椭圆验证 | 使用Transforms.eastNorthUpToFixedFrame计算旋转角 |
| 移动端触屏无法完成绘制 | 只监听了鼠标事件,没有监听触屏 | 用真机测试并查看事件触发 | 增加触屏事件或使用 Cesium 封装的原生点击事件 |
这张表里最值得注意的一点是“绘制结束后的状态清理”。在真实业务系统里,绘图工具往往不只一种,可能存在“点、线、面、椭圆、矩形”五个工具按钮。如果每个工具都在自己的startDraw函数里创建事件处理器,而没有统一管理“当前只能存在一个绘图会话”,用户点完椭圆再点矩形时,两个工具的事件可能互相叠加,最终出现各种离奇问题。
9. 最佳实践与工程化建议
9.1 把绘图能力封装成可取消的会话
不要在每个业务页面里直接写viewer.entities.add和ScreenSpaceEventHandler。更合理的做法是抽象一个DrawSessionManager,负责维护当前绘图会话。
每次调用startDrawEllipse时,先检查是否已有其它工具处于绘图状态,如果有就强制取消前一个会话。绘制完成后,统一通过回调返回结果,而不是让各业务页自己去监听全局鼠标事件。
这样设计以后,新增一个矩形绘制工具时,你只需要在工具函数内部绘制矩形相关的几何状态,会话管理、右键取消、Esc 取消、事件清理这些公共逻辑完全复用。
9.2 输出结构化结果,不要只操作 Entity
绘图完成后,业务系统通常需要把结果存到数据库,或者发送给后端做空间计算。不要在onFinished回调里只返回一个 Entity 对象,而应该返回独立的业务数据:
{ type: "ellipse", center: { longitude: 116.4, latitude: 39.9, height: 0 }, semiMajorAxis: 1200, semiMinorAxis: 1200, rotation: 0, crs: "EPSG:4326", style: { fillColor: "rgba(255,0,0,0.5)", outlineColor: "#ff0000" } }后续无论是 JSON 存储、WKT 转换还是 GeoJSON 表达,都能基于这个结构化对象继续做。Entity 只是渲染层的一个展示对象,不应该把核心业务数据绑死在 Cesium 的实例对象上。
9.3 统一约定坐标系和半径口径
三维 Cesium 项目经常混用多种坐标系,比如鼠标拾取得到 Cartesian3、业务库里存的是经纬度、底图服务用的是 Web Mercator。绘图工具输出前就要做好转换,最好在项目里定义一个统一的空间数据模型。
针对 Ellipse,最容易反复出问题的是“半径口径”。建议从第一天就在文档里写明:工具产生的半径是中心点高度面上的水平距离,单位是米。任何人看到返回值都不会误以为这个半径可以直接等同于地形上两点间的斜距。
9.4 谨慎对待每一帧回调中的计算量
CallbackProperty的回调函数在渲染过程中会被频繁调用。不要在回调里执行viewer.scene.pickPosition、viewer.scene.drillPick、网络请求、大数组遍历。这些操作会严重拖慢帧率。
更好的做法是:鼠标移动事件里只更新drawState中的数值型字段,CallbackProperty只是把这些字段读出来返回。整个预览过程的所有重计算都发生在事件回调中,而不是渲染回调中。
9.5 对外提供“取消”和“销毁”能力
当用户选定一个绘图工具后又切换到了另一个功能,工具需要能被强制取消。startDrawEllipse的返回值里保留一个cancel方法,就是为这个场景准备的。
页面销毁时,还需要统一对当前所有绘图事件做清理。不要等到用户刷新页面才让浏览器回收资源。长期运行的 SPA 项目里,如果反复进入、退出三维场景,不销毁ScreenSpaceEventHandler很容易造成内存增长和事件堆积。
9.6 预留国际化与样式定制
Cesium 绘图工具的交互提示,最好与项目 UI 解耦。上述代码中的showToast只是一个最简单的占位实现。在正式项目里,你可能会把提示文案替换成 Element Plus 的 Message、Ant Design Vue 的 message,或者是业务自研的状态栏提示。因此建议在startDrawEllipse的参数中透传一个onTip回调:
const session = startDrawEllipse({ viewer: viewer, onTip: (text) => { // 由 UI 层决定如何展示提示 ElMessage.info(text); }, onFinished: (result) => { // 业务后续处理 } });这一层抽象能避免绘图工具和具体 UI 框架绑定,让工具函数在不同项目之间复用。
9.7 不要忽略小范围的“极地测试”
很多 GIS 开发测试都在中低纬度地区进行,一跑到高纬度地区,坐标转换和角度逻辑就出问题。椭圆绘图工具上线前,建议至少做三组测试:
- 正常区域:北京或上海附近。
- 高纬度区域:北纬 70 度以上,验证旋转角方向。
- 跨 180 度经线区域:验证中心和边缘点的经纬度计算是否产生异常