
简介面向具备一定GIS基础的Cesium开发者这份可运行源码完整演示了自主漫游功能的实现流程适合在三维场景交互、数字孪生或智慧城市项目中复用。资源以WASD及方向键作为输入入口监听键盘事件切换小车的移动状态并利用CallbackProperty在每帧动态更新实体的位置与朝向最终将整套控制逻辑封装为class降低调用成本。压缩包共3个文件含主体HTML页面、项目配置及辅助说明文件包体仅6KB结构简单便于直接运行和修改。目前已有66人学习查看对想快速掌握Cesium键盘漫游原理的开发者有一定参考价值。除源码外配套内容还解释了Cesium应用初始化、事件绑定与实体更新的关键细节可直接替换模型或调整参数适配个性化场景。 前阵子接了某个城市级巡检演示项目提需求的时候对方说得很轻巧“就像无人机航拍那样让相机自己沿着路线飞一遍就行。”结果真做起来才发现这个“飞一遍”里全是细节路径怎么定义、相机姿态怎么平滑过渡、速度怎么控制、走到一半怎么暂停还有最要命的——怎么让三维场景在漫游过程中不抖、不卡、不穿模。这期间我反复查了 Cesium 官方文档和社区里的零散贴子最后整理出一套能直接跑起来的自主漫游方案也把踩过的坑一并记了下来这篇文章就是完整复盘。如果你正在做 Cesium 里的自动巡检、路线预览、建筑环绕展示或者数字孪生体漫游这篇实战笔记应该能帮你省掉不少弯路。文章会把方案选型、环境搭建、核心源码、排错记录全部摊开讲代码不是伪代码是从我项目里抽出来的可运行版本标题里写的“可运行源码”不是噱头。1. 先说结论自主漫游不只是相机沿路径走一遍很多第一次接触漫游需求的同学会以为只要给相机设置好一系列坐标点再每秒改一下视角位置就算完成了。真这么做你得到的只会是一段跳变剧烈、卡顿明显、观感很差的“瞬移视频”。自主漫游背后其实是三个维度的联动位置连续变化、姿态连续变化、场景资源按需加载。少了任何一环效果都撑不住。1.1 三种“会动”的漫游姿势对比在 Cesium 生态里常见的自主漫游方案可以分成三类我用自己的话做个对比表格方案实现方式优点缺点适用场景关键帧跳转式直接设置 viewer.camera.setView 按坐标点逐帧切换代码量最少画面跳变严重毫无平滑可言不推荐用于最终效果只适合调试样条插值巡航用 CatmullRomSpline 或 HermiteSpline 插值位置配合球面插值处理姿态平滑、可控、性能开销可控需要额外处理姿态插值和速度均匀性巡检路线、建筑环绕、无人机路径预览物理驱动漫游给相机挂速度、加速度、碰撞半径实时计算下一帧位置真实感最强支持交互避障实现复杂调试成本高游戏化场景、步行漫游、碰撞场景我这次选的是第二类样条插值巡航。原因很简单项目要求是“给一条预定义路线让相机自动飞过去”不需要用户实时控制方向但必须画面顺滑、能随时暂停继续。样条插值在数学上成熟、代码可控性强跑起来也稳。1.2 这次方案选用的完整技术路径整个技术路线可以概括成一句话用样条曲线描述路径用双轨插值控制位姿用 Clock 事件驱动刷新。也就是说路径不只是位置坐标数组而是拆成两条插值轨道位置轨道经纬度高度构成三维坐标走 CatmullRomSpline 插值确保曲线平滑经过所有途经点姿态轨道每个路径点额外记录相机的 heading航向角、pitch俯仰角、roll翻滚角走线性插值或球面线性插值确保视角方向也在连续变化。这样做的收益是路线拐弯时相机不会突然甩头从高空俯视切换到平视建筑时也不会瞬间跳变。后面第 3 节会重点说姿态插值的坑。2. 项目骨架与 Cesium 环境搭建跑不起来的代码等于废纸自主漫游的核心逻辑虽然不依赖特定的工程结构但如果环境没搭好后面所有调优都会变得很痛苦。先说一句实在话Cesium 的版本迭代不算慢网上很多老教程用的 API 已经废弃了照抄很容易在控制台看到一堆 deprecation 警告。2.1 Vite Cesium 的版本组合建议我这次用的是 Vite 5 Cesium 1.119 的组合。选 Vite 而不是 Webpack主要是开发体验好、热更新快而且 Vite 对 Cesium 的支持比早期好太多了不需要再做复杂的静态资源拷贝配置。依赖安装没有什么玄学直接npm create vitelatest cesium-route-tour -- --template vanilla cd cesium-route-tour npm install cesium但有个关键点必须提醒Cesium 的静态资源Assets、Workers、Widgets需要被正确加载。如果你不想折腾 CesiumVitePlugin可以用最朴素的方式在index.html里引入link hrefnode_modules/cesium/Build/Cesium/Widgets/widgets.css relstylesheet / script srcnode_modules/cesium/Build/Cesium/Cesium.js/script然后在你自己的main.js里通过全局的Cesium对象访问 API。这个方式不潮但它真的不会出幺蛾子。官方教程里推荐的import * as Cesium from cesium配合 Vite 时必须处理静态资源路径对于纯漫游功能来说反而容易把问题复杂化。2.2 基础场景配置光照、地形与默认相机角度漫游效果的“质感”很大程度上取决于基础场景。我在代码里固定做了这几项设置viewer new Cesium.Viewer(cesiumContainer, { baseLayerPicker: false, geocoder: false, homeButton: false, sceneModePicker: false, navigationHelpButton: false, animation: true, timeline: true, shouldAnimate: true, }); viewer.scene.globe.enableLighting true; viewer.scene.globe.depthTestAgainstTerrain true;enableLighting打开后场景会随太阳位置实时模拟光照漫游时建筑和地形的明暗变化能明显增强真实感这也是很多实战效果看起来“高级”的原因。depthTestAgainstTerrain必须打开否则相机贴近地表时会出现穿透地形的穿帮画面。地形这里我用的是 Cesium 默认离线地形。如果你做的是单体建筑漫游、不需要真实地理地形可以完全不加载地形服务直接把路径点设在一个局部坐标系里。但要注意Camera 飞行的角度和高度必须跟你自己定的地形基准面匹配不然相机会陷入地下或者悬空。如果你准备接入真实地形服务本地切片或在线服务都行但一定要在初始化 Viewer 之后显式设置 terrainProvider 不然默认的 Ellipsoid 地形会让路径高度计算和真实地表完全脱节。3. 漫游路径核心位置与姿态的双轨插值说到自主漫游绕不开的就是插值。位置插值相对直观姿态插值才是真正考验细节的地方。这一节我把两部分的原理和实现拆开讲代码可以直接抄。3.1 路径点定义与 CatmullRomSpline 使用要点路径点我习惯用一个结构体数组来描述const routePoints [ { lon: 120.15, lat: 30.28, height: 800, heading: 0, pitch: -45, roll: 0, duration: 5 }, { lon: 120.16, lat: 30.29, height: 600, heading: 30, pitch: -30, roll: 0, duration: 5 }, { lon: 120.18, lat: 30.28, height: 400, heading: -60, pitch: -20, roll: 0, duration: 5 }, ];每个点除了经纬度和高度还带了一个duration字段表示从前一个点飞到当前点需要多少秒。为什么要单独设置 duration因为不同的路径段飞行速度不一定相同。比如起飞段速度可以快一点到了目标建筑上空就得慢下来仔细看。基于这些点构造样条const positions routePoints.map(p Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height)); const spline new Cesium.CatmullRomSpline({ times: cumulativeTimes, // 例如 [0, 5, 10, 15] points: positions });这里有个新手高频错误times数组必须从 0 开始且单调递增。如果你的路径点很多建议先按duration计算累计时间再传给 CatmullRomSpline。如果不设置 times默认会按均匀参数生成实际效果会变成“每个点之间的飞行时间相同”而这不是我们想要的。Cartesian3 可以直接传入 CatmullRomSpline因为它内部会自动处理坐标维数。但注意Cesium 的样条曲线默认是在三维笛卡尔空间做的所以经纬度高度会被转成 Cartesian3 之后再做曲线插值。对于跨越大范围地理区域的路线这种插值在球面上会有一点点偏离但对城市级巡检和小场景漫游完全没问题。3.2 heading/pitch/roll 插值最容易踩的坑位置可以三维插值姿态却不能直接对着 heading 做线性插值。原因有两个第一heading 有角度循环问题。比如当前航向角是 350 度下一帧的目标角是 10 度如果直接线性插值相机会从 350 度逆时针转到 10 度多转了 340 度画面表现为突然大幅甩头。正确的做法是先把角度差归一到 (-180, 180] 区间function normalizedAngleDiff(a, b) { let diff b - a; while (diff Math.PI) diff - 2 * Math.PI; while (diff -Math.PI) diff 2 * Math.PI; return diff; }第二Cesium 的相机视角在 pitch 接近 ±90 度时会出现万向锁效应也就是所谓“抖动”。如果你的巡检路线包含从高空垂直往下看的视角pitch 接近 -90 度heading 会突然变得敏感插值稍有不慎画面就会剧烈旋转。我最终的方案是把每个路径点的 heading、pitch、roll 也作为插值控制参数用平滑的smoothstep或者slerp做过渡。具体代码在 3.3 里一起给出整体逻辑是当前时间 t 落在哪两个路径点之间计算出局部进度 u0 到 1位置用 spline 接口直接获取姿态用当前点和下一个点的 heading/pitch/roll 做带角度归一化的线性插值再用一个缓动函数做平滑。3.3 速度控制与匀速运动的实现逻辑路径是均匀参数化的卡特莫尔样条但地理坐标空间里的“均匀参数”不代表“均匀地面速度”。这个问题常被忽略你在局部坐标系里看起来匀速的样条经过经纬度转 Cartesian3 之后实际地面速度可能忽快忽慢。解决思路是重采样。我先对 spline 做密集采样比如每帧间隔 50ms 取一个点计算相邻点之间的距离累加得到总路程再根据总路程和总时长反推出匀速情况下每一帧应该到达的总路程位置最后用二分查找或者线性扫描找到对应的时间参数 t。这样位置插值在视觉上就是匀速的。这个逻辑对漫游体验至关重要。如果直接用原始 sample 函数获取位置你会看到相机在路径平缓处飞得很快、在拐弯处突然卡顿观感极差。4. 漫游控制器的完整实现附核心源码有了路径、姿态、速度这三块基石接下来就是把它们封装成一个可复用的漫游控制器。我写了一个独立的类RouteTourController包含启动、暂停、继续、终止、变速五个核心接口和一个内部的 tick 回调。4.1 控制器类设计状态机与 tick 驱动控制器用状态机管理漫游生命周期IDLE空闲、RUNNING运行中、PAUSED暂停、FINISHED结束。状态切换对应了每帧处理逻辑的开关。核心实现如下class RouteTourController { constructor(viewer, routePoints) { this.viewer viewer; this.routePoints routePoints; this.state IDLE; this.clock viewer.clock; this.spline null; this.totalDuration 0; this.accumulatedTime 0; this.speedFactor 1; this._precompute(); } _precompute() { const times []; let acc 0; times.push(acc); for (let i 1; i this.routePoints.length; i) { acc this.routePoints[i].duration; times.push(acc); } this.totalDuration acc; const positions this.routePoints.map(p Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height) ); this.spline new Cesium.CatmullRomSpline({ times, points: positions }); // 预计算姿态控制点 this.yawPoints this.routePoints.map(p Cesium.Math.toRadians(p.heading)); this.pitchPoints this.routePoints.map(p Cesium.Math.toRadians(p.pitch)); this.rollPoints this.routePoints.map(p Cesium.Math.toRadians(p.roll)); } start() { if (this.state RUNNING) return; this.state RUNNING; this.accumulatedTime 0; this.clock.shouldAnimate true; this.removeCallback this.viewer.clock.onTick.addEventListener(this._tick.bind(this)); } pause() { this.state PAUSED; } resume() { if (this.state ! PAUSED) return; this.state RUNNING; } stop() { this.state FINISHED; if (this.removeCallback) { this.removeCallback(); this.removeCallback undefined; } } _tick(clock) { if (this.state ! RUNNING) return; const deltaSeconds clock.clockDelta; this.accumulatedTime deltaSeconds * this.speedFactor; if (this.accumulatedTime this.totalDuration) { this.accumulatedTime this.totalDuration; this._applyPose(this.accumulatedTime / this.totalDuration, 1); this.stop(); return; } const progress this.accumulatedTime / this.totalDuration; this._applyPose(progress, this.accumulatedTime); } _applyPose(progress, currentTime) { if (!this.spline) return; const position this.spline.evaluate(currentTime); // 找到当前所处的路径段 const times this.spline.times; let segIndex 0; for (let i 0; i times.length - 1; i) { if (currentTime times[i] currentTime times[i 1]) { segIndex i; break; } } const segStart times[segIndex]; const segEnd times[segIndex 1]; const u (currentTime - segStart) / (segEnd - segStart); const smoothU Cesium.Math.smoothstep(u, 0, 1); let heading this.yawPoints[segIndex] normalizedAngleDiff(this.yawPoints[segIndex], this.yawPoints[segIndex 1]) * smoothU; let pitch this.pitchPoints[segIndex] (this.pitchPoints[segIndex 1] - this.pitchPoints[segIndex]) * smoothU; let roll this.rollPoints[segIndex] (this.rollPoints[segIndex 1] - this.rollPoints[segIndex]) * smoothU; this.viewer.camera.setView({ destination: position, orientation: { heading, pitch, roll } }); } }这个控制器有几点细节需要特别说明onTick监听的是viewer.clock的 tick 事件而不是自己写requestAnimationFrame循环。好处是能和 Cesium 的时间系统联动暂停时间轴时漫游也会停播放速度可以直接通过clock.multiplier控制。clock.clockDelta表示上一帧到这一帧的秒数用它累加漫游时间能保证不同帧率下的漫游速度一致。如果你在 144Hz 的显示器和 60Hz 的显示器上跑同一个任务漫游到同一个点的时间是一样的不会因为刷新率不同而变速。normalizedAngleDiff就是 3.2 里写的角度归一化函数实际代码中我把它定义成了模块内工具函数避免每次 tick 都重复计算。4.2 暂停、继续、重新开始的按键控制自主漫游实际演示时最常被现场提出来的需求就是“暂停一下停在当前视角我讲两句”。按键控制是刚需。我的实现非常简单直接在页面监听键盘document.addEventListener(keydown, (e) { if (e.key ) { e.preventDefault(); if (controller.state RUNNING) { controller.pause(); } else if (controller.state PAUSED) { controller.resume(); } } if (e.key r || e.key R) { controller.start(); } });暂停期间画面要保持静止所以_tick里必须判断状态只让 RUNNING 状态下的逻辑执行。这个看起来理所当然但很多人会忘记在 pause 时停掉时间轴导致clock.shouldAnimate还在跑相机不动但场景里的小车、粒子却在动演示时就穿帮了。4.3 把控制器接到 Cesium 相机上实际接入时记得先确保三维场景已经初始化完成。建议在viewer.scene.globe.tileLoadProgressEvent或者viewer.scene.postRender里等待首帧渲染完成后再调用controller.start()否则有可能出现相机已经飞到终点但地形瓦片才刚开始加载的尴尬情况。另外如果你的场景里有大量 3D Tiles 模型建议在漫游开始前预先加载模型所在区域的瓦片。一个实用做法是先用viewer.camera.flyTo飞到路线起点上空等viewer.scene.tileLoadProgressEvent触发的进度归零后再启动漫游。实测下来这个“预加载延迟启动”的动作能显著减少漫游过程中出现的瓦片弹出。5. 实测环节从代码到顺畅漫游的排错记录代码写完之后真正折磨人的是各种诡异的现象。我把自己在调试过程中遇到的几个高频问题整理了一下每个都标了根因和解决方式希望你能跳过这些坑。5.1 相机抖动被忽略的四元数过渡问题我最早实现姿态插值的时候直接用线性插值处理 heading/pitch/roll结果在路径拐弯处画面出现了明显的抖动。排查了很久才意识到是角度跳变问题当路径点 A 的 heading 是 170 度、路径点 B 的 heading 是 -170 度时线性插值会让航向角从 170 一路变到 -170这期间相机几乎转了 340 度画面看起来肯定是在疯狂旋转。后来的解决办法就是前面代码里的normalizedAngleDiff先把角度差归一化到 [-180, 180]再乘上插值比例。这样从 170 度到 -170 度的插值结果会变成从 170 度转到 190 度即 -170 360实际旋转只有 20 度视觉上完全正常。5.2 加载大场景时的预加载策略城市级 3D Tiles 模型体量很大漫游过程中不断加载瓦片会导致帧率剧烈波动。我的处理方式是在漫游路线的每个关键路径点提前“预热”周边瓦片。代码层面没有特别优雅的官方 API我是这么做的routePoints.forEach(p { const rectangle Cesium.Rectangle.fromDegrees( p.lon - 0.005, p.lat - 0.005, p.lon 0.005, p.lat 0.005 ); viewer.scene.camera.setView({ destination: rectangle, duration: 0 }); viewer.scene.render(); });这段代码会让场景引擎在内存中预加载这些区域但不会真正显示在哪因为随即就切回了起点。虽然听起来有点暴力实测对降低漫游过程中的卡顿确实有效。注意每渲染完一个区域要立即viewer.scene.render()强制触发一次渲染否则瓦片调度可能不会及时执行。如果你是加载本地离线地形和影像切片数据量更大建议直接限制一下相机默认的屏幕空间误差viewer.scene.screenSpaceError把默认的 2 调大到 4 或者 8漫游流畅度会有明显改善远看效果也几乎无感知差异。5.3 与动态光照、雷达特效、可视域分析组合时的取舍热搜词里提到的 Cesium 动态光照、雷达扫描、可视域分析这些特效理论上都可以和自主漫游叠加使用但必须注意 GPU 负载分配。我这里给出的经验是动态光照enableLighting可以开但不要在漫游过程中频繁改太阳位置否则会触发全局重光照瞬间掉帧。雷达扫描、雷达扇形这类特效如果用的是自定义 Primitive 或 PostProcessStage尽量把漫游速度降低一点因为特效计算量不小。可视域分析和天际线分析其实更适合静态视角下做自主漫游过程中开启这些分析会导致界面信息过载演示效果反而差。如果确实想同时展示可视域分析和漫游我的建议是先让漫游飞到目标点暂停再启动分析。这样一个“飞过去停下来分析”的节奏在演示时逻辑更清晰观众也更容易看明白。另外还有一个容易被忽略的点如果你用了viewer.scene.debugShowFramesPerSecond true或者开了 Cesium 的默认 FPS 显示叠加特效后帧率掉到 20 以下千万不要怀疑是代码写错了先检查是不是同时开了太多后处理特效。我遇到过最夸张的一次同时开着雾效、动态光照、雷达扫描三个 PostProcessStage帧率直接从 60 跌到 12关掉两个后立刻恢复流畅。写在最后的一点体会自主漫游这个功能真正做到位了就是“外行看着觉得很顺滑内行知道是插值和状态管理做得扎实”。我再分享几个自己常用的细节习惯第一路径点别拍脑袋随便定。我在项目里会在 Cesium 里先用viewer.entities.add把路径点用点状实体标出来再跑一遍漫游确认视角和姿态是否符合预期。调姿态时直接在浏览器控制台里改角度数值比反复改源码重跑快得多。第二漫游结束时让相机有一个自然的减速。我的实现里是用 smoothstep 对姿态做平滑位置方面想要减速的话可以在最后两个路径点之间把 time 参数映射成缓动曲线比如easeOutCubic这样相机到达终点时不会突兀地刹停。第三也是我认为最重要的自主漫游的代码一定要做成独立的控制器类而不是散落在各个回调里。你很难预料甲方后面会不会加“漫游到一半切换到另一个视角”或者“速度调快 1.5 倍”这种需求控制层和渲染层分离能让你面对临时改动时游刃有余。这篇文章里贴的代码片段就是我项目里跑过的完整逻辑的浓缩版拿回去接上你自己的 Cesium Viewer填入几个路径点差不多就能看到效果。后续如果你想继续往深做可以从这几个方向入手在漫游过程中叠加模型姿态控制、接入 three.js 共享 WebGL 上下文做更复杂的特效、或者把漫游路线做成工具化的路径编辑器。每一块都是另一个容量不小的实战话题了。本文还有配套的精品资源点击获取