threejs 实例 - 滚动的正方体

173 阅读3分钟

这段时间中把threejs 和 nextjs 看了看,打算基于这两者写个个人网站。

个人博客的首页想放一个threejs的正方体,这个正方体在不停的旋转,当鼠标放到正方体上时,正方体停止旋转。正方体的表面有网站的信息,通过点击具体信息可以跳转到不同的页面。

具体效果如下:

录屏2024-09-19 20.04.38.gif

创建场景

首先是react组件部分


import { useEffect, useRef } from 'react'
import Box from './Box'


export default function HomePage() {
    const canvasRef = useRef<HTMLCanvasElement>(null)
    routerRef.current = router

    useEffect(() => {
        const box = new Box(canvasRef.current!)
        box.render()

        return () => {
            box.clear()
        }

    }, [])

    return <canvas ref={ canvasRef } className='w-[100vw] h-[100vh]'></canvas>
}

threejs 是一种三维的canvas绘图,我们构建出三维场景场景后threejs会将场景绘制到canvas上面。

我们在useEffect中初始化了box对象,box对象封装了绘制三维物体的流程,下面我们来实现Box类。

Box类

Box类的结构如下:

class Box { 
    constructor() {
    }

    // 初始化事件
    init() { 
    }

    render() {
    }

    // canvas大小变化后,重新绘制canvas
    resize() {
    }

    // 正方体的动画
    animate() {  
    }

    // useEffect 的清理函数中调用
    clear() {
    }
}

box中主要有这几个方法下面我们来逐一实现:

初始化操作

首先准备材质和几何体:

import * as Three from 'three'
import blogImg from '@/assets/blog.jpg'
import workImg from '@/assets/work.jpg'
import closedImg from '@/assets/closed.jpg'

class Box { 

    private _mesh: Three.Mesh
    private _geometry: Three.BoxGeometry
    private _material: Three.Material[] | Three.Material
    private _scene: Three.Scene
    private _camera: Three.PerspectiveCamera
    private _renderer: Three.WebGLRenderer
    private _dom: HTMLCanvasElement
    
    constructor(dom: HTMLCanvasElement) {
        this._geometry = new Three.BoxGeometry(3, 3, 3)

        const blog = new Three.TextureLoader().load(blogImg.src)
        const work = new Three.TextureLoader().load(workImg.src)
        const closed = new Three.TextureLoader().load(closedImg.src)
        this._material = [
            new Three.MeshPhongMaterial({ map: blog, name: 'blog' }),
            new Three.MeshPhongMaterial({ map: work, name: 'work' }),
            new Three.MeshPhongMaterial({ map: closed }),
            new Three.MeshPhongMaterial({ map: closed }),
            new Three.MeshPhongMaterial({ map: closed }),
            new Three.MeshPhongMaterial({ map: closed })
        ]

        this._mesh = new Three.Mesh(this._geometry, this._material)
        
        this._scene = new Three.Scene()
        this._scene.background = new Three.Color(0xffffff)
        this._scene.add(this._mesh)     
    }

    // ...省略
}

获取图片路径的时候这边使用了nextjs框架,所以这段代码中:

import closedImg from '@/assets/closed.jpg'

closedImg是nextjs中StaticImageData对象类型,通过src属性能过图片的地址。

最后把创建的mesh对象添加到了this._scene中。

创建照相机,灯光,和轨道控制对象,渲染对象。

class Box {
    // ... 省略
    private _camera: Three.PerspectiveCamera
    private _renderer: Three.WebGLRenderer
    private _dom: HTMLCanvasElement
  
    constructor() {
        const { clientWidth, clientHeight } = dom
        this._dom = dom

        this._camera = new Three.PerspectiveCamera(45, clientWidth / clientHeight, .1, 1000)
        this._camera.position.set(0, 0, 10)

        const light = new Three.HemisphereLight('white', 'darkslategrey', 5)
        light.position.set(10, 10, 10)
        this._scene.add(light)

        const control = new OrbitControls(this._camera, dom)

        this._renderer = new Three.WebGLRenderer({ antialias: true, canvas: dom })
    }


    render() {
        this.init()
        this._renderer.render(this._scene, this._camera)
    }

  
}

以上的代码效果如下:

现在来实现Box类的resize()方法,当canvas大小变化的时候,threejs能过重新渲染。

class Box {

  constructor() {
    // ... 省略
    this.resize()
  }

  resize() {
      const { clientWidth, clientHeight } = this._dom
      this._camera.aspect = clientWidth / clientHeight
      this._camera.updateProjectionMatrix()

      this._renderer.setSize(clientWidth, clientHeight, false)
      this._renderer.setPixelRatio(window.devicePixelRatio)
  }
}

为了能让立方体能够旋转,给立方体添加旋转动画,来实现animate方法

class Box { 

    constructor() {
      // ... 
      window.addEventListener('resize', this.resize.bind(this))

    }
     // ... 省略
    render() {
        this.init()
        this._renderer.setAnimationLoop(() => {
            this.animate()
            this._renderer.render(this._scene, this._camera)
        })
    }

    animate() { 
        this._mesh.rotation.x += Three.MathUtils.degToRad(.3) 
        this._mesh.rotation.y += Three.MathUtils.degToRad(.3) 
        this._mesh.rotation.z += Three.MathUtils.degToRad(.3) 
    }

    resize() {
    }

 

    clear() {
    }
}

效果如下:

添加hover,点击效果

