实现一个 three.js 全景控制器吧
可以在 这个页面 先行体验这个全景控制器,试着拖拖拽拽以及缩放玩玩吧。
缘起
我们知道,全景展示的原理是,一个球或者一个正方体,内部贴上全景贴图,然后把摄像头放在正中间,转着看,就是全景图像了。需要转动时就改变相机的朝向,需要缩放时就改变相机的 fov,不能改变相机的位置,全景的相机必须要处于球体或立方体的中心,否则会畸变。
相机以及全景的示意图,图源 researchgate.net Qingyang Shen,如有侵权请联系删除
three.js 虽然有很多控制器,如map control、orbit control、trackball control,包括 r3f 的一大堆 controls,但几乎都是控制移动相机的位置,放大是把相机朝物体移动,缩小则是远离。不能满足我的需求,怎么办?
自己写一个吧。
刚好我们之前把用户交互抽象成了一个包,拿过来用吧。
那个包的作用,是把鼠标或触摸交互时,产生的结果(
move
/scale
/rotate
)变成事件,并把事件对应的结果(移动的距离、缩放中心和倍率、旋转中心和角度)在包内计算、作为事件的参数一起抛出来,免于调用者计算
import { MouseFormatter, type InteractEvents } from '@zimi/interact'
function PanoControl() {
const element = useThree((state) => state.gl.domElement)
const camera = useThree((state) => state.camera as PerspectiveCamera)
useEffect(() => {
// 这里只用 MouseFormatter 示意
// TouchFormatter 同理
const formatter = new MouseFormatter()
formatter.attach(element)
const onScale = (e: InteractEvents['scale'][0]) => {
updateCamera(camera, {
// 按交互所声明的缩放倍率执行缩放
fov: camera.fov / e.ratio,
})
}
// 交互包中的“移动”,即是控制器中的旋转
const onMove = (e: InteractEvents['move'][0]) => {
const prevState = getStateFromCamera(camera)
const ratio = camera.fov / canvasSize.height
// 鼠标(或手指触摸)移动的距离换算成旋转的角度
// 此处用 h/v 是利于人工可读性
updateCamera(camera, {
h: prevState.h + e.x * ratio,
// v (纵向的角度) 需要限制在 (0, 180)
v: clamp(prevState.v - e.y * ratio, EPS, 180 - EPS),
})
}
formatter.addListener('scale', onScale)
formatter.addListener('move', onMove)
return () => {
formatter.detach()
formatter.removeListener('scale', onScale)
formatter.removeListener('move', onMove)
}
}, [])
return <></>
}
工具函数
接下来我们再来实现 getStateFromCamera
和 updateCamera
函数
h / v 的 get & set 参考了 OrbitControls (我四五年前做 pano-controls 的时候,参考他实现了 get & set,但他的代码实现貌似变了,乍一看找不到之前参考的点了,懒得找了)
/**
* 返回相机的 h / v / fov
*/
export function getStateFromCamera(camera: PerspectiveCamera) {
const direction = new Vector3()
const spherical = new Spherical()
camera.getWorldDirection(direction)
spherical.setFromVector3(direction)
return {
h: (spherical.theta / Math.PI) * 180,
v: (spherical.phi / Math.PI) * 180,
fov: camera.fov,
}
}
/**
* means "is valid number"
*
* 局部使用的函数,名字短一点,我感觉整体可读性会更好
*/
function vn(v?: number): v is number {
return typeof v === 'number' && !Number.isNaN(v)
}
interface CameraState {
h?: number
v?: number
fov?: number
}
/**
* 根据 h / v / fov 更新相机状态
*/
export function updateCamera(camera: PerspectiveCamera, state: CameraState) {
const { h, v, fov } = state
if (!vn(fov) && !vn(h) && !vn(v)) {
return
}
if (vn(fov)) {
camera.fov = fov
}
if (vn(h) || vn(v)) {
const direction = new Vector3()
const target = new Vector3()
const spherical = new Spherical()
camera.getWorldDirection(direction)
spherical.setFromVector3(direction)
if (vn(h)) {
spherical.theta = (h / 180) * Math.PI
}
if (vn(v)) {
spherical.phi = (v / 180) * Math.PI
}
target.setFromSpherical(spherical).add(camera.position)
camera.lookAt(target)
}
camera.updateProjectionMatrix()
}
OK,一个控制相机 fov & 朝向的控制器就写好了
如果不嫌弃掺杂了业务代码,也可以看看具体实现代码:PanoControls/index.tsx