Three.js from Zero to Senior · Part 9 — Raycasting & Interaction
Turn a scene into a UI: how a Raycaster picks objects under the cursor, normalized device coordinates, hover and click handling, performance tips for picking, and dragging objects — with a live hover-and-select demo.
A WebGL canvas is a single DOM element. {Một canvas WebGL là một phần tử DOM duy nhất.} The browser has no idea there are cubes inside it — there’s no cube.addEventListener('click'). {Trình duyệt không hề biết có khối lập phương bên trong — không có cube.addEventListener('click').} To know which object the user clicked, you cast a ray from the camera through the mouse and ask the scene what it hit. {Để biết người dùng click vật nào, bạn bắn một tia từ camera qua chuột và hỏi scene nó trúng gì.} That’s raycasting, and it’s how every 3D UI works. {Đó là raycasting, và là cách mọi UI 3D hoạt động.}
Hover the grid to highlight, click to select — the HUD reports the live hit, its distance, and the exact world point. {Hover lưới để làm nổi, click để chọn — HUD báo cú trúng trực tiếp, khoảng cách, và điểm thế giới chính xác.}
Open the full demo {Mở demo đầy đủ}: /tools/threejs-raycasting-demo/.
The picking pipeline {Quy trình picking}
Picking is always the same three steps: {Picking luôn là ba bước giống nhau:}
- Convert the mouse position from CSS pixels to normalized device coordinates (NDC): the range
[-1, 1]on both axes, withyflipped (screenygrows down; NDCygrows up). {Đổi vị trí chuột từ pixel CSS sang toạ độ thiết bị chuẩn hoá (NDC): khoảng[-1, 1]trên cả hai trục, vớiyđảo (màn hìnhytăng xuống; NDCytăng lên).} - Point the raycaster from the camera through that NDC point. {Hướng raycaster từ camera qua điểm NDC đó.}
- Intersect against a list of objects; the result is sorted nearest first. {Cắt với một danh sách vật thể; kết quả được sắp gần nhất trước.}
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
function toNDC(event) {
const rect = canvas.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; // note the minus
}
function pick() {
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(pickables, false); // false = don't recurse
return hits[0] ?? null; // [0] = closest object the ray hit
}
Use getBoundingClientRect(), not clientWidth, to map the mouse — it accounts for the canvas’s position and any CSS scaling. {Dùng getBoundingClientRect(), không dùng clientWidth, để ánh xạ chuột — nó tính cả vị trí canvas và mọi co giãn CSS.} The flipped y is the single most common picking bug. {y bị đảo là lỗi picking phổ biến nhất.}
What an intersection gives you {Một giao điểm cho bạn gì}
Each hit is rich — far more than just “which object”: {Mỗi cú trúng rất giàu thông tin — nhiều hơn nhiều so với chỉ “vật nào”:}
const hit = pick();
if (hit) {
hit.object; // the Mesh that was hit
hit.distance; // distance from camera (sorting key)
hit.point; // exact world-space XYZ where the ray landed
hit.face; // which triangle
hit.uv; // texture coordinate at the hit — great for "paint on a surface"
}
hit.point lets you place a marker exactly where the user clicked; hit.uv lets you paint onto a texture; hit.distance is why the array is sorted. {hit.point cho bạn đặt marker đúng chỗ click; hit.uv cho bạn vẽ lên texture; hit.distance là lý do mảng được sắp xếp.}
Hover vs. click — wire them separately {Hover và click — nối riêng}
Run the raycast on pointermove for hover and on click for selection. {Chạy raycast trên pointermove cho hover và trên click cho chọn.} A common pattern keeps a hovered reference and only reacts when it changes, so you don’t re-apply the same highlight every frame: {Một mẫu phổ biến giữ tham chiếu hovered và chỉ phản ứng khi nó đổi, để không áp lại cùng highlight mỗi frame:}
canvas.addEventListener('pointermove', (e) => {
toNDC(e);
const hit = pick();
const obj = hit?.object ?? null;
if (obj !== hovered) {
hovered = obj;
canvas.style.cursor = obj ? 'pointer' : 'grab'; // affordance!
}
});
canvas.addEventListener('click', (e) => {
toNDC(e);
const hit = pick();
if (hit) select(hit.object);
});
Changing the cursor on hover is a small thing that makes a 3D scene feel like a UI. {Đổi con trỏ khi hover là chuyện nhỏ khiến scene 3D cảm giác như một UI.} Prefer pointer events over mouse events so touch works too. {Ưu tiên pointer event hơn mouse event để cảm ứng cũng chạy.}
Performance — don’t raycast the whole world every pixel of movement {Hiệu năng — đừng raycast cả thế giới mỗi pixel di chuyển}
Raycasting walks geometry, so a few habits keep it cheap: {Raycast duyệt geometry, nên vài thói quen giữ nó rẻ:}
- Pass an explicit list of pickable objects to
intersectObjects(list)— don’t ray the entire scene including helpers, lights, and the skybox. {Truyền danh sách rõ ràng các vật có thể chọn vàointersectObjects(list)— đừng ray cả scene gồm helper, đèn, skybox.} - Throttle hover raycasts to once per frame (set a flag on
pointermove, do the actual raycast in the render loop). {Tiết lưu hover: raycast một lần mỗi frame (đặt cờ ởpointermove, raycast thật trong loop).} - For dense meshes, raycast against invisible low-poly proxy boxes instead of the detailed geometry. {Với mesh dày, raycast vào hộp proxy ít poly vô hình thay vì geometry chi tiết.}
- Raycasting an
InstancedMeshworks and returns aninstanceId— useful, but test it under load. {Raycast mộtInstancedMeshchạy được và trảinstanceId— hữu ích, nhưng nhớ test khi tải nặng.}
Dragging objects {Kéo vật thể}
To drag, intersect the ray with a math plane (not a mesh) and move the object to that point. {Để kéo, cắt tia với một mặt phẳng toán học (không phải mesh) và dời vật tới điểm đó.} For the common cases, the addon DragControls wraps this up: {Cho các trường hợp phổ biến, addon DragControls gói sẵn:}
import { DragControls } from 'three/addons/controls/DragControls.js';
const drag = new DragControls(pickables, camera, renderer.domElement);
drag.addEventListener('dragstart', () => controls.enabled = false); // pause OrbitControls
drag.addEventListener('dragend', () => controls.enabled = true);
Disabling OrbitControls during a drag is essential — otherwise the camera fights the drag and the object jitters. {Tắt OrbitControls khi kéo là bắt buộc — nếu không camera giành với cú kéo và vật giật.}
The master’s warnings {Lời cảnh báo của sư phụ}
- Picks are offset / wrong? Your NDC math is off — use
getBoundingClientRect()and remember theyflip. {Chọn bị lệch/sai? Toán NDC sai — dùnggetBoundingClientRect()và nhớ đảoy.} - Nothing gets hit? You passed the wrong list, the objects are invisible/
raycast-disabled, or the camera moved after the NDC was computed. {Không trúng gì? Bạn truyền sai danh sách, vật vô hình/tắtraycast, hoặc camera dịch sau khi tính NDC.} - Hover feels laggy? You’re raycasting the entire scene on every
pointermove— restrict the list and throttle to once per frame. {Hover lag? Bạn raycast cả scene mỗipointermove— giới hạn danh sách và tiết lưu một lần mỗi frame.} - Object jumps while dragging?
OrbitControlsis still enabled — disable it ondragstart. {Vật nhảy khi kéo?OrbitControlsvẫn bật — tắt nó ởdragstart.}
Practice, or it didn’t happen {Luyện tập, không thì coi như chưa học}
- Read the hit {Đọc cú trúng}: in the demo, click different cubes and watch how
distanceand the worldpointchange with camera angle. {trong demo, click các khối khác nhau và xemdistancecùngpointthế giới đổi theo góc camera.} - Restrict the list {Giới hạn danh sách}: in code, add a grid helper to the scene and confirm passing an explicit
pickableslist keeps it un-pickable. {thêm grid helper vào scene và xác nhận truyền danh sáchpickablesrõ ràng giữ nó không chọn được.} - Place a marker {Đặt marker}: use
hit.pointto drop a small sphere exactly where you click. {dùnghit.pointthả một quả cầu nhỏ đúng chỗ bạn click.}
What’s next {Phần tiếp theo}
You now have every core skill: scene, materials, lights, models, animation, post-processing, performance, and interaction. {Giờ bạn có mọi kỹ năng cốt lõi: scene, vật liệu, đèn, model, animation, hậu kỳ, hiệu năng, và tương tác.} Part 10 is the capstone: we tie it all together into a production-grade mini-project, and cover the senior concerns that turn a demo into a real product — asset loading screens, responsive design, debugging tools, integrating Three.js into a framework, and a deployment checklist. {Phần 10 là bài tổng kết: ta ghép tất cả thành một mini-project mức production, và bàn các mối quan tâm senior biến một demo thành sản phẩm thật — màn hình nạp asset, responsive, công cụ debug, tích hợp Three.js vào framework, và checklist triển khai.}