Picking and Highlighting

Picking and Highlighting Objects

Picking is a raycast on pointermove for hover and on click for selection. Raising emissive, the light a material gives off itself, highlights a book without touching its texture:

bookshelf.js: enablePicking(), hover glow and click selectionJavaScript
export function enablePicking({ renderer, camera }, meshes, onClick = () => {}) {
  const canvas = renderer.domElement, raycaster = new THREE.Raycaster();
  const pick = (e) => aimRay(raycaster, e, camera).intersectObjects(meshes)[0] ?? null;
  const glow = (mesh, color) => [mesh.material].flat().forEach((m) => m.emissive.set(color));
  let hovered = null;
  canvas.addEventListener('pointermove', (event) => {
    const mesh = pick(event)?.object ?? null;
    if (mesh === hovered) return;
    if (hovered) glow(hovered, 0);
    if (mesh) glow(mesh, '#505050');
    hovered = mesh;
    canvas.style.cursor = mesh ? 'pointer' : '';
  });
  canvas.addEventListener('click', (event) => onClick(pick(event)));
}

A highlight changes the material, so every mesh sharing it lights up: give pickable meshes their own materials or use Bloom and Outline's OutlinePass. pointermove can fire faster than frames, so cast at most once a frame.

Hover a book to raise its emissive glow, click it to select itHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f5f1e8; font: 13px system-ui, sans-serif; color: #263238; }
  canvas { display: block; max-width: 100%; }
  p { margin: 4px 8px; }
</style>
<script type="importmap">
{ "imports": {
  "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
  "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<p id="out">Hover and click the books.</p>
<script type="module">
import * as THREE from 'three';

const BOOKS = [['The Quiet Harbor', '#1f5f8b', 4.6], ['Patterns of the Deep Web', '#5b3f99', 4.3], ['Salt and Saffron', '#e09a10', 4.8],
  ['Small Steps to Big Summits', '#3f7d3a', 4.1], ["The Clockmaker's Paradox", '#b5452f', 4.5], ['Gardens in Glass', '#2a9d8f', 4.4]];
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(600, 260);
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const camera = new THREE.PerspectiveCamera(35, 600 / 260, 0.1, 50);
camera.position.set(0, 0.3, 3.3);
camera.lookAt(0, -0.05, 0);
scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 1.2));
const sun = new THREE.DirectionalLight('#ffffff', 2.5);
sun.position.set(2, 3, 4);
scene.add(sun);
const plank = new THREE.Mesh(new THREE.BoxGeometry(3.2, 0.06, 0.4), new THREE.MeshStandardMaterial({ color: '#8d6e63' }));
plank.position.y = -0.52;
scene.add(plank);
// Each pickable book gets its own material, so a glow lights only that book
const books = BOOKS.map(([title, color, rating], i) => {
  const book = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.98, 0.12), new THREE.MeshStandardMaterial({ color }));
  book.name = title;
  book.userData = { rating };
  book.position.x = (i - 2.5) * 0.5;
  scene.add(book);
  return book;
});

function enablePicking(meshes, onClick) {
  const canvas = renderer.domElement, raycaster = new THREE.Raycaster(), ndc = new THREE.Vector2();
  const pick = (e) => {
    const r = canvas.getBoundingClientRect();
    ndc.set((e.clientX - r.left) / r.width * 2 - 1, 1 - (e.clientY - r.top) / r.height * 2);
    raycaster.setFromCamera(ndc, camera);
    return raycaster.intersectObjects(meshes)[0] ?? null;
  };
  const glow = (mesh, color) => mesh.material.emissive.set(color);   // light the material gives off
  let hovered = null;
  canvas.addEventListener('pointermove', (event) => {
    const mesh = pick(event)?.object ?? null;
    if (mesh === hovered) return;
    if (hovered) glow(hovered, 0);
    if (mesh) glow(mesh, '#505050');
    hovered = mesh;
    canvas.style.cursor = mesh ? 'pointer' : '';
  });
  canvas.addEventListener('click', (event) => onClick(pick(event)));
}

let selected = null;
enablePicking(books, (hit) => {
  if (selected) selected.position.y = 0;
  selected = hit?.object ?? null;
  if (selected) selected.position.y = 0.12;            // lift the chosen book
  document.getElementById('out').textContent = selected
    ? `Selected: ${selected.name} (rated ${selected.userData.rating})` : 'Nothing selected';
});
renderer.setAnimationLoop(() => renderer.render(scene, camera));

// Simulate a hover over the third book and a click on the fifth
renderer.render(scene, camera);
const r = renderer.domElement.getBoundingClientRect();
const send = (type, x, y) => renderer.domElement.dispatchEvent(new MouseEvent(type, { clientX: r.left + x, clientY: r.top + y, bubbles: true }));
send('click', 405, 130);
renderer.domElement.dispatchEvent(new PointerEvent('pointermove', { clientX: r.left + 255, clientY: r.top + 130 }));
</script>