threejs中渲染html

发布时间 2023-03-30 18:30:30作者: 如戏一场

背景

最近中看threejs的时候发现一个好玩的事情,可以在threejs中渲染普通的html。threejs本身可以做各种炫酷的界面,但是与用户交互的时候写起来没有用dom实现方便,但是如果可以将已有的dom渲染到threejs中,那么就可以实现非常炫酷的界面,也能提高用户的体验。

依赖介绍

这里使用react框架,
状态管理库选用的recoil(这个库是由react官方开发的一个状态管理库,个人感觉比redux,mobx好用),
需要threejs自然还有three这个库,
用@react-three/fiber来结合threejs与react(这个库很好用,像react组件一样搭建threejs场景),
然后使用@react-three/drei提供的Html工具来实现html的渲染,
中间的一些过度动画使用gsap来实现。

总结需要安装的依赖如下

npm i recoil threejs gsap @react-three/fiber @react-three/drei

@react-three/drei提供的Html

这个工具本身就可以实现将html渲染到three中,具体使用如下

import { Canvas } from '@react-three/fiber'
import { Html } from '@react-three/drei'
import { Vector3, Euler } from 'three'

function () {
    return (
        <Canvas>
           <Html
              position={new Vector3(1,1,1)} // 表示这个dom显示到3d世界中的位置
              rotation={new Euler(1,1,1)} // 表示这个dom在3d世界中旋转的角度
              transform
            >
              <div>
                这里写普通的react node即可
              </div>
            </Html> 
        </Canvas>
    )
}

这里的缺陷与想要达到的效果

若场景中有多个html,都分布在不同的位置,想要实现从一个html到另一个html,只能手动调整摄像头位置,若用手动调整的话,很难找到一个合适的位置去看这个html。
想要实现一个调用去看某个html的方法,场景就自动切换到合适的位置(这个html正面中心的位置,距离html多远可以由传参的方式决定),没有看的html就不渲染

实现的思路

每次实例化一个html都会注册一个唯一的key,当调用某个方法传入这个key时即可自动转换到当前html的观看位置,通过recoil创建一个atom,用来保存注册的html的key,同时导出一个方法用于调用跳转html视角。

store的实现

代码如下,将其存在 store.ts文件中

import { useCallback } from 'react'
import { atom, useSetRecoilState, useRecoilValue } from 'recoil'

export type TriggerKey = string
type SeeHtmlFn = () => void

type RenderHtml = {
  current?: TriggerKey, // 当前视角所在的html
  seeFnMap: Partial<Record<TriggerKey, SeeHtmlFn>> //保存了所有注册的html,在跳转到某个html时触发
}

const seeHtml = atom<RenderHtml>({ // 实例化一个atom
  key: 'seeHtml',
  default: {
    seeFnMap: {}
  }
})

export function useAddSee() { // 注册一个看某个html的方法
  const setSeeHtml = useSetRecoilState(seeHtml)

  const addSee = useCallback((key: TriggerKey, fn: SeeHtmlFn) => {
    setSeeHtml(oldValue => ({
      current: oldValue.current,
      seeFnMap: {
        ...oldValue.seeFnMap,
        [key]: fn
      }
    }))
  }, [])

  return addSee
}

export function useSetRenderCurrent() { // 注册渲染html时标记为当前的key
  const setSeeHtml = useSetRecoilState(seeHtml)

  const setRenderCurrent = useCallback((key: TriggerKey) => {
    setSeeHtml(oldValue => ({
      ...oldValue,
      current: key
    }))
  }, [])

  return setRenderCurrent
}

export function useRenderCurrent() { // 返回一个当前看的html的key
  const seeHtmlValue = useRecoilValue(seeHtml)

  return seeHtmlValue.current
}

export function useGoTo() { // 返回一个去到某个html观看视角的方法
  const seeHtmlValue = useRecoilValue(seeHtml)

  const goTo = useCallback((key: TriggerKey) => {
    seeHtmlValue.seeFnMap[key]?.()
  }, [seeHtmlValue])

  return goTo
}

export default seeHtml

component的实现

import { useCallback, useEffect, useRef } from 'react'
import { Html } from '@react-three/drei'
import { HtmlProps } from '@react-three/drei/web/Html'
import { useThree } from '@react-three/fiber'

