threejs-interaction

threejs-interaction

熱門

Three.js 互動 - 光線投射、控制、滑鼠/觸控輸入、物件選取。用於處理使用者輸入、實作點擊偵測、加入攝影機控制或建立互動式 3D 體驗。

2609星標
299分支
更新於 2026/7/9
SKILL.md
唯讀
名稱
threejs-interaction
描述

Three.js 互動 - 光線投射、控制、滑鼠/觸控輸入、物件選取。用於處理使用者輸入、實作點擊偵測、加入攝影機控制或建立互動式 3D 體驗。

Three.js 互動

快速開始

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";

// 攝影機控制
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;

// 用於點擊偵測的光線投射
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

function onClick(event) {
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects(scene.children);

  if (intersects.length > 0) {
    console.log("點擊:", intersects[0].object);
  }
}

window.addEventListener("click", onClick);

光線投射器 (Raycaster)

基本光線投射

const raycaster = new THREE.Raycaster();

// 從攝影機(滑鼠選取)
raycaster.setFromCamera(mousePosition, camera);

// 從任意原點與方向
raycaster.set(origin, direction); // origin: Vector3, direction: 正規化 Vector3

// 取得交點
const intersects = raycaster.intersectObjects(objects, recursive);

// intersects 陣列包含:
// {
//   distance: number,          // 距光線原點的距離
//   point: Vector3,            // 世界座標中的交點
//   face: Face3,               // 相交的面
//   faceIndex: number,         // 面索引
//   object: Object3D,          // 相交的物件
//   uv: Vector2,               // 交點的 UV 座標
//   uv1: Vector2,              // 第二 UV 通道
//   normal: Vector3,           // 插值後的面法線
//   instanceId: number         // 用於 InstancedMesh
// }

滑鼠位置轉換

const mouse = new THREE.Vector2();

function updateMouse(event) {
  // 全視窗
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
}

// 特定 canvas 元素
function updateMouseCanvas(event, canvas) {
  const rect = canvas.getBoundingClientRect();
  mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
  mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}

觸控支援

