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.
Một canvas WebGL là một phần tử DOM duy nhất. 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'). Để 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ì. Đó là raycasting, và là cách mọi UI 3D hoạt động.
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.
Quy trình picking
Picking luôn là ba bước giống nhau:
- Đổ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). - Hướng raycaster từ camera qua điểm NDC đó.
- 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
}
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. y bị đảo là lỗi picking phổ biến nhất.
Một giao điểm cho bạn gì
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 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 và click — nối riêng
Chạy raycast trên pointermove cho hover và trên click cho chọn. 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:
let hoverDirty = false;
canvas.addEventListener('pointermove', (e) => {
toNDC(e); // event chỉ cập nhật input mới nhất
hoverDirty = true;
});
function updateHoverOncePerFrame() {
if (!hoverDirty) return;
hoverDirty = false;
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);
});
Gọi updateHoverOncePerFrame() trong render loop. Pointer event có thể đến nhanh hơn refresh rate; coalescing như vậy giữ input mới nhất nhưng không trả phí raycast nhiều lần cho cùng một frame.
Đổi con trỏ khi hover là chuyện nhỏ khiến scene 3D cảm giác như một UI. Ưu tiên pointer event hơn mouse event để cảm ứng cũng chạy.
Hiệu năng — đừng raycast cả thế giới mỗi pixel di chuyển
Raycast duyệt geometry, nên vài thói quen giữ nó rẻ:
- Truyền danh sách rõ ràng các vật có thể chọn vào
intersectObjects(list)— đừng ray cả scene gồm helper, đèn, skybox. - Tiết lưu hover: raycast một lần mỗi frame (đặt cờ ở
pointermove, raycast thật trong loop). - Với mesh dày, raycast vào hộp proxy ít poly vô hình thay vì geometry chi tiết.
- Raycast một
InstancedMeshchạy được và trảinstanceId— hữu ích, nhưng nhớ test khi tải nặng.
Kéo vật thể
Để 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 đó. 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);
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.
Lời cảnh báo của sư phụ
- Chọn bị lệch/sai? Toán NDC sai — dùng
getBoundingClientRect()và nhớ đảoy. - Không trúng gì? Bạn truyền sai danh sách, vật vô hình/tắt
raycast, hoặc camera dịch sau khi tính NDC. - Hover lag? Bạn raycast cả scene mỗi
pointermove— giới hạn danh sách và tiết lưu một lần mỗi frame. - Vật nhảy khi kéo?
OrbitControlsvẫn bật — tắt nó ởdragstart.
Luyện tập, không thì coi như chưa học
- Đọc cú trúng: trong demo, click các khối khác nhau và xem
distancecùngpointthế giới đổi theo góc camera. - Giới hạn danh sách: thêm grid helper vào scene và xác nhận truyền danh sách
pickablesrõ ràng giữ nó không chọn được. - Đặt marker: dùng
hit.pointthả một quả cầu nhỏ đúng chỗ bạn click.
Phần tiếp theo
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. 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.