import { Vector3, Euler, Camera } from 'three'
import gsap from 'gsap'

import { useAddSee, TriggerKey, useRenderCurrent, useSetRenderCurrent } from './store'

type Props = {
  triggerKey: TriggerKey,
  style?: React.CSSProperties,
  className?: string,
  distance?: number,
} & HtmlProps

type RCamera = Camera & {
  manual?: boolean | undefined;
}

function RenderHtml({
  triggerKey,
  style,
  className,
  children,
  distance = 4, // 默认在距离html4个单位的地方观看
  position = new Vector3(),
  rotation = new Euler(),
  ...extra
}: Props) {
  const camera = useThree(state => state.camera)

  const prePosition = useRef<Vector3>()
  const preRotation = useRef<Euler>()
  const preCamera = useRef<RCamera>()
  const preD = useRef<number>()

  const addSee = useAddSee()
  const renderCurrent = useRenderCurrent()
  const setRenderCurrent = useSetRenderCurrent()

  const resetCamera = useCallback((camera: RCamera, position: Vector3, rotation: Euler, offset: Vector3) => { // 重置相机到最佳观看位置
    gsap.to(camera.position, {
      x: position.x + offset.x,
      y: position.y + offset.y,
      z: position.z + offset.z
    })
    gsap.to(camera.rotation, {
      x: rotation.x,
      y: rotation.y,
      z: rotation.z
    })
  }, [])

  useEffect(() => {
    const _position = position as Vector3
    const _rotation = rotation as Euler

    if (preCamera.current === camera && preD.current === distance &&
      prePosition.current && _position.equals(prePosition.current) &&
      preRotation.current && _rotation.equals(preRotation.current)) {
      return // 若位置,旋转角度,距离没有变化则不重新设置相机位置
    }

    preCamera.current = camera
    preD.current = distance
    prePosition.current = _position
    preRotation.current = _rotation

    const offset = new Vector3(0, 0, distance)
    offset.applyEuler(_rotation)

    addSee(triggerKey, () => {
      setRenderCurrent(triggerKey)
      resetCamera(camera, _position, _rotation, offset)
    })
  }, [camera, position, rotation, distance])

  useEffect(() => {
    if (renderCurrent !== triggerKey) return
    
    const _position = position as Vector3
    const _rotation = rotation as Euler
    const offset = new Vector3(0, 0, distance)
    offset.applyEuler(_rotation) // 将距离转换到平面的法线方向上
    
    resetCamera(camera, _position, _rotation, offset)
  }, [camera, position, rotation, distance, renderCurrent])

  if (renderCurrent !== triggerKey) { // 若当前渲染的html不是当前的这个则不渲染
    return <></>
  }

  return (
    <Html
      position={position}
      rotation={rotation}
      transform
      {...extra}
    >
      <div style={style} className={className}>
        {children}
      </div>
    </Html>
  )
}

export default RenderHtml

最后使用

import { useState, useCallback, useEffect } from 'react'

import { Vector3, Euler } from 'three'

import { useGoTo } from './renderHtml/store'
import RenderHtml from './renderHtml'

function RenderHtmlTest() {
  const [count, setCount] = useState(4)
  const goTo = useGoTo()

  useEffect(() => {
    document.addEventListener('click', () => {
        goTo('test')
    })
  }, [goTo])

  return (
    <RenderHtml
      triggerKey='test'
      distance={count}
      position={new Vector3(6, -1, 2)}
      rotation={new Euler(0, -Math.PI * 90 / 180, 0)}
    >
      <input />
      <p style={{ margin: '8px 0', backgroundColor: '#ccc', padding: '4px 8px', borderRadius: '4px' }}>摄像头距离: {count}</p>
      <button onClick={() => setCount(count + 1)}>摄像头距离+1</button>
      <br />
      <button style={{ marginTop: 8 }} onClick={() => setCount(count - 1)}>摄像头距离-1</button>
    </RenderHtml>
  )
}

总结

threejs结合html可以实现非常炫酷的页面,改变当前页面单调的访问方式。以上实现了快速切换到某个html,相当于切换路由了。