
1. 前言:从"轨迹巡航动画"到"真正开飞机环游世界"
在数字孪生、智慧城市和 WebGIS 项目中,我们经常能看到类似"航线飞行动画"的演示:给一段航路点的经纬度,让 Cesium 的 SampledPositionProperty 沿轨迹平滑插值,飞机模型头朝前沿着折线飞过去。
但如果你和我一样,心里始终有一个**"在真实地球贴地自由开飞机"**的执念------想从内蒙古呼和浩特机场拔地而起,掠过阴山山脉,顺着黄河峡谷低空通场,或者直接开着喷气客机翻越喜马拉雅山脉,你很快就会发现:轨迹插值根本不是飞行。
让一架飞机在真实的三维地球上"飞"起来,面临的工程和数学挑战远远超出普通的三维展示:
- 地球是曲率椭球体(WGS84):经纬度不是笛卡尔平面,往东飞 100 公里在赤道和在两极的经度跨度完全不同;
- 飞机姿态与局部坐标系:飞机的俯仰(Pitch)、滚转(Roll)和偏航(Heading)是以机身局部坐标系为基准的,而机身所在切平面的"朝上"向量每时每刻都在随着地表法线变化;
- 真实的飞行动力学 :滚转产生向心力转弯、拉杆产生爬升率、推油门产生推力加速度,而不是按下
A键直接把机头生硬地加减几度; - 全球真实地形(World Terrain):平原海拔几十米,高原海拔几千米。如果初始高度写死,飞机不是悬在半空就是直接埋在地底;
- 碰撞检测与穿山问题 :飞机以 900 km/h900\text{ km/h}900 km/h 高速飞行时,每秒位移达 250 米250\text{ 米}250 米,普通的低频地形采样会导致飞机在两帧之间直接从山前瞬移到山后;
- 工程架构与框架整合:Cesium 庞大的打包体量与 Vite 现代前端构建体系之间的摩擦,以及主线程渲染、物理循环、UI 响应之间的性能平衡。
为了把这个功能做透,我使用 Vue 3 + TypeScript + Vite + CesiumJS 从零构建了这个全球飞行模拟项目 ------ Cesium Flight World。
本文将剥开所有表面效果,深入到数学、动力学、架构设计与底层踩坑细节,完整解析如何实现一个高帧率、支持手柄控制、包含多航点自动驾驶以及具备真实碰撞爆燃机制的 Web 全球飞行系统。
【这里插入最终运行效果截图】
2. 最终实现的功能概览
在进入具体代码前,先来看当前系统已经落地的功能矩阵:
- 全球三维环境:基于 CesiumJS 接入天地图全球高清卫星影像、中文注记切片、Cesium World Terrain 全球数字高程模型及真实水面反射(Water Mask)。
- 独立飞行动力学核心(FlightCore):自主研发简易飞行动力学引擎,支持连续推力加速度、升阻平衡、基于协调转弯公式的倾斜转弯和过载响应。
- 全球任意位置起飞 :支持通过
Shift + 单击地球任意点设定起飞场,自动识别地表真实高程并匹配安全起飞高度。 - 航线规划与多航点自动驾驶(Route & Autopilot) :
- 支持交互式点击地球添加、撤销航点,全航线基于大圆航线(Great Circle)解算;
- 自动驾驶仪(Autopilot)通过三回路 PID 闭环控制飞机副翼、升降舵与油门,支持转弯提前量计算(Turn Anticipation)。
- 全双工操控输入:键盘按键与标准 Gamepad 游戏手柄统一抽象为标准化操纵量,支持摇杆微操与油门轴映射。
- 三模摄影机系统 :
- CHASE(第三人称追随):带动态滚转阻尼衰减与相机地面防穿透缓冲;
- NOSE(机头/第一人称驾驶舱视角):视线严格随姿态联动;
- GLOBE(全球态势大范围感知):宏观鸟瞰航线与地球。
- 两级地形碰撞与爆燃重置系统:当前点硬碰撞判定结合高速前向航迹预测采样;触地瞬间飞机隐蔽、地表生成基于原生 WebGL 复合图元的爆燃烟雾特效,2.2 秒后自动安全重置回待命状态。
- 高响应飞行仪表 HUD:显示真航向、空速、地速、升降速度、油门开度、MSL 海拔、AGL 离地净空、侧偏距(XTK)及到点剩余时间(ETA)。
3. 技术选型与分工边界
很多人做 Web 三维项目容易犯一个先入为主的错误:把一切都交给三维引擎。
在设计初期,我确立了一条核心设计铁律:
Cesium 只负责承载"世界与渲染",飞机的物理、导航与逻辑必须完全脱离 Cesium 单独存在。
整个系统的技术选型与职责分工如下:
| 技术 / 模块 | 具体用途 | 核心职责 |
|---|---|---|
| Vue 3 (Composition API) | 上层 UI 框架 | 负责飞行 HUD 仪表渲染、按钮交互、状态响应式分发 |
| TypeScript | 核心开发语言 | 提供严格的强类型推导(姿态、向量、遥测、状态机) |
| Vite | 本地开发与构建 | 极速热更新,负责生产代码的摇树优化(Tree-shaking)与打包 |
| CesiumJS | 三维地球视景引擎 | 负责地形加载、地球渲染、相机视口变换、模型图元管理 |
| 天地图 API | 遥感影像与注记服务 | 提供全球 0~18 级卫星正射影像及中文地理注记 |
| Cesium World Terrain | 全球三维地形服务 | 提供全球地表网格,用于起伏渲染、真高采样与碰撞计算 |
| glTF 2.0 (GLB) | 飞机网格模型 | 包含外部蒙皮与航行灯定位的双发喷气客机模型 |
| HTML5 Gamepad API | 外部硬件接入 | 轮询标准游戏手柄输入,将轴向信号映射为偏转量 |
4. 系统架构设计:Cesium != Flight Physics
为了实现上述解耦,整个项目的拓扑结构被分为清晰的四层流水线:
text
┌───────────────────────┐
│ Keyboard / 手柄 │
└───────────┬───────────┘
│ 原始硬件事件 / 轮询
▼
┌───────────────────────┐
│ InputManager │
└───────────┬───────────┘
│ 归一化输入 ControlInput (pitch, roll, yaw, throttle)
▼
┌──────────────────┐ ┌───────────────────────┐
│ RouteManager │ │ │
└────────┬─────────┘ │ │
│ │ │
▼ │ │
┌──────────────────┐ │ FlightCore │◄────── 物理时间步进 (Substep)
│NavigationComputer│ │ (纯数学飞行动力学核心) │
└────────┬─────────┘ │ │
│ │ │
▼ │ │
┌──────────────────┐ │ │
│ Autopilot │──►└───────────┬───────────┘
└──────────────────┘ │
(产生操纵量注入) │ 权威飞行状态 FlightState (lon, lat, alt, HPR, V...)
▼
┌─────────────────────┼─────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ AircraftRenderer │ │ CameraManager │ │ FlightHud │
│ (Cesium Model) │ │ (CHASE/NOSE/GLOBE)│ │ (Vue DOM / 15Hz) │
└─────────┬─────────┘ └─────────┬─────────┘ └───────────────────┘
│ │
└──────────┬──────────┘
▼
┌─────────────────────┐
│ CesiumJS (Viewer) │
└─────────────────────┘
关键原则:自动驾驶也不能"瞬移"
在很多 Demo 里,开启自动驾驶后,程序会直接通过 L.latLng 或插值器强行改写物体的经纬度。
但在本项目中:自动驾驶仪(Autopilot)没有任何修改飞机位置的权限。
当开启 AUTO 模式时,导航计算机算出偏航误差(Heading Error)和高度误差(Altitude Error),Autopilot 只能像真人飞行员一样,向 FlightCore 发送一个"左滚转 15 度压盘"或"拉杆抬升 3 度"的 ControlInput 指令。飞机如何转弯、受多大重力过载,必须老老实实通过动力学公式积分演进。
这样设计的好处是:手动控制和自动驾驶的底层表现是绝对统一的,系统随时可以无缝切入手工接管(Manual Override),飞机绝不会发生任何瞬移或物理抖动。
5. 项目工程目录结构
项目的工程结构遵循模块自治原则,避免巨石文件(Monolithic File):
text
src/
├── aircraft/ # 飞机模型定义与 Cesium 渲染器
│ ├── aircraftDefinitions.ts # 机型气动力参数(翼展、推重比、最大转弯率)
│ └── AircraftRenderer.ts # GLB 加载、可见性控制与矩阵应用
├── app/ # 顶层调度中枢
│ ├── FlightSimulator.ts # 主循环 (RAF)、状态流转、子系统协调
│ └── telemetry.ts # 遥测数据 ViewModel 构建
├── camera/ # 视口控制
│ └── CameraManager.ts # CHASE / NOSE / GLOBE 视线、阻尼及防穿山计算
├── cesium/ # Cesium 运行时适配
│ ├── aircraftFrame.ts # 机体局部正交坐标系转换 (ENU/ECEF/Matrix4)
│ ├── CesiumWorld.ts # Viewer 初始化、图层与地形配置
│ ├── runtime.ts # 桥接静态 IIFE Cesium API 的统一门户
│ └── TerrainSampler.ts # 异步地表高程采样器
├── collision/ # 地形碰撞系统
│ ├── TerrainCollisionManager.ts # 两级碰撞检测、高速航迹预测与地表缓存
│ └── types.ts # 碰撞事件与上下文类型
├── effects/ # 视景特效
│ └── ExplosionEffect.ts # 原生轻量化复合图元地表爆燃特效
├── flight/ # 飞行动力学核心 (Cesium-Free)
│ ├── FlightCore.ts # 运动学状态积分、协调转弯、推力模型
│ ├── safetyConfig.ts # 安全高程、防穿透间隙配置
│ └── types.ts # FlightState、ControlInput、SimulationMode
├── input/ # 输入抽象
│ └── InputManager.ts # 键盘与 Gamepad 信号融合与防抖
├── navigation/ # 导航与航线
│ ├── Autopilot.ts # 三回路闭环控制器
│ ├── NavigationComputer.ts # 大圆航线距离、航向、侧偏距测算
│ ├── RouteEditor.ts # 鼠标单击地球交互式航点编辑
│ ├── RouteManager.ts # 航路点列表维护与航段跃迁
│ └── RouteRenderer.ts # 测地线航路与航点标牌绘制
└── components/ # 视图层
└── FlightHud.vue # 飞行 HUD、仪表盘与操作横幅
6. Cesium + Vite 的架构深坑:ERR_CONNECTION_RESET
如果你尝试过在 Vite 项目里直接使用:
ts
import { Viewer, Cartesian3 } from 'cesium'
你大概率会在启动后遇到这样的噩梦:浏览器控制台报出一长串红色的 net::ERR_CONNECTION_RESET,页面瞬间假死。
为什么会这样?
CesiumJS 体量极为庞大,源码包含数以千计的独立 ESM 模块。在开发环境下,Vite 默认会尝试对依赖进行预构建(Pre-bundling),或者在浏览器请求时并发加载上千个未打包的 .js 碎片文件。在高并发请求瞬间打满本地 HTTP 管道时,很容易触发浏览器或 Dev Server 的连接重置,甚至导致内嵌 Web Worker(如 GeometryWorker、DracoLoader)因路径解析错误而崩溃。
优雅稳定的解决方案
不要强行去配置极为复杂的 Vite 插件链,针对当前项目,我采取了一种极其干净、工业级稳定的**"静态运行时隔离架构"**:
-
静态资源离线化 :
在
package.json中配置前置拷贝脚本copy-cesium-static.mjs,在构建和开发前,自动将node_modules/cesium/Build/Cesium/目录下的:Cesium.js(官方打包好的单文件运行时)Workers/ThirdParty/Assets/Widgets/
完整拷贝到前端工程的public/cesiumStatic/目录下。
-
全局脚本引入 :
在
index.html头部直接以经典脚本载入:html<script src="/cesiumStatic/Cesium.js"></script> <script> window.CESIUM_BASE_URL = '/cesiumStatic/' </script> -
类型与运行时代码桥接 (
src/cesium/runtime.ts) :为了让整个工程既享受 TypeScript 的完备类型提示,又完全规避 Vite 的模块分发问题,建立一个专用的运行时映射层:
ts// src/cesium/runtime.ts import type * as CesiumTypes from 'cesium' const CesiumRuntime = (window as unknown as { Cesium?: typeof CesiumTypes }).Cesium if (!CesiumRuntime) { throw new Error('[Cesium Runtime] window.Cesium 未加载,请检查 index.html') } // 导出运行时对象(单例门户) export const Cartesian3 = CesiumRuntime.Cartesian3 export const Cartographic = CesiumRuntime.Cartographic export const Matrix4 = CesiumRuntime.Matrix4 export const Viewer = CesiumRuntime.Viewer export const Model = CesiumRuntime.Model export const Transforms = CesiumRuntime.Transforms // ... // 纯静态类型直接复用 export type Cartesian3 = CesiumTypes.Cartesian3 export type FlightState = ...
工程内任何其他业务代码,一律禁止直接 from 'cesium' 导入运行时对象,必须统一经过 runtime.ts。这一套设计彻底终结了连接重置与 Worker 404 的顽疾。
7. 飞机的权威状态:为什么不用 Cartesian3 保存位置?
在 Cesium 开发中,我们最熟悉的莫过于笛卡尔三维向量 Cartesian3(基于地球地心的空间直角坐标,ECEF)。
很多开发者的第一反应是:飞机在空中飞,那我的飞控核心直接保存它的 position: Cartesian3,每一帧加上位移增量向量不就行了吗?
绝对不行!
为什么 Cartesian3 不能作为物理真值?
- 地球曲率误差累积:地球是一个椭球体(WGS84),如果直接在空间三维直角坐标系中累加线段,哪怕每一步只产生毫米级的切线漂移,飞行几千公里后,飞机就会偏离海平面法线,不知不觉冲入太空或钻入地下。
- 极区方位奇异性:在极地附近,局部空间的东向(East)和北向(North)会剧烈收敛,用全局直角坐标很难直接做标准的航向角(Heading)计算。
- 真实航行只认"大地基准":仪表盘里的海拔(MSL)、空速(Airspeed)、航向(Heading)全是建立在地理坐标(经度、纬度、高度)之上的。
因此,在 FlightCore 中,飞机的权威物理真值(Authoritative State)定义如下:
ts
export interface FlightState {
// === 权威空间坐标(大地坐标系统) ===
longitude: number // WGS84 经度 (弧度)
latitude: number // WGS84 纬度 (弧度)
altitudeMSL: number // 真实平均海平面高度 (米)
terrainHeight: number // 对应地面高程 (米)
agl: number // 离地净空高度 (米)
// === 飞行姿态(机体固有坐标) ===
heading: number // 真航向 (弧度, 0=正北, π/2=正东)
pitch: number // 俯仰角 (弧度, 上仰为正)
roll: number // 滚转角 (弧度, 右倾为正)
// === 动力学速度标量 ===
airspeed: number // 真空速 (m/s)
groundSpeed: number // 地速 (m/s)
verticalSpeed: number // 升降速度 (m/s)
throttle: number // 油门开度 (0.0 ~ 1.0)
// === 飞行管理模式 ===
controlMode: 'MANUAL' | 'AUTO'
flightPhase: 'GROUND' | 'CLIMB' | 'CRUISE' | 'DESCENT' | 'CRASHED'
}
规范 :核心内部所有角速度、角度计算一律使用 国际标准单位(弧度、米、m/s),只有流向 HUD 界面展示时,才由视图层转为人类习惯的"度(°)"、"公里/小时(km/h)"和"英尺/米"。
8. 机体局部坐标系与天崩地裂的"上下颠倒"之谜
在这个项目推进过程中,曾遇到一个极其诡异的 Bug:
开启第一人称 NOSE 视角或尾追 CHASE 视角时,天在下、地在上,整座地球倒悬在机舱顶上,一拉杆更是天翻地覆!
很多同学在修这种问题时,习惯随手在代码里打补丁:pitch += Math.PI 或者给相机翻转 180 度。但只要飞机一滚转或者飞过赤道,世界又会重新倒过来。
根因剖析:右手定则的跨国误会
早期代码为了从地理坐标生成机身的 4x4 局部变换矩阵(Matrix4),调用了 Cesium 内置的变换生成器:
ts
const generator = Transforms.localFrameToFixedFrameGenerator('north', 'east')
Cesium 的正交矩阵生成逻辑是:
Axis⃗0=firstAxis⃗ (North)\vec{Axis}_0 = \vec{firstAxis}\ (\text{North})Axis 0=firstAxis (North)
Axis⃗1=secondAxis⃗ (East)\vec{Axis}_1 = \vec{secondAxis}\ (\text{East})Axis 1=secondAxis (East)
Axis⃗2=Axis⃗0×Axis⃗1 (North×East)\vec{Axis}_2 = \vec{Axis}_0 \times \vec{Axis}_1\ (\text{North} \times \text{East})Axis 2=Axis 0×Axis 1 (North×East)
学过高中物理右手定则的同学马上就能看出问题:
在地表切平面上,正北向量叉乘正东向量,其右手大拇指是指向地心的(DOWN) !
也就是说,这个生成器生成的是一个标准的 NED(North-East-Down) 坐标系,它的第三列(Z 轴)指向的是地底深处:
Up⃗⋅SurfaceNormal⃗=−1.0\vec{Up} \cdot \vec{SurfaceNormal} = -1.0Up ⋅SurfaceNormal =−1.0
当后方的相机将这个 Z 轴当做视口的 Up 向量时,Cesium 当然会忠实地把地心朝上渲染到屏幕上方,从而导致天空跑到屏幕下方。
彻底根治:明确统一飞机局部正交系
我们必须抛弃黑盒生成器,在 src/cesium/aircraftFrame.ts 中手写推导严密的正交几何基向量:
【这里插入飞机机体三轴坐标系定义图】
-
确定地表法线与正东正北:
ts// 当前 ECEF 空间位置 const position = Cartesian3.fromRadians(lon, lat, alt) // 地表法线(机背垂直朝天向量基准) const upSurface = Ellipsoid.WGS84.geodeticSurfaceNormal(position) // 局部正东:在切平面上垂直于经线 const east = normalize(new Cartesian3(-Math.sin(lon), Math.cos(lon), 0)) // 局部正北:upSurface 叉乘 east 必定指向真北 const north = normalize(cross(upSurface, east)) -
合成机头指向(Forward⃗\vec{Forward}Forward )与机身朝上(Up⃗\vec{Up}Up ):
- 当航向角为 ψ\psiψ 时,水平投影前向为:
H⃗=cos(ψ)North⃗+sin(ψ)East⃗\vec{H} = \cos(\psi)\vec{North} + \sin(\psi)\vec{East}H =cos(ψ)North +sin(ψ)East - 水平横向基向量为:
R⃗horiz=−sin(ψ)North⃗+cos(ψ)East⃗\vec{R}_{horiz} = -\sin(\psi)\vec{North} + \cos(\psi)\vec{East}R horiz=−sin(ψ)North +cos(ψ)East - 考虑俯仰角 θ\thetaθ(Pitch,拉杆抬头为正)和滚转角 ϕ\phiϕ(Roll,右倾压坡度为正),最终合成的三维基向量为:
Forward⃗=cos(θ)H⃗+sin(θ)Up⃗surf\vec{Forward} = \cos(\theta)\vec{H} + \sin(\theta)\vec{Up}{surf}Forward =cos(θ)H +sin(θ)Up surf
Right⃗=cos(ϕ)R⃗horiz−sin(ϕ)Up⃗0\vec{Right} = \cos(\phi)\vec{R}{horiz} - \sin(\phi)\vec{Up}_0Right =cos(ϕ)R horiz−sin(ϕ)Up 0
Up⃗=Forward⃗×Right⃗\vec{Up} = \vec{Forward} \times \vec{Right}Up =Forward ×Right
- 当航向角为 ψ\psiψ 时,水平投影前向为:
-
填充 Matrix4 变换矩阵:
- 第 0 列 :Forward⃗\vec{Forward}Forward (机头,X 轴)
- 第 1 列 :Right⃗\vec{Right}Right (右翼,Y 轴)
- 第 2 列 :Up⃗\vec{Up}Up (机背天花板,Z 轴,满足 Up⃗⋅Up⃗surf>0.99\vec{Up} \cdot \vec{Up}_{surf} > 0.99Up ⋅Up surf>0.99)
- 第 3 列 :Position⃗\vec{Position}Position (当前空间直角坐标)
从此,无论飞机飞到北半球、赤道还是南极,机身、模型、相机与坐标系彻底严密闭环,天地倒挂的幽灵 Bug 被一次性根除。
9. 简化飞行动力学:让飞机"像飞机一样飞"
很多非专业飞行模拟项目最容易让人出戏的地方,是按下方向键时飞机就像个旋转木马一样直接贴地水平转圈。
真实飞机转弯靠的不是方向舵(Rudder),而是副翼(Aileron)倾斜机翼产生的升力水平分量。
1. 协调转弯模型(Coordinated Turn)
在 FlightCore.ts 中,我们引入了航空学中经典的协调转弯公式:
ω=g⋅tan(ϕ)V\omega = \frac{g \cdot \tan(\phi)}{V}ω=Vg⋅tan(ϕ)
- ggg 为重力加速度(9.8 m/s29.8\text{ m/s}^29.8 m/s2)
- ϕ\phiϕ 为滚转坡度角(Roll)
- VVV 为当前真实真空速(Airspeed)
ts
// 滚转角产生转弯角速度 (rad/s)
const effectiveRoll = this.state.roll
const turnRate = Math.abs(effectiveRoll) > 0.001
? (GRAVITY * Math.tan(effectiveRoll)) / Math.max(this.state.airspeed, 20)
: 0
// 更新真航向
this.state.heading = (this.state.heading + turnRate * dt) % (2 * Math.PI)
从这个公式可以自然得到航空物理规律:
- 想要快速转弯,必须把飞机"侧过来"(加大 Roll);
- 速度极高的喷气机转弯半径极其巨大,低速飞机转弯灵活。
2. 惯性滞后模型(Lag Dynamics)
真实物体的运动是有惯性的:
- 推油门:不是直接改速度,而是改变"目标速度",发动机产生纵向加速度逐步逼近目标速度;
- 拉俯仰杆:先改变飞机姿态角(Pitch),飞机迎角改变后,气动升力逐步将纵向速度转换为垂直爬升率(Vertical Speed)。
ts
// 速度响应一阶低通惯性滤波
const targetSpeed = this.definition.minAirspeed +
this.state.throttle * (this.definition.maxAirspeed - this.definition.minAirspeed)
const speedAlpha = 1 - Math.exp(-0.8 * dt)
this.state.airspeed += (targetSpeed - this.state.airspeed) * speedAlpha
3. 高倍速下的物理子步进(Physics Substepping)
系统支持 1×,2×,4×,8×,16×1\times, 2\times, 4\times, 8\times, 16\times1×,2×,4×,8×,16× 时间倍率。如果用户开启 16×16\times16× 巡航,一帧的时间步长可能高达 Δt=16×0.016s≈0.256s\Delta t = 16 \times 0.016\text{s} \approx 0.256\text{s}Δt=16×0.016s≈0.256s。
如果直接代入动力学方程,飞机会因为单步能量超调剧烈上下海豚跳。
因此在 FlightCore.step 中必须进行切片积分:
ts
step(input: ControlInput, rawDt: number): void {
const maxSubDt = 0.04 // 保证物理子步长绝不超过 40ms
let remaining = Math.min(rawDt, 0.5)
while (remaining > 0) {
const dt = Math.min(remaining, maxSubDt)
this.integrateStep(input, dt)
remaining -= dt
}
}
10. 大圆航线、导航解算与自动驾驶(Autopilot)
在地球表面规划航线,不能用平面两点线段插值。航程几千公里时,沿平面经纬度画出的直线其实是一条弯曲且耗油的航迹;真正的最短航迹是大圆航线(Great Circle)。
【这里插入航线规划与大圆航段效果图】
1. 测地线航程与航向解算
在 NavigationComputer.ts 中,使用球面哈弗辛(Haversine)与球面逆解公式计算目标方位角与侧偏距:
ts
// 计算从当前经纬度指向目标航点的初始真航向 (Bearing)
export function calculateBearing(fromLon: number, fromLat: number, toLon: number, toLat: number): number {
const dLon = toLon - fromLon
const y = Math.sin(dLon) * Math.cos(toLat)
const x = Math.cos(fromLat) * Math.sin(toLat) - Math.sin(fromLat) * Math.cos(toLat) * Math.cos(dLon)
return (Math.atan2(y, x) + 2 * Math.PI) % (2 * Math.PI)
}
2. 侧偏距纠偏(Cross Track Error, XTK)
如果因为强侧风或转弯超调导致飞机偏离了预定航线,导航计算机会算出垂直于航段的偏移距离 XTK\text{XTK}XTK。自动驾驶仪据此生成向航线内侧收拢的修正截击角(Intercept Angle)。
3. 转弯提前量预判(Turn Anticipation)
很多新手写航路巡航时,都是等飞机完全飞越航点上方才开始掉头,导致每次经过航点都会拐出一个极其丑陋的巨大直角。
系统引入了前瞻距离判定:
R=V2g⋅tan(ϕstandard)R = \frac{V^2}{g \cdot \tan(\phi_{standard})}R=g⋅tan(ϕstandard)V2
Danticipate=R⋅tan(ΔTurnAngle2)D_{anticipate} = R \cdot \tan\left(\frac{\Delta \text{TurnAngle}}{2}\right)Danticipate=R⋅tan(2ΔTurnAngle)
当距离下一航点小于 DanticipateD_{anticipate}Danticipate 时,提前宣告航段完成,自动驾驶仪提前平滑转弯切入下一航段,航迹如丝般顺滑。
11. 摄影机系统的工程考量与地形防穿透
相机是模拟器交互的眼睛,但第三人称跟随相机(CHASE)暗藏杀机。
1. 为什么你的跟随相机会让人头晕?
如果相机的 up 向量完全机械死锁在飞机的机背上,飞机一旦做 30 度的快速滚转机动,屏幕外的用户看到整个世界以极快速度天旋地转,极易诱发严重的 3D 眩晕症。
解决办法是在 CameraManager.updateChase 中引入滚转阻尼解耦(Roll Dampening):
ts
// 提取相机平滑跟踪矩阵时,故意大幅削弱 roll 对相机上方向的影响
const frame = createAircraftFrame(state, {
pitchScale: 0.45, // 俯仰跟随适度减弱
rollScale: 0.12, // 滚转跟随大幅度衰减,地平线保持相对稳定
})
这样飞机倾斜机翼时,相机会有一种高级的空气动力学滞后感,观感极其舒适。
2. 相机专属的地形防穿透保护
设想这样一个场景:飞机在山谷中低空贴地超低空飞行,飞机本身并未撞山;但由于尾追相机位于飞机后方 145 米处,后方的山坡直接把相机"吞"进了地下,画面瞬间变成一片透明的网格穿模。
相机的防穿地必须与飞机解耦!
每次计算出相机目标点后,先逆解其大地高度,并与地面高程对比:
ts
// 强制保障相机离地安全间距
const carto = Cartographic.fromCartesian(desiredPosition, Ellipsoid.WGS84)
const minSafeAltitude = state.terrainHeight + FlightSafetyConfig.cameraTerrainClearance // 至少 10m
if (carto.height < minSafeAltitude) {
carto.height = minSafeAltitude
// 沿地表法线抬高相机
Cartesian3.fromRadians(carto.longitude, carto.latitude, carto.height, Ellipsoid.WGS84, desiredPosition)
}
千万注意 :相机可以且应该被强行抬升防穿地,但飞机绝对不能做这种作弊式强制抬升!飞机一旦触地,必须走真实坠毁流程。
12. 真实地形碰撞与高速防穿透(Anti-Tunneling)
在游戏开发中这被称为"隧穿效应"(Tunneling)。
以高速喷气机为例:巡航速度 900 km/h≈250 m/s900\text{ km/h} \approx 250\text{ m/s}900 km/h≈250 m/s。
如果地形采样采用常规的 0.75 秒轮询一次,在这 0.75 秒内,飞机已经跨越了将近 190 米的空间距离。如果面前是一座薄刃型的陡峭山脊,上一帧飞机在山前,下一帧飞机已经在山后,两者采样的高度都高于山脚,飞机便神不知鬼不觉地"瞬移穿山"。
【这里插入地形碰撞与爆炸特效演示图】
为了实现工业级的严谨碰撞,我设计了独立模块 TerrainCollisionManager.ts,采取两级检测策略:
Level 1:当前点硬高度差碰撞(Hard Clearance)
实时比较真实海平面海拔(MSL)与当地地表:
Clearance=altitudeMSL−terrainHeight−aircraftClearance (3m)\text{Clearance} = \text{altitudeMSL} - \text{terrainHeight} - \text{aircraftClearance}\ (3\text{m})Clearance=altitudeMSL−terrainHeight−aircraftClearance (3m)
一旦在非起飞着陆状态下 Clearance≤0\text{Clearance} \le 0Clearance≤0,判定硬碰撞。
Level 2:高速前向多点投影预测(Forward Trajectory Look-Ahead)
根据地速和航向,向前预测 0.5s0.5\text{s}0.5s、1.0s1.0\text{s}1.0s、2.0s2.0\text{s}2.0s 的未来坐标:
Δdi=Vg⋅Δti\Delta d_i = V_g \cdot \Delta t_iΔdi=Vg⋅Δti
predLati=ϕ+cos(ψ)ΔdiR\text{predLat}_i = \phi + \frac{\cos(\psi)\Delta d_i}{R}predLati=ϕ+Rcos(ψ)Δdi
predLoni=λ+sin(ψ)ΔdiR⋅cos(ϕ)\text{predLon}_i = \lambda + \frac{\sin(\psi)\Delta d_i}{R \cdot \cos(\phi)}predLoni=λ+R⋅cos(ϕ)sin(ψ)Δdi
predAlti=altitudeMSL+Vz⋅Δti\text{predAlt}_i = \text{altitudeMSL} + V_z \cdot \Delta t_ipredAlti=altitudeMSL+Vz⋅Δti
模块内部维护了一个基于空间经纬度网格的异步高程缓存队列,提前预热飞机前方航迹上的地形高度。一旦检测到前方即将钻进山体,系统能够提前截获碰撞事件,彻底消除高速穿透。
13. 坠机、爆炸与生命周期状态机
很多初学者做模拟器,飞机撞山后就不知道该怎么办了:要么直接报错停住,要么继续在地下抽搐。
合理的方案是建立一个完整的应用级仿真状态机(SimulationMode):
text
[ 页面刷新加载 ]
↓
( SETUP ) ─── 飞机在当地地表安全高度 (Terrain + 2000m AGL) 静止悬停预览
│ 相机锁定飞机,物理不步进,不消耗算力
Shift+Click 选点
↓
( READY ) ─── 移动到新点地表上方 (Terrain + 1000m AGL),依然静止待命
│ 可自由规划航线、观察地形
点击【开始飞行】
↓
( FLYING ) ─── 初始化巡航速度 155 m/s,物理与动力学循环全面启动
│
撞击地形 (TERRAIN_IMPACT)!
↓
( CRASHED ) ─── 1. 物理步进冻结,Autopilot 立即断开
│ 2. 隐藏机身模型 (aircraft.setVisible(false))
│ 3. 切换至 CrashCamera (在撞击点后上方 80m/40m 凝视)
│ 4. 播放真实时间 2.2 秒纯原生 WebGL 爆燃与烟雾粒子
↓ (等待 2.2s)
( RESETTING )── 飞机自动在撞击点正上方安全空域 (Terrain + 1200m AGL) 重生
↓ 速度清零,机身恢复显示,相机恢复 CHASE
( READY ) ─── 航线完整保留,HUD 提示恢复,等待用户再次点击开始
爆炸特效的一个重要细节
在 ExplosionEffect.ts 中,我们用 Cesium 的 PointPrimitiveCollection 构建了一套轻量级粒子爆发簇(白炽闪光 -> 膨胀烈焰火球 -> 升腾衰减浓烟)。
这里有一个极容易踩坑的细节:爆炸动画的驱动时间,必须使用 performance.now() 的物理现实时间,绝不能使用模拟器时钟!
否则,如果用户当前开着 16×16\times16× 仿真倍速,一撞山,原本绚烂的 2.2 秒爆炸会在 0.130.130.13 秒内一闪而过瞬间消失,用户甚至还没看清发生了什么就被重置了。
14. 核心实现代码精选
为了让大家更直观地理解,这里精选项目中几个最具代表性的核心实现片段。
1. 统一正交基底推导 (src/cesium/aircraftFrame.ts)
ts
export function computeAircraftBasis(
state: Readonly<FlightState>,
options: AircraftBasisOptions = {},
result?: AircraftBasis,
): AircraftBasis {
const pitchScale = options.pitchScale ?? 1.0
const rollScale = options.rollScale ?? 1.0
const position = Cartesian3.fromRadians(state.longitude, state.latitude, state.altitudeMSL)
const upSurface = Ellipsoid.WGS84.geodeticSurfaceNormal(position, new Cartesian3())
// 地表正东向量
const east = new Cartesian3(-Math.sin(state.longitude), Math.cos(state.longitude), 0)
Cartesian3.normalize(east, east)
// 地表正北向量
const north = Cartesian3.cross(upSurface, east, new Cartesian3())
Cartesian3.normalize(north, north)
// 融合真航向 (Heading)
const cosH = Math.cos(state.heading)
const sinH = Math.sin(state.heading)
const h = new Cartesian3(cosH * north.x + sinH * east.x, cosH * north.y + sinH * east.y, cosH * north.z + sinH * east.z)
const rHoriz = new Cartesian3(-sinH * north.x + cosH * east.x, -sinH * north.y + cosH * east.y, -sinH * north.z + cosH * east.z)
// 融合俯仰 (Pitch)
const p = state.pitch * pitchScale
const cosP = Math.cos(p)
const sinP = Math.sin(p)
const forward = new Cartesian3(cosP * h.x + sinP * upSurface.x, cosP * h.y + sinP * upSurface.y, cosP * h.z + sinP * upSurface.z)
const up0 = new Cartesian3(-sinP * h.x + cosP * upSurface.x, -sinP * h.y + cosP * upSurface.y, -sinP * h.z + cosP * upSurface.z)
// 融合滚转 (Roll)
const r = state.roll * rollScale
const cosR = Math.cos(r)
const sinR = Math.sin(r)
const right = new Cartesian3(cosR * rHoriz.x - sinR * up0.x, cosR * rHoriz.y - sinR * up0.y, cosR * rHoriz.z - sinR * up0.z)
const up = new Cartesian3(sinR * rHoriz.x + cosR * up0.x, sinR * rHoriz.y + cosR * up0.y, sinR * rHoriz.z + cosR * up0.z)
return { position, forward, right, up, upSurface }
}
2. 两级地形碰撞检测器 (src/collision/TerrainCollisionManager.ts)
ts
export class TerrainCollisionManager {
check(state: Readonly<FlightState>, dt: number): CollisionState {
if (this.locked) return this.noCollision(state)
// Level 1: 实时高度差硬碰撞检测
const clearance = state.altitudeMSL - state.terrainHeight - FlightSafetyConfig.aircraftTerrainClearance
if (clearance <= 0 && state.flightPhase !== 'GROUND') {
return this.triggerCollision(state, state.terrainHeight, 'HARD_CLEARANCE')
}
// Level 2: 高速前向航迹探测
this.predictionElapsed += dt
if (this.predictionElapsed >= 0.15 && state.groundSpeed > 20) {
this.predictionElapsed = 0
this.scheduleLookAheadSamples(state)
}
const lookAheadHit = this.checkLookAheadTerrain(state)
if (lookAheadHit) {
return this.triggerCollision(state, lookAheadHit.terrainHeight, 'PREDICTION_IMPACT')
}
return this.noCollision(state)
}
}
15. 项目中踩过的八大工程深坑
做完这个项目,我把最惨痛的教训梳理成这八条备忘录,供做类似项目的同行避坑:
- 不要在 Vite 下直接 ESM 引用大型 Cesium :上千个碎片的 HTTP 请求会触发
ERR_CONNECTION_RESET。采用预构建 runtime 单文件 +public/cesiumStatic映射是目前最稳健的方案。 - 切记配置
CESIUM_BASE_URL:如果不配或配错路径,Worker、Draco 压缩库和底层材质切片会报 404,导致地球变成纯蓝黑球体。 - 永远警惕
localFrameToFixedFrameGenerator('north', 'east'):北乘东是向下的!一不留神就会导致相机视口底朝天、天地颠倒。 - 初始化千万不能用固定海平面 MSL :固定 1000m 在海边很安全,在内蒙古或者云贵高原,飞机从出生的那一毫秒起就已经被埋在山里了。必须
Terrain + AGL! - HUD 绝对不要每秒刷新 60 次:Cesium 视口渲染 60 帧没问题,但用 Vue 每秒触发 60 次经纬度和航向数字的 DOM 重绘是巨大的性能浪费。限制在 10~15Hz 观感丝滑且极省 CPU。
- 显示地形不等于有物理碰撞:Cesium 的地形只是显卡渲染的三角网,不会主动拦截模型。想要碰撞,必须建立独立的高度差与前向探测器。
- 高速物体必须防隧穿:在低帧率或高倍速下,仅检查当前点 AGL 是无法防止穿山的,必须结合速度向量做航段线段采样与前向预测。
- 相机与机身必须分别做防穿山:飞机超低空飞过山脊时,尾随相机会比机身更靠近后方的山体,相机必须单独抬高,否则视觉体验瞬间坍塌。
16. 架构性能控制:多频异构调度
前端最容易把项目写卡的坏习惯,就是把所有逻辑全部塞到同一个 requestAnimationFrame 里。
在本系统中,各个模块被严格安排在不同的调度节拍上:
| 模块名称 | 调度频率 | 调度方式 | 考量原因 |
|---|---|---|---|
| Cesium WebGL 视景渲染 | 60 FPS (动态) | requestAnimationFrame |
保障视觉丝滑度、模型位姿与相机视口流畅跟随 |
| FlightCore 飞行动力学 | 定步长切片 | Substepping (≤0.04s\le 0.04\text{s}≤0.04s) | 防止高倍速时微积分发散与数值超调 |
| 前向地形穿透预测 | ~6 Hz (0.15s) | 异步节流轮询 | 防止高频并发发起 GeoTIFF/网格地形采样请求 |
| Vue HUD 数据绑定 | ~12 Hz (0.08s) | 时间累加触发 | 大幅减轻 DOM 树与虚拟节点的 diff 开销 |
| 大圆航段与转弯预判 | 随物理步进触发 | 事件驱动 | 仅在接近拐点时进行三角函数计算 |
17. 总结与未来演进
做完这个项目最大的感触是:在真实数字地球上做交互,远比在孤立的平地场景复杂得多。
把一个 GLB 格式的客机丢到 Cesium 里让它顺着折线动起来,充其量只是一段演示脚本;而当我们把曲率基底、动力学连续性、手柄映射、大圆导航、多相机防穿透与地形碰撞重置严丝合缝地拼装在一起时,它才真正具备了一个模拟系统的骨架。
未来可以继续深化的方向:
- 全球机场数据库与跑道对正:支持输入 ICAO 代码(如 ZBAA、ZSAM)一键跳转机场跑道,引入真实的地面起落架摩擦力与滑行抬轮(Rotate)逻辑。
- 近地警告系统(GPWS / TAWS):在 HUD 和座舱中引入"Terrain Ahead, Pull Up"的真实语音警报与地形成像雷达。
- 真实动态天气与风场:引入全球高空风场切片,将偏航角与地速漂移真实反馈在仪表上。
希望这篇文章能为正在从事 WebGIS、三维地球可视化、数字孪生以及航行仿真的开发者们提供一份真正具备落地价值的实战参考!