Cesium 的三维场景里,Ellipse(椭圆)不是“画个图形”这么简单,它更多承担的是范围圈选、缓冲区分析、雷达/信号覆盖示意、园区安全区域标绘这类具体业务。很多开发者第一次接触 Cesium 时,会直接用viewer.entities.add把椭圆当成普通对象添加,结果发现交互绘制、参数修改、批量渲染、鼠标拾取这些环节全都要单独处理。那这次就把“Cesium 绘图工具 - Ellipse”完整拆开,讲清楚两种实现方式、交互绘制怎么封装、批量调用怎么做,以及本地验证和排错思路。
先说结论:Cesium 绘制椭圆可以分为 Entity 模式和 Primitive/Geometry 模式。Entity 模式方便调试、支持动态更新,适合中小规模交互业务;Primitive 模式更适合大量静态椭圆批量渲染。如果你只是做一个“鼠标点两点生成圆圈/椭圆圈”的工具,用 Entity + CallbackProperty 已经完全够用;如果后续要加载几千上万个椭圆,就要切到EllipseGeometry+GeometryInstance的批处理方案。
本文不讨论复杂的三维建模,也不会引入额外的大型框架,核心围绕 Cesium 本身的ellipseAPI 和EllipseGeometry展开。适合正在做“Cesium 绘图工具”功能模块、对交互式标绘、批量生成椭圆有需求的 WebGIS 开发者阅读。
1. Cesium 绘图工具 Ellipse 核心能力速览
先把项目能做什么、依赖什么环境列清楚,这样你可以快速判断这个技术方案是否符合手头的业务场景。
| 能力项 | 说明 |
|---|---|
| 技术栈 | CesiumJS 前端三维地球渲染 |
| 主要功能 | 椭圆面、圆面、椭圆边界、椭圆拉伸体、批量椭圆绘制 |
| 底层 API | Entity API 的ellipse、Primitive API 的EllipseGeometry |
| 坐标单位 | 半轴长度单位固定为“米” |
| 交互绘制 | 通过ScreenSpaceEventHandler监听点击和鼠标移动实现 |
| 平台支持 | PC 浏览器、移动端浏览器(需具备 WebGL 支持) |
| 部署方式 | npm 工程 / 静态页面 CDN 引用 |
| 是否支持批量任务 | 支持,可通过数组循环,也可通过GeometryInstance合并渲染 |
| 是否需要大量显存 | 否,前端 WebGL 渲染,性能瓶颈主要在浏览器 GPU 和实体数量 |
| 适合场景 | 地图范围圈选、雷达覆盖区、安全区域标绘、缓冲分析可视化 |
注意:文中代码均基于 CesiumJS 的常规 API 语法。实际版本之间可能有个别属性名称的差异,建议以你当前安装的 Cesium 版本自带类型定义为准。如果你使用 Vue3 + Vite,直接把核心逻辑放入组件生命周期即可。
2. 适用场景与使用边界
Ellipse 不是只能画“一个扁的圆”那么简单。从工程角度来说,它可以承担的任务主要有:
- 地图范围圈选:比如在三维场景中框选某个区域,然后交给后端的空间查询接口做分析。
- 缓冲区示意:以某个设备点为中心,显示覆盖半径圆。
- 雷达或信号覆盖:用半透明的椭圆表示覆盖方向,配合
rotation旋转参数,可以模拟定向天线或雷达波束范围。 - 区域规划显示:在建筑、园区、管线节点上方标注规划区域。
- 数据可视化:将后端返回的多个椭圆配置批量渲染成图层。
做一个椭圆绘制工具时,你还要考虑“它不擅长什么”。比如:
- 椭圆的边界是数学曲线,不是复杂多边形,不能表达不规则的行政区边界。
- 半长轴和半短轴单位是米,如果需求方给的是经纬度跨度,需要先换算成米。
- Entity 模式数量过多时,比如超过几百上千个,性能会明显下降;此时要考虑 Primitive 模式。
- 直接处理海量动态更新时,简单用
viewer.entities.removeAll()再重建会产生卡顿。
另外一个容易被忽视的问题是合规边界。地图底图、地理数据、标绘区域都可能涉及第三方版权、测绘资质和隐私问题。技术方案只能在业务自身授权范围内使用,尤其在做人员位置、私人住宅等敏感区域圈选时,要确保数据来源合法,结果不用于侵犯隐私的用途。这篇文章只讨论 Cesium 前端开发方法,不讨论如何获取或绕行任何数据授权限制。
3. 环境准备与 Cesium 接入
一个完整的 Cesium 椭圆绘图功能,最少需要以下环境:
- Node.js 16 以上,npm 或 pnpm。
- 一个能运行 WebGL 的现代浏览器,Chrome / Edge 均可。
- CesiumJS 包,建议用 npm 管理。
- 可选:Cesium ion 的 Token,或者自己配置在线影像/地形服务作为底图。
如果你不想用 Vue,直接用纯 HTML 页面也可以。这里以 Vite 工程为例:
npm create vite@latest cesium-ellipse-demo -- --template vanilla cd cesium-ellipse-demo npm install npm install cesium npm run dev工程创建后,在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 Drawing Tool</title> <style> html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; } </style> </head> <body> <div id="cesiumContainer"></div> <script type="module" src="/main.js"></script> </body> </html>在main.js中初始化 Cesium Viewer:
import * as Cesium from 'cesium'; import 'cesium/Build/CesiumUnminified/Widgets/widgets.css'; // 如果你有 Cesium ion Token,在这里配置 // Cesium.Ion.defaultAccessToken = 'your token'; const viewer = new Cesium.Viewer('cesiumContainer', { animation: false, timeline: false, baseLayerPicker: false, geocoder: false, homeButton: false, sceneModePicker: false, navigationHelpButton: false, fullscreenButton: false, infoBox: false, selectionIndicator: false }); // 如果要使用地形,可以在这里配置地形 Provider viewer.scene.globe.enableLighting = true;如果不需要在线底图,也可以给 Viewer 指定一个可访问的地图服务地址。不同版本对imageryProvider的写法有些差异,更稳妥的方式是先把 Viewer 默认初始化和底图加载跑通,再继续后面的业务逻辑。
4. Entity API 绘制静态 Ellipse 参数详解
先看最简单的方式:直接往场景里添加一个椭圆。
const ellipseEntity = viewer.entities.add({ id: 'demo-ellipse', name: '示例椭圆', position: Cesium.Cartesian3.fromDegrees(113.5, 22.7, 0), ellipse: { semiMajorAxis: 5000, semiMinorAxis: 3000, height: 0, material: Cesium.Color.fromCssColorString('#00a8ff').withAlpha(0.35), outline: true, outlineColor: Cesium.Color.WHITE, rotation: Cesium.Math.toRadians(30), stRotation: Cesium.Math.toRadians(0) } }); viewer.flyTo(ellipseEntity);这段代码的关键参数解释如下:
| 参数 | 作用 | 注意事项 |
|---|---|---|
semiMajorAxis | 椭圆半长轴 | 单位米,和经纬度坐标本身没有直接换算关系 |
semiMinorAxis | 椭圆半短轴 | 单位米,当它等于半长轴时,椭圆退化为圆 |
height | 椭圆所处高度 | 相对于椭球体表面的高度,默认 0 |
extrudedHeight | 椭圆拉伸高度 | 设置后椭圆会变成圆柱体/椭圆体侧表面 |
material | 椭圆面材质 | 支持纯色、图片、渐变等多种方式 |
outline | 是否显示边界线 | 需要配合outlineColor使用 |
rotation | 椭圆整体旋转 | 弧度制,0 表示不旋转,数值从正北方向开始计算 |
stRotation | 纹理旋转 | 通常不设置,默认 0 |
granularity | 弧度细粒度 | 数值越小,椭圆弧线段越平滑,同时顶点越多 |
这里补充一个重要关系:在使用rotation时,角度的含义是绕椭圆的中心点旋转,而不是绕世界坐标轴旋转。也就是说,同一个椭圆放置在不同的经纬度上,同样设置的rotation会让它围绕各自本地坐标系旋转,这一点在地图大范围拼接时特别有用。
如果业务需要“空心椭圆”,可以设置fill: false,只保留边界:
viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(113.5, 22.7), ellipse: { semiMajorAxis: 8000, semiMinorAxis: 4000, fill: false, outline: true, outlineColor: Cesium.Color.YELLOW, outlineWidth: 2 } });注意:outlineWidth在部分平台和显卡驱动下不一定生效,WebGL 对线宽的限制比较严格,这也是很多开发者遇到“细线设置无效”的原因。
5. 高精度静态渲染:EllipseGeometry + Primitive
Entity 很好用,但它为了维护“动态属性”,内部承载了较多开销。如果你要渲染上千个椭圆,或者这些椭圆基本不再变化,建议改用 Primitive + Geometry。
const instance = new Cesium.GeometryInstance({ geometry: new Cesium.EllipseGeometry({ center: Cesium.Cartesian3.fromDegrees(113.5, 22.7, 0), semiMajorAxis: 5000, semiMinorAxis: 3000, height: 0, vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT }), attributes: { color: Cesium.ColorGeometryInstanceAttribute.fromColor( Cesium.Color.fromCssColorString('#00a8ff').withAlpha(0.5) ) } }); const primitive = new Cesium.Primitive({ geometryInstances: [instance], appearance: new Cesium.PerInstanceColorAppearance({ translucent: true, closed: false }) }); viewer.scene.primitives.add(primitive);EllipseGeometry相当于底层的几何描述,它生成三角形顶点数据;GeometryInstance负责把多个几何体实例组织在一起;PerInstanceColorAppearance允许每个实例拥有独立的颜色。
这种方式的优点是多实例合并绘制。当你需要一次性添加 1000 个固定椭圆时,不要创建 1000 个Primitive,而是把 1000 个GeometryInstance全部放到一个Primitive的geometryInstances数组里:
const instances = sites.map((item) => { return new Cesium.GeometryInstance({ geometry: new Cesium.EllipseGeometry({ center: Cesium.Cartesian3.fromDegrees(item.lng, item.lat, item.height || 0), semiMajorAxis: item.semiMajorAxis, semiMinorAxis: item.semiMinorAxis, height: item.height || 0, vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT }), attributes: { color: Cesium.ColorGeometryInstanceAttribute.fromColor( Cesium.Color.fromCssColorString(item.color).withAlpha(item.alpha || 0.4) ) } }); }); const mergedPrimitive = new Cesium.Primitive({ geometryInstances: instances, appearance: new Cesium.PerInstanceColorAppearance({ translucent: true }) }); viewer.scene.primitives.add(mergedPrimitive);需要说明的是,Primitive 方式添加后,想直接通过修改semiMajorAxis动态更新比较麻烦。适合“数据不变,只是批量展示”的图层;如果要做交互绘制和后续二次编辑,仍然建议使用 Entity。
6. 做一个可交互绘制 Ellipse 的绘图工具
这一节解决“如何在三维地球上用鼠标绘制椭圆”的问题。整体交互流程很简单:
- 点击“绘制椭圆”按钮,进入绘制态。
- 第一次鼠标左键点击,确认椭圆中心点。
- 移动鼠标,动态生成临时半长轴半径,并在场景里做实时预览。
- 第二次点击,根据中心点和边缘点确定长半轴,再按比例生成椭圆。
先准备绘制按钮:
<div style="position: absolute; left: 20px; top: 20px; z-index: 100; display: flex; gap: 8px;"> <button id="btnEllipse">绘制椭圆</button> <button id="btnCircle">绘制圆面</button> <button id="btnCancel">取消绘制</button> </div>核心的绘制状态对象:
const drawState = { status: 'idle', // idle | pickingCenter | pickingRadius ratio: 0.6, // 半短轴 / 半长轴比例,圆面时为 1 center: null, // 中心点 Cartesian3 radius: 0, // 半长轴长度,单位米 previewEntity: null }; const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);在地球表面拾取点的方式,我建议优先用射线方式获取地形或椭球上的点,而不是直接用scene.pick,这样画线时不容易被已有的实体对象干扰。
function pickGlobePosition(windowPosition) { const ray = viewer.camera.getPickRay(windowPosition); if (!ray) { return undefined; } const picked = viewer.scene.globe.pick(ray, viewer.scene); if (Cesium.defined(picked)) { return picked; } return viewer.camera.pickEllipsoid(windowPosition, viewer.scene.globe.ellipsoid); }然后绑定鼠标事件:
function startDrawing(mode) { drawState.mode = mode; drawState.ratio = mode === 'circle' ? 1 : 0.6; drawState.status = 'pickingCenter'; drawState.center = null; drawState.radius = 0; drawState.previewEntity = null; } function createPreviewEntity() { drawState.previewEntity = viewer.entities.add({ position: drawState.center, ellipse: { semiMajorAxis: new Cesium.CallbackProperty(() => drawState.radius, false), semiMinorAxis: new Cesium.CallbackProperty( () => drawState.radius * drawState.ratio, false ), material: Cesium.Color.fromCssColorString('#00a8ff').withAlpha(0.3), outline: true, outlineColor: Cesium.Color.CYAN } }); } handler.setInputAction((click) => { if (drawState.status === 'idle') { return; } const position = pickGlobePosition(click.position); if (!position) { return; } if (drawState.status === 'pickingCenter') { drawState.center = position; drawState.radius = 1; createPreviewEntity(); drawState.status = 'pickingRadius'; return; } if (drawState.status === 'pickingRadius') { finalizeEllipse(); drawState.status = 'idle'; } }, Cesium.ScreenSpaceEventType.LEFT_CLICK); handler.setInputAction((movement) => { if (drawState.status !== 'pickingRadius') { return; } const position = pickGlobePosition(movement.endPosition); if (!position || !drawState.center) { return; } const rawDistance = Cesium.Cartesian3.distance(drawState.center, position); drawState.radius = Math.min(rawDistance, 500000); }, Cesium.ScreenSpaceEventType.MOUSE_MOVE);最后确认生成正式的椭圆实体:
function finalizeEllipse() { if (drawState.previewEntity) { viewer.entities.remove(drawState.previewEntity); drawState.previewEntity = null; } const radius = drawState.radius; if (!radius || radius <= 0) { return; } viewer.entities.add({ position: drawState.center, ellipse: { semiMajorAxis: radius, semiMinorAxis: radius * drawState.ratio, material: Cesium.Color.fromCssColorString('#00a8ff').withAlpha(0.4), outline: true, outlineColor: Cesium.Color.WHITE, height: 0 } }); }对应的按钮绑定代码:
document.getElementById('btnEllipse').addEventListener('click', () => startDrawing('ellipse')); document.getElementById('btnCircle').addEventListener('click', () => startDrawing('circle')); document.getElementById('btnCancel').addEventListener('click', () => { drawState.status = 'idle'; if (drawState.previewEntity) { viewer.entities.remove(drawState.previewEntity); drawState.previewEntity = null; } });注意,上面的Cartesian3.distance是三维空间直线距离。如果设备覆盖范围较小,比如几公里以内,作为半长轴足够用。如果业务需要严格的球面距离,则需要把中心点和边缘点分别转成Cartographic,再使用测地线相关方法计算地面弧长。实际项目中更稳妥的做法是根据精度要求选择距离算法,并在代码注释里明确说明。
这里实现的绘制逻辑是“中心点 + 一个半径点”,生成的预览形态仍然是由ratio决定的椭圆。如果你希望鼠标点就是椭圆的边缘点,并且让半长轴方向跟随鼠标方向,就还需要在每次移动时根据中心点与鼠标点之间的方位角设置rotation。这个扩展在真实开发中很常见。
7. Ellipse 绘制后的参数修改与旋转角度控制
进入三维场景的椭圆实体,默认情况下不是只能看不能改。因为 Entity 的属性大多是Property,你可以通过.setValue()方法动态修改。
例如捕捉最后生成的实体,然后修改半长轴:
let lastEntity = null; function createEllipse(params) { const entity = viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(params.lng, params.lat, params.height || 0), ellipse: { semiMajorAxis: params.semiMajorAxis, semiMinorAxis: params.semiMinorAxis, rotation: Cesium.Math.toRadians(params.rotation || 0), material: Cesium.Color.fromCssColorString(params.color || '#00a8ff').withAlpha(params.alpha || 0.4), outline: true, outlineColor: Cesium.Color.WHITE } }); lastEntity = entity; return entity; } // 修改半长轴 lastEntity.ellipse.semiMajorAxis.setValue(12000); // 修改旋转角度 lastEntity.ellipse.rotation.setValue(Cesium.Math.toRadians(60)); // 改为动态变化 lastEntity.ellipse.semiMinorAxis = new Cesium.CallbackProperty(() => { const base = 8000; return base + Math.sin(Date.now() / 1000) * 2000; }, false);这里有几个细节值得实际验证:
- 如果你把
semiMajorAxis传入的是一个固定数值,Cesium 内部会把它包装成ConstantProperty,此时可以调用setValue更新。 - 如果你希望它随时间或业务状态变化,可以直接把它赋值为
CallbackProperty。 rotation的输入必须是弧度,如果把角度数字直接传进去,椭圆的旋转会远超出预期。- 快捷键 Esc 取消绘制、双击删除实体这类交互,都需要自己用
ScreenSpaceEventHandler监听对应键盘和鼠标事件,Entity API 本身不提供“绘制工具”级别的封装。
对颜色透明度,建议封装一个统一样式类,这样绘制时和批量加载时的样式保持一致。比如半透明蓝色是很多地图平台默认的“可选区”状态,直接用:
const DEFAULT_ELLIPSE_STYLE = { fillColor: Cesium.Color.fromCssColorString('#00a8ff').withAlpha(0.35), strokeColor: Cesium.Color.WHITE, outline: true };真正常用的“绘图工具”,不只是画一个面,还包括:选中后高亮、鼠标拖动边缘点改变大小、属性面板编辑、删除按钮。单篇文章无法全部铺开,但架构上建议把“几何参数”和“视图实体”分开:几何参数保存在业务数据层,视图实体只是它的可视化投影。修改时先改数据层,再同步刷新实体属性,这样后期接入后端保存和回显会容易很多。
8. 接口 API 与批量任务调用模板
在 GIS 工程里,前端很少只画一个椭圆就结束。更多场景是后端通过接口返回一批椭圆配置,或者前端把用户绘制的椭圆提交到后端。
以下是一组常用的接口请求参数:
{ "name": "设备覆盖范围", "center": { "lng": 114.06, "lat": 22.55 }, "height": 0, "semiMajorAxis": 1500, "semiMinorAxis": 900, "rotation": 30, "color": "#00a8ff", "alpha": 0.35, "outline": true }前端请求接口并批量添加:
async function loadEllipseFromApi(url) { const res = await fetch(url); const data = await res.json(); if (!Array.isArray(data)) { console.error('接口返回格式错误,期望数组'); return; } const features = data.map((item) => { return viewer.entities.add({ name: item.name || 'ellipse-' + Math.random().toString(16).slice(2), position: Cesium.Cartesian3.fromDegrees( item.center.lng, item.center.lat, item.height || 0 ), ellipse: { semiMajorAxis: item.semiMajorAxis, semiMinorAxis: item.semiMinorAxis, rotation: Cesium.Math.toRadians(item.rotation || 0), material: Cesium.Color.fromCssColorString(item.color || '#00a8ff').withAlpha(item.alpha || 0.35), outline: !!item.outline, outlineColor: Cesium.Color.WHITE } }); }); return features; }如果是批量加载上百个固定椭圆,建议不要使用viewer.entities.add循环,而是采用第 5 节提到的GeometryInstance合并方案,这样渲染压力会小很多。
批量删除时,注意不要把viewer.entities当作普通数组操作。正确的清理思路是维护一个图层数组,统一移除:
const ellipseEntityList = []; function clearEllipseLayer() { ellipseEntityList.forEach((entity) => { if (!entity.isDestroyed && viewer.entities.contains(entity)) { viewer.entities.remove(entity); } }); ellipseEntityList.length = 0; }如果需要把用户画好的椭圆导出到后端,可以使用Cartographic.fromCartesian把中心点转回经纬度:
function getEllipseEntityGeoJson(entity) { const carto = Cesium.Cartographic.fromCartesian(entity.position.getValue()); const lng = Cesium.Math.toDegrees(carto.longitude); const lat = Cesium.Math.toDegrees(carto.latitude); return { type: 'Feature', properties: { semiMajorAxis: entity.ellipse.semiMajorAxis.getValue(), semiMinorAxis: entity.ellipse.semiMinorAxis.getValue(), rotation: entity.ellipse.rotation ? entity.ellipse.rotation.getValue() : 0 },