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.

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:}

  1. Convert the mouse position from CSS pixels to normalized device coordinates (NDC): the range [-1, 1] on both axes, with y flipped (screen y grows down; NDC y grows 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ới y đảo (màn hình y tăng xuống; NDC y tăng lên).}
  2. Point the raycaster from the camera through that NDC point. {Hướng raycaster từ camera qua điểm NDC đó.}
  3. 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ào intersectObjects(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 InstancedMesh works and returns an instanceId — useful, but test it under load. {Raycast một InstancedMesh chạ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 the y flip. {Chọn bị lệch/sai? Toán NDC sai — dùng getBoundingClientRect() và nhớ đảo y.}
  • 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ắt raycast, 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ỗi pointermove — giới hạn danh sách và tiết lưu một lần mỗi frame.}
  • Object jumps while dragging? OrbitControls is still enabled — disable it on dragstart. {Vật nhảy khi kéo? OrbitControls vẫ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}

  1. Read the hit {Đọc cú trúng}: in the demo, click different cubes and watch how distance and the world point change with camera angle. {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. Restrict the list {Giới hạn danh sách}: in code, add a grid helper to the scene and confirm passing an explicit pickables list keeps it un-pickable. {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. Place a marker {Đặt marker}: use hit.point to drop a small sphere exactly where you click. {dùng hit.point thả 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.}