jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Three.js from Zero to Senior · Part 18 — Interaction: Raycasting, Click-to-Move & a Character State Machine

Turn taps into 3D intent. Raycast to pick the character or the ground, move with click-to-move, face the travel direction, and wire a tiny state machine that drives Idle ⇄ Walk ⇄ Run cross-fades — coexisting with OrbitControls. Live demo.

6 MIN READ Updated JUL 12, 2026

Một nhân vật chỉ biết phát clip là con rối. Cao trào của mạch này làm nó biết phản hồi: click mặt đất thì đi tới đó; click nhân vật thì vẫy tay. Chất keo là một raycaster (tap 2D → điểm 3D) và một state machine nhỏ quyết định chạy cross-fade nào của Part 17.

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

Raycasting: một tia xuyên qua pixel

Một cú chạm màn hình là 2D; thế giới là 3D. Raycaster bắn một tia từ camera xuyên qua pixel được chạm và báo nó trúng gì, sắp theo gần-trước. Phần lắt léo duy nhất là đổi toạ độ pixel sang Normalized Device Coordinates (−1…1):

const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();

function setNDC(e) {
  const r = canvas.getBoundingClientRect();          // account for canvas offset
  pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
  pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1; // y is flipped
}

raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObject(target, true);  // true = recurse children
if (hits.length) console.log(hits[0].point, hits[0].object);

Dùng intersectObject(obj, true) cho một cây con (model glTF là cây mesh — bạn cần recursive), hoặc intersectObjects([a, b], true) cho nhiều. hits[0] là điểm trúng gần nhất, có .point (vị trí thế giới), .object, .face.distance.

Sống chung với OrbitControls: tap khác drag

Nếu mọi pointer-up đều kích hoạt hành động, xoay camera cũng sẽ làm nhân vật di chuyển. Cách sửa là chỉ coi là tap nếu con trỏ gần như không di chuyển giữa down và up:

let downPos = null;
canvas.addEventListener('pointerdown', (e) => { downPos = { x: e.clientX, y: e.clientY }; });
canvas.addEventListener('pointerup', (e) => {
  if (downPos && Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y) < 8) handleTap(e);
  downPos = null;   // a bigger move was an orbit drag — ignore it
});

OrbitControls giữ drag; bạn giữ tap. Ngưỡng 8px giúp cảm ứng dễ chịu mà không cướp cử chỉ xoay.

Ưu tiên chọn: nhân vật trước, mặt đất sau

Một cú chạm có thể mang hai nghĩa, nên xếp thứ tự raycast theo ý định. Thử nhân vật trước; trượt thì rơi xuống mặt đất:

function handleTap(e) {
  setNDC(e); raycaster.setFromCamera(pointer, camera);
  if (raycaster.intersectObject(model, true).length) { emote('Wave'); return; } // hit body
  const hit = raycaster.intersectObject(floor)[0];                              // hit ground
  if (hit) setDestination(hit.point);
}

Thứ tự “mục tiêu cụ thể nhất thắng” là cách mọi editor và game xử lý nghĩa click chồng nhau.

Click-to-move: lái về phía điểm

Lưu điểm đến, rồi mỗi frame tiến về phía nó, xoay để hướng theo, và dừng khi tới:

let target = null;
function setDestination(p) { target = p.clone(); target.y = 0; marker.position.copy(target); marker.visible = true; }

function tick(dt) {
  if (target) {
    const dx = target.x - model.position.x, dz = target.z - model.position.z;
    const dist = Math.hypot(dx, dz);
    if (dist > 0.12) {
      go(running ? 'Running' : 'Walking');                  // state machine
      const step = Math.min(dist, speed * dt);             // never overshoot
      model.position.x += (dx / dist) * step;
      model.position.z += (dz / dist) * step;
      model.rotation.y = Math.atan2(dx, dz);               // face where you walk
    } else { target = null; marker.visible = false; go('Idle'); }
  }
}

Hai chi tiết làm nó “đúng cảm giác”: kẹp bước theo khoảng cách còn lại để nhân vật không vượt quá và rung, và đặt rotation.y = atan2(dx, dz) để luôn hướng về phía đi. Một vòng ring nhấp nháy cho người chơi biết cú click đã được nhận.

Bộ điều phối trạng thái tối thiểu

Demo dùng một bộ điều phối nhỏ: state hiện tại, guard và vài transition. Nó minh hoạ nguyên tắc tập trung hoá, nhưng chưa phải finite-state machine production vì chưa có event queue, transition table, cancellation token hay illegal-state assertion.

