
做GIS可视化项目的时候几乎每次需求评审都会出现一句话能不能让模型在地图上动起来再具体一点就是Cesium里加载一辆车或者一架无人机用键盘控制它前进、后退、转向像玩游戏一样在数字地球里巡游。这个功能听着很炫其实拆开看就是“模型渲染 坐标换算 键盘消息 渲染循环”四件事。这篇文章我不讲虚的直接以Cesium中渲染3D模型并用键盘控制在地图上移动为例把完整实现过程、核心代码和踩过的坑都写出来适合做数字孪生、智慧园区、楼宇漫游、设备巡检的Cesium开发者参考。1. 项目思路与整体方案拆解1.1 需求到底在说什么先用大白话梳理一下需求在三维地图场景中加载一个带几何外观的3D模型并让用户通过键盘操作模型实时移动。这个需求在地图可视化里很常见但和普通游戏引擎里的角色控制有一个明显区别Cesium的地图是建立在真实地理坐标上的模型位置必须对应经纬度和高度而不是局部坐标的x/y/z。所以不能像Three.js里那样直接把position.x speed就完事必须处理地理坐标到笛卡尔坐标的转换还要保证模型始终贴合地表或维持设定高度。把这个点想清楚后面代码就顺了。这个功能最典型的应用场景是数字孪生园区巡检、智慧港口的车辆调度演示、无人机航线模拟以及一些展厅项目里的三维漫游。用户不一定真的会用“WASD”去控制模型走完全程但有了键盘控制能力模型就不再是一个静态摆放的gizmo而是一个可以实时交互的“活对象”。很多时候这种交互既是演示亮点也是后续做路径规划、碰撞检测的雏形。1.2 技术选型为什么要用Entity模型而不是3D TilesCesium加载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 vitelatest cesium-keyboard-demo cd cesium-keyboard-demo npm install cesium然后在页面里创建viewerimport * 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会很痛苦。