我们公司做的是无人机方向的业务,其中一块是航线规划:用户在地图上打航点,给每个航点配动作,比如飞到这儿拍张照。
拍照就绕不开一个问题------云台相机到底朝哪儿拍。俯仰多少度、偏航多少度、要不要变焦,这三个参数决定了照片里是目标物,还是一片草地。
早期我们就是给三个输入框,让用户填数字。填完谁也不知道对不对,只能飞一趟看结果,回来再改。参考大疆的地面站在这块的做法很成熟:地图上把相机视锥画出来,右下角再放一个鹰眼预览,拖一拖就能对准。我们参考这个思路做了一版。
这篇文章讲这套东西是怎么实现的:整体结构、关键代码, 其实我们做好的项目比这个完善很多, 航线的所有功能一起其他的一些功能基本都具备, 这次只是把这个技术点拿出来单独说说一下而已,欢迎大家指正错误。
代码用的是 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 换成你自己的。
如果这篇对你有帮助,点个赞让我知道一下,后面把航点动作那块也写一篇。
