相机跟随之轨道控制器|征稿活动V5

效果

长按鼠标右键,旋转视角。

鼠标滚轮,缩放视角。

长按鼠标中键,平移视角。

如果你对这个模型感觉很熟悉,你肯定没看错,这是 threejs 中的 examples

https://threejs.org/examples/#webgl_animation_keyframes

咱们就是从中借鉴(CCVV)出的代码,在抄的时候,感受其中的思路与思想,本文的重点就是分享其中的要领与精髓。

在此,感谢开源者们与模型作者等人的贡献。 :kissing_heart:

实现

思路

实现思路就是个球。


风在动还是树在动?长按鼠标,手指在动,模型也跟着动?

不,是心在动!

实际上是相机在动!

此控制器叫做 OrbitControls , Orbit 是轨道的意思,轨道控制器的作用是让相机在一定的轨道上运行,就像是卫星环绕地球的轨道一样!

这个轨迹就是个球!!!相机就在这个球上做运动!

如何让相机对着目标一直拍摄呢?简单地用 lookAt 就行喽!

scope.object.lookAt(scope.target);

球坐标系

既然原理思路是个球,自然用球坐标系去算相机的位置。

此处加一个文章链接,讲述坐标系的那些事情:https://mp.weixin.qq.com/s/3vut2vfoQG6OH4OtMtZGsg

确定球坐标需要以下几点,而这些点正好对应了操作。

  • theta 水平方向角度,对应相机左右移动
  • phi 竖直角度,对应相机上下移动
  • radius 半径,对应相机与目标点的距离
  • target 圆心坐标,对应观察点的位置修改

此处直接贴上球坐标系的代码。assets\src\math\Spherical.ts

/**
 * Ref: https://en.wikipedia.org/wiki/Spherical_coordinate_system
 *
 * The polar angle (phi) is measured from the positive y-axis. The positive y-axis is up.
 * The azimuthal angle (theta) is measured from the positive z-axis.
 */

import { IVec3Like, math } from "cc";


class Spherical {
    radius: number = 1
    phi: number = 0
    theta: number = 0

    constructor(radius = 1, phi = 0, theta = 0) {

        this.radius = radius;
        this.phi = phi; // polar angle
        this.theta = theta; // azimuthal angle

        return this;

    }

    set(radius, phi, theta) {

        this.radius = radius;
        this.phi = phi;
        this.theta = theta;

        return this;

    }

    copy(other) {

        this.radius = other.radius;
        this.phi = other.phi;
        this.theta = other.theta;

        return this;

    }

    // restrict phi to be between EPS and PI-EPS
    makeSafe() {

        const EPS = 0.000001;
        this.phi = Math.max(EPS, Math.min(Math.PI - EPS, this.phi));

        return this;

    }

    setFromVector3(v) {

        return this.setFromCartesianCoords(v.x, v.y, v.z);

    }

    setFromCartesianCoords(x, y, z) {

        this.radius = Math.sqrt(x * x + y * y + z * z);

        if (this.radius === 0) {

            this.theta = 0;
            this.phi = 0;

        } else {

            this.theta = Math.atan2(x, z);
            this.phi = Math.acos(math.clamp(y / this.radius, - 1, 1));

        }

        return this;

    }

    clone() {
        return new Spherical().copy(this);
    }

    toVec3(out: IVec3Like) {
        const phi = this.phi;
        const radius = this.radius;
        const theta = this.theta;

        const sinPhiRadius = Math.sin(phi) * radius;
        out.x = sinPhiRadius * Math.sin(theta);
        out.y = Math.cos(phi) * radius;
        out.z = sinPhiRadius * Math.cos(theta);
        return this;

    }

}

export { Spherical };

再贴上同步相机的核心代码,具体逻辑可以参考下面的注释。

 private updateObject() {
        const scope = this;
        // 球坐标系
        const spherical = this.spherical;
        // 球坐标系变换量
        const sphericalDelta = this.sphericalDelta;
        // 平移变化量
        const panOffset = this.panOffset;

        // 计算当前状态的球坐标系(原点为目标点,半径为距离)
        const position = scope.object.position;
        // 算出原点为目标点下的向量 (笛卡尔坐标系)
        offset.set(position).subtract(scope.target);
        // 将笛卡尔坐标系转换成球坐标系
        spherical.setFromVector3(offset);

        // 加上角度变化,计算最终的角度
        spherical.theta += sphericalDelta.theta;
        spherical.phi += sphericalDelta.phi;
       
        // 加上半径变化量 
        spherical.radius *= this.scale

        // 加上圆心位置偏移量 
        scope.target.add(panOffset);

        //  算出原点为目标点下的向量 (笛卡尔坐标系)
        spherical.toVec3(offset)

        // 算出原始坐标系下的点
        position.set(scope.target).add(offset);
        scope.object.position = position;

       // 计算朝向
        scope.object.lookAt(scope.target);

        // 变化量归0
        sphericalDelta.set(0, 0, 0);
        panOffset.set(0, 0, 0);
        this.scale = 1;

    }

