这段时间中把threejs 和 nextjs 看了看,打算基于这两者写个个人网站。
个人博客的首页想放一个threejs的正方体,这个正方体在不停的旋转,当鼠标放到正方体上时,正方体停止旋转。正方体的表面有网站的信息,通过点击具体信息可以跳转到不同的页面。
具体效果如下:
创建场景
首先是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>
}
效果如下: