做GIS可视化项目的时候,几乎每次需求评审都会出现一句话:能不能让模型在地图上动起来?再具体一点,就是Cesium里加载一辆车或者一架无人机,用键盘控制它前进、后退、转向,像玩游戏一样在数字地球里巡游。这个功能听着很炫,其实拆开看就是“模型渲染 + 坐标换算 + 键盘消息 + 渲染循环”四件事。这篇文章我不讲虚的,直接以Cesium中渲染3D模型并用键盘控制在地图上移动为例,把完整实现过程、核心代码和踩过的坑都写出来,适合做数字孪生、智慧园区、楼宇漫游、设备巡检的Cesium开发者参考。
1. 项目思路与整体方案拆解
1.1 需求到底在说什么
先用大白话梳理一下需求:在三维地图场景中加载一个带几何外观的3D模型,并让用户通过键盘操作模型实时移动。这个需求在地图可视化里很常见,但和普通游戏引擎里的角色控制有一个明显区别:Cesium的地图是建立在真实地理坐标上的,模型位置必须对应经纬度和高度,而不是局部坐标的x/y/z。所以不能像Three.js里那样直接把position.x += speed就完事,必须处理地理坐标到笛卡尔坐标的转换,还要保证模型始终贴合地表或维持设定高度。把这个点想清楚,后面代码就顺了。
这个功能最典型的应用场景是数字孪生园区巡检、智慧港口的车辆调度演示、无人机航线模拟,以及一些展厅项目里的三维漫游。用户不一定真的会用“WASD”去控制模型走完全程,但有了键盘控制能力,模型就不再是一个静态摆放的gizmo,而是一个可以实时交互的“活对象”。很多时候,这种交互既是演示亮点,也是后续做路径规划、碰撞检测的雏形。
1.2 技术选型:为什么要用Entity模型而不是3D Tiles
Cesium加载3D模型一般有两条路:一是直接用Entity的model图形,支持glTF/glb模型,适合单机、单模型、交互控制;二是使用3D Tiles,适合大场景倾斜摄影、BIM等批量模型,但要做单体控制和键盘操作会比较麻烦。本次需求场景是单个可操控模型,我选择Entity + glb,理由有三个:一是glb是二进制格式,加载快,Cesium原生支持;二是Entity的position/orientation属性可以动态更新,天然适合做运动控制;三是代码量小,不需要额外的tileset解析和调试成本。如果后续要换模型,直接把uri换成另一个glb文件即可,改动最小。
有人可能会问,Cesium加载3D Tiles也支持glTF模型啊,为什么不用?因为3D Tiles的定位是“海量模型的调度优化”,它把一个模型拆成多个瓦片,按需加载。你想在运行时不断更新某个模型的位置,需要对tileset做模型矩阵变换,要么修改root.transform,要么重建瓦片,复杂度高出一大截。而Entity从设计上就是“一个可随时间变化的图形对象”,position可以绑定Property,也可以直接赋新值,天生适合做动态控制。
1.3 整体架构:输入、状态、渲染三层分离
键盘控制看起来是小事,但设计不好很容易乱。我的做法是分成三层:输入层、状态层、渲染层。输入层负责监听keydown/keyup,维护一个按键集合,例如记录当前按下了哪些键;状态层持有模型的当前经纬度、朝向、速度等变量;渲染层在每一帧里根据按键集合和deltaTime计算新的位置并更新模型。这样做的好处是:按住按键不会因为系统自动重复触发导致状态突变,松开按键后能立刻停止,多个按键同时按下也能自然处理(比如一边前进一边转向)。而如果直接在keydown事件里改位置,一旦按键重复触发,画面会一卡一卡地跳。
这个分层思路其实和游戏里的角色控制器非常像。你可以理解成:键盘只是发出“我想前进”的请求,真正决定走多远的是渲染循环里的速度和时间差。这样整个逻辑可维护性高很多,后期如果要把键盘控制改成手柄控制、自动寻路,只需要替换输入层,不需要动状态层和渲染层。
2. 环境准备与模型加载
2.1 搭建最小的Cesium运行环境
这里以Vite + Cesium为例,也可以直接用CDN方式引入,看你项目基础。我用npm方式,需要安装cesium依赖,并申请一个Cesium ion access token,不用token也可以打开基础地球,但要加载在线地形或某些影像源时会受限。初始化代码大概是:
npm create vite@latest cesium-keyboard-demo cd cesium-keyboard-demo npm install cesium然后在页面里创建viewer:
import * as Cesium from 'cesium'; Cesium.Ion.defaultAccessToken = '你的token'; const viewer = new Cesium.Viewer('cesiumContainer', { animation: false, timeline: false, scene3DOnly: true, baseLayerPicker: false, });scene3DOnly: true可以去掉2D/哥伦布视图切换,避免模型在非3D模式下坐标混乱。另外我通常会加一个地形提供方,让模型能贴合地面,但这一步不是必须的,先用默认椭球也能跑通。第一次调试建议先不加地形,减少变量。
2.2 加载glTF/glb模型:核心参数与常见坑
Cesium加载一个本地glb模型最直接的方式是:
const entity = viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(116.3913, 39.9075, 0), model: { uri: '/models/vehicle.glb', scale: 1.0, minimumPixelSize: 128, }, }); viewer.flyTo(entity);这里要特别注意minimumPixelSize。当相机离模型很远时,Cesium为了保证模型可见,会强制模型放大到至少128像素,这个属性对调试很有用,但正式控制移动时如果距离很远,模型尺寸会显得很大,需要你根据场景调整或直接删掉。还有一个更隐蔽的坑:如果glb模型是用Blender、3ds Max之类工具导出的,尺寸和单位可能和Cesium默认不一致。模型大了就缩小scale,位置飘在半空就调height,这些都属于加载模型后的常规修正。
如果模型带有动画或者复杂材质,建议关注模型的贴图大小和顶点数。Cesium渲染3D模型的性能和浏览器GPU强相关,一个动辄几百MB的glb会让地图卡到没法看。我一般会对模型做Draco压缩,或者用gltfpack减面,控制在5MB以内,交互流畅度会好很多。
2.3 模型节点操作与位置校正
Cesium的Entity模型支持访问模型的子节点,你可以用model.nodeTransformations去做局部动画,比如让车轮旋转、机械臂抬升。这个功能在做设备操控类项目时很实用,但键盘控制整体移动时一般用不到。不过有一点值得提:当模型方向不对时,你可以通过节点变换临时把整个模型转个方向,而不是回建模软件重新导出。
entity.model.nodeTransformations = { 'Root': { translation: Cesium.Cartesian3.ZERO, rotation: Cesium.Quaternion.fromAxisAngle(Cesium.Cartesian3.UNIT_Z, Cesium.Math.toRadians(90)), scale: new Cesium.Cartesian3(1, 1, 1), } };这里的'Root'是模型根节点名称,不同模型不一样,需要在调试里把模型节点打印出来看看。如果你定位node名麻烦,更推荐用下一章里的orientation做整体朝向控制,那是Entity的通用机制,和模型内部节点无关。
3. 键盘控制模型移动的核心实现
3.1 键盘监听:事件绑定和按键状态管理
我先声明一个全局的按键状态对象,而不是直接在事件里动模型:
const keys = { w: false, s: false, a: false, d: false, }; window.addEventListener('keydown', (e) => { const key = e.key.toLowerCase(); if (key in keys) { e.preventDefault(); keys[key] = true; } }); window.addEventListener('keyup', (e) => { const key = e.key.toLowerCase(); if (key in keys) keys[key] = false; }); window.addEventListener('blur', () => { Object.keys(keys).forEach(k => keys[k] = false); });e.preventDefault()一定要加,尤其是方向键,否则浏览器会把页面滚来滚去,干扰交互。同时建议监听blur事件,当窗口失焦时把keys全部重置为false,避免切换窗口回来后模型还在“自己跑”。如果你用的是setInterval去轮询键盘状态,也可以,但没有onTick里更新那么自然,因为后者天然和Cesium的渲染帧同步。
3.2 移动计算:为什么不能直接改经纬度
在写每帧更新前,先说清楚Cesium里的坐标系统。Cesium的位置用Cartesian3表示,单位是米,坐标系原点在地心。这个坐标拿来渲染很方便,但人类能理解的是经纬度和海拔,也就是Cartographic。所以移动模型有两种写法:一种是直接改经纬度,简单但方向不精确;另一种是先在模型所在位置建立一个局部坐标系,取东北天方向向量,再用向量位移,精度高且和模型朝向完全一致。我实际项目里用的是第二种,因为前者的经度增量在不同纬度上对应的实际距离不一样,纬度越高,同样的经度差距离越小。
举一个具体例子:在北纬60度的地方,1度经度对应的地面距离只有赤道处的一半。如果你在代码里固定lon += 0.001,模型在赤道附近和在高纬度地区的移动速度会差一倍,这在跨区域地图演示中会非常明显。而用局部坐标向量计算,东北天三个方向上的单位都是米,无论模型在哪里,速度都能保持一致。
具体计算可以写成这样:
function moveByLocalOffset(entity, east, north, up) { const position = entity.position.getValue(Cesium.JulianDate.now()); const transform = Cesium.Transforms.eastNorthUpToFixedFrame(position); const offset = Cesium.Matrix4.multiplyByPoint( transform, new Cesium.Cartesian3(east, north, up), new Cesium.Cartesian3() ); entity.position = offset; }这段逻辑的含义是:以模型当前位置为原点建立“东-北-天”局部坐标系,把(east, north, up)看成在这个坐标系里的位移量,再通过矩阵转换到全局地心坐标系。这样无论是向东、向北还是向上移动,都是按真实地表方向来算的。
3.3 朝向控制与渲染循环整合
移动逻辑通常放在viewer.clock.onTick事件里。这个事件在每个渲染帧都会触发,适合做实时控制。我用一个速度变量moveSpeed表示每秒移动多少米,再取两帧之间的时间差deltaTime,计算出这一帧应该移动的距离。为了不引入额外库,可以用viewer.clock.lastTickTime和viewer.clock.currentTime手动计算:
let moveSpeed = 50; // 米/秒 let heading = 0; // 弧度,0表示正北 viewer.clock.onTick.addEventListener((clock) => { const deltaTime = Cesium.JulianDate.secondsDifference(clock.currentTime, clock.lastTickTime); if (deltaTime <= 0) return; if (keys.a) heading -= 1.5 * deltaTime; if (keys.d) heading += 1.5 * deltaTime; // 这里先更新模型朝向 const position = entity.position.getValue(clock.currentTime); const quaternion = Cesium.Transforms.headingPitchRollQuaternion( position, new Cesium.HeadingPitchRoll(heading, 0, 0) ); entity.orientation = quaternion; // 再根据W/S计算前后位移 let forwardOffset = 0; if (keys.w) forwardOffset += moveSpeed * deltaTime; if (keys.s) forwardOffset -= moveSpeed * deltaTime; if (forwardOffset !== 0) { const transform = Cesium.Transforms.eastNorthUpToFixedFrame(position); const north = Math.cos(heading) * forwardOffset; const east = Math.sin(heading) * forwardOffset; const offset = Cesium.Matrix4.multiplyByPoint( transform, new Cesium.Cartesian3(east, north, 0), new Cesium.Cartesian3() ); entity.position = offset; } });这里的heading是弧度,Cesium的heading定义是“从正北方向开始顺时针旋转”,正东是90度,正南是180度。Math.cos(heading)算出向北的分量,Math.sin(heading)算出向东的分量。如果你验证后发现方向反了,多半是模型建模时的正方向问题,要单独调整模型,而不是改这个公式。
3.4 完整可用的键盘控制核心代码
把上面几块拼起来,一个最小的可用逻辑如下,我直接贴一段能跑的代码:
const keys = { w: false, s: false, a: false, d: false }; let moveSpeed = 50; let heading = Cesium.Math.toRadians(0); window.addEventListener('keydown', (e) => { const key = e.key.toLowerCase(); if (key in keys) { e.preventDefault(); keys[key] = true; } }); window.addEventListener('keyup', (e) => { const key = e.key.toLowerCase(); if (key in keys) keys[key] = false; }); window.addEventListener('blur', () => { Object.keys(keys).forEach(k => keys[k] = false); }); viewer.clock.onTick.addEventListener((clock) => { const dt = Cesium.JulianDate.secondsDifference(clock.currentTime, clock.lastTickTime); if (dt <= 0 || dt > 0.1) return; if (keys.a) heading -= 1.5 * dt; if (keys.d) heading += 1.5 * dt; const position = entity.position.getValue(clock.currentTime); if (!position) return; const quaternion = Cesium.Transforms.headingPitchRollQuaternion( position, new Cesium.HeadingPitchRoll(heading, 0, 0) ); entity.orientation = quaternion; let forwardOffset = 0; if (keys.w) forwardOffset += moveSpeed * dt; if (keys.s) forwardOffset -= moveSpeed * dt; if (forwardOffset !== 0) { const transform = Cesium.Transforms.eastNorthUpToFixedFrame(position); const north = Math.cos(heading) * forwardOffset; const east = Math.sin(heading) * forwardOffset; const offset = Cesium.Matrix4.multiplyByPoint( transform, new Cesium.Cartesian3(east, north, 0), new Cesium.Cartesian3() ); entity.position = offset; } });这段代码里有个细节:dt > 0.1直接忽略。为什么要有这个上限?因为当你切换浏览器标签页再切回来时,Cesium的clock可能会给出一个比较大的时间差,如果不做限制,模型会瞬间瞬移几十米,体验很糟糕。这是我在实际项目里踩过坑后才加上的判断。
4. 实操过程中的坑与调试方法
4.1 模型方向不对:glTF坐标轴与Cesium朝向的差异
这个几乎是必踩的坑。glTF模型的默认朝向是:模型面向Z轴正方向,上方是Y轴;而Cesium的heading从正北顺时针计算,且Entity需要设置orientation才能让模型朝向指定方向。很多模型设计师会习惯把车头朝X轴或Y轴,在建模软件里看得没问题,一进Cesium就“横着走”。解决办法有两种。一是在建模软件里把模型正面朝向Y轴重新导出;二是在Cesium里做旋转补偿,比如在HeadingPitchRoll的heading上额外加一个固定偏移。
我的经验是:先别写键盘逻辑,直接在固定位置测试模型朝向。把heading设成0,看车头是不是正北,如果不是,记下偏差角度,然后在代码里统一加上这个偏差:
const fixedHeading = heading + Cesium.Math.toRadians(90); // 假设你的模型默认朝东具体加多少度,要根据你的模型实际朝向测试。不要一上来就怀疑公式,先在静态场景里把模型朝向调正,再进入动起来阶段。
4.2 相机跟随:让模型始终保持在视野中心
键盘控制模型时,如果相机不动,很快模型就跑出屏幕外了。最基础的方式是设置viewer.trackedEntity = entity,这样Cesium会自动让相机跟随模型。但默认跟随效果可能不够好,尤其是你需要类似第三人称视角的时候。我的做法是在onTick里手动控制相机:
const position = entity.position.getValue(clock.currentTime); viewer.camera.lookAt(position, new Cesium.HeadingPitchRange( heading - Cesium.Math.toRadians(180), Cesium.Math.toRadians(-25), 200 ));HeadingPitchRange里第一个参数是相机相对模型的方位角,-180表示让相机在模型正后方,第二个参数是俯仰角,第三个是距离。这样看起来更像开车跟随视角。注意,手动更新相机后不能再设置trackedEntity,否则两者会互相干扰,出现画面乱跳。根据需求选择一个方案就好。
4.3 地形贴合与穿模问题
Cesium的Entity模型默认不会自动贴合地形,即使你设置了地形,它也只按你给定的高度渲染。也就是说,模型可能悬空,也可能陷入山坡。最简单的方法是在onTick里取当前经纬度处的地形高度,让模型的高度跟随地表:
const cartographic = Cesium.Cartographic.fromCartesian(position); const height = viewer.scene.globe.getHeight(cartographic); if (height !== undefined) { entity.position = Cesium.Cartesian3.fromDegrees( Cesium.Math.toDegrees(cartographic.longitude), Cesium.Math.toDegrees(cartographic.latitude), height ); }注意,getHeight返回的是地形表面高度,不一定包含3D Tiles中的建筑物高度。如果场景里有倾斜摄影建筑,想做到不穿楼,就得做碰撞检测了,这属于进阶话题。如果你的模型只是一辆车在平地上演示,高度跟随地形就足够了。
4.4 键盘失灵和卡顿问题速查
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 按W没反应 | 键盘事件没监听到 | 改成监听window,并检查是否有preventDefault |
| 按住键移动一卡一卡 | keydown自动重复触发 | 使用按键状态集合,而不是在事件里直接改位置 |
| 切窗口回来模型瞬移 | clock时间差过大 | 在onTick里限制dt > 0.1时忽略 |
| 模型方向横着走 | glTF默认朝向和Cesium不一致 | 加heading固定补偿,或重新导出模型 |
| 模型飘在空中或穿地 | 未跟随地形高度 | 使用globe.getHeight动态贴合地形 |
| 相机乱跳 | trackedEntity和手动lookAt同时使用 | 二选一 |
这张表基本覆盖了我调试过程中遇到的大部分问题。如果你遇到类似情况,按这个顺序排查基本能定位。
5. 进阶:从键盘控制到自动导航
5.1 点击地图让模型自动跑过去
键盘控制只是基础玩法,很多项目最终需要的是让模型沿规划路线自动移动。思路其实很相近:用ScreenSpaceEventHandler拾取用户点击的地面坐标,然后把该坐标作为终点,把键盘控制改成读取路径列表。
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction((movement) => { const ray = viewer.camera.getPickRay(movement.position); const cartesian = viewer.scene.globe.pick(ray, viewer.scene); if (cartesian) { // 把cartesian转成经纬度,存到路径数组里 } }, Cesium.ScreenSpaceEventType.LEFT_CLICK);路径移动时,可以计算模型到下一个目标点的距离和方向,速度自适应。这个升级里,键盘控制计算heading部分的代码完全复用,只需要把“根据按键计算偏移”换成“根据目标点方位计算偏移”。核心公式其实都一样,只是输入来源变了。
5.2 多模型控制与状态管理
如果你的场景里不只有一辆车,而是要同时控制多个模型,比如无人机、船、人物,那千万不能再像本文一样为每个模型写一个onTick逻辑。我的做法是抽象一个MovableEntity类,把模型对象、位置、朝向、速度、按键状态封在一起,类里面只提供update(dt)方法,然后在同一个onTick里遍历所有实例。
这样做的好处是每个模型的状态相互隔离,不会因为某个模型的事件逻辑而互相影响。唯一的难点在于多模型时键盘控制需要区分当前选中的模型,一般可以通过点击模型拾取或者按Tab切换。如果你有兴趣,还可以在这个类里加事件回调,比如模型到某个点之后自动触发下一段动作。
5.3 动态光照与场景增强
如果你用的是倾斜摄影或BIM数据,并且想让模型在“楼宇之间”穿行,除了碰撞检测,还要考虑光线问题。Cesium的模型默认受场景光照影响,而Cesium本身支持动态太阳位置和环境光设置。比如viewer.scene.globe.enableLighting = true能开启动态光照,模型的明暗会随时间和位置变化。
这里有个小提示:如果模型在室内或阴影处看起来特别暗,可以适当调高环境光强度,或者给模型添加lighting相关参数,否则暗部细节会一片黑,用户体验较差。这类场景增强不需要改变键盘控制逻辑,只是在你觉得画面“发灰”“发黑”的时候,去调整光照参数,不要误以为是模型材质出了问题。
最后说一点我的真实感受:这类型交互在Cesium里跑起来,真正烦人的往往不是功能本身,而是坐标转换和模型朝向的几个细节。建议你在开发时准备一个固定的glb测试模型,把键盘控制先调通,再换真实业务模型,这样能省下很多“模型不转向”“方向反了”的排查时间。另外代码里所有角度相关变量统一用弧度,别混着用,Cesium里度数转弧度只用一行Cesium.Math.toRadians,但混用之后查bug会很痛苦。