实现一个 three.js 全景控制器吧

可以在 这个页面 先行体验这个全景控制器,试着拖拖拽拽以及缩放玩玩吧。

缘起

我们知道,全景展示的原理是,一个球或者一个正方体,内部贴上全景贴图,然后把摄像头放在正中间,转着看,就是全景图像了。需要转动时就改变相机的朝向,需要缩放时就改变相机的 fov,不能改变相机的位置,全景的相机必须要处于球体或立方体的中心,否则会畸变。

Structure-of-virtual-panorama-camera-rig-The-vertical-and-horizontal-field-of-view-FoV.png

相机以及全景的示意图,图源 researchgate.net Qingyang Shen,如有侵权请联系删除

three.js 虽然有很多控制器,如map controlorbit controltrackball 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 <></>
}

工具函数

接下来我们再来实现 getStateFromCameraupdateCamera 函数

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

如非特别声明,本站作品均为原创,遵循【自由转载-保持署名-非商用-非衍生 创意共享 3.0 许可证】。

对于转载作品,如需二次转载,请遵循原作许可。