最近在项目上有个活儿:要在Cesium里拖模型改位置。
熟悉三维开发的朋友应该知道,Three.js提供了十分便捷的控件来操作模型(TransformControls),但是Cesium在这方面有所欠缺:虽然通过参数设置也能实现功能,但对用户来说操作不够直观。
折腾了两三天,总算搞出来一个,用起来还算直观。效果看图:
说实话,实现起来真不轻松,坐标转换、交互逻辑、Cesium事件机制全踩了一遍。在这儿跟大家唠唠,就当抛砖引玉了。
本文聊啥:从数据流的角度,把整个系统的计算过程捋一遍。
1. 模型设置阶段的数据流
首先,需要获取到模型或组合模型的中心点,从而设置操作的中心点并生成对应的操作轴。
大致的过程:模型设置 → 计算包围球 → 生成操作轴
下面是详细的步骤:
calcSceneBS() - 计算场景的基准点
- 把所有对象的包围球拢一块儿,拿到整个场景的大包围球(
_sceneSphere)和中心基准点(_basePt)
_createAxis() - 创建坐标轴
- 位置更新:Cesium自带的CallbackProperty属性实现实时的位置更新
- 方向轴的计算逻辑:
- 起点 = 中心点(_center)
- 终点 = 起点 + 轴方向向量 × 轴长度(_axisLength)
// 获取基准点
calcSceneBS() {
const boundingSpheres = [];
for (let i = 0; i < this._targetList.length; i++) {
const model = this._targetList[i];
let sphere = model.boundingSphere;
if (model instanceof Cesium.Model) {
sphere = this.getModelBS(model);
} else if (model instanceof Cesium.Primitive) {
const translation = Cesium.Matrix4.getTranslation(model.modelMatrix, new Cesium.Cartesian3);
const boundingSpheres = model._boundingSpheres;
sphere = new Cesium.BoundingSphere(translation, boundingSpheres.radius);
} else if (model.point) {
sphere = new Cesium.BoundingSphere(model.position.getValue(), 10);
}
if (sphere) {
boundingSpheres.push(sphere);
}
}
if (boundingSpheres.length > 0) {
this._sceneSphere = Cesium.BoundingSphere.fromBoundingSpheres(boundingSpheres, new Cesium.BoundingSphere());
}
if (this._basePt === undefined && this._sceneSphere && this._sceneSphere.center) {
this._basePt = Cesium.Cartographic.fromCartesian(this._sceneSphere.center);
this._basePt.longitude = Cesium.Math.toDegrees(this._basePt.longitude);
this._basePt.latitude = Cesium.Math.toDegrees(this._basePt.latitude);
}
}
// 创建xyz轴
_createAxis(name, color) {
const positionsCallback = new Cesium.CallbackProperty(() => {
if (!this._isValidCartesian(this._center)) return [];
const axes = this._getLocalAxes();
if (!axes) return [];
// 计算轴的方向向量
const dir = name === 'X' ? axes.xAxis : name === 'Y' ? axes.yAxis : axes.zAxis;
if (!this._isValidCartesian(dir)) return [];
const start = Cesium.Cartesian3.clone(this._center, new Cesium.Cartesian3());
const end = Cesium.Cartesian3.add(
start,
Cesium.Cartesian3.multiplyByScalar(dir, this._axisLength, new Cesium.Cartesian3()),
new Cesium.Cartesian3()
);
return [start, end];
}, false);
const entity = this._viewer.entities.add({
name,
polyline: {
positions: positionsCallback,
width: this._selectedAxis === name ? 10 : 5,
clampToGround: false,
material: new Cesium.PolylineArrowMaterialProperty(color)
}
});
this._axisEntities.push(entity);
}
2. 交互过程的数据流
完整的数据流:鼠标点击 → 拾取检测 → 坐标转换 → 变换计算 → 矩阵应用 → 视觉更新
2.1 鼠标操作
我们需要通过场景拾取判断是否点击了有效的操作轴。
- 三维拾取策略
- 椭球面拾取:在XY平面上拖的时候用,顺着地球弧度抓点 (pickPositionOnEllipsoid)
- 平面拾取:在YZ/XZ竖墙上拖的时候用,保证墙面精度(pickPositionOnPlane)
- 事件驱动架构
- Transform:变换开始(初始化-左键按下)
- Transforming:变换进行中(实时更新-鼠标移动)
- Transformed:变换完成(结束-左键抬起)
- 通过事件传递完整的变换参数,实现解耦
_onLeftDown(event) {
if (!this._isActive) return;
// 拾取操作轴
const pickedObject = this._scene.pick(event.position);
if (pickedObject && pickedObject.id) {
const axisName = Cesium.clone(pickedObject.id.name, true);
const validAxes = ["X", "Y", "Z", "XY", "XZ", "YZ",
"XY_CIRCLE", "XZ_CIRCLE", "YZ_CIRCLE",
"SCALE_X", "SCALE_Y", "SCALE_Z"];
if (validAxes.indexOf(axisName) >= 0) {
this._pickedObject = pickedObject;
this._highlightPickedObject(this._pickedObject);
this._viewer.scene.screenSpaceCameraController.enableRotate = false;
this._viewer.scene.screenSpaceCameraController.enableLook = false;
this._selectedAxis = axisName;
this._isDragging = true;
this._mouseDownPos = undefined;
this._mouseDownXY = event.position;
const X = ["XY", "XY_CIRCLE", "X", "Y", "Z"];
const Y = ["YZ", "YZ_CIRCLE"];
const Z = ["XZ", "XZ_CIRCLE"];
if (X.indexOf(this._selectedAxis) > -1) {
this._mouseDownPos = this.pickPositionOnEllipsoid(event.position);
if (this._transType.indexOf("MOVE") >= 0) this.calcFixedAxis()
}
if (Y.indexOf(this._selectedAxis) > -1) {
this._mouseDownPos = this.pickPositionOnPlane(event.position, "YZ");
if (this._transType.indexOf("MOVE") >= 0) this.calcFixedAxis()
}
if (Z.indexOf(this._selectedAxis) > -1) {
this._mouseDownPos = this.pickPositionOnPlane(event.position, "XZ");
if (this._transType.indexOf("MOVE") >= 0) this.calcFixedAxis()
}
// 触发 Transform 事件
this._raiseEvent("Transform", {
position: this._mouseDownPos,
cartographic: this._mouseDownPos,
axis: this._selectedAxis
});
}
}
}
_onMouseMove(event) {
if (!this._isActive || !this._isDragging) return;
if (this._selectedAxis === "") return;
let pos = undefined;
const X = ["XY", "XY_CIRCLE", "X", "Y", "Z"];
const Y = ["YZ", "YZ_CIRCLE"];
const Z = ["XZ", "XZ_CIRCLE"];
if (X.indexOf(this._selectedAxis) > -1) pos = this.pickPositionOnEllipsoid(event.endPosition);
if (Y.indexOf(this._selectedAxis) > -1) pos = this.pickPositionOnPlane(event.endPosition, "YZ");
if (Z.indexOf(this._selectedAxis) > -1) pos = this.pickPositionOnPlane(event.endPosition, "XZ");
// 触发 Transforming 事件
try {
this._raiseEvent("Transforming", {
MouseDownPos: this._mouseDownPos,
MouseMovePos: pos,
MouseDownXY: this._mouseDownXY,
MouseMoveXY: event.endPosition,
SelectedAxis: this._selectedAxis,
FixedAxis: this._fixedAxis
});
} catch (err) {
console.error('Error raising Transforming event:', err);
}
this._requestRender();
}
2.2 拾取操作及坐标转换
pickPositionOnEllipsoid() - 椭球面拾取
你可以把地球椭球面当成一个可以"长高"的气球——中心点越高,气球就吹得越大,然后在这个新球面上做精准的"指哪打哪"。
三步搞定:
- 撑大椭球:把 WGS84 椭球的三条半径都拉长,拉长的幅度就是中心点的高度值。简单说,就是给地球"增高"了那么一截。
- 射线碰瓷:从相机射出一条线,去撞这个被撑大后的椭球面,算出撞到哪里了。
- 坐标翻译:把撞到的那个点的空间坐标(笛卡尔坐标)换算成咱们熟悉的经纬度。
pickPositionOnPlane() - 平面拾取
这个原理说白了就是"画个大三角,看视线能不能撞上"——用巨型三角形跟相机视线做碰撞测试,专门对付那种竖着的平面(YZ平面和XZ平面)。
关键机制:
-
三角形打包策略
搞一个超大的三角形,大到视线肯定能撞上:YZ平面三角形(沿经度线竖着切):
点1: (经度, 纬度-r, -2000m) // 底下左边角 点2: (经度, 纬度, 高度+缩放+1000m) // 顶上尖(参考点) 点3: (经度, 纬度+r, -2000m) // 底下右边角XZ平面三角形(沿纬度线竖着切):
点1: (经度-r, 纬度, -2000m) 点2: (经度, 纬度, 高度+缩放+1000m) 点3: (经度+r, 纬度, -2000m)三个点拼成一个竖起来的巨大扇形,-2000m是确保扎到地底下,+1000m是确保高过头顶。
-
平面方向计算
根据经度算个方向向量:(cos(经度), sin(经度), 0)
这个向量就像指南针,告诉我们这个竖平面该朝哪个经度方向"立起来"。 -
视线拉长
相机视线太短?干脆拉长到1000公里,保证能跟大三角形撞上,就这么简单粗暴。
/**
* 在椭球面上拾取位置
* @param {Cesium.Cartesian2} screenPosition 屏幕位置
* @returns {Cesium.Cartographic} 地理坐标
*/
pickPositionOnEllipsoid(screenPosition) {
if (!this._initSphere || !this._initSphere.center) return undefined;
// 获取中心点的地理坐标
const centerCartographic = Cesium.Cartographic.fromCartesian(this._initSphere.center);
// 根据中心点高度调整椭球半径
const ellipsoid = Cesium.clone(Cesium.Ellipsoid.WGS84, true);
ellipsoid.radii.x += centerCartographic.height;
ellipsoid.radii.y += centerCartographic.height;
ellipsoid.radii.z += centerCartographic.height;
const adjustedEllipsoid = new Cesium.Ellipsoid(
ellipsoid.radii.x,
ellipsoid.radii.y,
ellipsoid.radii.z
);
// 拾取位置
const cartesian = this._viewer.camera.pickEllipsoid(screenPosition, adjustedEllipsoid, new Cesium.Cartesian3());
if (cartesian === undefined) return undefined;
// 转换为地理坐标
const cartographic = Cesium.Cartographic.fromCartesian(cartesian);
return cartographic;
}
/**
* 在指定平面上拾取位置
* @param {Cesium.Cartesian2} screenPosition 屏幕位置
* @param {String} planeType 平面类型 ("XY"|"YZ"|"XZ")
* @returns {Cesium.Cartographic} 地理坐标
*/
pickPositionOnPlane(screenPosition, planeType) {
this._calcCallbackArgs();
if (!this._initSphere || !this._initSphere.center) return undefined;
if (!this._callbackCartographic) return undefined;
const r = this._axisZoom * 1e-5;
const lon = this._callbackCartographic.longitude;
const lat = this._callbackCartographic.latitude;
const height = this._callbackCartographic.height;
// 创建三角形用于平面相交测试
let i, n, a;
if (planeType === "YZ") {
i = Cesium.Cartesian3.fromRadians(lon, lat - r, -2e3);
n = Cesium.Cartesian3.fromRadians(lon, lat, height + this._axisZoom * 0.95 + 1e3);
a = Cesium.Cartesian3.fromRadians(lon, lat + r, -2e3);
} else {
// XZ 平面
i = Cesium.Cartesian3.fromRadians(lon - r, lat, -2e3);
n = Cesium.Cartesian3.fromRadians(lon, lat, height + this._axisZoom * 0.95 + 1e3);
a = Cesium.Cartesian3.fromRadians(lon + r, lat, -2e3);
}
const ray = this._viewer.camera.getPickRay(screenPosition);
if (!ray) return undefined;
// 计算平面法向量(基于经度)
const centerCartographic = Cesium.Cartographic.fromCartesian(this._initSphere.center);
const s = Math.cos(centerCartographic.longitude);
const l = Math.sin(centerCartographic.longitude);
const d = 0;
const plane = new Cesium.Plane.fromPointNormal(
new Cesium.Cartesian3(0, 0, 0),
new Cesium.Cartesian3(s, l, d)
);
// 创建射线终点
const rayEnd = new Cesium.Cartesian3();
rayEnd.x = ray.origin.x + ray.direction.x * 1e6;
rayEnd.y = ray.origin.y + ray.direction.y * 1e6;
rayEnd.z = ray.origin.z + ray.direction.z * 1e6;
// 线段与三角形相交测试
const intersection = Cesium.IntersectionTests.lineSegmentTriangle(
ray.origin,
rayEnd,
i,
n,
a
);
if (intersection === undefined) return undefined;
// 转换为地理坐标
const cartographic = Cesium.Cartographic.fromCartesian(intersection);
return cartographic;
}
/**
* 计算回调参数(用于动态更新)
*/
_calcCallbackArgs() {
if (!this._initSphere || !this._initSphere.center) return;
this._callbackCenter = this._initSphere.center;
this._callbackCartographic = Cesium.Cartographic.fromCartesian(this._callbackCenter);
this._callbackLonDeg = Cesium.Math.toDegrees(this._callbackCartographic.longitude);
this._callbackLatDeg = Cesium.Math.toDegrees(this._callbackCartographic.latitude);
this._callbackHeight = this._callbackCartographic.height;
this._viewer.scene.requestRender();
}
2.3 变换矩阵
这段代码干的是个精细活儿:算模型的变形矩阵,把模型从一个地方挪到另一个地方,还能转角度。
-
计算步骤:
-
算新位置:
新坐标 = 老位置 + 偏移量把经纬度(得先转弧度)和高度变成三维空间里的笛卡尔坐标,高度直接线性相加就行。 -
摆姿势 角度转弧度 → 打包成四元数 → 按"朝向→抬头→歪头"的顺序组合。四元数这东西就是用来记旋转的,比欧拉角靠谱。
-
局部变换矩阵:
变换矩阵 = 旋转(四元数)× 缩放(三轴)。 注意这里没包含平移,位置变化靠坐标系切换来实现。 -
切换坐标系(最绕的一步)
- 先整明白两个坐标系:
- ECEF(地心地固) :地球中心为原点,全球通用坐标系
- ENU(东北天) :以某点为中心的局部坐标系,X东Y北Z天
- 先整明白两个坐标系:
-
变换链条:
- 把局部变换从"老位置的ENU"转到"全球ECEF": [老ENU→ECEF的逆矩阵] × [局部变换矩阵]
- 再把结果从ECEF转到"新位置的ENU": [新ENU→ECEF矩阵] × 上一步结果
-
-
最终公式:
最终矩阵 = 新ENU→ECEF × (局部变换 × 老ENU→ECEF的逆矩阵)
getMatrixOfTileset(tileset, deltaLon, deltaLat, deltaHeight, heading, pitch, roll, scaleX, scaleY, scaleZ, baseLon, baseLat, baseHeight) {
let baseLonRad = baseLon;
let baseLatRad = baseLat;
let baseHeightM = baseHeight;
// 计算基准位置和目标位置
const basePos = Cesium.Cartesian3.fromRadians(baseLonRad, baseLatRad, baseHeightM);
const targetPos = Cesium.Cartesian3.fromRadians(
baseLonRad + deltaLon,
baseLatRad + deltaLat,
baseHeightM + deltaHeight
);
// 角度转换为弧度
heading = Cesium.Math.toRadians(heading);
pitch = Cesium.Math.toRadians(pitch);
roll = Cesium.Math.toRadians(roll);
// 创建四元数旋转
const quaternion = Cesium.Quaternion.fromHeadingPitchRoll(
new Cesium.HeadingPitchRoll(heading, pitch, roll)
);
const scale = new Cesium.Cartesian3(scaleX, scaleY, scaleZ);
// 创建变换矩阵(旋转 + 缩放)
const transform = Cesium.Matrix4.fromTranslationQuaternionRotationScale(
Cesium.Cartesian3.ZERO,
quaternion,
scale
);
// 坐标系变换
const baseFrame = Cesium.Transforms.eastNorthUpToFixedFrame(basePos);
const baseFrameInv = Cesium.Matrix4.inverse(baseFrame, new Cesium.Matrix4);
const targetFrame = Cesium.Transforms.eastNorthUpToFixedFrame(targetPos);
// 合成最终变换矩阵
const result = Cesium.Matrix4.multiply(transform, baseFrameInv, new Cesium.Matrix4);
return Cesium.Matrix4.multiply(targetFrame, result, new Cesium.Matrix4);
}
2.4 模型与轴的位置更新
applyTransformToTileset() - 给模型套矩阵(把算好的变换矩阵拍到3D Tileset模型上)
- 1. 增量式变换:基于鼠标按下时的初始矩阵(
_mouseDownMM)继续做变换,而不是每次从头来。简单说就是在原来的基础上接着改,支持连续拖拽。 - 2. 矩阵合成 :
最终矩阵 = 变换矩阵 × 初始矩阵。 矩阵乘法顺序不能乱,这样旋转、缩放、位移才能正确叠加。 - 3. 实时更新 :算完直接赋值给模型的
modelMatrix,改完立刻就能看到效果,不搞延迟。
_getLocalAxes() - 获取当前中心点的东北天(ENU)坐标系三个轴的方向
- 1. 建个本地坐标系:以
_center为中心点建ENU坐标系,用eastNorthUpToFixedFrame算出它到全球坐标系(ECEF)的转换矩阵。 - 2. 提取三个方向:
东轴(X) = ENU矩阵 × (1, 0, 0) // 东方向北轴(Y) = ENU矩阵 × (0, 1, 0) // 北方向天轴(Z) = ENU矩阵 × (0, 0, 1) // 天方向
用 multiplyByPointAsVector 是为了保证算出来的是纯方向向量,不受平移影响。最后得到的是全球坐标系里那三个轴的指向。
- 3. 简单说就是
把"东、北、天"这三个本地方向,换算成全球坐标系里的方向向量,保证它们互相垂直且长度是1。
applyTransformToTileset(tileset, deltaLon, deltaLat, deltaHeight, heading, pitch, roll, scaleX, scaleY, scaleZ, baseLon, baseLat, baseHeight) {
// 获取变换矩阵
const transformMatrix = this.getMatrixOfTileset(
tileset, deltaLon, deltaLat, deltaHeight, heading, pitch, roll,
scaleX, scaleY, scaleZ, baseLon, baseLat, baseHeight
);
if (transformMatrix) {
// 将变换矩阵应用到初始模型矩阵
const resultMatrix = Cesium.Matrix4.multiply(transformMatrix, this._mouseDownMM, new Cesium.Matrix4);
tileset.modelMatrix = resultMatrix;
}
}
_getLocalAxes() {
let enuMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(this._center)
// 从ENU矩阵提取坐标轴
const xAxis = Cesium.Matrix4.multiplyByPointAsVector(
enuMatrix,
new Cesium.Cartesian3(1, 0, 0),
new Cesium.Cartesian3()
);
const yAxis = Cesium.Matrix4.multiplyByPointAsVector(
enuMatrix,
new Cesium.Cartesian3(0, 1, 0),
new Cesium.Cartesian3()
);
const zAxis = Cesium.Matrix4.multiplyByPointAsVector(
enuMatrix,
new Cesium.Cartesian3(0, 0, 1),
new Cesium.Cartesian3()
);
return { xAxis, yAxis, zAxis };
}
3. 收个尾
肯定还有些能优化的地儿,比如轴的大小跟着相机远近自动缩放这种细节。
回头把完整代码整出来开源,争取做到装就能用。