无人机航线里的云台角度怎么调?Cesium 做了个视锥 + 鹰眼联动

我们公司做的是无人机方向的业务,其中一块是航线规划:用户在地图上打航点,给每个航点配动作,比如飞到这儿拍张照。

拍照就绕不开一个问题------云台相机到底朝哪儿拍。俯仰多少度、偏航多少度、要不要变焦,这三个参数决定了照片里是目标物,还是一片草地。

早期我们就是给三个输入框,让用户填数字。填完谁也不知道对不对,只能飞一趟看结果,回来再改。参考大疆的地面站在这块的做法很成熟:地图上把相机视锥画出来,右下角再放一个鹰眼预览,拖一拖就能对准。我们参考这个思路做了一版。

这篇文章讲这套东西是怎么实现的:整体结构、关键代码, 其实我们做好的项目比这个完善很多, 航线的所有功能一起其他的一些功能基本都具备, 这次只是把这个技术点拿出来单独说说一下而已,欢迎大家指正错误。

代码用的是 Cesium,最终是一个单文件 HTML,本地双击就能跑。文章里贴的是关键片段,完整文件太长了,需要的看文末。

效果大致是:左边三维地图上一个相机 + 两个视锥体,右下角一块鹰眼小窗口。在鹰眼里拖动画面,地图上的视锥跟着一起转。

一、为什么光填数字不行

航点上配云台,用户其实要回答三件事:

  • 飞机离地多高
  • 镜头朝哪个方向、往下压多少度
  • 用广角还是拉变焦

这三个值单独看都很好理解,合在一起就抽象了。俯仰 -30°、偏航 70°、变焦 3 倍,画面里到底是什么,没人算得出来。

把视锥画到地图上之后就直观了:那个半透明的锥体打在地面上覆盖多大一块,一眼就能看见。再配个鹰眼窗口,相当于提前预览了相机画面,拖着调就行。

调参从「猜数字」变成「看到哪拍到哪」,这是这个功能的全部意义。


二、整体怎么搭

技术选型很克制:一个 HTML、Cesium、两个 Viewer。

  • 主地图 Viewer:卫星影像 + 全球地形,负责展示相机位置、高度杆、视锥体
  • 鹰眼 Viewer:把相机摆到和无人机一样的位置姿态,模拟出画面

最关键的一条约定:数据流必须是单向的

text 复制代码
用户操作(拖拽 / 滚轮 / 刻度尺)
    ↓
修改 state
    ↓
scheduleRender()
    ↓
主图重绘视锥  +  鹰眼相机对齐  +  HUD 刷新读数

千万别让主图和鹰眼各算各的。我一开始图省事,鹰眼里改完角度直接读 hawkViewer.camera.heading 反过来更新视锥,结果拖到某些角度两边就对不上了,排查半天。后来统一成「只有 state 是真相」,问题消失。

state 长这样:

js 复制代码
const state = {
  placed: false,          // 是否已经放置过相机
  lon: 114.0663243,
  lat: 30.4834225,
  groundHeight: 0,        // 落点的地形高程(绝对高)
  relativeHeight: 120,    // 相机离地高度
  heading: 70,            // 偏航角
  pitch: 0,               // 俯仰角,负值朝下
  roll: 0,
  ptz: 5,                 // 变焦倍数
  isFocus: false,         // false=广角模式,true=变焦模式
  terrainDistance: 0,     // 视线中心到地面的距离
  far: 300,               // 视锥远平面
};

渲染入口用 requestAnimationFrame 做了合并,因为拖拽时 pointermove 触发非常密集,每次都重建视锥图元会明显掉帧:

js 复制代码
let renderHandle = 0;

/** 状态改完调它就行,同一帧内的多次调用会被合并成一次 */
function scheduleRender() {
  if (renderHandle) return;
  renderHandle = requestAnimationFrame(() => {
    renderHandle = 0;
    renderMainScene();  // 主图:高度杆 + 双视锥
    renderHawkScene();  // 鹰眼:相机姿态 + 视场角
    renderHud();        // 界面:刻度尺、取景框、读数
  });
}

三、视锥怎么画

