Raycaster (picking)
A Raycaster casts a ray through the scene and reports which objects it hits. Use for picking with mouse / touch, hit-tests in VR, line-of-sight checks, drag-and-drop.
Mouse picking, intersection, performance
EXAMPLE
import * as THREE from 'three';
// 1) Setup
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 1000);
const scene = new THREE.Scene();
// 2) Mouse picking
function onPointerMove(event) {
// Convert pixel coords to normalised device coords [-1, 1]
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
}
window.addEventListener('pointermove', onPointerMove);
function onPointerDown(event) {
onPointerMove(event);
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(scene.children, /* recursive */ true);
if (intersects.length > 0) {
const hit = intersects[0];
console.log('clicked:', hit.object.name);
console.log('point:', hit.point); // world position
console.log('distance:', hit.distance); // from camera
console.log('face:', hit.face); // hit triangle
console.log('uv:', hit.uv); // texture coord
hit.object.material.color.setHex(0xff0000);
}
}
window.addEventListener('pointerdown', onPointerDown);
// 3) Hover effects — highlight on mouseover
let hovered = null;
function tick() {
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(meshes, false);
if (hits.length > 0) {
if (hovered !== hits[0].object) {
if (hovered) hovered.material.emissive.setHex(0);
hovered = hits[0].object;
hovered.material.emissive.setHex(0x444444);
}
} else if (hovered) {
hovered.material.emissive.setHex(0);
hovered = null;
}
renderer.render(scene, camera);
requestAnimationFrame(tick);
}
// 4) Ray from arbitrary origin + direction (not from camera)
const origin = new THREE.Vector3(0, 5, 0);
const direction = new THREE.Vector3(0, -1, 0).normalize();
raycaster.set(origin, direction);
const hits = raycaster.intersectObjects(scene.children, true);
// Use for: line-of-sight (player can see enemy?), bullet trajectory, AI vision
// 5) Limit raycast distance
raycaster.near = 0;
raycaster.far = 100; // skip anything farther
// 6) Layers — raycast against a specific layer
raycaster.layers.set(1); // only objects on layer 1
mesh.layers.set(1); // put mesh on layer 1
// raycaster will skip meshes on other layers
// 7) Pick from a specific list (much faster than scene.children)
const pickables = [];
// During scene construction:
scene.traverse((obj) => {
if (obj.isMesh && obj.userData.pickable) pickables.push(obj);
});
const hits = raycaster.intersectObjects(pickables, false);
// 8) intersectObject (singular) — check one object
const hits = raycaster.intersectObject(specificMesh, true);
// 9) Drag and drop
let selected = null;
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // XZ plane at y=0
const point = new THREE.Vector3();
function onDown(e) {
setPointer(e);
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(pickables, false);
if (hits.length > 0) selected = hits[0].object;
}
function onMove(e) {
if (!selected) return;
setPointer(e);
raycaster.setFromCamera(pointer, camera);
raycaster.ray.intersectPlane(plane, point); // project ray onto plane
selected.position.copy(point);
}
function onUp() { selected = null; }
// 10) Touch support
function onTouch(event) {
if (event.touches.length === 0) return;
const t = event.touches[0];
pointer.x = (t.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(t.clientY / window.innerHeight) * 2 + 1;
}
window.addEventListener('touchstart', onTouch);
window.addEventListener('touchmove', onTouch);
// 11) Custom intersect handling — BufferGeometry hit testing
raycaster.params.Points.threshold = 0.1; // for points
raycaster.params.Line.threshold = 0.05; // for lines
// Points and lines need a threshold (a ray rarely hits them exactly)
// 12) Hit position — show indicator at hit point
function tick() {
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(scene.children, true);
if (hits.length > 0) {
indicator.position.copy(hits[0].point);
indicator.visible = true;
} else {
indicator.visible = false;
}
renderer.render(scene, camera);
requestAnimationFrame(tick);
}
// 13) Visualise the ray for debugging
const arrow = new THREE.ArrowHelper(
raycaster.ray.direction,
raycaster.ray.origin,
100, // length
0xff0000, // color
);
scene.add(arrow);
// 14) Picking via screen position vs camera frustum
// setFromCamera uses unprojection — accurate for perspective + orthographic
raycaster.setFromCamera(pointer, camera);
// 15) Performance tips
// • Limit candidates — pickables array instead of scene.children
// • Skip recursion when possible (intersectObjects(arr, false))
// • Use layers to filter
// • Limit raycaster.far
// • For thousands of objects: spatial partition (BVH, octree)
// BVH for large scenes — three-mesh-bvh package
// npm i three-mesh-bvh
import { computeBoundsTree, disposeBoundsTree, acceleratedRaycast } from 'three-mesh-bvh';
THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree;
THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree;
THREE.Mesh.prototype.raycast = acceleratedRaycast;
mesh.geometry.computeBoundsTree(); // pre-compute on geometry
// Now raycasts against this mesh use BVH — orders of magnitude faster on big meshes
// 16) Common patterns
// Click to add an object
function onClick(e) {
setPointer(e);
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObject(floor);
if (hits.length > 0) {
const cube = new THREE.Mesh(
new THREE.BoxGeometry(0.5, 0.5, 0.5),
new THREE.MeshStandardMaterial({ color: 0x0ea5e9 }),
);
cube.position.copy(hits[0].point);
cube.position.y += 0.25; // sit on the floor
scene.add(cube);
}
}
// Click to delete
function onClick(e) {
setPointer(e);
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(scene.children, true);
if (hits.length > 0 && hits[0].object.userData.deletable) {
scene.remove(hits[0].object);
hits[0].object.geometry.dispose();
hits[0].object.material.dispose();
}
}
// 17) Common bugs
// • Forgetting to convert mouse to NDC [-1, 1]
// • Y flipped (Y goes UP in NDC, DOWN in screen) → multiply by -1
// • Setting raycaster.layers without setting mesh.layers → no hits
// • Recursive vs non-recursive — Group children won't be tested if recursive=false
// • Hitting transparent meshes you didn't intend to — set renderOrder + set raycast to undefined to skip
// 18) Best practices
// ✅ Maintain a 'pickables' array; don't traverse the whole scene per frame
// ✅ Use layers for fast filtering
// ✅ BVH for large meshes / high-poly scenes
// ✅ Set far/near to limit ray range
// ✅ Handle pointermove (not mousemove) — works on touch + pen
// ✅ Apply NDC conversion correctly (X * 2 - 1, Y inverted)
Why it matters
Raycaster + a curated pickables array covers all interactive 3D needs — click to select, hover effects, drag-and-drop, line-of-sight. For huge scenes, switch to three-mesh-bvh; BVH lookup is orders of magnitude faster than naive triangle iteration.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
const ray = new THREE.Raycaster(); ray.setFromCamera(mouseNDC, camera); const hits = ray.intersectObjects(scene.children);Try it Yourself »
Exercise
Picking is done with…
const r = new THREE.
();
Nine letters.
Discussion
Loading…