
Threejs Core Raycaster
- 19 installs
- 11 repo stars
- Updated July 8, 2026
- openaec-foundation/three.js-claude-skill-package
Helps with ai & agent building tasks.
About
threejs-core-raycaster is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- threejs-core-raycaster
- AI & Agent Building
- AI-coding skill
Threejs Core Raycaster by the numbers
- 19 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #10,587 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/three.js-claude-skill-package --skill threejs-core-raycasterAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 19 |
|---|---|
| repo stars | ★ 11 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/three.js-claude-skill-package ↗ |
What it does
Helps with ai & agent building tasks.
Files
threejs-core-raycaster
Quick Reference
Raycaster Constructor
import * as THREE from 'three';
const raycaster = new THREE.Raycaster(
origin, // Vector3 — default: (0, 0, 0)
direction, // Vector3 — MUST be normalized, default: (0, 0, -1)
near, // number — minimum distance, default: 0
far // number — maximum distance, default: Infinity
);Properties
| Property | Type | Default | Description |
|---|---|---|---|
ray | Ray | -- | Underlying Ray object (origin + direction) |
near | number | 0 | Minimum intersection distance |
far | number | Infinity | Maximum intersection distance |
camera | Camera | -- | Required for raycasting against Sprite objects |
layers | Layers | layer 0 | Layer mask -- only objects on matching layers are tested |
params | object | see below | Per-type intersection thresholds |
Params Defaults
raycaster.params = {
Mesh: {},
Line: { threshold: 1 }, // world units distance for line hits
LOD: {},
Points: { threshold: 1 }, // world units distance for point hits
Sprite: {}
};Critical Warnings
NEVER raycast on every mousemove event without throttling -- this causes severe frame drops on complex scenes. ALWAYS throttle to the render loop via requestAnimationFrame or limit to 30-60 checks per second.
NEVER pass recursive: true on large scene graphs when you have a flat array of target objects -- ALWAYS maintain a flat array of selectable objects and pass recursive: false to avoid unnecessary tree traversal.
NEVER forget to normalize the direction vector when using raycaster.set() -- an unnormalized direction produces incorrect distance values in all intersection results.
NEVER allocate a new results array every frame -- ALWAYS reuse an array via the optionalTarget parameter and clear it with array.length = 0 after processing.
NEVER raycast against the entire scene.children when only a subset of objects is interactive -- ALWAYS maintain a separate array of selectable objects or use layers for filtering.
NEVER omit raycaster.camera when raycasting against Sprite objects -- the raycaster requires the camera reference to compute sprite screen-space bounds.
---
Core Methods
set(origin, direction)
Manually sets the ray origin and direction. Direction MUST be normalized.
const origin = new THREE.Vector3(0, 1, 0);
const direction = new THREE.Vector3(0, -1, 0); // already normalized
raycaster.set(origin, direction);setFromCamera(coords, camera)
Sets the ray from normalized device coordinates (NDC) and a camera. This is the standard mouse-picking method.
coords:Vector2with x and y in range [-1, +1]camera:PerspectiveCameraproduces a ray from the camera through the point;OrthographicCameraproduces a parallel ray
const mouse = new THREE.Vector2();
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);intersectObject(object, recursive?, optionalTarget?)
Tests a single object (and optionally its descendants) for intersections.
recursive(default:true): whentrue, tests all descendantsoptionalTarget: reusable array to avoid allocation- Returns:
Intersection[]ALWAYS sorted by distance (nearest first)
intersectObjects(objects, recursive?, optionalTarget?)
Tests an array of objects for intersections.
recursive(default:true): whentrue, tests descendants of each objectoptionalTarget: reusable array to avoid allocation- Returns:
Intersection[]ALWAYS sorted by distance (nearest first)
---
Intersection Object Format
Every intersection in the returned array has this structure:
interface Intersection {
distance: number; // distance from ray origin to hit point
point: Vector3; // hit point in world space
face: Face | null; // hit face ({a, b, c} vertex indices + normal)
faceIndex: number; // index of hit face in the geometry
object: Object3D; // the intersected object reference
uv?: Vector2; // UV coordinates at intersection point
uv1?: Vector2; // second UV set (if available)
normal?: Vector3; // interpolated surface normal at hit point
instanceId?: number; // instance index (only for InstancedMesh)
}ALWAYS check intersects.length > 0 before accessing intersects[0]. The array is empty when no objects are hit.
---
NDC Conversion (Normalized Device Coordinates)
Converting DOM mouse coordinates to NDC is required for setFromCamera. The NDC space ranges from -1 (left/bottom) to +1 (right/top).
// For fullscreen canvas (canvas fills entire window)
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// For non-fullscreen canvas (canvas has offset within page)
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;ALWAYS use getBoundingClientRect() when the canvas does NOT fill the entire viewport. Using window.innerWidth/Height on a partial canvas produces incorrect NDC values and misaligned picking.
---
InstancedMesh Picking
When raycasting against InstancedMesh, the intersection result includes instanceId identifying which instance was hit:
import * as THREE from 'three';
const intersects = raycaster.intersectObject(instancedMesh);
if (intersects.length > 0) {
const instanceId = intersects[0].instanceId;
// Retrieve the instance's transform matrix
const matrix = new THREE.Matrix4();
instancedMesh.getMatrixAt(instanceId, matrix);
// Example: change the color of the hit instance
const color = new THREE.Color();
instancedMesh.getColorAt(instanceId, color);
instancedMesh.setColorAt(instanceId, new THREE.Color(0xff0000));
instancedMesh.instanceColor.needsUpdate = true;
}ALWAYS check that instanceId !== undefined before using it -- non-InstancedMesh objects do not have this property.
---
Layer Filtering
Raycaster respects the Layers system. Only objects whose layers overlap with raycaster.layers are tested.
// Assign objects to layers
selectableMesh.layers.set(1); // exclusively on layer 1
decorationMesh.layers.set(2); // exclusively on layer 2
// Configure raycaster to only test layer 1
raycaster.layers.set(1);
// Now intersectObjects skips all objects NOT on layer 1
// Enable multiple layers
raycaster.layers.enable(1);
raycaster.layers.enable(2);
// Now tests objects on layer 1 OR layer 2
// Reset to default (layer 0 only)
raycaster.layers.set(0);Use layers to create picking groups: interactive objects on layer 1, non-interactive decoration on layer 2, helpers/gizmos on layer 3.
---
Performance Guidelines
1. Throttle mousemove raycasting -- NEVER raycast on every mousemove event. Use a flag checked in the render loop:
let needsRaycast = false;
canvas.addEventListener('pointermove', (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
needsRaycast = true;
});
function animate() {
if (needsRaycast) {
raycaster.setFromCamera(mouse, camera);
// perform intersection tests
needsRaycast = false;
}
renderer.render(scene, camera);
requestAnimationFrame(animate);
}2. Use layers -- assign interactive objects to a specific layer and set raycaster.layers accordingly to skip non-interactive geometry entirely.
3. Maintain a flat selectable array -- instead of raycasting against scene with recursive: true, keep a separate selectableObjects[] array and pass recursive: false.
4. Reuse the intersections array -- pass optionalTarget to avoid garbage collection:
const intersections = [];
raycaster.intersectObjects(selectableObjects, false, intersections);
// process results...
intersections.length = 0;5. Bounding sphere pre-check -- Three.js automatically tests bounding spheres before triangle intersection. Ensure geometry.computeBoundingSphere() has been called (it is called automatically on first render, but manual call is needed if raycasting before first render).
6. Limit `near`/`far` -- narrow the ray range when you know the expected intersection distance to skip distant objects early.
---
Reference Links
- references/methods.md -- Complete Raycaster API signatures
- references/examples.md -- Working code examples for picking, hover, and InstancedMesh
- references/anti-patterns.md -- What NOT to do with raycasting
Official Sources
- https://threejs.org/docs/#api/en/core/Raycaster
- https://threejs.org/docs/#api/en/core/Layers
Anti-Patterns (Three.js Raycaster)
1. Raycasting on Every mousemove
// WRONG: Raycasts on every single mousemove event (can fire 100+ times/second)
canvas.addEventListener('mousemove', (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(objects, true); // runs hundreds of times/sec
// ...
});
// CORRECT: Update coordinates in the event, raycast in the render loop
let needsRaycast = false;
canvas.addEventListener('pointermove', (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
needsRaycast = true;
});
function animate() {
if (needsRaycast) {
raycaster.setFromCamera(mouse, camera);
raycaster.intersectObjects(objects, false, intersections);
// process...
intersections.length = 0;
needsRaycast = false;
}
renderer.render(scene, camera);
requestAnimationFrame(animate);
}WHY: mousemove fires at the browser's input rate (often 100-240Hz). Raycasting is expensive on complex scenes. Deferring to requestAnimationFrame limits it to the display refresh rate.
---
2. Raycasting Against the Entire Scene with recursive: true
// WRONG: Traverses every object in the scene graph including lights, helpers, etc.
const hits = raycaster.intersectObjects(scene.children, true);
// CORRECT: Maintain a flat array of only the objects you want to pick
const selectableObjects = [meshA, meshB, meshC];
const hits = raycaster.intersectObjects(selectableObjects, false);WHY: recursive: true on scene.children traverses lights, cameras, helpers, groups, and every nested child. This wastes CPU on objects that cannot be meaningfully selected. A flat array with recursive: false tests only the exact objects you need.
---
3. Allocating a New Array Every Frame
// WRONG: Creates a new array every frame, generating garbage for the GC
function animate() {
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(objects); // new array each call
// ...
requestAnimationFrame(animate);
}
// CORRECT: Reuse a single array via optionalTarget
const intersections = [];
function animate() {
raycaster.setFromCamera(mouse, camera);
raycaster.intersectObjects(objects, false, intersections);
// process intersections...
intersections.length = 0; // clear for next frame
requestAnimationFrame(animate);
}WHY: Every call without optionalTarget allocates a new array. In animation loops (60fps), this creates 60 arrays per second that the garbage collector must clean up, causing micro-stutters.
---
4. Using window.innerWidth on Non-Fullscreen Canvas
// WRONG: Assumes canvas fills the entire browser window
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// CORRECT: Use the canvas bounding rect for accurate NDC conversion
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;WHY: When the canvas does not fill the entire viewport (e.g., sidebar layouts, embedded viewers), window.innerWidth/Height produces incorrect NDC values. The ray will miss the intended target by the offset of the canvas within the page.
---
5. Forgetting to Normalize the Direction Vector
// WRONG: Direction is not normalized — distances in results will be wrong
const direction = new THREE.Vector3(1, -2, 0.5); // length != 1
raycaster.set(origin, direction);
// CORRECT: ALWAYS normalize the direction
const direction = new THREE.Vector3(1, -2, 0.5).normalize();
raycaster.set(origin, direction);WHY: The distance property in intersection results assumes a normalized direction vector. An unnormalized direction produces scaled distances that do not correspond to world units, breaking sorting and distance-based logic.
---
6. Missing instanceId Check for InstancedMesh
// WRONG: Assumes instanceId is always present
const hits = raycaster.intersectObjects(mixedObjects);
if (hits.length > 0) {
const id = hits[0].instanceId; // undefined if hit object is a regular Mesh!
instancedMesh.setColorAt(id, color); // crash or NaN behavior
}
// CORRECT: Check instanceId before using it
const hits = raycaster.intersectObjects(mixedObjects, false, intersections);
if (intersections.length > 0) {
const hit = intersections[0];
if (hit.instanceId !== undefined) {
// InstancedMesh hit
instancedMesh.setColorAt(hit.instanceId, color);
instancedMesh.instanceColor.needsUpdate = true;
} else {
// Regular mesh hit
hit.object.material.color.copy(color);
}
}
intersections.length = 0;WHY: instanceId is ONLY present on intersections with InstancedMesh. Accessing it on a regular Mesh intersection returns undefined, which causes silent failures or NaN errors when passed to setColorAt.
---
7. Not Setting raycaster.camera for Sprite Picking
// WRONG: Raycasting against Sprites without setting raycaster.camera
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(sprites); // incorrect results
// CORRECT: Set raycaster.camera before intersecting Sprites
const raycaster = new THREE.Raycaster();
raycaster.camera = camera; // required for Sprite screen-space bounds
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(sprites);WHY: Sprites are always camera-facing billboards. The raycaster needs the camera reference to compute the sprite's screen-space bounding box. Without it, sprite intersection tests produce incorrect or no results.
---
8. Not Clearing optionalTarget Array
// WRONG: Results accumulate across frames because optionalTarget is never cleared
const intersections = [];
function animate() {
raycaster.setFromCamera(mouse, camera);
raycaster.intersectObjects(objects, false, intersections);
// intersections grows every frame! Contains stale results
if (intersections.length > 0) {
handleHit(intersections[0]); // may be a stale result from a previous frame
}
requestAnimationFrame(animate);
}
// CORRECT: Clear the array after processing
const intersections = [];
function animate() {
raycaster.setFromCamera(mouse, camera);
raycaster.intersectObjects(objects, false, intersections);
if (intersections.length > 0) {
handleHit(intersections[0]);
}
intersections.length = 0; // ALWAYS clear after processing
requestAnimationFrame(animate);
}WHY: The optionalTarget array is NOT cleared automatically by intersectObjects — results are appended. Without clearing, the array grows unbounded and contains stale intersection data from previous frames.
---
9. Using offsetX/offsetY Instead of clientX/clientY
// WRONG: offsetX/offsetY is relative to the event target, but breaks with CSS transforms
mouse.x = (event.offsetX / canvas.width) * 2 - 1;
mouse.y = -(event.offsetY / canvas.height) * 2 + 1;
// CORRECT: Use clientX/clientY with getBoundingClientRect
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;WHY: offsetX/offsetY are relative to the event target element, but they do not account for CSS transforms, scaling, or devicePixelRatio differences between the canvas element size and its CSS layout size. clientX/clientY with getBoundingClientRect() is the reliable approach.
---
10. Forgetting needsUpdate After Changing InstancedMesh Colors
// WRONG: Color change is not visible because GPU buffer is not updated
instancedMesh.setColorAt(id, new THREE.Color(0xff0000));
// missing: instancedMesh.instanceColor.needsUpdate = true;
// CORRECT: ALWAYS set needsUpdate after modifying instance attributes
instancedMesh.setColorAt(id, new THREE.Color(0xff0000));
instancedMesh.instanceColor.needsUpdate = true;WHY: setColorAt modifies the CPU-side buffer. The GPU buffer is not updated until needsUpdate is set to true. Without it, the color change is invisible until another operation triggers a buffer upload.
Working Code Examples (Three.js Raycaster)
Example 1: Mouse Click Picking
Select an object by clicking on it. Uses a flat array of selectable objects and a reusable intersections array.
import * as THREE from 'three';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 5, 10);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Create selectable objects
const selectableObjects = [];
const geometry = new THREE.BoxGeometry(1, 1, 1);
for (let i = 0; i < 10; i++) {
const material = new THREE.MeshStandardMaterial({ color: 0x4488ff });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set((i - 5) * 2, 0, 0);
scene.add(mesh);
selectableObjects.push(mesh);
}
// Add light
scene.add(new THREE.DirectionalLight(0xffffff, 1));
scene.add(new THREE.AmbientLight(0x404040));
// Raycasting setup
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const intersections = [];
let selectedObject = null;
renderer.domElement.addEventListener('click', (event) => {
// Convert mouse to NDC
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
raycaster.intersectObjects(selectableObjects, false, intersections);
// Deselect previous
if (selectedObject) {
selectedObject.material.color.set(0x4488ff);
selectedObject = null;
}
// Select new
if (intersections.length > 0) {
selectedObject = intersections[0].object;
selectedObject.material.color.set(0xff4444);
}
intersections.length = 0; // clear for reuse
});
function animate() {
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate();---
Example 2: Hover Detection with Pointer Move
Highlight objects on mouse hover. Uses a flag to throttle raycasting to the render loop.
import * as THREE from 'three';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 5, 10);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Create hoverable objects
const hoverableObjects = [];
const geometry = new THREE.SphereGeometry(0.5, 32, 16);
for (let i = 0; i < 8; i++) {
const material = new THREE.MeshStandardMaterial({ color: 0x22cc88 });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(Math.cos(i * 0.8) * 4, 0, Math.sin(i * 0.8) * 4);
scene.add(mesh);
hoverableObjects.push(mesh);
}
scene.add(new THREE.DirectionalLight(0xffffff, 1));
scene.add(new THREE.AmbientLight(0x404040));
// Raycasting setup
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const intersections = [];
let hoveredObject = null;
let needsRaycast = false;
renderer.domElement.addEventListener('pointermove', (event) => {
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
needsRaycast = true;
});
function animate() {
if (needsRaycast) {
raycaster.setFromCamera(mouse, camera);
raycaster.intersectObjects(hoverableObjects, false, intersections);
// Unhover previous
if (hoveredObject) {
hoveredObject.material.color.set(0x22cc88);
hoveredObject.material.emissive.set(0x000000);
hoveredObject = null;
}
// Hover new
if (intersections.length > 0) {
hoveredObject = intersections[0].object;
hoveredObject.material.color.set(0xffcc00);
hoveredObject.material.emissive.set(0x333300);
renderer.domElement.style.cursor = 'pointer';
} else {
renderer.domElement.style.cursor = 'default';
}
intersections.length = 0;
needsRaycast = false;
}
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate();---
Example 3: InstancedMesh Picking
Pick individual instances from an InstancedMesh using instanceId.
import * as THREE from 'three';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 10, 15);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Create InstancedMesh with 100 instances
const count = 100;
const geometry = new THREE.BoxGeometry(0.8, 0.8, 0.8);
const material = new THREE.MeshStandardMaterial();
const instancedMesh = new THREE.InstancedMesh(geometry, material, count);
const dummy = new THREE.Object3D();
const defaultColor = new THREE.Color(0x4488ff);
for (let i = 0; i < count; i++) {
dummy.position.set(
(i % 10) * 1.5 - 7.5,
0,
Math.floor(i / 10) * 1.5 - 7.5
);
dummy.updateMatrix();
instancedMesh.setMatrixAt(i, dummy.matrix);
instancedMesh.setColorAt(i, defaultColor);
}
scene.add(instancedMesh);
scene.add(new THREE.DirectionalLight(0xffffff, 1));
scene.add(new THREE.AmbientLight(0x404040));
// Raycasting setup
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const intersections = [];
const selectedColor = new THREE.Color(0xff4444);
let lastSelectedId = -1;
renderer.domElement.addEventListener('click', (event) => {
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
raycaster.intersectObject(instancedMesh, false, intersections);
// Deselect previous instance
if (lastSelectedId >= 0) {
instancedMesh.setColorAt(lastSelectedId, defaultColor);
}
if (intersections.length > 0 && intersections[0].instanceId !== undefined) {
const id = intersections[0].instanceId;
instancedMesh.setColorAt(id, selectedColor);
lastSelectedId = id;
// Retrieve instance transform
const matrix = new THREE.Matrix4();
instancedMesh.getMatrixAt(id, matrix);
const position = new THREE.Vector3();
position.setFromMatrixPosition(matrix);
console.log(`Selected instance ${id} at position:`, position);
} else {
lastSelectedId = -1;
}
instancedMesh.instanceColor.needsUpdate = true;
intersections.length = 0;
});
function animate() {
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate();---
Example 4: Layer-Based Selective Picking
Use layers to separate interactive objects from non-interactive decoration.
import * as THREE from 'three';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 5, 10);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Layer assignments
const LAYER_DEFAULT = 0;
const LAYER_INTERACTIVE = 1;
const LAYER_DECORATION = 2;
// Camera must see all layers
camera.layers.enable(LAYER_INTERACTIVE);
camera.layers.enable(LAYER_DECORATION);
// Interactive objects (layer 1)
const interactiveGroup = [];
for (let i = 0; i < 5; i++) {
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: 0x4488ff })
);
mesh.position.set(i * 2 - 4, 0, 0);
mesh.layers.set(LAYER_INTERACTIVE);
scene.add(mesh);
interactiveGroup.push(mesh);
}
// Decoration objects (layer 2) — NOT pickable
for (let i = 0; i < 20; i++) {
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(0.2, 8, 8),
new THREE.MeshStandardMaterial({ color: 0x888888 })
);
mesh.position.set(
(Math.random() - 0.5) * 12,
(Math.random() - 0.5) * 6,
(Math.random() - 0.5) * 6
);
mesh.layers.set(LAYER_DECORATION);
scene.add(mesh);
}
scene.add(new THREE.DirectionalLight(0xffffff, 1));
scene.add(new THREE.AmbientLight(0x404040));
// Raycaster — ONLY tests layer 1 (interactive)
const raycaster = new THREE.Raycaster();
raycaster.layers.set(LAYER_INTERACTIVE);
const mouse = new THREE.Vector2();
const intersections = [];
renderer.domElement.addEventListener('click', (event) => {
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
// Can safely test scene.children — decoration is filtered by layers
raycaster.intersectObjects(scene.children, false, intersections);
if (intersections.length > 0) {
console.log('Hit interactive object:', intersections[0].object);
}
intersections.length = 0;
});
function animate() {
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate();---
Example 5: Downward Raycast for Ground Snapping
Cast a ray downward from an object to snap it to terrain height.
import * as THREE from 'three';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 10, 15);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Create terrain (uneven plane)
const terrainGeometry = new THREE.PlaneGeometry(20, 20, 32, 32);
terrainGeometry.rotateX(-Math.PI / 2);
const vertices = terrainGeometry.attributes.position;
for (let i = 0; i < vertices.count; i++) {
const x = vertices.getX(i);
const z = vertices.getZ(i);
vertices.setY(i, Math.sin(x * 0.5) * Math.cos(z * 0.5) * 2);
}
terrainGeometry.computeVertexNormals();
const terrain = new THREE.Mesh(
terrainGeometry,
new THREE.MeshStandardMaterial({ color: 0x44aa44 })
);
scene.add(terrain);
// Object to snap to ground
const character = new THREE.Mesh(
new THREE.CapsuleGeometry(0.3, 1, 8, 16),
new THREE.MeshStandardMaterial({ color: 0xff8800 })
);
scene.add(character);
scene.add(new THREE.DirectionalLight(0xffffff, 1));
scene.add(new THREE.AmbientLight(0x404040));
// Ground-snapping raycaster
const downRay = new THREE.Raycaster();
const downDirection = new THREE.Vector3(0, -1, 0);
const rayOrigin = new THREE.Vector3();
const intersections = [];
function snapToGround(object) {
// Cast ray from above the object downward
rayOrigin.copy(object.position);
rayOrigin.y = 50; // start high above
downRay.set(rayOrigin, downDirection);
downRay.intersectObject(terrain, false, intersections);
if (intersections.length > 0) {
object.position.y = intersections[0].point.y + 0.8; // offset for capsule height
}
intersections.length = 0;
}
// Animate character moving along X axis, snapping to terrain
let time = 0;
function animate() {
time += 0.01;
character.position.x = Math.sin(time) * 8;
character.position.z = Math.cos(time * 0.7) * 8;
snapToGround(character);
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate();API Signatures Reference (Three.js Raycaster)
Raycaster
Constructor
new Raycaster(
origin?: Vector3, // Ray origin — default: (0, 0, 0)
direction?: Vector3, // Ray direction — MUST be normalized — default: (0, 0, -1)
near?: number, // Minimum intersection distance — default: 0
far?: number // Maximum intersection distance — default: Infinity
): RaycasterProperties
raycaster.ray: Ray // The underlying Ray (has .origin and .direction)
raycaster.near: number // Minimum distance — default: 0
raycaster.far: number // Maximum distance — default: Infinity
raycaster.camera: Camera // REQUIRED for Sprite raycasting — set manually
raycaster.layers: Layers // Layer mask for filtering — default: layer 0 enabled
raycaster.params: { // Per-type intersection thresholds
Mesh: {},
Line: { threshold: number }, // default: 1 (world units)
LOD: {},
Points: { threshold: number },// default: 1 (world units)
Sprite: {}
}---
set(origin: Vector3, direction: Vector3): void
Manually sets the ray origin and direction.
origin: starting point of the raydirection: MUST be normalized (unit length). Unnormalized direction produces wrong distance values.
import * as THREE from 'three';
const raycaster = new THREE.Raycaster();
raycaster.set(
new THREE.Vector3(0, 10, 0),
new THREE.Vector3(0, -1, 0) // pointing down, already normalized
);---
setFromCamera(coords: Vector2, camera: Camera): void
Configures the ray based on normalized device coordinates and a camera.
coords:Vector2with x in [-1, +1] (left to right) and y in [-1, +1] (bottom to top)camera:PerspectiveCameracreates a ray from camera origin through the NDC point;OrthographicCameracreates a parallel ray
import * as THREE from 'three';
const mouse = new THREE.Vector2();
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);For non-fullscreen canvases:
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;---
intersectObject(object: Object3D, recursive?: boolean, optionalTarget?: Intersection[]): Intersection[]
Tests a single Object3D (and optionally its descendants) for ray intersections.
object: the Object3D to testrecursive(default:true): whentrue, also tests all descendants in the scene graphoptionalTarget: pass a reusable array to avoid allocation — the array is NOT cleared automatically, results are appended- Returns:
Intersection[]sorted by distance (nearest first)
import * as THREE from 'three';
const raycaster = new THREE.Raycaster();
const intersections = [];
raycaster.setFromCamera(mouse, camera);
// Test a single mesh and its children
const hits = raycaster.intersectObject(group, true, intersections);
// hits === intersections (same reference)---
intersectObjects(objects: Object3D[], recursive?: boolean, optionalTarget?: Intersection[]): Intersection[]
Tests an array of Object3D instances for ray intersections.
objects: array of Object3D instances to testrecursive(default:true): whentrue, tests descendants of each objectoptionalTarget: pass a reusable array to avoid allocation — results are appended, NOT cleared- Returns:
Intersection[]sorted by distance (nearest first)
import * as THREE from 'three';
const raycaster = new THREE.Raycaster();
const intersections = [];
const selectableObjects = [meshA, meshB, meshC];
raycaster.setFromCamera(mouse, camera);
raycaster.intersectObjects(selectableObjects, false, intersections);
if (intersections.length > 0) {
console.log('Nearest hit:', intersections[0].object.name);
}
intersections.length = 0; // clear for next frame---
Intersection Object
Each element in the returned intersections array:
interface Intersection {
distance: number; // Distance from ray origin to hit point (world units)
point: Vector3; // Hit point in world space
face: Face | null; // Hit face: { a: number, b: number, c: number, normal: Vector3 }
faceIndex: number; // Index of the hit face in geometry.index
object: Object3D; // Reference to the intersected Object3D
uv?: Vector2; // UV coordinates at intersection (requires UV attribute)
uv1?: Vector2; // Second UV set at intersection (if geometry has uv1)
normal?: Vector3; // Interpolated face normal at intersection point
instanceId?: number; // Instance index — ONLY present for InstancedMesh hits
}Face Object
interface Face {
a: number; // vertex index A
b: number; // vertex index B
c: number; // vertex index C
normal: Vector3; // face normal (NOT interpolated)
}---
Layers (used for filtering)
The raycaster.layers property is a Layers instance controlling which objects the raycaster tests.
layers.set(channel: number): void // Enable ONLY this channel (0-31), disable all others
layers.enable(channel: number): void // Enable this channel (additive)
layers.enableAll(): void // Enable all 32 channels
layers.toggle(channel: number): void // Toggle a channel on/off
layers.disable(channel: number): void // Disable a specific channel
layers.disableAll(): void // Disable all channels
layers.isEnabled(channel: number): boolean // Check if a channel is enabled
layers.test(other: Layers): boolean // Bitwise AND test — true if any channel overlapsThe raycaster ONLY tests objects where raycaster.layers.test(object.layers) returns true. By default, both raycaster and objects are on layer 0, so everything is tested.
---
Raycaster params Detail
The params object controls intersection sensitivity for non-mesh types:
import * as THREE from 'three';
const raycaster = new THREE.Raycaster();
// Increase line picking sensitivity (default: 1 world unit)
raycaster.params.Line.threshold = 3;
// Increase point cloud picking sensitivity (default: 1 world unit)
raycaster.params.Points.threshold = 0.5;Line.threshold: maximum perpendicular distance from the ray to count as a hit on a Line/LineSegments geometryPoints.threshold: maximum distance from the ray to count as a hit on a Points geometry- Mesh objects use exact triangle intersection — no threshold needed
- Sprite objects use screen-space bounds —
raycaster.cameraMUST be set