Cesium 里画视锥用 FrustumGeometry(填充)配 FrustumOutlineGeometry(描边)。

js 复制代码
function addFrustum({ fov, far, color, outlineColor }) {
  const origin = getCameraPosition();
  const orientation = getFrustumOrientation();

  const frustum = new Cesium.PerspectiveFrustum({
    fov: Cesium.Math.toRadians(fov),
    aspectRatio: 16 / 9,   // 相机传感器宽高比
    near: 0.5,
    far: far,
  });

  const fill = mainViewer.scene.primitives.add(
    new Cesium.Primitive({
      geometryInstances: new Cesium.GeometryInstance({
        geometry: new Cesium.FrustumGeometry({
          frustum, origin, orientation,
          vertexFormat: Cesium.VertexFormat.POSITION_ONLY,
        }),
        attributes: {
          color: Cesium.ColorGeometryInstanceAttribute.fromColor(
            Cesium.Color.fromCssColorString(color)
          ),
        },
      }),
      appearance: new Cesium.PerInstanceColorAppearance({
        flat: true, translucent: true, closed: true,
      }),
      // 同步创建,否则拖拽时锥体会一闪一闪地延迟出现
      asynchronous: false,
    })
  );
  // 描边同理,换成 FrustumOutlineGeometry
}

有个细节值得说:asynchronous 默认是 true,Cesium 会把几何体扔到 worker 里异步创建。静态场景没问题,但拖拽时每帧都在重建,异步会导致锥体闪烁。改成 false 之后就稳了。

远平面拉多长也需要算一下。太短了锥体飘在半空,太长了扎穿地球:

js 复制代码
/**
 * 按俯仰角估算视线大概多远够到地面,再放大一点让锥体扎进地表,
 * 视觉上才有「打在地上」的感觉。
 * 俯仰接近 0° 时视线几乎不落地,用高度的固定倍数兜底。
 */
function computeFar() {
  const pitchRad = Math.abs(Cesium.Math.toRadians(state.pitch));
  const toGround = pitchRad > 0.05
    ? state.relativeHeight / Math.sin(pitchRad)
    : state.relativeHeight * 4;

  return clamp(toGround * 1.35, 80, 2000);
}

四、为什么画了两个视锥

这个问题被问得最多,单独说一下。

绿色 是当前实际视野,跟着变焦走,倍数拉大就变细。 黄色是广角参考框,视场角固定(演示里是 28°),不随变焦变化。

黄色不是第二套参数,它就是把尺子:让你知道「广角本来能拍这么大一片,现在变焦之后只取了中间这一小块」。做定点取证类任务时这个对照很有用。黄锥 FOV 别开太大,否则地面覆盖会显得夸张。

变焦和视场角的关系我用的是一个很朴素的近似:

js 复制代码
/**
 * 变焦倍数 → 视场角。
 * 倍数越大视野越窄,上限不超过广角参考锥,
 * 否则低倍数时锥体张得太夸张,地图上看着糊成一片。
 */
function fovOf(ptz) {
  return Math.min(28, round1(120 / ptz));
}

两个锥体从同一个相机起点出发,黄锥 FOV 更大,绿锥嵌在里面。远平面几乎一样长(黄锥只略短一点防 z-fighting),千万别把黄锥远平面压太短,否则看起来会变成「一前一后」两段,而不是外框套内框:

js 复制代码
addFrustum({ fov: fovOf(state.ptz), far: state.far, ... });        // 绿:当前视野
addFrustum({ fov: 28,               far: state.far * 0.98, ... }); // 黄:广角参照

所以切到广角 1X 时两个锥体几乎重合,这是正常的,不是画重了。


五、踩的第一个坑:视锥朝天

最开始我直接把相机那套 heading / pitch 塞给了 FrustumGeometry,结果视锥竖着朝天,怎么调俯仰都不对。

原因是 FrustumGeometry 的朝向约定和 Camera.setView 不是一回事。正确姿势有两步,缺一不可:

js 复制代码
function getFrustumOrientation() {
  const position = getCameraPosition();

  const hpr = new Cesium.HeadingPitchRoll(
    Cesium.Math.toRadians(state.heading),
    Cesium.Math.toRadians(state.roll),   // 第二个参数放 roll
    Cesium.Math.toRadians(state.pitch)   // 第三个参数才是 pitch
  );

  const orientation = Cesium.Transforms.headingPitchRollQuaternion(position, hpr);

  // 关键:再绕本地 X 轴补一次 -90°,少了这步就朝天
  const tilt = Cesium.Quaternion.fromAxisAngle(
    Cesium.Cartesian3.UNIT_X,
    -Cesium.Math.PI_OVER_TWO
  );

  return Cesium.Quaternion.multiply(orientation, tilt, new Cesium.Quaternion());
}

注意 HeadingPitchRoll 的第二、三个参数被换了位置。这不是笔误,是为了配合后面那次 -90° 旋转。补上之后俯仰给负值,视锥才会老老实实打向地面。


六、踩的第二个坑:鹰眼滚轮好使,拖拽没反应

事件我一开始绑在 Cesium 的 canvas 上。滚轮变焦正常,鼠标拖拽完全没动静。

原因是 canvas 上的鼠标事件被 Cesium 内部的 ScreenSpaceEventHandler 截胡了。与其去跟它抢,不如绕开------在鹰眼面板上铺一层透明 div,让这层接管指针事件:

css 复制代码
.hawk-drag-layer {
  position: absolute;
  /* 四周留出 28px 把刻度尺露出来,避免抢走刻度尺的点击 */
  top: 28px; left: 28px; right: 28px; bottom: 0;
  z-index: 3;
  cursor: grab;
  background: transparent;
  touch-action: none;
}

拖拽换算这样做,横向改偏航、纵向改俯仰:

js 复制代码
layer.addEventListener("pointermove", (e) => {
  if (!dragging) return;

  const dx = e.clientX - lastX;
  const dy = lastY - e.clientY;  // 屏幕 Y 向下为正,这里翻过来
  lastX = e.clientX;
  lastY = e.clientY;

  const deltaHeading = (dx / 320) * 120;
  const deltaPitch   = (dy / 240) * 60;

  // 一次只改一个轴:横向移动多就改偏航,纵向多就改俯仰。
  // 两个一起改的话,斜着拖会同时转两个方向,很难对准目标。
  if (Math.abs(deltaHeading) >= Math.abs(deltaPitch)) {
    state.heading = round1(clamp(toHeading180(state.heading + deltaHeading), -180, 180));
  } else {
    state.pitch = round1(clamp(state.pitch + deltaPitch, -90, 35));
  }

  scheduleRender();
});

「一次只改一个轴」这个判断挺有必要的。两个轴同时响应的话,鼠标稍微斜一点画面就同时转两个方向,用户很难把镜头怼到想要的位置。

另外记得把鹰眼 Viewer 的默认漫游全关掉,不然它自己也会动,会出现「画面飘了但视锥没动」:

js 复制代码
const controller = hawkViewer.scene.screenSpaceCameraController;
controller.enableInputs = false;
controller.enableRotate = false;
controller.enableTranslate = false;
controller.enableZoom = false;
controller.enableTilt = false;
controller.enableLook = false;

七、几个小细节

变焦要用乘法

js 复制代码
function zoomByWheel(direction) {
  const ratio = state.ptz >= 100 ? 1.2 : 1.25;

  let next = direction > 0
    ? Math.min(160, state.ptz * ratio)
    : Math.max(1, state.ptz / ratio);

  state.ptz = normalizePtz(next);
  state.isFocus = true;   // 滚了轮说明用户想变焦,自动切模式
  scheduleRender();
}

变焦在感知上是指数的:1X→2X 和 100X→101X 完全不是一回事。用加减的话低倍段太慢、高倍段太快,必须用乘除。

高度杆解决空间感问题

相机悬在半空时,用户根本判断不出它离地多高。从落点竖一根虚线杆上去就清楚了:

js 复制代码
heightPoleEntity = mainViewer.entities.add({
  polyline: {
    positions: [groundPosition, cameraPosition],
    width: 2,
    material: new Cesium.PolylineDashMaterialProperty({
      color: Cesium.Color.fromCssColorString("rgba(45,140,240,0.95)"),
      dashLength: 14,
    }),
  },
});

