name: vtj-raycasting-system description: Implements ray picking and collision detection. Use when selecting objects, detecting block interactions, or optimizing raycaster performance.
vite-threejs Raycasting System
Overview
本项目的射线系统主要用于 方块交互(挖掘、放置)和 目标选择。
核心原则:始终使用 iMouse.normalizedMouse 获取 NDC 坐标,射线检测结果通过 mitt 事件通知。
When to Use
- 实现点击拾取功能
- 检测鼠标悬停对象
- 实现方块交互(挖掘、放置)
- 添加目标锁定功能
基础 Raycaster 模式
import * as THREE from 'three'
import Experience from './experience.js'
import emitter from './utils/event-bus.js'
export default class ObjectPicker {
constructor() {
this.experience = new Experience()
this.scene = this.experience.scene
this.camera = this.experience.camera.instance
this.iMouse = this.experience.iMouse
this.raycaster = new THREE.Raycaster()
this.intersects = []
// 配置
this.params = {
enabled: true,
maxDistance: 100,
}
// 绑定事件
this._handleClick = this._handleClick.bind(this)
emitter.on('input:mouse_down', this._handleClick)
}
_handleClick({ button }) {
if (button !== 0 || !this.params.enabled) return
// 使用 IMouse 的 normalizedMouse(MANDATORY)
const ndc = this.iMouse.normalizedMouse
this.raycaster.setFromCamera(ndc, this.camera)
// 检测交叉
this.intersects = this.raycaster.intersectObjects(
this.scene.children,
true // recursive
)
if (this.intersects.length > 0) {
const hit = this.intersects[0]
emitter.emit('game:object-picked', {
object: hit.object,
point: hit.point,
distance: hit.distance,
})
}
}
destroy() {
emitter.off('input:mouse_down', this._handleClick)
}
}
屏幕中心射线(第一人称准星)
const CENTER_SCREEN = new THREE.Vector2(0, 0)
update() {
// 第一人称:从屏幕中心发射
this.raycaster.setFromCamera(CENTER_SCREEN, this.camera)
// 或者第三人称:从鼠标位置发射
// this.raycaster.setFromCamera(this.iMouse.normalizedMouse, this.camera)
const intersects = this.raycaster.intersectObjects(this.targets, true)
// ...
}
方块交互模式
本项目的 BlockRaycaster 实现了体素方块的射线检测:
// src/js/interaction/block-raycaster.js
export default class BlockRaycaster {
constructor() {
this.experience = new Experience()
this.camera = this.experience.camera.instance
this.iMouse = this.experience.iMouse
this.raycaster = new THREE.Raycaster()
this.raycaster.far = 8 // 最大交互距离
this.params = {
useMouse: false, // false = 屏幕中心, true = 鼠标位置
}
this.result = {
hit: false,
blockPos: null,
faceNormal: null,
adjacentPos: null, // 放置方块的位置
}
}
update(terrainMeshes) {
// 选择射线原点
const ndc = this.params.useMouse
? this.iMouse.normalizedMouse
: new THREE.Vector2(0, 0)
this.raycaster.setFromCamera(ndc, this.camera)
const intersects = this.raycaster.intersectObjects(terrainMeshes, false)
if (intersects.length > 0) {
const hit = intersects[0]
// 计算方块坐标(向下取整到格子中心)
const blockX = Math.floor(hit.point.x - hit.face.normal.x * 0.5)
const blockY = Math.floor(hit.point.y - hit.face.normal.y * 0.5)
const blockZ = Math.floor(hit.point.z - hit.face.normal.z * 0.5)
// 计算相邻方块位置(放置用)
const adjacentX = blockX + Math.round(hit.face.normal.x)
const adjacentY = blockY + Math.round(hit.face.normal.y)
const adjacentZ = blockZ + Math.round(hit.face.normal.z)
this.result = {
hit: true,
blockPos: new THREE.Vector3(blockX, blockY, blockZ),
faceNormal: hit.face.normal.clone(),
adjacentPos: new THREE.Vector3(adjacentX, adjacentY, adjacentZ),
distance: hit.distance,
}
} else {
this.result.hit = false
}
return this.result
}
}
交互管理器
// src/js/interaction/block-interaction-manager.js
export default class BlockInteractionManager {
constructor(terrainRenderer) {
this.terrainRenderer = terrainRenderer
this.raycaster = new BlockRaycaster()
this._handleMouseDown = this._handleMouseDown.bind(this)
emitter.on('input:mouse_down', this._handleMouseDown)
}
_handleMouseDown({ button }) {
const result = this.raycaster.result
if (!result.hit) return
if (button === 0) {
// 左键:挖掘方块
this.terrainRenderer.removeBlock(
result.blockPos.x,
result.blockPos.y,
result.blockPos.z
)
} else if (button === 2) {
// 右键:放置方块
this.terrainRenderer.placeBlock(
result.adjacentPos.x,
result.adjacentPos.y,
result.adjacentPos.z,
this.currentBlockType
)
}
}
update() {
// 每帧更新射线检测
const meshes = this.terrainRenderer.getMeshes()
this.raycaster.update(meshes)
}
destroy() {
emitter.off('input:mouse_down', this._handleMouseDown)
}
}
层过滤
使用 layers 过滤检测对象:
// 设置层
const LAYER_TERRAIN = 1
const LAYER_PLAYER = 2
const LAYER_UI = 3
// 设置对象层
terrainMesh.layers.set(LAYER_TERRAIN)
playerMesh.layers.set(LAYER_PLAYER)
// 配置 Raycaster 只检测特定层
this.raycaster.layers.set(LAYER_TERRAIN) // 只检测地形
// 或启用多个层
this.raycaster.layers.enable(LAYER_TERRAIN)
this.raycaster.layers.enable(LAYER_PLAYER)
性能优化
限制检测距离
this.raycaster.near = 0.1
this.raycaster.far = 50 // 限制最大距离
分组检测
// 只检测相关对象组,而非整个场景
const intersects = this.raycaster.intersectObjects(
this.interactableGroup.children,
false // 不递归检测子对象
)
降低检测频率
update() {
this._frameCount++
// 每 3 帧检测一次
if (this._frameCount % 3 !== 0) return
this.raycaster.setFromCamera(...)
// ...
}
使用 BVH(边界体积层次)
对于复杂几何体,考虑使用 three-mesh-bvh:
import { computeBoundsTree } from 'three-mesh-bvh'
// 为复杂几何体构建 BVH
mesh.geometry.computeBoundsTree()
// 射线检测会自动使用 BVH 加速
悬停检测
export default class HoverDetector {
constructor() {
this.hoveredObject = null
}
update() {
const ndc = this.iMouse.normalizedMouse
this.raycaster.setFromCamera(ndc, this.camera)
const intersects = this.raycaster.intersectObjects(this.targets)
const newHovered = intersects.length > 0 ? intersects[0].object : null
if (newHovered !== this.hoveredObject) {
if (this.hoveredObject) {
emitter.emit('game:hover-exit', { object: this.hoveredObject })
}
if (newHovered) {
emitter.emit('game:hover-enter', { object: newHovered })
}
this.hoveredObject = newHovered
}
}
}
Common Mistakes
❌ 手动计算 NDC
// BAD
const x = (event.clientX / window.innerWidth) * 2 - 1
const y = -(event.clientY / window.innerHeight) * 2 + 1
this.raycaster.setFromCamera(new THREE.Vector2(x, y), this.camera)
// GOOD
this.raycaster.setFromCamera(this.iMouse.normalizedMouse, this.camera)
❌ 检测整个场景
// BAD: 检测所有对象,性能差
const intersects = this.raycaster.intersectObjects(this.scene.children, true)
// GOOD: 只检测相关对象
const intersects = this.raycaster.intersectObjects(this.interactables, false)
❌ 忘记设置 far 距离
// BAD: 默认 far = Infinity
this.raycaster = new THREE.Raycaster()
// GOOD: 限制检测距离
this.raycaster = new THREE.Raycaster()
this.raycaster.far = 50
❌ 在事件处理中忘记检查条件
// BAD: 没有检查是否有效
_handleClick({ button }) {
const hit = this.intersects[0] // 可能为空!
this.doSomething(hit.object)
}
// GOOD: 检查条件
_handleClick({ button }) {
if (button !== 0) return
if (!this.result.hit) return
this.doSomething(this.result.blockPos)
}
Quick Reference
| 需求 | 做法 |
|---|---|
| 从鼠标发射射线 | raycaster.setFromCamera(iMouse.normalizedMouse, camera) |
| 从屏幕中心发射 | raycaster.setFromCamera(new Vector2(0, 0), camera) |
| 限制距离 | raycaster.far = 50 |
| 层过滤 | raycaster.layers.set(LAYER_ID) |
| 获取点击位置 | intersects[0].point |
| 获取面法线 | intersects[0].face.normal |
| 体素方块特有 | 说明 |
|---|---|
| 方块坐标 | floor(hit.point - normal * 0.5) |
| 相邻方块 | blockPos + round(normal) |
| 面法线 | 用于确定点击的是哪个面 |