一套飞控内核,两个渲染后端:three.js 与 CesiumJS 同构实践

一套飞控内核,两个渲染后端:把无人机塞进浏览器时踩过的坑

项目地址:github.com/chenlong201... 在线 Demo:chenlong2019.github.io/drone-camer...

先说结论:这个库做到了一件事------同一套飞行控制内核,同时驱动 three.js 的本地沙盒和 CesiumJS 的真实地球,面板、航线编辑、影像链路一行都不用改。换后端只改一个参数。

开源世界里"浏览器里飞无人机"的项目不少,但绝大多数是两种形态:要么是 FPV 飞行游戏(只关心第一人称爽感),要么是在地球模型上放一个会动的小飞机(只关心坐标对不对)。同时把「飞控状态机 + 影像链路 + 双渲染后端」做完整的,我没找到。

这篇文章不吹功能,只讲架构上必须做的判断,和几个只有真正写下去才会撞见的坑。


一、为什么非要两个后端

真实的无人机软件有三类场景,对渲染的要求是矛盾的:

场景 需要什么 three.js CesiumJS
航线规划、避障推演、教学演示 完全可控的沙盒世界,任意摆建筑、任意调光照 ✅ ❌(地球场景不好塞自定义小场景)
真实地址勘察、测绘、汇报 真实地形与卫星影像,经纬度必须是真的 ❌ ✅
影像采集与全景 稳定的离屏渲染、可复现的相机位姿 ✅ 一般

如果按常规做法,这就是两个项目、两套代码、两份面板、两个 bug 列表。所以第一个设计目标就定成了:内核与渲染彻底解耦,面板与后端彻底解耦。


二、第一个决定:内核不许碰 DOM,也不许 import three

drone-sim.ts 是整个库的地基,它只有一个职责:给定时间步长,推进飞行状态。

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 成员会破坏结构类型兼容。

我第一版就是那么写的,结果把两个适配器传给面板时编译器直接报错:

python 复制代码
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,而是显式声明面板真正需要的最小契约:

ts 复制代码
// 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 个。接口不小而全,但换来的是:

ts 复制代码
new DroneControlPanel(droneFly, opts)   // three 后端
new DroneControlPanel(cesiumAdapter, opts) // Cesium 后端

同一份面板代码,两种后端,零改动。航线面板同理。这是整个项目里最划算的一笔交易。


四、后端只在一个地方分叉

所有差异都被压缩进了一个工厂函数的参数里:

ts 复制代码
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 字符串

给航点加标签,最直觉的写法:

ts 复制代码
viewer.entities.add({
  position,
  point: { pixelSize: 10, color: '#3ddc97' },     // ← 点了是纯黑
  label: { text: '航点 1', fillColor: 'white' },   // ← 标签根本不显示
})

不报错、不警告,点和标签就是不对。因为 Cesium 的 Color 属性要的是 Cesium.Color 实例,不是 CSS 字符串------构造时不校验,渲染时才表现成"纯黑"或"什么都不画"。

ts 复制代码
const C = Cesium
point: { color: C.Color.fromCssColorString('#3ddc97') }
label: { fillColor: C.Color.WHITE }

坑 2:scene.pick 拿不到实体 id

想实现"点一下航点选中它",标准做法是 scene.pick。对 entity 里的 point,返回的是这样一团东西:

js 复制代码
{ primitive: PointPrimitive, collection: PointPrimitiveCollection, id: 'xxx' }

它是个包装对象 ,不是一个 Entity,pickedEntity.id 和你的 entity.id 对不上(一个是内部 primitive id)。试了几轮映射都不稳,最后放弃 pick,改成屏幕投影就近判定:

ts 复制代码
// 把所有航点投影到屏幕,找距离指针最近的那个(阈值 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 路径上做:

ts 复制代码
// 取 ArrayBuffer → 只改 JSON chunk 里的 extensionsRequired → Blob URL 交给 Cesium
const url = await resolveModelUrl(glbUrl)  // 无扩展可摘时原样返回,零开销

MAGIC、version、BIN chunk 全部原样搬过去,只从 extensionsRequired 里摘掉那一个字符串。资产文件一个字节没动。