let activeName = null, emoting = false, currentEmoteAction = null;

function go(name) {                       // locomotion transitions
  if (emoting || activeName === name) return;   // don't fight an emote / no-op
  fadeTo(name, 0.3, true);               // cross-fade + loop (Part 17)
  activeName = name;
}
function emote(name) {                    // one-shot overlay
  emoting = true; currentEmoteAction = actions[name];
  fadeTo(name, 0.15, false);
}
mixer.addEventListener('finished', (event) => {
  if (event.action !== currentEmoteAction) return;
  emoting = false;
  go(target ? (running ? 'Running' : 'Walking') : 'Idle');
});

Ba lớp bảo vệ đáng giá: kiểm tra activeName === name khiến go() idempotent, guard emoting ngăn di chuyển huỷ cú vẫy, và handler finished lọc đúng action trước khi khôi phục locomotion. Khi số state/transition tăng, chuyển các rule này thành event + transition table có test thay vì thêm boolean.

Con trỏ hover, rẻ

Con trỏ pointer khi rê qua thứ click được là điểm cộng — nhưng raycast trên mọi pointermove thì lãng phí. Bỏ qua khi đang nhấn (bạn đang xoay), và chỉ test trên cây con của model:

let pendingHover = null;
canvas.addEventListener('pointermove', (e) => {
  if (!downPos) pendingHover = { x: e.clientX, y: e.clientY };
});

// Once per animation frame, not once per raw pointer event.
function updateHover() {
  if (!pendingHover) return;
  setNDC(pendingHover); pendingHover = null;
  raycaster.setFromCamera(pointer, camera);
  canvas.classList.toggle('overChar', raycaster.intersectObject(hitProxy, true).length > 0);
}

Hiệu năng: raycast khi cần

Raycast duyệt qua hình học, nên không miễn phí. Các quy tắc:

  • Raycast theo sự kiện, không phải mỗi frame — một cú tap, một lần hover; đừng trong vòng render “phòng khi”.
  • Chọn mặt đất rẻ. Cắt một THREE.Plane toán học rẻ hơn nhiều so với một mesh lớn nếu bạn chỉ cần điểm.
  • Giới hạn mục tiêu. Truyền một mảng tường minh, đừng scene.children; dùng Layers để loại object không tương tác.
  • Thô trước. Với nhiều object, test bounding sphere/box trước hình học chính xác.

Khắc phục: triệu chứng → nguyên nhân → cách sửa

SymptomLikely causeFix
Clicks land in the wrong placeNDC ignores canvas offsetuse getBoundingClientRect() in setNDC
Orbiting also moves the characterno tap-vs-drag checkgate handleTap behind the move threshold
Nothing is ever hitnon-recursive raycast on a glTF treeintersectObject(model, true)
Character jitters at the targetovershoot past the pointclamp step to remaining dist
Emote gets cancelled instantlymovement calls go() over itguard with an emoting flag
Faces the wrong wayasset forward axis/yaw offset differsmake forward axis + yaw offset an asset contract
Frame rate drops on hoverraycasting every moveskip while pressed; scope targets

Lời cảnh báo của bậc thầy

  • Luôn raycast qua getBoundingClientRect(), đừng window.innerWidth — canvas nhúng và fullscreen đều làm hỏng phép tính ngây thơ.
  • Tập trung hoá chuyển trạng thái. Rải play() khắp nơi sẽ kẹt/chồng trạng thái; một go() là nguồn chân lý duy nhất.
  • Cho go() idempotent. Nó chạy mỗi frame; phải no-op khi đã ở đúng trạng thái.
  • Di chuyển node, không phải mocap. Dùng clip tại-chỗ và tự dịch node cha — clip root-motion sẽ chọi với click-to-move.

Thực hành

  1. Thêm điều khiển phím: WASD đặt vận tốc trực tiếp (không target), nuôi cùng cỗ máy go().
  2. Thay raycast mesh đất bằng ray.intersectPlane và xác nhận vẫn đúng — và rẻ hơn.
  3. Thêm một prop click được; khi trúng, cho nhân vật đi tới rồi Wave.
  4. Làm mượt cú quay: lerp rotation.y về góc đích thay vì nhảy phắt.

Bạn đang ở đâu sau chặng asset + character

Qua Part 14–18 bạn đã có pipeline import, compression, animation và interaction. Chặng tiếp theo đi xuống lớp khó debug nhất: Part 19 biến local/world/view/clip space, quaternion và precision thành công cụ giải lỗi thay vì công thức phải học thuộc.