function onTouchStart(event) {
  event.preventDefault();

  if (event.touches.length === 1) {
    const touch = event.touches[0];
    mouse.x = (touch.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(touch.clientY / window.innerHeight) * 2 + 1;

    raycaster.setFromCamera(mouse, camera);
    const intersects = raycaster.intersectObjects(clickableObjects);

    if (intersects.length > 0) {
      handleSelection(intersects[0]);
    }
  }
}

renderer.domElement.addEventListener("touchstart", onTouchStart);

光線投射器選項

const raycaster = new THREE.Raycaster();

// 近/遠裁剪(預設:0, Infinity)
raycaster.near = 0;
raycaster.far = 100;

// 線段/點的精確度
raycaster.params.Line.threshold = 0.1;
raycaster.params.Points.threshold = 0.1;

// 圖層(僅與特定圖層的物件相交)
raycaster.layers.set(1);

高效光線投射

// 僅檢查特定物件
const clickables = [mesh1, mesh2, mesh3];
const intersects = raycaster.intersectObjects(clickables, false);

// 使用圖層過濾
mesh1.layers.set(1); // 可點擊圖層
raycaster.layers.set(1);

// 限制光線投射頻率(懸停效果)
let lastRaycast = 0;
function onMouseMove(event) {
  const now = Date.now();
  if (now - lastRaycast < 50) return; // 最高 20fps
  lastRaycast = now;

  // 在此進行光線投射
}

攝影機控制

OrbitControls

import { OrbitControls } from "three/addons/controls/OrbitControls.js";

const controls = new OrbitControls(camera, renderer.domElement);

// 阻尼(平滑移動)
controls.enableDamping = true;
controls.dampingFactor = 0.05;

// 旋轉限制
controls.minPolarAngle = 0; // 頂部
controls.maxPolarAngle = Math.PI / 2; // 地平線
controls.minAzimuthAngle = -Math.PI / 4; // 左
controls.maxAzimuthAngle = Math.PI / 4; // 右

// 縮放限制
controls.minDistance = 2;
controls.maxDistance = 50;

// 啟用/停用功能
controls.enableRotate = true;
controls.enableZoom = true;
controls.enablePan = true;

// 自動旋轉
controls.autoRotate = true;
controls.autoRotateSpeed = 2.0;

// 目標點(環繞點)
controls.target.set(0, 1, 0);

// 在動畫循環中更新
function animate() {
  controls.update(); // 阻尼與自動旋轉需要
  renderer.render(scene, camera);
}

FlyControls

import { FlyControls } from "three/addons/controls/FlyControls.js";

const controls = new FlyControls(camera, renderer.domElement);
controls.movementSpeed = 10;
controls.rollSpeed = Math.PI / 24;
controls.dragToLook = true;

// 使用 delta 更新
function animate() {
  controls.update(clock.getDelta());
  renderer.render(scene, camera);
}

FirstPersonControls

import { FirstPersonControls } from "three/addons/controls/FirstPersonControls.js";

const controls = new FirstPersonControls(camera, renderer.domElement);
controls.movementSpeed = 10;
controls.lookSpeed = 0.1;
controls.lookVertical = true;
controls.constrainVertical = true;
controls.verticalMin = Math.PI / 4;
controls.verticalMax = (Math.PI * 3) / 4;

function animate() {
  controls.update(clock.getDelta());
}

PointerLockControls

import { PointerLockControls } from "three/addons/controls/PointerLockControls.js";

const controls = new PointerLockControls(camera, document.body);

// 點擊鎖定游標
document.addEventListener("click", () => {
  controls.lock();
});

controls.addEventListener("lock", () => {
  console.log("游標已鎖定");
});

controls.addEventListener("unlock", () => {
  console.log("游標已解鎖");
});

// 移動
const velocity = new THREE.Vector3();
const direction = new THREE.Vector3();
const moveForward = false;
const moveBackward = false;

document.addEventListener("keydown", (event) => {
  switch (event.code) {
    case "KeyW":
      moveForward = true;
      break;
    case "KeyS":
      moveBackward = true;
      break;
  }
});

function animate() {
  if (controls.isLocked) {
    direction.z = Number(moveForward) - Number(moveBackward);
    direction.normalize();

    velocity.z -= direction.z * 0.1;
    velocity.z *= 0.9; // 摩擦力

    controls.moveForward(-velocity.z);
  }
}

TrackballControls

import { TrackballControls } from "three/addons/controls/TrackballControls.js";

const controls = new TrackballControls(camera, renderer.domElement);
controls.rotateSpeed = 2.0;
controls.zoomSpeed = 1.2;
controls.panSpeed = 0.8;
controls.staticMoving = true;

function animate() {
  controls.update();
}

MapControls

import { MapControls } from "three/addons/controls/MapControls.js";

const controls = new MapControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.screenSpacePanning = false;
controls.maxPolarAngle = Math.PI / 2;

TransformControls

用於移動/旋轉/縮放物件的 Gizmo。

import { TransformControls } from "three/addons/controls/TransformControls.js";

const transformControls = new TransformControls(camera, renderer.domElement);
scene.add(transformControls);

// 附加到物件
transformControls.attach(selectedMesh);

// 切換模式
transformControls.setMode("translate"); // 'translate', 'rotate', 'scale'

// 變更空間
transformControls.setSpace("local"); // 'local', 'world'

// 大小
transformControls.setSize(1);

// 事件
transformControls.addEventListener("dragging-changed", (event) => {
  // 拖曳時停用軌道控制
  orbitControls.enabled = !event.value;
});

transformControls.addEventListener("change", () => {
  renderer.render(scene, camera);
});

// 鍵盤快捷鍵
window.addEventListener("keydown", (event) => {
  switch (event.key) {
    case "g":
      transformControls.setMode("translate");
      break;
    case "r":
      transformControls.setMode("rotate");
      break;
    case "s":
      transformControls.setMode("scale");
      break;
    case "Escape":
      transformControls.detach();
      break;
  }
});

DragControls

直接拖曳物件。

import { DragControls } from "three/addons/controls/DragControls.js";

const draggableObjects = [mesh1, mesh2, mesh3];
const dragControls = new DragControls(
  draggableObjects,
  camera,
  renderer.domElement,
);

dragControls.addEventListener("dragstart", (event) => {
  orbitControls.enabled = false;
  event.object.material.emissive.set(0xaaaaaa);
});

dragControls.addEventListener("drag", (event) => {
  // 限制在地面平面
  event.object.position.y = 0;
});

dragControls.addEventListener("dragend", (event) => {
  orbitControls.enabled = true;
  event.object.material.emissive.set(0x000000);
});

選取系統

點擊選取

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let selectedObject = null;

function onMouseDown(event) {
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects(selectableObjects);

  // 取消選取前一個
  if (selectedObject) {
    selectedObject.material.emissive.set(0x000000);
  }

  // 選取新的
  if (intersects.length > 0) {
    selectedObject = intersects[0].object;
    selectedObject.material.emissive.set(0x444444);
  } else {
    selectedObject = null;
  }
}

框選

import { SelectionBox } from "three/addons/interactive/SelectionBox.js";
import { SelectionHelper } from "three/addons/interactive/SelectionHelper.js";

const selectionBox = new SelectionBox(camera, scene);
const selectionHelper = new SelectionHelper(renderer, "selectBox"); // CSS 類別

document.addEventListener("pointerdown", (event) => {
  selectionBox.startPoint.set(
    (event.clientX / window.innerWidth) * 2 - 1,
    -(event.clientY / window.innerHeight) * 2 + 1,
    0.5,
  );
});

document.addEventListener("pointermove", (event) => {
  if (selectionHelper.isDown) {
    selectionBox.endPoint.set(
      (event.clientX / window.innerWidth) * 2 - 1,
      -(event.clientY / window.innerHeight) * 2 + 1,
      0.5,
    );
  }
});

document.addEventListener("pointerup", (event) => {
  selectionBox.endPoint.set(
    (event.clientX / window.innerWidth) * 2 - 1,
    -(event.clientY / window.innerHeight) * 2 + 1,
    0.5,
  );

  const selected = selectionBox.select();
  console.log("選取的物件:", selected);
});

懸停效果

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let hoveredObject = null;

function onMouseMove(event) {
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects(hoverableObjects);

  // 重設前一個懸停
  if (hoveredObject) {
    hoveredObject.material.color.set(hoveredObject.userData.originalColor);
    document.body.style.cursor = "default";
  }

  // 套用新的懸停
  if (intersects.length > 0) {
    hoveredObject = intersects[0].object;
    if (!hoveredObject.userData.originalColor) {
      hoveredObject.userData.originalColor =
        hoveredObject.material.color.getHex();
    }
    hoveredObject.material.color.set(0xff6600);
    document.body.style.cursor = "pointer";
  } else {
    hoveredObject = null;
  }
}

window.addEventListener("mousemove", onMouseMove);

鍵盤輸入

const keys = {};

document.addEventListener("keydown", (event) => {
  keys[event.code] = true;
});

document.addEventListener("keyup", (event) => {
  keys[event.code] = false;
});

function update() {
  const speed = 0.1;

  if (keys["KeyW"]) player.position.z -= speed;
  if (keys["KeyS"]) player.position.z += speed;
  if (keys["KeyA"]) player.position.x -= speed;
  if (keys["KeyD"]) player.position.x += speed;
  if (keys["Space"]) player.position.y += speed;
  if (keys["ShiftLeft"]) player.position.y -= speed;
}

世界-螢幕座標轉換

世界轉螢幕

function worldToScreen(position, camera) {
  const vector = position.clone();
  vector.project(camera);

  return {
    x: ((vector.x + 1) / 2) * window.innerWidth,
    y: (-(vector.y - 1) / 2) * window.innerHeight,
  };
}

// 將 HTML 元素定位在 3D 物件上方
const screenPos = worldToScreen(mesh.position, camera);
element.style.left = screenPos.x + "px";
element.style.top = screenPos.y + "px";

螢幕轉世界

function screenToWorld(screenX, screenY, camera, targetZ = 0) {
  const vector = new THREE.Vector3(
    (screenX / window.innerWidth) * 2 - 1,
    -(screenY / window.innerHeight) * 2 + 1,
    0.5,
  );

  vector.unproject(camera);

  const dir = vector.sub(camera.position).normalize();
  const distance = (targetZ - camera.position.z) / dir.z;

  return camera.position.clone().add(dir.multiplyScalar(distance));
}

光線-平面交點

function getRayPlaneIntersection(mouse, camera, plane) {
  const raycaster = new THREE.Raycaster();
  raycaster.setFromCamera(mouse, camera);

  const intersection = new THREE.Vector3();
  raycaster.ray.intersectPlane(plane, intersection);

  return intersection;
}

// 地面平面
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const worldPos = getRayPlaneIntersection(mouse, camera, groundPlane);

事件處理最佳實踐

class InteractionManager {
  constructor(camera, renderer, scene) {
    this.camera = camera;
    this.renderer = renderer;
    this.scene = scene;
    this.raycaster = new THREE.Raycaster();
    this.mouse = new THREE.Vector2();
    this.clickables = [];

    this.bindEvents();
  }

  bindEvents() {
    const canvas = this.renderer.domElement;

    canvas.addEventListener("click", (e) => this.onClick(e));
    canvas.addEventListener("mousemove", (e) => this.onMouseMove(e));
    canvas.addEventListener("touchstart", (e) => this.onTouchStart(e));
  }

  updateMouse(event) {
    const rect = this.renderer.domElement.getBoundingClientRect();
    this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
    this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
  }

  getIntersects() {
    this.raycaster.setFromCamera(this.mouse, this.camera);
    return this.raycaster.intersectObjects(this.clickables, true);
  }

  onClick(event) {
    this.updateMouse(event);
    const intersects = this.getIntersects();

    if (intersects.length > 0) {
      const object = intersects[0].object;
      if (object.userData.onClick) {
        object.userData.onClick(intersects[0]);
      }
    }
  }

  addClickable(object, callback) {
    this.clickables.push(object);
    object.userData.onClick = callback;
  }

  dispose() {
    // 移除事件監聽器
  }
}

// 使用方式
const interaction = new InteractionManager(camera, renderer, scene);
interaction.addClickable(mesh, (intersect) => {
  console.log("點擊位置:", intersect.point);
});

效能提示

  1. 限制光線投射次數:節流 mousemove 處理器
  2. 使用圖層:過濾光線投射目標
  3. 簡化碰撞網格:使用不可見的簡單幾何體進行光線投射
  4. 不需要時停用控制controls.enabled = false
  5. 批次更新:將互動檢查分組
// 使用較簡單的幾何體進行光線投射
const complexMesh = loadedModel;
const collisionMesh = new THREE.Mesh(
  new THREE.BoxGeometry(1, 1, 1),
  new THREE.MeshBasicMaterial({ visible: false }),
);
collisionMesh.userData.target = complexMesh;
clickables.push(collisionMesh);

另請參閱

  • threejs-fundamentals - 攝影機與場景設定
  • threejs-animation - 動畫互動
  • threejs-shaders - 視覺回饋效果