地面点、高度杆、相机点这三个实体都只创建一次,之后只更新坐标,不要每帧删了重建。

别覆盖鹰眼相机的 aspectRatio

这个坑不大但很隐蔽。给鹰眼相机设视场角时,顺手把 aspectRatio 也设成了 16:9,结果画面被拉伸变形了------因为鹰眼面板本身是 420×300。让它跟随画布自己的比例就行:

js 复制代码
const frustum = hawkViewer.camera.frustum;
if (frustum instanceof Cesium.PerspectiveFrustum) {
  frustum.fov = Cesium.Math.toRadians(state.isFocus ? fovOf(state.ptz) : 28);
  // 不要覆盖 aspectRatio,否则画面会被拉伸
}

地面距离要设上限

用射线跟椭球求交来量视线中心到地面的距离。但俯仰接近 0° 时,射线其实是打到地平线的,算出来三万多米,显示出来很莫名其妙:

js 复制代码
function measureTerrainDistance() {
  const ray = new Cesium.Ray(hawkViewer.camera.position, hawkViewer.camera.direction);
  const hit = Cesium.IntersectionTests.rayEllipsoid(ray, hawkViewer.scene.globe.ellipsoid);

  // 超过 20km 认为已经打到地平线外了,界面上显示 "--" 更合理
  if (hit && hit.start > 0 && hit.start < 20000) {
    return round1(hit.start);
  }
  return 0;
}

八、这版做到哪、没做哪

做了的:地面点竖杆放相机、双视锥、鹰眼拖拽和滚轮、刻度尺快调、主图实时联动。

没做的:完整的航线编辑和航点动作落库、航线文件的导入导出、不同机型的云台能力差异(俯仰范围、变焦上限这些我先写成常量了)。

所以它更适合拿来理解这条链路,或者当个起手样例,往自己的航线配置里接的时候再补业务那一层。


九、最后

配云台这事,难的从来不是公式,而是用户能不能把参数和真实画面对上号。

视锥解决「看得见」,鹰眼解决「调得动」,两个凑一起,配航点动作的效率提升是很明显的。做完这版之后,我们内部试用基本不用再靠试飞去验证角度了。


需要完整代码

完整实现是一个单文件 HTML,大约 900 行,本地双击就能跑(需要联网加载 Cesium 和底图)。文章里放不下,需要的同学评论区留言或者私信我,我单独发给你

拿到之后记得把里面的 Cesium Ion Token 换成你自己的。

如果这篇对你有帮助,点个赞让我知道一下,后面把航点动作那块也写一篇。

相关推荐
张元清1 小时前
React useMeasure Hook:用 ResizeObserver 测量 DOM 元素 (2026)
javascript·react.js
思码梁田1 小时前
CSS display 属性:从元素类型转换到隐藏元素的实用指南
前端·css·display·inline·block·none·inline-block
Csvn1 小时前
⚡ Vite 依赖预构建(optimizeDeps)的 3 个经典坑:改了源码不生效、新增依赖 404、缓存怎么清都不行
前端
A24207349301 小时前
Vue3 + TypeScript:后端数据在表格内渲染后进行增删改的完整实现步骤
前端·javascript·typescript
他们叫我 悦儿遥遥雨1 小时前
.NET 8 Web开发入门(六):Blazor 全栈开发——告别 JavaScript 焦虑
前端·javascript·.net
用户2181697049302 小时前
Flutter(九)StatelessWidget StatefullWidget
前端
paopaokaka_luck2 小时前
基于springboot3+vue3的乡村医生诊疗管理系统(AI助手、协同过滤算法、webSocket实时聊天、Echarts图形化分析)
前端·网络·人工智能·spring boot·websocket·网络协议·echarts
编程风暴2 小时前
3类人群的数据分析实习类型选择+4条选错红线
java·前端·数据分析
其美杰布-富贵-李2 小时前
Vue 3 模块导入教程:import、export 与文件组织
前端·javascript·vue.js