旋转

监听鼠标点击事件的位置变化量,根据方向修改球坐标系的变化量,直接看代码吧!

    private rotateLeft(angle) {
        this.sphericalDelta.theta -= angle;
    }

    private rotateUp(angle) {
        this.sphericalDelta.phi -= angle;
    }

    private handleMouseMoveRotate(event: EventMouse) {
        const rotateDelta = this.rotateDelta
        const clientHeight = screen.windowSize.height
        event.getDelta(rotateDelta)
        this.rotateDelta.multiplyScalar(this.rotateSpeed)

        this.rotateLeft(2 * Math.PI * rotateDelta.x / clientHeight); // yes, height
        this.rotateUp(-2 * Math.PI * rotateDelta.y / clientHeight);

        this.updateObject();
    }

缩放

监听鼠标滚轮事件,根据滚轮方向,修改缩放变化量(暂未处理正交相机情况)。

    private dollyOut(dollyScale) {
        this.scale /= dollyScale;
    }

    private dollyIn(dollyScale) {
        this.scale *= dollyScale;
    }

    private handleMouseWheel(event: EventMouse) {
        if (event.getScrollY() > 0) {
            this.dollyIn(this.getZoomScale());
        } else if (event.getScrollY() < 0) {
            this.dollyOut(this.getZoomScale());
        }
        this.updateObject();
    }

平移

监听鼠标点击事件的位置变化量,根据鼠标方向变化量和相机的朝向修改平移的变化量。

    private panLeft(distance) {
        const v = this.object.right
        v.multiplyScalar(- distance);
        this.panOffset.add(v);
    };

    private panUp(distance) {
        const v = this.object.up
        v.multiplyScalar(-distance);
        this.panOffset.add(v);
    };

    private pan(deltaX, deltaY) {
        const clientHeight = screen.windowSize.height
        const scope = this;
        const offset = this.offset

        // perspective
        const position = scope.object.position;
        offset.set(position).subtract(scope.target);
        let targetDistance = offset.length();

        // half of the fov is center to top of screen
        targetDistance *= Math.tan(scope.camera.fov / 2 * Math.PI / 180.0);

        // we use only clientHeight here so aspect ratio does not distort speed
        this.panLeft(2 * deltaX * targetDistance / clientHeight);
        this.panUp(2 * deltaY * targetDistance / clientHeight);
    }

    private handleMouseMovePan(event: EventMouse) {
        event.getDelta(this.panDelta)
        this.panDelta.multiplyScalar(this.panSpeed)
        this.pan(this.panDelta.x, this.panDelta.y);
        this.updateObject();
    }

阻尼 damping

直接设置相机位置会有些生硬,可以在每帧对变化量(角度,平移)做衰减处理,核心代码如下。

// 增加一点点变化
spherical.theta += sphericalDelta.theta * scope.dampingFactor;
spherical.phi += sphericalDelta.phi * scope.dampingFactor;
Vec3.scaleAndAdd(scope.target, scope.target, panOffset, scope.dampingFactor)

// 衰减变化量
sphericalDelta.theta *= 1 - scope.dampingFactor;
sphericalDelta.phi *= 1 - scope.dampingFactor;
panOffset.multiplyScalar(1 - scope.dampingFactor);

小结

TODO

  • 处理正交相机
  • 处理触摸事件
  • 完善组件控制(开关,控制范围等)
  • 自定义鼠标事件(例如修改鼠标左键为旋转等)

代码

代码仓库:https://github.com/baiyuwubing/cocos-creator-examples/tree/3.6/examples

这里也贴上轨道控制器的完整代码(后续应该还会更新,最终版本以github上为准)
assets\src\controls\OrbitControls.ts

import { _decorator, Component, Node, Vec3, v3, math, v2, quat, Quat, input, Input, EventMouse, screen, Camera } from 'cc';
import { Spherical } from '../math/Spherical';
const { ccclass, property } = _decorator;