坑 4:拖拽航点会和地球漫游打架

在 3D 地球上拖一个航点,指针一动,Cesium 的相机漫游也跟着动------用户在拖航点,画面却在转。

而且增量不能按世界坐标算,得按屏幕空间算:

ts 复制代码
// 每像素对应多少米(在给定距离下)
const mpp = (2 * distance * Math.tan(fovy / 2)) / canvasHeight

事件要挂在容器的捕获阶段 ,并且 stopPropagation(),否则 Cesium 的手势识别器会先拿到事件:

ts 复制代码
container.addEventListener('pointerdown', onDown, { capture: true })

坑 5:全景取景时,机体会"穿帮"

拍摄全景时相机要绕机体转一圈取景,如果用普通的跟随视角逻辑,俯仰一压低,相机会穿到机身上,画面里出现一块黑------而且那正是要拼进全景的图。

修法是取景期间把光心固定在机体自转轴上,只转朝向不做偏移;同时取景帧里隐藏机体模型和航线实体:

ts 复制代码
// 取景相机光心 = 机体位置(不随俯仰摆动)
// 取景帧: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 随窗口宽高比变化:

ts 复制代码
fovY = 2 * atan(tan(30°) / aspect)

于是宽屏下垂直视场骤降:

视口 垂直 FOV
1600 × 900 约 36°
1400 × 500 约 23°

早期版本把俯仰层写死在 +25° / −15° / −55°,在 fovY < 41° 的宽屏上三层不再重叠------拼出来的全景中间整行是空的,像一条没有被拍到的地平线。

第一版补丁是"填空隙"(取更近的已覆盖行铺过去),但那是治症状。根因在分层写死,改法是让分层跟着实际 fovY 走:

ts 复制代码
// 相邻层中心距不超过垂直 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 直接回给模型。

ini 复制代码
模型: 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 式批处理):

bash 复制代码
node mcp/drone-cli.mjs run task.json

八、部署上线时又踩了两个

上线到 GitHub Pages(/drone-camera/ 子路径)时,两个坑很典型:

1. CSS 里的 @import 不会被构建工具改写 base

css 复制代码
@import url('/Cesium/Widgets/widgets.css');  /* 构建后还是 /Cesium/...,子路径下 404 */

CSS 里的绝对 URL 是"外部资源",Vite 不会给它加 base。改成运行时按 import.meta.env.BASE_URL 注入 <link>。

2. Cesium 的 Workers / Assets 路径要显式告诉它

ts 复制代码
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 里问,尤其是三个方向上的:

  1. 你们真实业务里,是沙盒视图用得多还是真实地球用得多?
  2. 全景拍摄的分层策略,真机上还有哪些约束我没建模到?
  3. 如果要接 AI 规划层,你希望它以什么形态存在(MCP 工具?还是库内的一个 MissionPlanner 类?)
相关推荐
夏天要喝冰可乐1 天前
用 Codex 连接 Blender,我做了一块可以走进楼宇的大屏
前端·three.js
ab1237683 天前
three.js 完整笔记
笔记·three.js
答案answer6 天前
不用写一行Three.js代码,也能搭出复杂数字孪生场景
github·three.js
纸片人7 天前
甲方只给了一张卫星截图,我用 Three.js + Blender 建了一座 3D 能源站
three.js·数据可视化
纸片人7 天前
从一座城市钻进一个房间:Three.js + Blender 做一个五级钻取的数字楼宇大屏
three.js·数据可视化
妖精的羽翼7 天前
Web3D(Three.js)应用分级 + 学习路线规划(由浅入深,贴合接单 + 求职)
学习·three.js
踩着两条虫8 天前
我在浏览器里“盖”了一个村:Vue + Three.js 大屏开发踩坑记
前端·vue.js·three.js
子龙_8 天前
来在网页上用threejs画个蛋白质吧
前端·webgl·three.js
沧沧凉凉15 天前
一段需求、零素材,Claude Code 派了 33 个代理给我做了个 3D 动作游戏
游戏·ai编程·three.js