做某三维可视化项目时,我被一个“看似简单”的问题卡住了整整两天:轨迹点明明算得很准确,模型运动时朝向却乱七八糟,一会儿横着飞,一会儿倒着走。最后我把问题拆到最底层,发现核心其实就一句话——根据两个坐标点算出Cesium的Heading、Pitch,然后生成对应的模型矩阵。这句话说起来容易,做起来却牵扯到坐标系、欧拉角约定、地球曲率好几个坑。这篇文章就是我最终采用的完整思路和代码,适合做卫星轨迹模拟、飞行器朝向控制、摄像头指向目标点、模型沿路径贴地移动这类需求,也适合刚接触Cesium、被姿态计算绕晕的人。
1. 为什么两个点就能决定一个朝向:坐标系才是破题钥匙
1.1 方向向量需要放在“参考系”里才有意义
三维空间里一个刚体要完全定住姿态,理论上需要三个角度:Heading、Pitch、Roll。但两个坐标点只能确定一条直线,也就是一个方向向量,所以只能唯一确定Heading和Pitch,Roll没法从两个点里直接推出来,工程上一般取0。如果后面真的需要Roll,就得额外引入参考向量,比如飞机机翼的侧滑方向、相机镜头的旋转角度等。
关键认知是:一个方向向量本身没有绝对方向,只有把它放到某个坐标系里去描述,才算得出角度。Cesium里最常用的是ECEF地心坐标系和某点处的局部ENU坐标系。ECEF是整个地球的“全局坐标系”,原点在地心,x轴指向赤道与本初子午线交点方向,z轴指向北极。而ENU是“站着某个点上看”,三个轴分别是东、北、上。同一个方向向量,在ECEF里看是一组直角分量,在ENU里看就是另一组分量。
Cesium里的Heading和Pitch定义在“局部坐标系”上:Heading是从正北方向开始顺时针转的角度,Pitch是相对于水平面的上下夹角,正值为向上。所以要计算两个坐标点的朝向,正确做法是在起点处建立一个ENU局部坐标系,再看终点落在局部坐标系的哪个方向。这跟很多人第一直觉“直接用终点减起点算个角度”完全不一样,因为ECEF向量没有“正北”这个基准。
1.2 从“全局ECEF”到“局部ENU”只需要一个矩阵
Cesium提供了一个现成方法:Transforms.eastNorthUpToFixedFrame(origin),它能在指定原点生成一个从ENU局部坐标系到ECEF坐标系的4x4矩阵。这个矩阵的旋转部分相当于把ENU的三个轴投影到ECEF下,平移部分就是原点在ECEF里的坐标。
拿到这个矩阵后,再求它的逆矩阵,就能把一个ECEF坐标点转换到以origin为原点的局部坐标。这就是整个过程的核心。很多教程绕来绕去,其实本质就两件事:先把世界坐标转成局部ENU坐标,然后从局部坐标的东、北、上三个分量反算出Heading和Pitch。下面的代码会在第2节和第3节反复用到,建议直接记住。
const enuToFixed = Cesium.Transforms.eastNorthUpToFixedFrame(start); const fixedToEnu = Cesium.Matrix4.inverse(enuToFixed, new Cesium.Matrix4()); const endLocal = Cesium.Matrix4.multiplyByPoint(fixedToEnu, end, new Cesium.Cartesian3());这里有个细节:因为start就是ENU坐标系的原点,所以endLocal的x、y、z分量可以直接当作“从起点指向终点的局部方向向量”,不需要再减一次起点坐标。如果忘了这一点,就可能多减一次,数值全乱。我在调试时就是在这里反复核对,最后才确认endLocal已经是相对向量。
1.3 两个坐标点的最典型使用场景
为什么这功能高频出现?我总结下来基本是三类:
- 模型跟随轨迹:飞机、导弹、无人机在路径上运动,模型头部要始终朝向下一个路径点。
- 相机对准目标:摄像头或者观察者视角要始终对着一个动态目标,需要实时计算Heading和Pitch。
- 标记牌朝向:地图上的箭头、方向标签、广告牌,要指向另一个坐标点。
这三类需求本质都一样:已知位置A和位置B,需要让一个“有朝向的物体”从A看向B。区别只是最后一步把角度喂给谁——喂给模型矩阵、喂给相机、还是喂给entity的orientation属性。只要核心的HPR算对了,后面都好办。
2. Heading和Pitch的数学推导:其实就是东、北、上三个分量
2.1 角度定义先抠清楚
Cesium里的Heading和Pitch不是随便定义的,务必先抠清楚:
- Heading:绕局部坐标系的z轴旋转的角度,正北为0,顺时针增加。正东是90°,正南是180°,正西是270°。
- Pitch:绕局部坐标系的y轴旋转的角度,正值为向上仰起,负值为向下俯冲,范围是-90°到90°。
- Roll:绕局部坐标系的x轴旋转角度,正值为向右倾斜,通常两个点算姿态时取0。
我刚开始没有细看文档,以为Heading是从正北逆时针转的,结果所有角度都镜像反了。这一点必须在动手写公式前确认清楚。
2.2 从ENU分量到角度的公式
假设我们在起点处建立了ENU局部坐标系,终点在局部坐标系里的坐标是(east, north, up)。那么方向向量已经拆分成了三个分量,接下来只需要两个公式:
heading = atan2(east, north) pitch = asin(up / length)其中length = sqrt(east^2 + north^2 + up^2)。
为什么Heading用atan2(east, north)而不是atan2(north, east)?因为Heading的基准是正北,也就是north轴,顺时针转向east轴。atan2(y, x)的语义是“从x轴正向起逆时针转到(y,x)的角度”,所以我们把north放进第二个参数,east放进第一个参数,正好得到从北向顺时针到东向的角度。如果两个参数反了,正东会算成0°,正北会算成90°,又是个隐蔽坑。
atan2返回的是弧度,范围是(-π, π],而Cesium的Heading习惯用0到2π表示,所以算完要判断一下,小于0就加2π。
let heading = Math.atan2(east, north); if (heading < 0.0) { heading += Cesium.Math.TWO_PI; }Pitch用asin(up / length),这是因为Pitch本质是方向向量与水平面的夹角,而up / length正好是方向向量在竖直方向的投影比例。返回范围是[-π/2, π/2],不需要额外归一化,但浮点计算里up / length可能因为舍入误差稍微超出[-1, 1],最好用Cesium.Math.clamp夹一下。
2.3 边界情况:重合、天顶、接近水平
公式本身简单,但工程上必须处理边界。
两点坐标重合时,方向向量长度接近0,east、north、up全是0,atan2(0, 0)返回0,asin(0)也返回0,表面上看起来计算成功,实际上是一个完全无意义的角度。所以在计算前要判断长度,太小就直接返回null或者保持上一帧姿态。
方向接近天顶时,也就是终点几乎在起点正上方,east和north都接近0,Pitch接近90°,此时Heading失去意义,因为绕竖直方向转多少度看起来都一样。代码里依然会算出一个默认Heading,但业务上应该特殊处理,比如保持之前的值,或者等方向离开天顶区域后再更新。
方向接近水平时,up很小,Pitch接近0,Heading很稳定,这是最好的情况。大部分轨迹跟踪都希望方向尽量水平,实测中只要处理好坐标转换,不会出问题。
3. 一个函数搞定“两点坐标到模型矩阵”
3.1 getHprFromPoints完整实现
把上面的推导封装成一个函数,方便在项目里直接调用。这个函数接受两个Cartesian3坐标点,返回一个Cesium.HeadingPitchRoll对象。
function getHprFromPoints(start, end, ellipsoid) { ellipsoid = ellipsoid || Cesium.Ellipsoid.WGS84; // 1. 建立起点的ENU局部坐标系,并求逆矩阵 const enuToFixed = Cesium.Transforms.eastNorthUpToFixedFrame(start, ellipsoid); const fixedToEnu = Cesium.Matrix4.inverse(enuToFixed, new Cesium.Matrix4()); // 2. 把终点变换到ENU局部坐标,得到东、北、上分量 const endLocal = Cesium.Matrix4.multiplyByPoint(fixedToEnu, end, new Cesium.Cartesian3()); const east = endLocal.x; const north = endLocal.y; const up = endLocal.z; // 3. 判断长度,太近的点没有计算意义 const length = Cesium.Cartesian3.magnitude(endLocal); if (length < 1e-6) { return undefined; } // 4. 反算Heading和Pitch let heading = Math.atan2(east, north); if (heading < 0.0) { heading += Cesium.Math.TWO_PI; } const pitch = Math.asin(Cesium.Math.clamp(up / length, -1.0, 1.0)); return new Cesium.HeadingPitchRoll(heading, pitch, 0.0); }这版代码我实测跑过,几个关键点是:第2步的endLocal直接就是相对向量;clamp能防止浮点误差导致asin报错;返回undefined比返回一个空角度更安全,调用方可以自己决定“保持上一帧”还是“原地不动”。
调用时注意,传入的起点终点都应该是Cartesian3,如果手里是经纬度,先转换:
const start = Cesium.Cartesian3.fromDegrees(116.0, 39.0, 0.0); const end = Cesium.Cartesian3.fromDegrees(116.5, 39.5, 5000.0); const hpr = getHprFromPoints(start, end);3.2 用headingPitchRollToFixedFrame生成模型矩阵
拿到HeadingPitchRoll之后,最省事的做法是直接用Cesium自带的转换方法生成模型矩阵:
const modelMatrix = Cesium.Transforms.headingPitchRollToFixedFrame( start, hpr, Cesium.Ellipsoid.WGS84, Cesium.Transforms.eastNorthUpToFixedFrame );这个方法的工作原理是:先在起点处建立一个ENU局部坐标系,然后在局部坐标系里按HeadingPitchRoll旋转,最后把结果放回ECEF世界坐标里。返回的Matrix4就是标准的模型矩阵,可以直接赋值给模型。
需要注意最后那个参数。默认就是Transforms.eastNorthUpToFixedFrame,写不写都可以,但如果你换了别的局部坐标系,比如Transforms.northEastDownToFixedFrame,那同样的HPR会得到完全不同的朝向。原因很简单,NED坐标系里x轴朝北、y轴朝东、z轴朝下,和ENU的x东y北z上是不同的轴序和轴方向。我建议所有代码统一用ENU,不要混用,混一次就够你排查半天的。
3.3 把矩阵挂到模型上:两种方式
Cesium里挂姿态常见有两种方式,区别挺大。
第一种是直接给model.modelMatrix赋值。这种方式下模型的位置、朝向全部由矩阵决定,模型自身的局部坐标系原点就在start处。
const entity = viewer.entities.add({ position: start, model: { uri: "./model.gltf", modelMatrix: modelMatrix } });第二种是给entity设置position和orientation,用四元数表示朝向:
const orientation = Cesium.Transforms.headingPitchRollQuaternion(start, hpr); const entity = viewer.entities.add({ position: start, orientation: orientation, model: { uri: "./model.gltf" } });两种方式最终视觉效果可能一致,但使用场景不同。如果你的模型需要与轨迹点强绑定,并且你会频繁替换模型矩阵,第一种更直接。如果你用的是CZML或者entity的position property,第二种更自然,因为orientation本身就能被CallbackProperty动态驱动。
要提醒的是:不要同时既给modelMatrix又给orientation,两者会互相干扰,位置和旋转可能出现叠加。我见过有人两个都写了,结果模型转到一半又额外旋转一次,怎么调都不对。
3.4 动态更新:别在每一帧里new太多东西
如果模型是运动的,HPR需要不断重新计算。最朴素的写法是在viewer.scene.preUpdate事件里每一帧都算一次,但要注意几点:
- 每次
Matrix4.inverse都会创建新矩阵,高频执行时尽量复用临时变量。 - 每次
Transforms.headingPitchRollToFixedFrame也会创建新对象,如果只是给entity用,优先选择headingPitchRollQuaternion,因为四元数本身更轻量。 - 如果使用
CallbackProperty,Cesium会在需要时求值,比手动刷帧省心得多。
我实际项目里用的简化模式是这样的:
const orientationProperty = new Cesium.CallbackProperty(function (time, result) { const currentPos = entity.position.getValue(time); if (!currentPos || !targetPos) { return undefined; } const hpr = getHprFromPoints(currentPos, targetPos); if (!hpr) { return undefined; } return Cesium.Transforms.headingPitchRollQuaternion(currentPos, hpr, result); }, false);这样每一帧需要的只是一个四元数,不会创建太多矩阵对象,性能压力小很多。
4. 实测数据与“长距离就失真”的现象
4.1 一组真实计算结果
为了验证算法,我在沙盒里用固定起点和几个不同终点跑了一遍。起点是经度116.0、纬度39.0、高度0,终点分别取了四类典型位置。
| 终点坐标 | 方向含义 | 计算Heading | 计算Pitch |
|---|---|---|---|
| 经度116.0,纬度40.0,高度0 | 正北方向 | 约0° | 约0.45° |
| 经度117.0,纬度39.0,高度0 | 正东方向 | 约90° | 约0.38° |
| 经度116.5,纬度39.5,高度5000 | 东北斜上方 | 约37.6° | 约4.4° |
| 经度116.0,纬度39.0,高度10000 | 垂直向上 | 无意义 | 约90° |
前两行很能说明问题:明明两个点都在海平面高度,为什么Pitch不是0°?这就是地球曲率在搞鬼。起点处的ENU坐标系是一个切平面,终点在地球表面上,但由于地表是弯曲的,终点并不在这个切平面里,而是在切平面往上偏一点的地方。所以从起点“望向”终点,视线并不是完全水平的,会有一个微小的仰角。
4.2 曲率带来的up分量:距离越大越明显
这个现象不是误差,而是几何事实。用简单的圆球模型估算一下,假设地球半径约6371公里,弧长距离为s,那么由于曲率产生的up分量约等于s^2 / (2 * 半径),对应的Pitch偏差约等于s / (2 * 半径)弧度。
| 两点距离 | 曲率导致的up分量 | 对应的Pitch偏差 |
|---|---|---|
| 100公里 | 约0.78公里 | 约0.45° |
| 500公里 | 约19.6公里 | 约2.25° |
| 1000公里 | 约78.5公里 | 约4.5° |
所以如果你的数据源是有人机航线、导弹弹道这种大跨度轨迹,直接用两点ECEF向量算出来的Pitch会带有明显的“虚高”仰角。此时要分清楚自己到底想要什么:如果模型是“直线飞向目标点”,那这个仰角是真实需要的;如果模型是“贴地沿路径移动”,那这个仰角就是假的,需要修正。
4.3 需要精确沿地表运动时的替代算法
当需求是“贴地大范围运动”时,不要用ECEF两点连线,改用椭球测地线来算Heading会更合理。Cesium提供了EllipsoidGeodesic,可以拿到沿椭球面的起始方位角。
const startCarto = Cesium.Cartographic.fromDegrees(116.0, 39.0, 0.0); const endCarto = Cesium.Cartographic.fromDegrees(117.0, 39.0, 0.0); const geodesic = new Cesium.EllipsoidGeodesic(startCarto, endCarto); const heading = geodesic.startHeading; const surfaceDistance = geodesic.surfaceDistance;需要Pitch时,再结合高度差计算:
const heightDiff = endCarto.height - startCarto.height; const pitch = Math.atan2(heightDiff, surfaceDistance);这个方案下,沿地表路径的Heading是严格按椭球面测地线算的,不会出现上面那种“虚高Pitch”。但它只适合“沿地表到达目标”的语义,不适合“直线瞄准目标”的语义。两种语义没有对错,只是场景不同,要用对地方。
5. 我踩过的坑:从弧度到相机,从Y-up到矩阵
5.1 弧度忘转角度,调试半小时
计算完的Heading和Pitch默认是弧度,输出时忘记转成角度,就会看到一堆0.785、1.57之类的数字。如果你习惯肉眼核对角度,记得统一用Cesium.Math.toDegrees转换,或者直接把代码里的返回结果先转成度数打印一遍。这不是什么高深问题,但确实很容易忽略,尤其是从其他地方抄来代码时,单位混在一起最致命。
5.2 给Camera用和给Model用,Pitch符号要小心
这是我这次项目中最大的坑。Cesium的HeadingPitchRoll里明明写着Pitch正值为向上,但Camera的Pitch约定却不太一样。给viewer.camera.setView传Pitch时,正值表示向下俯看,负值才是向上仰视。也就是说,同一个“A点看向B点”的HPR,用给模型是正确的,直接丢给Camera就会上下颠倒。
我当时的排查结果是:先确认模型矩阵没问题,再发现相机传参时Pitch方向反了。最终的解决办法很简单,在传给Camera前把Pitch取反,或者在相机逻辑里单独写一个适配函数:
const cameraHpr = new Cesium.HeadingPitchRoll(hpr.heading, -hpr.pitch, hpr.roll);如果你在做一个“相机跟着模型看向目标”的功能,这两个符号必须分开处理,不能图省事共用一个对象。
5.3 glTF模型的Y-up和Z-up问题
很多外部建模软件导出的glTF模型是Y-up,也就是模型自己的y轴朝上,而Cesium的局部ENU坐标系里z轴朝上。这种情况下直接把算好的HPR灌进去,模型会侧躺或者倒转。虽然现代glTF规范里可以通过asset.extras或节点根部旋转处理,但实际导入时仍然会遇到轴不一致。
处理方法通常是两种:一种是模型制作阶段就转成Z-up;另一种是在Cesium里包一层额外的模型矩阵,先把模型自身坐标系的Y轴转到Z轴,再叠加我们算出的HPR矩阵。后者不建议硬编码,因为不同模型可能转法不一样,最好在资源管线里统一处理好再上屏。
5.4 同时设置position和modelMatrix导致位移重叠
给entity同时设置position和model.modelMatrix时,两者都可能包含平移信息。modelMatrix里已经包含“模型放在ECEF的哪个位置”了,再给position,相当于在矩阵位移基础上又叠加了一次position平移。轻则模型位置偏移,重则矩阵和position语义冲突导致绘制闪烁。
我的建议是二选一:如果你要走modelMatrix路线,就把position当作逻辑标识,或者同样设置成矩阵的平移原点;如果你走position + orientation路线,就完全不要给modelMatrix赋值。别两个都写,省下来的代码量不值得你去调半天。
最后再分享一个我实际在用的技巧:如果模型要沿一条平滑路径运动,不要每一帧都用“当前点”和“下一路径点”硬算Heading。路径点较近时Heading会跳得很厉害,尤其是目标点距离只有几米时。正确做法是先把整条路径做平滑采样,然后用“当前位置点”和“前方一段距离后的插值点”来算姿态,相当于给转向加了阻尼,视觉上会顺滑很多。这个方法不复杂,但对飞行器、车辆的姿态表现提升非常明显。