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

最近在项目中遇到一个需求:需要在Cesium中动态编辑模型的位置

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

为此,我花了两三天时间开发了一个类似的模型位置编辑控件,实现了较为直观的交互操作。

具体效果如下图所示。

整个实现过程还是有些复杂的,涉及到了坐标转换、交互处理、Cesium事件机制等。 因此在这里做个简单的分享,给大家提供一些参考。

本文主要从数据流的角度,说明整个系统的计算和变化过程:

1. 模型设置阶段的数据流

首先,需要获取到模型或组合模型的中心点,从而设置操作的中心点并生成对应的操作轴。

大致的过程:模型设置 → 计算包围球 → 生成操作轴

javascript 复制代码
    // 获取基准点
    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 鼠标操作

我们需要判断拾取的操作轴,根据不同的轴使用不同的拾取方法。

javascript 复制代码
_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 拾取操作及坐标转换

javascipt 复制代码
    /**
     * 在椭球面上拾取位置
     * @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 变换矩阵

javascript 复制代码
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 矩阵应用到模型

javaScript 复制代码
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;
    }
}

2.5 轴更新

javascript 复制代码
_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. 简单总结一下

应该还有其他优化的部分,比如轴大小随视距缩放之类的。

有机会把完整代码放出来开源,争取达到开箱即用的效果。

相关推荐
丫丫7237343 天前
Three.js 模型树结构与节点查询学习笔记
javascript·webgl
allenjiao5 天前
WebGPU vs WebGL:WebGPU什么时候能完全替代WebGL?Web 图形渲染的迭代与未来
前端·图形渲染·webgl·threejs·cesium·webgpu·babylonjs
mapvthree6 天前
mapvthree Engine 设计分析——二三维一体化的架构设计
webgl·数字孪生·mapvthree·jsapi2d·jsapigl·引擎对比
GISer_Jing7 天前
3D Cesium渲染架剖析
javascript·3d·webgl
Swift社区7 天前
用 Chrome DevTools 深度分析 Vue WebGL 内存泄漏(进阶篇)
vue.js·webgl·chrome devtools
GISer_Jing9 天前
3DThreeJS渲染核心架构深度解析
javascript·3d·架构·webgl
ThreePointsHeat9 天前
Unity 关于打包WebGL + jslib录制RenderTexture画面
unity·c#·webgl
整点可乐10 天前
关于cesium的primitive的modelMatrix的应用
前端·javascript·cesium
GISer_Jing10 天前
Three.js核心技术解析:3D开发指南
javascript·3d·webgl