Skip to content

Raycaster

这是一个可以直接在 VitePress 页面中运行的 Three.js 示例。上方显示运行效果,下方展示对应源码。

运行效果

当前选中:未选中

原理

Raycaster 可以理解成从相机发出一条不可见的射线,用这条射线去检测场景里的物体。鼠标点击屏幕时,拿到的是二维坐标;Three.js 需要先把这个二维坐标转换成标准设备坐标,再结合相机生成一条射线。

标准设备坐标的范围是 -11。横向左侧是 -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()