相机跟随之轨道控制器

白玉无冰

共 19156字,需浏览 39分钟

 · 2022-11-22

效果

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

鼠标滚轮,缩放视角。

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

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

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

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

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

实现

思路

实现思路就是个球。


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

不,是心在动!

实际上是相机在动!

此控制器叫做 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

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

浏览 111
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报