使用Three.js开发过3D网页的同学大概也使用过轨道控制器OrbitControls这个插件,借助OrbitControls可以方便地上下左右360度浏览3D场景,就像这样:
图1
今天我们就从0开发一个简易的OrbitControls插件以熟悉它背后的实现原理。
首先用Three.js渲染出如图1所示的三维场景:
import * as THREE from './three.module.js';
import { OBJLoader } from './OBJLoader.js';
// import { OrbitControls } from './OrbitControls.js';
let camera, scene, renderer, light, loader, object, controls, boxGeometry, boxMaterial, boxMesh;
init();
animate();
function init() {
// 场景
scene = new THREE.Scene();
scene.background = new THREE.Color(0xcce0ff);
// 相机
camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 1000);
camera.position.z = 300;
// 加载头像模型
loader = new OBJLoader();
loader.load('WaltHead.obj', function (obj) {
object = obj;
object.scale.multiplyScalar(0.8);
scene.add(object);
});
// 加载头像模型底座
boxGeometry = new THREE.BoxGeometry(50, 50, 50);
boxMaterial = new THREE.MeshBasicMaterial({ color: 0xbabec6 });
boxMesh = new THREE.Mesh(boxGeometry, boxMaterial);
scene.add(boxMesh);
boxMesh.translateY(-25);
// 灯光
scene.add(new THREE.AmbientLight(0x666666));
light = new THREE.DirectionalLight(0xdfebff, 1);
light.position.set(50, 200, 100);
light.castShadow = true;
scene.add(light);
// 渲染器
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
// 轨道控制器
// controls = new OrbitControls(camera, renderer.domElement);
document.body.appendChild(renderer.domElement);
}
function animate() {
requestAnimationFrame(animate);
render();
}
function render() {
renderer.render(scene, camera);
}
由于没有使用Three.js官方自带的OrbitControls插件,所以当前场景是完全静止的。下面我们就来自己实现一个轨道控制器,使场景可以转动起来。
在使用Three.js官方自带的OrbitControls插件转动场景时我们发现:整个旋转过程中相机始终环绕着三维场景的中心点,究其原因是由于在OrbitControls插件的作用下相机始终是在一个以三维场景中心为球心的球面上运动着。正如下图所示:
图2
O点是三维场景的中心点,A点是鼠标移动之后新的相机位置,在转动场景时相机则始终在A点所处的球面上运动,并且相机始终看向三维场景的中心点,于是如何在鼠标移动后计算新的相机位置是实现OrbitControls插件的关键所在。
针对图2我们绘制了如下的辅助线:
图3
在图3中:线段AA’垂直于XZ轴构成的平面,A’在XZ轴平面上,线段A’B垂直于Z轴、交点为B,线段A’C垂直于X轴、交点为C,线段AD垂直于Y轴、交点为D,与的夹角代表相机在水平方向的旋转角度,与的夹角代表相机在垂直方向的旋转角度。
由图3可知:当鼠标经过一次移动后相机移动到A点,欲求A点的XYZ坐标则相当于求A’点的XZ坐标、D点的Y坐标。A’的X坐标等于线段OA’的长度乘以sin(α), A’的Z坐标等于线段OA’的长度乘以cos(α),所以接下来我们只需要求得线段OA’的长度、α角的大小即可。根据图3中图形的平行关系可知线段OA’的长度等于线段DA的长度,而线段DA的长度是可以通过线段OA的长度乘以sin(β)计算得到的,而线段OA作为球体的半径是可以利用相机初始位置到三维场景中心点的距离轻松求得的,于是只要计算出β角的大小即可以最终计算出线段OA’的长度。接下来就是计算β角和α角的问题了,解决了这两者就能最终计算得到A的X坐标、Z坐标。
在使用Three.js官方自带的OrbitControls插件转动场景时我们还发现:鼠标移动时其水平或垂直方向的坐标变化幅度越大相应的相机在水平或垂直方向的角度变化幅度也越大,同时鼠标的移动方向与相机的旋转方向是相反的,也就是说每次鼠标移动距离的变化量与每次相机旋转角度的变化量之间是负相关的。本文中我们暂且把鼠标的移动距离与旋转角度之间的负相关系数设置为-0.015,也就是移动1个像素对应旋转角度0.015个弧度。至于鼠标一次移动的距离我们则可以通过鼠标事件的event.movementX、event.movementY来方便地获取到。
现在假设相机移动之前水平方向的角度为h、垂直方向的角度为v,于是计算A的X坐标、Z坐标我们可以用如下的等式去实现:
A点的X坐标 = OA的长度 * sin(β) * sin(α) = OA的长度 * sin(v – 0.015 * event.movementY) * sin(h – 0.015 * event.movementX);
A点的Z坐标 = OA的长度 * sin(β) * cos(α) = OA的长度 * sin(v – 0.015 * event.movementY) * cos(h – 0.015 * event.movementX)。
而关于A的Y坐标,上面有提到过其实就是求D点的Y坐标,由图3可知:
A点的Y坐标 = OA的长度 * cos(β) = OA的长度 * cos(v – 0.015 * event.movementY)。
接着我们将上述的逻辑用代码去实现,并封装成一个OrbitControls.js插件。
import * as THREE from './three.module.js';
class OrbitControls {
constructor(camera, domElement) {
this.camera = camera;
this.domElement = domElement;
this.target = new THREE.Vector3(0, 0, 0); // 相机的注视点坐标
let initCameraPosition = this.camera.position; // 相机的初始位置
this.radius = initCameraPosition.distanceTo(this.target); // 根据相机初始的位置计算球体的半径
this.rotationH = Math.atan2(initCameraPosition.x, initCameraPosition.z); // 记录相机初始位置时水平方向的角度
this.rotationV = Math.acos(initCameraPosition.y / this.radius); // 记录相机初始位置时垂直方向的角度
this.isMouseDown = false; // 记录鼠标是否按下
this.addEventListener(); // 监听鼠标事件
}
addEventListener() {
this.domElement.addEventListener("mousedown", () => {
this.isMouseDown = true;
});
this.domElement.addEventListener("mouseup", () => {
this.isMouseDown = false;
});
this.domElement.addEventListener("mousemove", (event) => {
if (!this.isMouseDown) return;
this.rotationH -= event.movementX * 0.015; // 计算相机在水平方向的角度
this.rotationV -= event.movementY * 0.015; // 计算相机在垂直方向的角度
/**
* 将相机在垂直方向的角度限制在0-180度之内
*/
if (this.rotationV < 1/180*Math.PI) {
this.rotationV = 1/180*Math.PI;
}
if (this.rotationV > Math.PI - 1/180*Math.PI) {
this.rotationV = Math.PI - 1/180*Math.PI;
}
/**
* 计算新的相机位置
*/
this.camera.position.x = this.radius * Math.sin(this.rotationV) * Math.sin(this.rotationH);
this.camera.position.y = this.radius * Math.cos(this.rotationV);
this.camera.position.z = this.radius * Math.sin(this.rotationV) * Math.cos(this.rotationH);
this.camera.lookAt(this.target); // 使相机始终注视目标点
});
}
}
export { OrbitControls };
具体使用时我们只需引入OrbitControls.js,并实例化一下即可:
import { OrbitControls } from './OrbitControls.js';
……
controls = new OrbitControls( camera, renderer.domElement );
……
完整的工程文件如下:
threejs-case-source: Three.js 3D效果集锦、Three.js源码解析。 – Gitee.com