下面来实现下面两个效果:

  • 鼠标hover到立方体时候,立方体停止旋转。
  • 鼠标点击到立方体具体某个面的时候,能够跳转到对应的路由。

threejs中有个 raycaster 类,用来判断鼠标与物体的碰撞。

我们创建一个RayCaster类:

import * as Three from 'three'

class RayCaster {
    rayCaster: Three.Raycaster = new Three.Raycaster()
    pointer: Three.Vector2 = new Three.Vector2()

    constructor() {
    }

    intersect(
        camera: Three.Camera,
        scene: Three.Scene
    ) { 
        this.rayCaster.setFromCamera(this.pointer, camera)
        const intersects = this.rayCaster.intersectObjects(scene.children)
        return intersects
    }

    set(x: number, y: number) { 
        this.pointer.set(x, y)
    }
}

export default RayCaster

这个类中有个 intersect 方法,我们通过这个方面来判断鼠标与当前物体是否有碰撞。

修改Box代码:

import RayCaster from './RayCaster'


function handleMouseMove(event: MouseEvent, pointer: Three.Vector2) {
    pointer.x = ( event.clientX / window.innerWidth ) * 2 - 1;
    pointer.y = - ( event.clientY / window.innerHeight ) * 2 + 1;
}

class Box { 
    // ... 省略
    private _raycaster: RayCaster = new RayCaster()
    private _handleMove: (event: MouseEvent) => void

    constructor() {

        // ... 省略
         this._handleMove = (event) => { 
            handleMouseMove(event, this._raycaster.pointer)
        }
    }

    init() {
        window.addEventListener('pointermove', this._handleMove)
    }

    render() {
        this.init()
        this._renderer.setAnimationLoop(() => {
          // 如果鼠标与立方体有接触 intersects 数组不为空
            const intersects = this._raycaster.intersect(this._camera, this._scene);
            !intersects.length && this.animate()
            this._renderer.render(this._scene, this._camera)
        })
    }

    animate() { 
        this._mesh.rotation.x += Three.MathUtils.degToRad(.3) 
        this._mesh.rotation.y += Three.MathUtils.degToRad(.3) 
        this._mesh.rotation.z += Three.MathUtils.degToRad(.3) 
    }

    resize() {
    }

 

    clear() {
        window.removeEventListener('resize', this.resize)
        this._renderer.setAnimationLoop(null)
        window.removeEventListener('mousemove', this._handleMove)
    }
}

效果如下:

下面给添加点击效果,点击效果和hover效果相似,当鼠标点击到立方体制定面的时候,我们要执行相应的回调函数。

import RayCaster from './RayCaster'


function handleMouseMove(event: MouseEvent, pointer: Three.Vector2) {
    pointer.x = ( event.clientX / window.innerWidth ) * 2 - 1;
    pointer.y = - ( event.clientY / window.innerHeight ) * 2 + 1;
}

class Box { 
    // ... 省略
    private _raycaster: RayCaster = new RayCaster()
    private _handleMove: (event: MouseEvent) => void
    //  点击新增
    private _clickRayCaster: RayCaster = new RayCaster()
    private _handleClick: (event: MouseEvent) => void
    cb: (pathName: string) => void

    constructor(dom: HTMLCanvasElement, cb: (pathName: string) => void) {

        this.cb = cb

        // ... 省略
         this._handleMove = (event) => { 
            handleMouseMove(event, this._raycaster.pointer)
         }
        
         this._handleClick = (event) => { 
            handleMouseMove(event, this._clickRayCaster.pointer)
        }
    }

    init() {
        window.addEventListener('pointermove', this._handleMove)
        window.addEventListener('click', this._handleClick)
    }

    render() {
        this.init()
        let hasTrigger = false

        this._renderer.setAnimationLoop(() => {
            const intersects = this._raycaster.intersect(this._camera, this._scene);

            const intersectsByClick = this._clickRayCaster.intersect(this._camera, this._scene);
        
            if (intersectsByClick[0] &&
                this._material[intersectsByClick[0].face?.materialIndex!].name &&
                !hasTrigger) {
                hasTrigger = true
                try { 
                    // 通过materialIndex 判断出 点击的那个材质面
                    this.cb(this._material[intersectsByClick[0].face?.materialIndex!].name)
                    hasTrigger = false
                } catch (e) {
                    
                } finally {
                    
                }
            }
            !intersects.length && this.animate()
            this._renderer.render(this._scene, this._camera)
        })
    }

    animate() { 
        this._mesh.rotation.x += Three.MathUtils.degToRad(.3) 
        this._mesh.rotation.y += Three.MathUtils.degToRad(.3) 
        this._mesh.rotation.z += Three.MathUtils.degToRad(.3) 
    }

    resize() {
    }

 

    clear() {
        window.removeEventListener('resize', this.resize)
        this._renderer.setAnimationLoop(null)
        window.removeEventListener('mousemove', this._handleMove)
        window.removeEventListener('click', this._handleClick)
    }
}

import { useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import Box from './Box'


export default function HomePage() {
    const canvasRef = useRef<HTMLCanvasElement>(null)
    const router = useRouter()
    const routerRef = useRef(router)
    routerRef.current = router

    useEffect(() => {
        const box = new Box(canvasRef.current!, (pathName: string) => pathName && routerRef.current.push(pathName))
        box.render()

        return () => {
            box.clear()
        }

    }, [])

    return <canvas ref={ canvasRef } className='w-[100vw] h-[100vh]'></canvas>
}

效果如下: