Three.js 交互 #

交互让 3D 场景变得可操作,用户可以通过鼠标、键盘、触摸等方式与场景中的物体进行交互。

交互概述 #

text
┌─────────────────────────────────────────────────────────────┐
│                    Three.js 交互方式                          │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐         │
│  │   鼠标交互    │  │   键盘交互   │  │   触摸交互   │         │
│  ├─────────────┤  ├─────────────┤  ├─────────────┤         │
│  │ 点击、悬停   │  │ 按键检测    │  │ 手势识别    │         │
│  │ 拖拽、滚轮   │  │ 快捷键      │  │ 多点触控    │         │
│  └─────────────┘  └─────────────┘  └─────────────┘         │
│                                                              │
│  核心技术:Raycaster(射线检测)                               │
│                                                              │
└─────────────────────────────────────────────────────────────┘

射线检测(Raycaster) #

射线检测是 Three.js 交互的核心技术,用于检测鼠标/触摸点与 3D 物体的交叉。

基本用法 #

javascript
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

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

function checkIntersection() {
  raycaster.setFromCamera(mouse, camera);
  
  const intersects = raycaster.intersectObjects(scene.children);
  
  if (intersects.length > 0) {
    const intersected = intersects[0];
    console.log('点击的物体:', intersected.object);
    console.log('交点位置:', intersected.point);
    console.log('距离:', intersected.distance);
  }
}

window.addEventListener('mousemove', onMouseMove);
window.addEventListener('click', checkIntersection);

Raycaster 属性 #

属性 类型 描述
ray Ray 射线对象
camera Camera 相机对象
near Number 近距离
far Number 远距离

Raycaster 方法 #

javascript
raycaster.setFromCamera(mouse, camera);

const intersects = raycaster.intersectObjects(objects);

const intersects = raycaster.intersectObjects(objects, true);

const intersects = raycaster.intersectObject(object);

const intersects = raycaster.intersectObjects([mesh1, mesh2]);

raycaster.set(origin, direction);

const intersects = raycaster.intersectObjects(objects);

Intersect 对象 #

javascript
const intersect = intersects[0];

intersect.distance;
intersect.point;
intersect.face;
intersect.faceIndex;
intersect.object;
intersect.uv;

鼠标交互 #

点击选择 #

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

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) {
    if (selectedObject) {
      selectedObject.material.emissive.setHex(0x000000);
    }
    
    selectedObject = intersects[0].object;
    selectedObject.material.emissive.setHex(0x555555);
  }
}

window.addEventListener('click', onClick);

悬停效果 #

javascript
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(scene.children);
  
  if (hoveredObject) {
    hoveredObject.material.emissive.setHex(0x000000);
    hoveredObject = null;
  }
  
  if (intersects.length > 0) {
    hoveredObject = intersects[0].object;
    hoveredObject.material.emissive.setHex(0x333333);
    document.body.style.cursor = 'pointer';
  } else {
    document.body.style.cursor = 'default';
  }
}

window.addEventListener('mousemove', onMouseMove);

拖拽物体 #

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

const objects = [];
for (let i = 0; i < 5; i++) {
  const mesh = new THREE.Mesh(
    new THREE.BoxGeometry(1, 1, 1),
    new THREE.MeshStandardMaterial({ color: Math.random() * 0xffffff })
  );
  mesh.position.set(Math.random() * 4 - 2, 0.5, Math.random() * 4 - 2);
  scene.add(mesh);
  objects.push(mesh);
}

const dragControls = new DragControls(objects, camera, renderer.domElement);

dragControls.addEventListener('dragstart', (event) => {
  event.object.material.emissive.setHex(0x333333);
});

dragControls.addEventListener('drag', (event) => {
  event.object.position.y = 0.5;
});

dragControls.addEventListener('dragend', (event) => {
  event.object.material.emissive.setHex(0x000000);
});

自定义拖拽 #

javascript
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const intersection = new THREE.Vector3();
let selectedObject = null;
let isDragging = false;

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(objects);
  
  if (intersects.length > 0) {
    isDragging = true;
    selectedObject = intersects[0].object;
    controls.enabled = false;
  }
}

function onMouseMove(event) {
  if (!isDragging || !selectedObject) return;
  
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
  
  raycaster.setFromCamera(mouse, camera);
  raycaster.ray.intersectPlane(plane, intersection);
  
  selectedObject.position.x = intersection.x;
  selectedObject.position.z = intersection.z;
}

function onMouseUp() {
  isDragging = false;
  selectedObject = null;
  controls.enabled = true;
}

window.addEventListener('mousedown', onMouseDown);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);

鼠标滚轮 #

javascript
function onWheel(event) {
  event.preventDefault();
  
  camera.position.z += event.deltaY * 0.01;
}

window.addEventListener('wheel', onWheel, { passive: false });

