一套飞控内核,两个渲染后端:把无人机塞进浏览器时踩过的坑
项目地址:github.com/chenlong201… 在线 Demo:chenlong2019.github.io/drone-camer…
先说结论:这个库做到了一件事——同一套飞行控制内核,同时驱动 three.js 的本地沙盒和 CesiumJS 的真实地球,面板、航线编辑、影像链路一行都不用改。换后端只改一个参数。
开源世界里"浏览器里飞无人机"的项目不少,但绝大多数是两种形态:要么是 FPV 飞行游戏(只关心第一人称爽感),要么是在地球模型上放一个会动的小飞机(只关心坐标对不对)。同时把「飞控状态机 + 影像链路 + 双渲染后端」做完整的,我没找到。
这篇文章不吹功能,只讲架构上必须做的判断,和几个只有真正写下去才会撞见的坑。
一、为什么非要两个后端
真实的无人机软件有三类场景,对渲染的要求是矛盾的:
| 场景 | 需要什么 | three.js | CesiumJS |
|---|---|---|---|
| 航线规划、避障推演、教学演示 | 完全可控的沙盒世界,任意摆建筑、任意调光照 | ✅ | ❌(地球场景不好塞自定义小场景) |
| 真实地址勘察、测绘、汇报 | 真实地形与卫星影像,经纬度必须是真的 | ❌ | ✅ |
| 影像采集与全景 | 稳定的离屏渲染、可复现的相机位姿 | ✅ | 一般 |
如果按常规做法,这就是两个项目、两套代码、两份面板、两个 bug 列表。所以第一个设计目标就定成了:内核与渲染彻底解耦,面板与后端彻底解耦。
二、第一个决定:内核不许碰 DOM,也不许 import three
drone-sim.ts 是整个库的地基,它只有一个职责:给定时间步长,推进飞行状态。
// 没有任何 DOM / three / Cesium 依赖,Node 里裸跑
sim.step(dt) // 推进物理
sim.position // { x, y, z } 本地米坐标
sim.heading // 航向
sim.phase // 上电 / 解锁 / 起飞 / 悬停 / 返航 / 降落
sim.battery // 电池与剩余续航
里面装的是真正会咬人的东西:
- 限高限距(默认 120 m / 500 m,可整体关掉)
- 失效保护(低电量返航、失联返航、超时悬停)
- AABB 避障(航点连线与障碍物包围盒求交,命中就绕行)
- 航点任务的完整状态机(执行、暂停、恢复、继续、跳点、循环)
- 电池模型(爬升比平飞更耗电,悬停也有基础功耗)
放在 Node 里跑意味着:飞控逻辑可以无头测试,不需要开浏览器。后面所有"AI 指挥无人机"的能力,本质上都建立在这个前提上——如果把飞控写在渲染循环里,AI 想控制它就必须先有一个活着的浏览器窗口,那就没法做批处理了。
这一步的代价是:内核不能直接用 three 的 Vector3、不能用 Cesium 的 Cartesian3,得自己实现向量运算。收益是换后端时内核一行不动。
三、第二个决定:面板靠接口,不靠继承
面板(上电、起飞、摇杆、飞行模式、云台)是最容易写死在一个后端上的部分——它要读飞行状态,要调动作。
"继承一个 BaseDrone 然后让两个后端各自继承"看起来最自然,但它有个死结:TypeScript 的 private 成员会破坏结构类型兼容。
我第一版就是那么写的,结果把两个适配器传给面板时编译器直接报错:
Argument of type 'DroneFly' is not assignable to parameter of type 'DronePanelHost'.
Types have separate declarations of a private property 'sim'.
原因是 DroneFly 和 CesiumDroneAdapter 各自声明了 private readonly sim,即使类型一样,TS 也认为它们是两个不兼容的私有字段(这是 TypeScript 的 nominal 检查,私有成员按声明来源区分)。
修法不是把字段改 public,而是显式声明面板真正需要的最小契约:
// drone-control.ts —— 面板只认这个接口,不认识任何后端
export interface DronePanelHost {
readonly sim: DroneSim
powerOn(): void
arm(): void
takeoff(): Promise<void>
land(): Promise<void>
// ... 共 28 个成员
}
export class DroneControlPanel {
constructor(host: DronePanelHost, options?: { container?: HTMLElement }) {}
}
DronePanelHost 28 个成员,MissionPanelHost(航线面板)也是 28 个。接口不小而全,但换来的是:
new DroneControlPanel(droneFly, opts) // three 后端
new DroneControlPanel(cesiumAdapter, opts) // Cesium 后端
同一份面板代码,两种后端,零改动。航线面板同理。这是整个项目里最划算的一笔交易。
四、后端只在一个地方分叉
所有差异都被压缩进了一个工厂函数的参数里:
import { createDroneControl } from 'drone-camera'
// three.js / WebGPU
const fly = await createDroneControl({
engine: 'three',
scene, camera, renderer, controls,
modelUrl: '/models/dji_mini4_pro.glb',
})
// CesiumJS
await loadCesiumScript('/Cesium/Cesium.js')
const fly = await createDroneControl({
engine: 'cesium',
viewer,
lon: 116.391, lat: 39.906,
})
再往外,面板、航线、采集、全景、影像集全部面向接口,只 import type(这个细节很重要:如果 Cesium 路径不小心 import 了 three 的运行时,打包时 three 就被拖进 Cesium 包了,下面会讲)。
五、Cesium 后端的五个坑
这部分是全文最"值钱"的地方——每条都是先怀疑别处、最后才定位到根因的。
坑 1:entity 的图形属性不吃 CSS 字符串
给航点加标签,最直觉的写法:
viewer.entities.add({
position,
point: { pixelSize: 10, color: '#3ddc97' }, // ← 点了是纯黑
label: { text: '航点 1', fillColor: 'white' }, // ← 标签根本不显示
})
不报错、不警告,点和标签就是不对。因为 Cesium 的 Color 属性要的是 Cesium.Color 实例,不是 CSS 字符串——构造时不校验,渲染时才表现成"纯黑"或"什么都不画"。
const C = Cesium
point: { color: C.Color.fromCssColorString('#3ddc97') }
label: { fillColor: C.Color.WHITE }
坑 2:scene.pick 拿不到实体 id
想实现"点一下航点选中它",标准做法是 scene.pick。对 entity 里的 point,返回的是这样一团东西:
{ primitive: PointPrimitive, collection: PointPrimitiveCollection, id: 'xxx' }
它是个包装对象,不是一个 Entity,pickedEntity.id 和你的 entity.id 对不上(一个是内部 primitive id)。试了几轮映射都不稳,最后放弃 pick,改成屏幕投影就近判定:
// 把所有航点投影到屏幕,找距离指针最近的那个(阈值 14px)
const screen = C.SceneTransforms.worldToWindowCoordinates(scene, cartesian)
反而更准:用户点的是"屏幕上看到的那个点",投影判定和视觉完全一致,也不受 Cesium 内部图元结构变化影响。
坑 3:共享 GLB 资产不能为单一渲染器改
DJI Mini 4 Pro 的 GLB 里声明了 KHR_lights_punctual 扩展(PBR 灯光),而 Cesium 拒载这个扩展,直接抛 Unsupported glTF Extension。
最省事的想法:把扩展从 GLB 里删掉。但那个 GLB 同时被 three 后端用着,three 认这个扩展、还会用到里面的灯珠发光——为了一个渲染器改共享资产,是在给另一个渲染器制造 bug。
解法是加载期净化,只在 Cesium 路径上做:
// 取 ArrayBuffer → 只改 JSON chunk 里的 extensionsRequired → Blob URL 交给 Cesium
const url = await resolveModelUrl(glbUrl) // 无扩展可摘时原样返回,零开销
MAGIC、version、BIN chunk 全部原样搬过去,只从 extensionsRequired 里摘掉那一个字符串。资产文件一个字节没动。
坑 4:拖拽航点会和地球漫游打架
在 3D 地球上拖一个航点,指针一动,Cesium 的相机漫游也跟着动——用户在拖航点,画面却在转。
而且增量不能按世界坐标算,得按屏幕空间算:
// 每像素对应多少米(在给定距离下)
const mpp = (2 * distance * Math.tan(fovy / 2)) / canvasHeight
事件要挂在容器的捕获阶段,并且 stopPropagation(),否则 Cesium 的手势识别器会先拿到事件:
container.addEventListener('pointerdown', onDown, { capture: true })
坑 5:全景取景时,机体会"穿帮"
拍摄全景时相机要绕机体转一圈取景,如果用普通的跟随视角逻辑,俯仰一压低,相机会穿到机身上,画面里出现一块黑——而且那正是要拼进全景的图。
修法是取景期间把光心固定在机体自转轴上,只转朝向不做偏移;同时取景帧里隐藏机体模型和航线实体:
// 取景相机光心 = 机体位置(不随俯仰摆动)
// 取景帧:body.visible = false, missionEntities.visible = false
成片才 takePhoto,取景帧只用来合成。
六、影像链路:因为位姿是已知的,所以不需要 SfM
绝大多数"全景拼接"教程会带你去学特征匹配(SIFT/ORB)、去装 OpenCV。这里不需要——每一张虚拟照片的相机位姿是精确已知的,因为相机就是我们自己控制的。
拼接就是逐像素反投影:
对每个输出像素(等距柱状图上的方向向量)
→ 找到覆盖该方向的源图
→ 用该图的位姿 + 内参反算源图像素坐标
→ 采样,写回
没有特征匹配、没有 RANSAC、没有光束法平差、没有 OpenCV 依赖。
全景是怎么"飞"出来的
真机的约束决定了拍摄策略。DJI Mini 4 Pro 的云台水平行程只有 ±30°(本库按 ±5° 保守建模),而 180° 全景需要 ±90° 水平视野——云台根本转不出来。
所以:
- 水平靠机体原地自转(空间坐标不动,Yaw 转 7 或 8 个方位)
- 垂直靠云台俯仰分层
- 180°:7 方位 × 3 俯仰 = 21 张
- 360°:8 方位 + 垂直层数按实际视场自适应 + 天顶天底
一句话:水平广角靠机身转,垂直分层靠云台俯仰。老款 Mavic 2 有大角度云台 Pan 可以自己转着拍,Mini 系列不行——这个差别会直接决定代码结构。
这里有个很隐蔽的 FOV 陷阱
相机水平 FOV 固定 60°,垂直 FOV 随窗口宽高比变化:
fovY = 2 * atan(tan(30°) / aspect)
于是宽屏下垂直视场骤降:
| 视口 | 垂直 FOV |
|---|---|
| 1600 × 900 | 约 36° |
| 1400 × 500 | 约 23° |
早期版本把俯仰层写死在 +25° / −15° / −55°,在 fovY < 41° 的宽屏上三层不再重叠——拼出来的全景中间整行是空的,像一条没有被拍到的地平线。
第一版补丁是"填空隙"(取更近的已覆盖行铺过去),但那是治症状。根因在分层写死,改法是让分层跟着实际 fovY 走:
// 相邻层中心距不超过垂直 FOV 的 60%,保证至少 40% 重叠,再补天顶/天底
sphericalPitchGrid(verticalFovDeg)
改完以后同一个全景在 16:9 和 21:9 下都能无缝拼上。
七、让 AI 开飞机
这是这个项目最不像"无人机模拟器"的部分。
仓库里带了一个 MCP server(mcp/drone-mcp-server.mjs,手写 stdio JSON-RPC,零框架依赖),暴露 14 个工具:
drone_state drone_takeoff drone_land drone_return_home
drone_spin_to drone_camera_mode drone_capture_photo drone_panorama
drone_screenshot drone_viewer_shot drone_sessions drone_session_export
drone_session_photos drone_shutdown
关键设计:截图以 MCP 的 image content 直接回给模型。
模型: drone_takeoff
模型: drone_panorama(mode=pano-360)
模型: drone_viewer_shot(yaw=90, pitch=-10, fov=90) → 收到一张图
模型: "东侧拼接有明显接缝,重新拍这一列" → drone_spin_to(90) → drone_capture_photo
模型能看见自己拍的画面,并据此修正下一次动作——这才是"AI 指挥"和"AI 点按钮"的区别。
底下是一个 headless driver:按需拉起 vite + 无头 Chromium(软件 GL),渲染监听 127.0.0.1 随机端口,空闲 10 分钟自动回收,对调用方来说就像一个本地服务。同一套能力还有个 CLI 形态(Blender 式批处理):
node mcp/drone-cli.mjs run task.json
八、部署上线时又踩了两个
上线到 GitHub Pages(/drone-camera/ 子路径)时,两个坑很典型:
1. CSS 里的 @import 不会被构建工具改写 base
@import url('/Cesium/Widgets/widgets.css'); /* 构建后还是 /Cesium/...,子路径下 404 */
CSS 里的绝对 URL 是"外部资源",Vite 不会给它加 base。改成运行时按 import.meta.env.BASE_URL 注入 <link>。
2. Cesium 的 Workers / Assets 路径要显式告诉它
window.CESIUM_BASE_URL = `${base}Cesium/` // 必须在加载脚本前设置
await loadCesiumScript(`${base}Cesium/Cesium.js`)
不设的话,Cesium.js 会自己从 script src 推断——推断出站点根,子路径部署下 Workers 全部 404,症状是"能渲染但什么都没画出来"。
顺带一个体验问题:首屏白屏了 40 秒。查下来不是加载慢,是 init() 里 await 了 14MB 的机体 GLB——首帧被资源加载阻塞。修法是两步:HDRI 从 4k 降到 2k(23MB → 8MB,自写 flat-RGBE 编码做降采样,太阳亮度 52 万量级完整保留),以及给 GLB 下载期加一个明确的加载提示,而不是让用户对着白屏猜。
九、结果
| 项 | 数字 |
|---|---|
| 库规模 | 28 个 TS 文件 / 26,144 行 |
| 内核外部依赖 | 0(Node 可直接跑) |
| 面板契约 | DronePanelHost 28 成员 / MissionPanelHost 28 成员 |
| MCP 工具 | 14 个,截图以 image content 回传 |
| 静态场景性能 | 15 draw calls / 60 FPS(按材质分 8 桶合并 + InstancedMesh) |
| 全景 | 180° 21 张 / 360° 自适应分层 |
| 依赖 | three(peer)+ 可选 Cesium,拼接链路零 CV 依赖 |
在线 Demo 可以点开直接玩(双后端都开着): chenlong2019.github.io/drone-camer…
npm:npm i drone-camera(MIT)
十、诚实的边界
- 这是模拟与规划层,不是真机飞控。DJI Mini 4 Pro 不开放底层传感器与姿态接口,接真机需要换平台(比如 PX4/MAVSDK)。
- "智能飞行"这件事,SLAM 不是必需的——它更像是上游的建模工具(把现实变成模型)。真正缺的是决策层:给一个目标("把这座桥拍清楚"),自动生成航点、机位、云台角度的规划器。这块还没做,也是我认为最值得做的下一步。
- 全景拼接依赖"位姿精确已知"这个前提,所以它对真实照片(手机拍的)不成立——真实照片走的是 EXIF/GPS 导入路径,只做几何校验,不做拼接。
如果这个库对你有用,Star 一下是最实际的鼓励。有问题可以直接在 issue 里问,尤其是三个方向上的:
- 你们真实业务里,是沙盒视图用得多还是真实地球用得多?
- 全景拍摄的分层策略,真机上还有哪些约束我没建模到?
- 如果要接 AI 规划层,你希望它以什么形态存在(MCP 工具?还是库内的一个 MissionPlanner 类?)