const STATE = {
    NONE: - 1,
    ROTATE: 0,
    DOLLY: 1,
    PAN: 2,
    TOUCH_ROTATE: 3,
    TOUCH_PAN: 4,
    TOUCH_DOLLY_PAN: 5,
    TOUCH_DOLLY_ROTATE: 6
};
const EPS = math.EPSILON;
const twoPI = 2 * Math.PI;


@ccclass('OrbitControls')
export class OrbitControls extends Component {

    // "target" sets the location of focus, where the object orbits around
    @property
    target: Vec3 = v3()

    // How far you can dolly in and out ( PerspectiveCamera only )
    @property
    minDistance = 0;
    @property
    maxDistance = 9999999;


    // How far you can orbit vertically, upper and lower limits.
    // Range is 0 to Math.PI radians.
    minPolarAngle = 0; // radians
    maxPolarAngle = Math.PI; // radians

    // How far you can orbit horizontally, upper and lower limits.
    // If set, the interval [ min, max ] must be a sub-interval of [ - 2 PI, 2 PI ], with ( max - min < 2 PI )
    minAzimuthAngle = - Infinity; // radians
    maxAzimuthAngle = Infinity; // radians

    // Set to true to enable damping (inertia)
    // If damping is enabled, you must call controls.update() in your animation loop
    @property
    enableDamping = false;
    @property
    dampingFactor = 0.05;

    // This option actually enables dollying in and out; left as "zoom" for backwards compatibility.
    // Set to false to disable zooming
    enableZoom = true;
    zoomSpeed = 1.0;

    // Set to false to disable rotating
    enableRotate = true;
    rotateSpeed = 1.0;

    // Set to false to disable panning
    enablePan = true;
    panSpeed = 1.0;
    screenSpacePanning = true; // if false, pan orthogonal to world-space direction camera.up
    keyPanSpeed = 7.0; // pixels moved per arrow key push


    // Set to true to automatically rotate around the target
    // If auto-rotate is enabled, you must call controls.update() in your animation loop
    autoRotate = false;
    autoRotateSpeed = 2.0; // 30 seconds per orbit when fps is 60


    private object: Node
    private camera: Camera
    private state = STATE.NONE;
    // current position in spherical coordinates
    private readonly spherical = new Spherical();
    private readonly sphericalDelta = new Spherical();
    private scale = 1;
    private readonly panOffset = v3();
    private zoomChanged = false;
    private readonly rotateStart = v2();
    private readonly rotateEnd = v2();
    private readonly rotateDelta = v2();
    private readonly panStart = v2();
    private readonly panEnd = v2();
    private readonly panDelta = v2();
    private readonly dollyStart = v2();
    private readonly dollyEnd = v2();
    private readonly dollyDelta = v2();
    private readonly pointers = [];
    private readonly pointerPositions = {};


    onLoad() {
        this.object = this.node;
        this.camera = this.node.getComponent(Camera);
    }

    start() {
        // so camera.up is the orbit axis
        Quat.rotationTo(this.quat, this.object.up, Vec3.UP);
        Quat.invert(this.quatInverse, this.quat);
        this.spherical.radius = Vec3.distance(this.object.position, this.target)
    }

    onEnable() {
        input.on(Input.EventType.MOUSE_WHEEL, this.onMouseWheel, this);
        input.on(Input.EventType.MOUSE_DOWN, this.onMouseDown, this);
        input.on(Input.EventType.MOUSE_MOVE, this.onMouseMove, this);
    }

    onDisable() {
        input.off(Input.EventType.MOUSE_WHEEL, this.onMouseWheel, this);
        input.off(Input.EventType.MOUSE_DOWN, this.onMouseDown, this);
        input.off(Input.EventType.MOUSE_MOVE, this.onMouseMove, this);
    }
    onDestroy() {

    }


    private onMouseDown(event: EventMouse) {

    }

    private rotateLeft(angle) {
        this.sphericalDelta.theta -= angle;
    }

    private rotateUp(angle) {
        this.sphericalDelta.phi -= angle;
    }

    private handleMouseMoveRotate(event: EventMouse) {

        const rotateDelta = this.rotateDelta
        const clientHeight = screen.windowSize.height
        event.getDelta(rotateDelta)
        this.rotateDelta.multiplyScalar(this.rotateSpeed)

        this.rotateLeft(2 * Math.PI * rotateDelta.x / clientHeight); // yes, height

        this.rotateUp(-2 * Math.PI * rotateDelta.y / clientHeight);

        this.updateObject();

    }