键盘交互 #

基本按键检测 #

javascript
const keys = {};

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

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

function animate() {
  requestAnimationFrame(animate);
  
  const speed = 0.1;
  
  if (keys['KeyW'] || keys['ArrowUp']) {
    mesh.position.z -= speed;
  }
  if (keys['KeyS'] || keys['ArrowDown']) {
    mesh.position.z += speed;
  }
  if (keys['KeyA'] || keys['ArrowLeft']) {
    mesh.position.x -= speed;
  }
  if (keys['KeyD'] || keys['ArrowRight']) {
    mesh.position.x += speed;
  }
  if (keys['Space']) {
    mesh.position.y += speed;
  }
  if (keys['ShiftLeft']) {
    mesh.position.y -= speed;
  }
  
  renderer.render(scene, camera);
}
animate();

快捷键 #

javascript
const shortcuts = {
  'KeyR': () => mesh.rotation.set(0, 0, 0),
  'KeyG': () => mesh.position.set(0, 0, 0),
  'KeyS': () => mesh.scale.set(1, 1, 1),
  'Delete': () => scene.remove(mesh),
  'Escape': () => deselectAll()
};

window.addEventListener('keydown', (event) => {
  if (shortcuts[event.code]) {
    shortcuts[event.code]();
  }
});

组合键 #

javascript
const keys = {};

window.addEventListener('keydown', (event) => {
  keys[event.code] = true;
  
  if (keys['ControlLeft'] && keys['KeyC']) {
    console.log('复制');
  }
  if (keys['ControlLeft'] && keys['KeyV']) {
    console.log('粘贴');
  }
  if (keys['ShiftLeft'] && keys['KeyA']) {
    console.log('全选');
  }
});

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

触摸交互 #

触摸事件 #

javascript
function onTouchStart(event) {
  event.preventDefault();
  
  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(objects);
  
  if (intersects.length > 0) {
    console.log('触摸到物体');
  }
}

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

function onTouchEnd(event) {
  console.log('触摸结束');
}

renderer.domElement.addEventListener('touchstart', onTouchStart, { passive: false });
renderer.domElement.addEventListener('touchmove', onTouchMove, { passive: false });
renderer.domElement.addEventListener('touchend', onTouchEnd);

双指缩放 #

javascript
let initialDistance = 0;
let initialZoom = 1;

function onTouchStart(event) {
  if (event.touches.length === 2) {
    initialDistance = getDistance(event.touches[0], event.touches[1]);
    initialZoom = camera.position.z;
  }
}

function onTouchMove(event) {
  if (event.touches.length === 2) {
    const currentDistance = getDistance(event.touches[0], event.touches[1]);
    const scale = initialDistance / currentDistance;
    camera.position.z = initialZoom * scale;
  }
}

function getDistance(touch1, touch2) {
  const dx = touch1.clientX - touch2.clientX;
  const dy = touch1.clientY - touch2.clientY;
  return Math.sqrt(dx * dx + dy * dy);
}

renderer.domElement.addEventListener('touchstart', onTouchStart);
renderer.domElement.addEventListener('touchmove', onTouchMove);

双指旋转 #

javascript
let initialAngle = 0;

function onTouchStart(event) {
  if (event.touches.length === 2) {
    initialAngle = getAngle(event.touches[0], event.touches[1]);
  }
}

function onTouchMove(event) {
  if (event.touches.length === 2) {
    const currentAngle = getAngle(event.touches[0], event.touches[1]);
    const deltaAngle = currentAngle - initialAngle;
    camera.rotation.y += deltaAngle * 0.01;
    initialAngle = currentAngle;
  }
}

function getAngle(touch1, touch2) {
  const dx = touch1.clientX - touch2.clientX;
  const dy = touch1.clientY - touch2.clientY;
  return Math.atan2(dy, dx);
}

交互控制器 #

OrbitControls 交互 #

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

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

controls.enableDamping = true;
controls.dampingFactor = 0.05;

controls.minDistance = 2;
controls.maxDistance = 20;

controls.minPolarAngle = 0;
controls.maxPolarAngle = Math.PI / 2;

controls.enablePan = true;
controls.panSpeed = 1;

controls.enableZoom = true;
controls.zoomSpeed = 1;

controls.enableRotate = true;
controls.rotateSpeed = 1;

controls.addEventListener('change', () => {
  console.log('相机改变');
});

controls.addEventListener('start', () => {
  console.log('开始交互');
});

controls.addEventListener('end', () => {
  console.log('结束交互');
});

TransformControls #

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

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

transformControls.attach(mesh);

transformControls.addEventListener('dragging-changed', (event) => {
  controls.enabled = !event.value;
});

