jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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.

4 MIN READ Updated JUL 12, 2026

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.

Mở demo toàn màn hình

Quy trình picking

Picking luôn là ba bước giống nhau:

  1. Đổ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ới y đảo (màn hình y tăng xuống; NDC y tăng lên).
  2. Hướng raycaster từ camera qua điểm NDC đó.
  3. 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 InstancedMesh chạ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ớ đảo y.
  • 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? OrbitControls vẫn bật — tắt nó ở dragstart.

Luyện tập, không thì coi như chưa học

  1. Đọc cú trúng: trong demo, click các khối khác nhau và xem distance cùng point thế giới đổi theo góc camera.
  2. Giới hạn danh sách: thêm grid helper vào scene và xác nhận truyền danh sách pickables rõ ràng giữ nó không chọn được.
  3. Đặt marker: dùng hit.point thả 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.