    private onMouseMove(event: EventMouse) {
        // console.log("onMouseMove", event.getButton())
        switch (event.getButton()) {
            case EventMouse.BUTTON_RIGHT: {
                this.handleMouseMoveRotate(event);
                break;
            }
            case EventMouse.BUTTON_MIDDLE: {
                this.handleMouseMovePan(event);
                break;
            }
        }
    }

    private panLeft(distance) {

        //v.setFromMatrixColumn(objectMatrix, 0); // get X column of objectMatrix
        const v = this.object.right
        v.multiplyScalar(- distance);
        this.panOffset.add(v);

    };

    private panUp(distance) {

        // if (scope.screenSpacePanning === true) {

        // v.setFromMatrixColumn(objectMatrix, 1);

        // } else {

        //     v.setFromMatrixColumn(objectMatrix, 0);
        //     v.crossVectors(scope.object.up, v);

        // }
        const v = this.object.up
        v.multiplyScalar(-distance);
        this.panOffset.add(v);

    };

    private pan(deltaX, deltaY) {
        const clientHeight = screen.windowSize.height
        const scope = this;
        const offset = this.offset
        // if (scope.object.isPerspectiveCamera) {

        // perspective
        const position = scope.object.position;
        offset.set(position).subtract(scope.target);
        let targetDistance = offset.length();

        // half of the fov is center to top of screen
        targetDistance *= Math.tan(scope.camera.fov / 2 * Math.PI / 180.0);

        // we use only clientHeight here so aspect ratio does not distort speed
        this.panLeft(2 * deltaX * targetDistance / clientHeight);
        this.panUp(2 * deltaY * targetDistance / clientHeight);

        // } else if (scope.object.isOrthographicCamera) {

        //     // orthographic
        //     panLeft(deltaX * (scope.object.right - scope.object.left) / scope.object.zoom / element.clientWidth, scope.object.matrix);
        //     panUp(deltaY * (scope.object.top - scope.object.bottom) / scope.object.zoom / element.clientHeight, scope.object.matrix);

        // } else {

        //     // camera neither orthographic nor perspective
        //     console.warn('WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.');
        //     scope.enablePan = false;

        // }
    }

    private handleMouseMovePan(event: EventMouse) {


        event.getDelta(this.panDelta)
        this.panDelta.multiplyScalar(this.panSpeed)
        this.pan(this.panDelta.x, this.panDelta.y);
        this.updateObject();

    }

    private onMouseWheel(evt: EventMouse) {
        // evt.preventSwallow()
        this.handleMouseWheel(evt)
    }

    private getZoomScale() {
        return Math.pow(0.95, this.zoomSpeed);
    }

    private dollyOut(dollyScale) {
        // const scope = this

        // if (scope.object.isPerspectiveCamera) {

        this.scale /= dollyScale;

        // } else if (scope.object.isOrthographicCamera) {

        //     scope.object.zoom = Math.max(scope.minZoom, Math.min(scope.maxZoom, scope.object.zoom * dollyScale));
        //     scope.object.updateProjectionMatrix();
        //     zoomChanged = true;

        // } else {

        //     console.warn('WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.');
        //     scope.enableZoom = false;

        // }

    }

    private dollyIn(dollyScale) {

        // if (scope.object.isPerspectiveCamera) {

        this.scale *= dollyScale;

        // } else if (scope.object.isOrthographicCamera) {

        //     scope.object.zoom = Math.max(scope.minZoom, Math.min(scope.maxZoom, scope.object.zoom / dollyScale));
        //     scope.object.updateProjectionMatrix();
        //     zoomChanged = true;

        // } else {

        //     console.warn('WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.');
        //     scope.enableZoom = false;

        // }

    }

    private handleMouseWheel(event: EventMouse) {
        if (event.getScrollY() > 0) {
            this.dollyIn(this.getZoomScale());
        } else if (event.getScrollY() < 0) {
            this.dollyOut(this.getZoomScale());
        }

        this.updateObject();
    }

