如何实现精准操控?Cesium模型移动旋转控件实现

201 阅读9分钟

最近在项目上有个活儿:要在Cesium里拖模型改位置

熟悉三维开发的朋友应该知道,Three.js提供了十分便捷的控件来操作模型(TransformControls),但是Cesium在这方面有所欠缺:虽然通过参数设置也能实现功能,但对用户来说操作不够直观。

折腾了两三天,总算搞出来一个,用起来还算直观。效果看图:

Cesium控件.gif

说实话,实现起来真不轻松,坐标转换、交互逻辑、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()  - 椭球面拾取

你可以把地球椭球面当成一个可以"长高"的气球——中心点越高,气球就吹得越大,然后在这个新球面上做精准的"指哪打哪"。

三步搞定

  1. 撑大椭球:把 WGS84 椭球的三条半径都拉长,拉长的幅度就是中心点的高度值。简单说,就是给地球"增高"了那么一截。
  2. 射线碰瓷:从相机射出一条线,去撞这个被撑大后的椭球面,算出撞到哪里了。
  3. 坐标翻译:把撞到的那个点的空间坐标(笛卡尔坐标)换算成咱们熟悉的经纬度。

pickPositionOnPlane() - 平面拾取

这个原理说白了就是"画个大三角,看视线能不能撞上"——用巨型三角形跟相机视线做碰撞测试,专门对付那种竖着的平面(YZ平面和XZ平面)。

关键机制

  1. 三角形打包策略
    搞一个超大的三角形,大到视线肯定能撞上:

    YZ平面三角形(沿经度线竖着切):

    1: (经度, 纬度-r, -2000m)   // 底下左边角2: (经度, 纬度, 高度+缩放+1000m) // 顶上尖(参考点)3: (经度, 纬度+r, -2000m)   // 底下右边角
    

    XZ平面三角形(沿纬度线竖着切):

    点1: (经度-r, 纬度, -2000m)
    点2: (经度, 纬度, 高度+缩放+1000m)
    点3: (经度+r, 纬度, -2000m)
    

    三个点拼成一个竖起来的巨大扇形,-2000m是确保扎到地底下,+1000m是确保高过头顶。

  2. 平面方向计算
    根据经度算个方向向量:(cos(经度), sin(经度), 0)
    这个向量就像指南针,告诉我们这个竖平面该朝哪个经度方向"立起来"。

  3. 视线拉长
    相机视线太短?干脆拉长到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天
    • 变换链条

      1. 把局部变换从"老位置的ENU"转到"全球ECEF": [老ENU→ECEF的逆矩阵] × [局部变换矩阵]
      2. 再把结果从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. 收个尾

肯定还有些能优化的地儿,比如轴的大小跟着相机远近自动缩放这种细节。

回头把完整代码整出来开源,争取做到装就能用。