Raycaster
这是一个可以直接在 VitePress 页面中运行的 Three.js 示例。上方显示运行效果,下方展示对应源码。
运行效果
当前选中:未选中
原理
Raycaster 可以理解成从相机发出一条不可见的射线,用这条射线去检测场景里的物体。鼠标点击屏幕时,拿到的是二维坐标;Three.js 需要先把这个二维坐标转换成标准设备坐标,再结合相机生成一条射线。
标准设备坐标的范围是 -1 到 1。横向左侧是 -1,右侧是 1;纵向顶部是 1,底部是 -1。完成转换后,调用 raycaster.setFromCamera(pointer, camera) 就能得到从相机穿过鼠标位置的射线。
raycaster.intersectObjects(objects) 会返回射线命中的物体列表,并且默认按距离从近到远排序。通常取第一个结果,就是鼠标当前指向的最前面的物体。
源码
js
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
const canvas = document.querySelector('canvas')
const scene = new THREE.Scene()
scene.background = new THREE.Color('#111827')
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000)
camera.position.set(0, 2.4, 6)
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true
})
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(window.innerWidth, window.innerHeight)
const ambientLight = new THREE.AmbientLight('#ffffff', 0.8)
scene.add(ambientLight)
const directionalLight = new THREE.DirectionalLight('#ffffff', 1.6)
directionalLight.position.set(4, 6, 5)
scene.add(directionalLight)
const objects = []
const colors = ['#38bdf8', '#f97316', '#22c55e']
const names = ['蓝色立方体', '橙色球体', '绿色圆锥']
const geometries = [
new THREE.BoxGeometry(1, 1, 1),
new THREE.SphereGeometry(0.62, 32, 16),
new THREE.ConeGeometry(0.7, 1.2, 32)
]
geometries.forEach((geometry, index) => {
const material = new THREE.MeshStandardMaterial({
color: colors[index],
roughness: 0.45,
metalness: 0.05
})
const mesh = new THREE.Mesh(geometry, material)
mesh.name = names[index]
mesh.position.x = (index - 1) * 1.8
scene.add(mesh)
objects.push(mesh)
})
const gridHelper = new THREE.GridHelper(7, 7)
scene.add(gridHelper)
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
// Raycaster 负责从相机方向发出一条射线,并检测这条射线穿过了哪些物体。
const raycaster = new THREE.Raycaster()
// setFromCamera 需要的是标准设备坐标,范围是 -1 到 1。
const pointer = new THREE.Vector2()
let hoveredObject = null
let selectedObject = null
const setPointerFromEvent = (event) => {
const rect = canvas.getBoundingClientRect()
// 将鼠标在 canvas 中的像素坐标转换成 WebGL 使用的标准设备坐标。
// x: 左侧是 -1,右侧是 1;y: 顶部是 1,底部是 -1。
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
}
const resetObjectScale = (object) => {
// 选中的物体要保持选中态,所以这里只还原普通悬停物体。
if (object && object !== selectedObject) {
object.scale.setScalar(1)
}
}
const pickObject = () => {
// 根据当前鼠标坐标和相机生成射线。
raycaster.setFromCamera(pointer, camera)
// intersectObjects 会按距离从近到远返回命中的物体,取第一个就是最前面的物体。
const [firstHit] = raycaster.intersectObjects(objects)
return firstHit?.object ?? null
}
const handlePointerMove = (event) => {
setPointerFromEvent(event)
const object = pickObject()
if (object === hoveredObject) {
return
}
resetObjectScale(hoveredObject)
hoveredObject = object
if (hoveredObject && hoveredObject !== selectedObject) {
hoveredObject.scale.setScalar(1.12)
}
}
const handleClick = (event) => {
setPointerFromEvent(event)
const object = pickObject()
if (!object) {
if (selectedObject) {
selectedObject.material.emissive.set('#000000')
selectedObject.scale.setScalar(1)
}
selectedObject = null
return
}
if (selectedObject) {
selectedObject.material.emissive.set('#000000')
selectedObject.scale.setScalar(1)
}
selectedObject = object
selectedObject.material.emissive.set('#334155')
selectedObject.scale.setScalar(1.18)
}
canvas.addEventListener('pointermove', handlePointerMove)
canvas.addEventListener('click', handleClick)
function animate() {
requestAnimationFrame(animate)
objects.forEach((object) => {
object.rotation.y += 0.008
})
controls.update()
renderer.render(scene, camera)
}
animate()