效果
长按鼠标右键,旋转视角。
鼠标滚轮,缩放视角。
长按鼠标中键,平移视角。
如果你对这个模型感觉很熟悉,你肯定没看错,这是 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
end
原文链接:https://mp.weixin.qq.com/s/ACGbuEyssHBTGhZaJ8mRcQ
更多精彩,欢迎关注,微信公众号【白玉无冰】