使用Three.js开发过3D网页的伙伴大概也使用过轨道控制器OrbitControls这个插件,借助OrbitControls可以很方便地360度浏览3D场景,就像这样:
图1
今天我们就从0开发一个简易的OrbitControls插件以熟悉它背后的实现原理。
首先使用Three.js加载出如图1所示的场景:
javascript
import * as THREE from 'three'
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'
let camera, scene, renderer, light, loader, object, controls, boxGeometry, boxMaterial, boxMesh
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('./models/obj/walt/WaltHead.obj', function (obj) {
object = obj
object.scale.multiplyScalar(0.8)
scene.add(object)
})
// 加载头像模型底座
boxGeometry = new THREE.BoxGeometry(50, 50, 50)
boxMaterial = new THREE.MeshLambertMaterial({ 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() {
// controls.update()
render()
requestAnimationFrame(animate)
}
function render() {
renderer.render(scene, camera)
}
init()
animate()
由于没有启用OrbitControls插件,所以当前场景是完全静止的。下面我们就来自己实现一个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, <math xmlns="http://www.w3.org/1998/Math/MathML"> O B ⃗ \vec{OB} </math>OB 与 <math xmlns="http://www.w3.org/1998/Math/MathML"> O A ′ ⃗ \vec{OA'} </math>OA′ 的夹角 <math xmlns="http://www.w3.org/1998/Math/MathML"> α \alpha </math>α代表相机在水平方向的旋转角度, <math xmlns="http://www.w3.org/1998/Math/MathML"> O D ⃗ \vec{OD} </math>OD 与 <math xmlns="http://www.w3.org/1998/Math/MathML"> O A ⃗ \vec{OA} </math>OA 的夹角 <math xmlns="http://www.w3.org/1998/Math/MathML"> β \beta </math>β代表相机在垂直方向的旋转角度。
由图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插件转动场景时我们还发现:
鼠标移动时其水平或垂直方向的坐标变化幅度越大相应的相机在水平或垂直方向的角度变化幅度也越大,同时鼠标的移动方向与相机的旋转方向是相反的,也就是说每次鼠标移动距离的变化量与每次相机旋转角度的变化量之间是负相关的。本文中我们暂且把鼠标的移动距离与旋转角度之间的负相关系数设置为-1/180*Math.PI,也就是移动1个像素对应旋转角度1度。至于鼠标一次移动的距离我们则可以通过鼠标事件的event.movementX、event.movementY来方便地获取到。
现在假设相机移动之前水平方向的角度为h、垂直方向的角度为v,于是计算相机移动到点A后的X坐标、Z坐标我们可以用如下的等式去实现:
A的X坐标 = OA的长度 * sin(β) * sin(α) = OA的长度 * sin(v - 1/180*Math.PI * event.movementY) * sin(h - 1/180*Math.PI * event.movementX);
A的Z坐标 = OA的长度 * sin(β) * cos(α) = OA的长度 * sin(v - 1/180*Math.PI * event.movementY) * cos(h - 1/180*Math.PI * event.movementX)。
而关于A的Y坐标,上面有提到过其实就是求D点的Y坐标,由图3可知:
A的Y坐标 = OA的长度 * cos(β) = OA的长度 * cos(v - 1/180*Math.PI * event.movementY)。
编码实现
接下来我们用代码去实现上述的逻辑,同时封装一个OrbitControls类:
javascript
class OrbitControls {
constructor(object, domElement) {
// 保存相机对象
this.object = object
// 保存渲染画布
this.domElement = domElement
// 相机的注视点坐标
this.target = new THREE.Vector3()
// 根据相机初始的位置计算球体的半径
this.radius = this.getDistance()
// 记录相机在水平垂直方向的角度
this.rotationH = Math.atan2(this.object.position.x, this.object.position.z)
this.rotationV = Math.acos(this.object.position.y / this.radius)
// 记录鼠标是否按下
this.isMouseDown = false
// 处理鼠标事件
this.handleMouseEvent()
}
update() {
// 使相机始终注视目标点
this.object.lookAt(this.target)
}
getDistance() {
return this.object.position.distanceTo(this.target)
}
handleMouseEvent() {
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 * 1 / 180 * Math.PI
this.rotationV -= event.movementY * 1 / 180 * Math.PI
// 将相机在垂直方向的角度限制在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.object.position.set(
this.radius * Math.sin(this.rotationV) * Math.sin(this.rotationH),
this.radius * Math.cos(this.rotationV),
this.radius * Math.sin(this.rotationV) * Math.cos(this.rotationH)
)
})
}
}
具体调用方法与Three.js官方的OrbitControls插件用法一致:
javascript
// 轨道控制器
controls = new OrbitControls(camera, renderer.domElement)
......
// 以下方法在动画函数中调用
controls.update()
总结
本文的这个简易的OrbitControls插件的实现原理:
将相机放置到以注视点为球心的一个球面上,根据鼠标偏移量实时计算相机的旋转角度,进而更新相机在球面上的坐标位置即可实现绕着某个点360度浏览场景的效果。
本文使用的Three.js版本为r150。 本文完整的工程文件:手写一个简易的Three.js OrbitControls