window.addEventListener('keydown', (event) => {
  switch (event.code) {
    case 'KeyT':
      transformControls.setMode('translate');
      break;
    case 'KeyR':
      transformControls.setMode('rotate');
      break;
    case 'KeyS':
      transformControls.setMode('scale');
      break;
  }
});

高级交互 #

点击高亮 #

javascript
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const objects = [];
let selectedObjects = [];

const highlightMaterial = new THREE.MeshBasicMaterial({
  color: 0xff0000,
  transparent: true,
  opacity: 0.5
});

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(objects);
  
  if (intersects.length > 0) {
    const object = intersects[0].object;
    
    if (event.shiftKey) {
      const index = selectedObjects.indexOf(object);
      if (index > -1) {
        selectedObjects.splice(index, 1);
        object.material = object.userData.originalMaterial;
      } else {
        object.userData.originalMaterial = object.material;
        object.material = highlightMaterial;
        selectedObjects.push(object);
      }
    } else {
      selectedObjects.forEach(obj => {
        obj.material = obj.userData.originalMaterial;
      });
      selectedObjects = [];
      
      object.userData.originalMaterial = object.material;
      object.material = highlightMaterial;
      selectedObjects.push(object);
    }
  }
}

window.addEventListener('click', onClick);

信息提示 #

javascript
const tooltip = document.createElement('div');
tooltip.style.position = 'absolute';
tooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
tooltip.style.color = 'white';
tooltip.style.padding = '5px 10px';
tooltip.style.borderRadius = '4px';
tooltip.style.pointerEvents = 'none';
tooltip.style.display = 'none';
document.body.appendChild(tooltip);

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(objects);
  
  if (intersects.length > 0) {
    const object = intersects[0].object;
    tooltip.textContent = object.userData.name || '未命名';
    tooltip.style.left = event.clientX + 10 + 'px';
    tooltip.style.top = event.clientY + 10 + 'px';
    tooltip.style.display = 'block';
  } else {
    tooltip.style.display = 'none';
  }
}

window.addEventListener('mousemove', onMouseMove);

完整示例 #

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

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(5, 5, 5);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

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

const ambientLight = new THREE.AmbientLight(0x404040, 0.5);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(5, 5, 5);
scene.add(directionalLight);

const objects = [];
const colors = [0xff6b6b, 0x6bffb8, 0x6bb8ff, 0xffb86b, 0xb86bff];

for (let i = 0; i < 5; i++) {
  const geometry = new THREE.BoxGeometry(1, 1, 1);
  const material = new THREE.MeshStandardMaterial({ color: colors[i] });
  const mesh = new THREE.Mesh(geometry, material);
  mesh.position.set((i - 2) * 2, 0.5, 0);
  mesh.userData.name = `立方体 ${i + 1}`;
  mesh.userData.originalColor = colors[i];
  scene.add(mesh);
  objects.push(mesh);
}

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let hoveredObject = null;
let selectedObject = 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(objects);
  
  if (hoveredObject && hoveredObject !== selectedObject) {
    hoveredObject.material.emissive.setHex(0x000000);
  }
  
  if (intersects.length > 0) {
    hoveredObject = intersects[0].object;
    if (hoveredObject !== selectedObject) {
      hoveredObject.material.emissive.setHex(0x333333);
    }
    document.body.style.cursor = 'pointer';
  } else {
    hoveredObject = null;
    document.body.style.cursor = 'default';
  }
}

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(objects);
  
  if (selectedObject) {
    selectedObject.material.emissive.setHex(0x000000);
  }
  
  if (intersects.length > 0) {
    selectedObject = intersects[0].object;
    selectedObject.material.emissive.setHex(0x555555);
    console.log('选中:', selectedObject.userData.name);
  } else {
    selectedObject = null;
  }
}

const keys = {};

function onKeyDown(event) {
  keys[event.code] = true;
  
  if (event.code === 'Delete' && selectedObject) {
    scene.remove(selectedObject);
    const index = objects.indexOf(selectedObject);
    if (index > -1) objects.splice(index, 1);
    selectedObject = null;
  }
}

function onKeyUp(event) {
  keys[event.code] = false;
}

window.addEventListener('mousemove', onMouseMove);
window.addEventListener('click', onClick);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);

function animate() {
  requestAnimationFrame(animate);
  
  const speed = 0.05;
  if (selectedObject) {
    if (keys['ArrowUp']) selectedObject.position.z -= speed;
    if (keys['ArrowDown']) selectedObject.position.z += speed;
    if (keys['ArrowLeft']) selectedObject.position.x -= speed;
    if (keys['ArrowRight']) selectedObject.position.x += speed;
  }
  
  controls.update();
  renderer.render(scene, camera);
}
animate();

window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

下一步 #

现在你已经掌握了交互系统,接下来学习 纹理,为物体添加丰富的纹理效果!

最后更新:2026-03-28