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.
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.
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 và .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.Planetoá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ùngLayersđể 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
| Symptom | Likely cause | Fix |
|---|---|---|
| Clicks land in the wrong place | NDC ignores canvas offset | use getBoundingClientRect() in setNDC |
| Orbiting also moves the character | no tap-vs-drag check | gate handleTap behind the move threshold |
| Nothing is ever hit | non-recursive raycast on a glTF tree | intersectObject(model, true) |
| Character jitters at the target | overshoot past the point | clamp step to remaining dist |
| Emote gets cancelled instantly | movement calls go() over it | guard with an emoting flag |
| Faces the wrong way | asset forward axis/yaw offset differs | make forward axis + yaw offset an asset contract |
| Frame rate drops on hover | raycasting every move | skip while pressed; scope targets |
Lời cảnh báo của bậc thầy
- Luôn raycast qua
getBoundingClientRect(), đừngwindow.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ộtgo()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
- 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áygo(). - Thay raycast mesh đất bằng
ray.intersectPlanevà xác nhận vẫn đúng — và rẻ hơn. - Thêm một prop click được; khi trúng, cho nhân vật đi tới rồi
Wave. - Làm mượt cú quay:
lerprotation.yvề 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.