    private readonly offset = v3();
    // so camera.up is the orbit axis
    private readonly quat = quat(); //.setFromUnitVectors(object.up, v3(0, 1, 0));
    private readonly quatInverse = quat();
    private readonly lastPosition = v3();
    private readonly lastQuaternion = quat();
    private updateObject() {
        const scope = this;
        const offset = this.offset;
        const quat = this.quat;
        const quatInverse = this.quatInverse;
        const lastPosition = this.lastPosition;
        const lastQuaternion = this.lastQuaternion;
        const spherical = this.spherical;
        const sphericalDelta = this.sphericalDelta;
        const panOffset = this.panOffset;

        const position = scope.object.position;
        offset.set(position).subtract(scope.target);

        // rotate offset to "y-axis-is-up" space
        Vec3.transformQuat(offset, offset, quat)

        // angle from z-axis around y-axis
        spherical.setFromVector3(offset);
        if (scope.autoRotate && this.state === STATE.NONE) {
            // rotateLeft(getAutoRotationAngle());
        }

        if (scope.enableDamping) {

            spherical.theta += sphericalDelta.theta * scope.dampingFactor;
            spherical.phi += sphericalDelta.phi * scope.dampingFactor;

        } else {

            spherical.theta += sphericalDelta.theta;
            spherical.phi += sphericalDelta.phi;

        }

        // restrict theta to be between desired limits

        let min = scope.minAzimuthAngle;
        let max = scope.maxAzimuthAngle;
        if (isFinite(min) && isFinite(max)) {

            if (min < - Math.PI) min += twoPI; else if (min > Math.PI) min -= twoPI;
            if (max < - Math.PI) max += twoPI; else if (max > Math.PI) max -= twoPI;
            if (min <= max) {

                spherical.theta = Math.max(min, Math.min(max, spherical.theta));

            } else {

                spherical.theta = spherical.theta > (min + max) / 2 ? Math.max(min, spherical.theta) : Math.min(max, spherical.theta);

            }

        }

        // restrict phi to be between desired limits
        spherical.phi = Math.max(scope.minPolarAngle, Math.min(scope.maxPolarAngle, spherical.phi));
        spherical.makeSafe();
        spherical.radius *= this.scale;

        // restrict radius to be between desired limits
        spherical.radius = Math.max(scope.minDistance, Math.min(scope.maxDistance, spherical.radius));

        // move target to panned location

        if (scope.enableDamping === true) {

            // scope.target.addScaledVector(panOffset, scope.dampingFactor);
            Vec3.scaleAndAdd(scope.target, scope.target, panOffset, scope.dampingFactor)

        } else {

            scope.target.add(panOffset);

        }

        // offset.setFromSpherical(spherical);
        spherical.toVec3(offset)

        // rotate offset back to "camera-up-vector-is-up" space
        // offset.applyQuaternion(quatInverse);
        Vec3.transformQuat(offset, offset, quatInverse)


        position.set(scope.target).add(offset);
        scope.object.position = position;
        scope.object.lookAt(scope.target);
        if (scope.enableDamping === true) {

            sphericalDelta.theta *= 1 - scope.dampingFactor;
            sphericalDelta.phi *= 1 - scope.dampingFactor;
            panOffset.multiplyScalar(1 - scope.dampingFactor);

        } else {

            sphericalDelta.set(0, 0, 0);
            panOffset.set(0, 0, 0);

        }

        this.scale = 1;

        // update condition is:
        // min(camera displacement, camera rotation in radians)^2 > EPS
        // using small-angle approximation cos(x/2) = 1 - x^2 / 8

        if (this.zoomChanged || Vec3.squaredDistance(lastPosition, scope.object.position) > EPS || 8 * (1 - Quat.dot(lastQuaternion, scope.object.rotation)) > EPS) {

            // scope.dispatchEvent(_changeEvent);
            lastPosition.set(scope.object.position);
            lastQuaternion.set(scope.object.rotation);
            this.zoomChanged = false;
            return true;

        }

        return false;
    }

    update(deltaTime: number) {
        this.updateObject()
    }
}



视频预览与核心思路讲解:

https://www.bilibili.com/video/BV19W4y1s765

end

原文链接:https://mp.weixin.qq.com/s/ACGbuEyssHBTGhZaJ8mRcQ

更多精彩,欢迎关注,微信公众号【白玉无冰】

9赞

火钳刘明,大佬武功盖世

不不不,是心在动,血在烧

不敢当 :rofl:,只是ctrl+ccltr+v 的操作

:rofl:
是 心在跳情在烧

大佬, 妙呀 :+1:

:rofl:
是抄的喵

1赞

能加上触控就更妙了