第八篇:交互入门:鼠标拾取物体

发布于:2025-08-12 ⋅ 阅读:(18) ⋅ 点赞:(0)

第八篇:交互入门:鼠标拾取物体

引言

交互是3D应用的核心灵魂,它让用户从旁观者变为参与者。Three.js提供了强大的射线检测(Raycaster)功能,可实现物体拾取、拖拽等交互效果。本文将深入解析交互技术原理,并通过Vue3实现一个交互式3D展厅,让你掌握用户与3D世界沟通的桥梁技术。


在这里插入图片描述

1. 射线检测(Raycaster)原理
1.1 射线检测流程
鼠标点击屏幕
标准化设备坐标
相机发射射线
检测与物体交点
返回最近交点
1.2 核心代码实现
<script setup>
import { ref, onMounted } from 'vue';
import * as THREE from 'three';

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const intersectedObjects = ref([]);

// 初始化事件监听
onMounted(() => {
  const canvas = renderer.domElement;
  canvas.addEventListener('mousemove', onMouseMove);
  canvas.addEventListener('click', onClick);
});

// 更新鼠标位置
function onMouseMove(event) {
  // 将鼠标位置归一化为设备坐标(-1到+1)
  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);
  
  // 更新响应式数据
  intersectedObjects.value = intersects.map(i => i.object);
}
</script>
1.3 性能优化策略
// 只检测特定物体
const interactiveObjects = [obj1, obj2, obj3];
const intersects = raycaster.intersectObjects(interactiveObjects);

// 节流检测频率
let lastCheck = 0;
function onMouseMove(event) {
  const now = Date.now();
  if (now - lastCheck < 50) return; // 20FPS检测
  lastCheck = now;
  
  // 执行检测...
}

2. 基础交互实现
2.1 悬停高亮效果
<script setup>
// 当前悬停的物体
const hoverObject = ref(null);

// 高亮材质
const highlightMaterial = new THREE.MeshBasicMaterial({
  color: 0xffff00,
  wireframe: true
});

watch(intersectedObjects, (intersects) => {
  const newHover = intersects.length > 0 ? intersects[0] : null;
  
  // 移除旧高亮
  if (hoverObject.value) {
    hoverObject.value.material = hoverObject.value.userData.originalMaterial;
  }
  
  // 应用新高亮
  if (newHover) {
    newHover.userData.originalMaterial = newHover.material;
    newHover.material = highlightMaterial;
    hoverObject.value = newHover;
  } else {
    hoverObject.value = null;
  }
});
</script>
2.2 点击选择物体
<template>
  <div v-if="selectedObject" class="info-panel">
    <h3>{{ selectedObject.name }}</h3>
    <p>位置: {{ selectedObject.position.toArray() }}</p>
  </div>
</template>

<script setup>
const selectedObject = ref(null);

function onClick() {
  if (intersectedObjects.value.length > 0) {
    selectedObject.value = intersectedObjects.value[0];
  } else {
    selectedObject.value = null;
  }
}
</script>
2.3 拖拽物体
let dragObject = null;
let dragOffset = new THREE.Vector3();

function onMouseDown(event) {
  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects(scene.children);
  
  if (intersects.length > 0) {
    dragObject = intersects[0].object;
    
    // 计算物体中心到交点的偏移
    dragOffset.copy(intersects[0].point)
              .sub(dragObject.position);
    
    // 添加移动和释放事件
    canvas.addEventListener('mousemove', onDragMove);
    canvas.addEventListener('mouseup', onDragEnd);
  }
}

function onDragMove(event) {
  if (!dragObject) return;
  
  // 更新射线
  raycaster.setFromCamera(mouse, camera);
  
  // 创建拖拽平面(与相机视线垂直)
  const dragPlane = new THREE.Plane();
  dragPlane.setFromNormalAndCoplanarPoint(
    camera.getWorldDirection(new THREE.Vector3()),
    dragObject.position
  );
  
  // 计算交点
  const intersectPoint = new THREE.Vector3();
  raycaster.ray.intersectPlane(dragPlane, intersectPoint);
  
  // 应用位置(考虑偏移)
  dragObject.position.copy(intersectPoint.sub(dragOffset));
}

function onDragEnd() {
  dragObject = null;
  canvas.removeEventListener('mousemove', onDragMove);
  canvas.removeEventListener('mouseup', onDragEnd);
}

3. 高级交互技术
3.1 变换控制器(TransformControls)
<script setup>
import { TransformControls } from 'three/addons/controls/TransformControls.js';

const transformControls = ref(null);

onMounted(() => {
  // 创建变换控制器
  transformControls.value = new TransformControls(
    camera, 
    renderer.domElement
  );
  
  // 监听变换事件
  transformControls.value.addEventListener('dragging-changed', (event) => {
    orbitControls.enabled = !event.value;
  });
  
  scene.add(transformControls.value);
});

// 绑定到选中物体
watch(selectedObject, (obj) => {
  if (obj) {
    transformControls.value.attach(obj);
  } else {
    transformControls.value.detach();
  }
});
</script>
3.2 碰撞检测
// 使用Cannon.js进行物理碰撞检测
const physicsWorld = new CANNON.World();

// 创建物理体
const physicsBody = new CANNON.Body({
  mass: 0, // 静态物体
  shape: new CANNON.Box(new CANNON.Vec3(1, 1, 1))
});

// 在拖拽中检测碰撞
function onDragMove() {
  // 更新物理体位置
  physicsBody.position.copy(dragObject.position);
  
  // 检测碰撞
  physicsWorld.step(1/60);
  const collisions = physicsWorld.contacts;
  
  if (collisions.length > 0) {
    // 处理碰撞反馈(如震动、变色)
    gsap.to(dragObject.material.color, {
      r: 1, g: 0, b: 0,
      duration: 0.2,
      yoyo: true,
      repeat: 1
    });
  }
}
3.3 多物体选择
const selectedObjects = ref([]);

function onClick(event) {
  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects(scene.children);
  
  if (intersects.length > 0) {
    const object = intersects[0].object;
    
    // Ctrl多选
    if (event.ctrlKey) {
      const index = selectedObjects.value.indexOf(object);
      if (index === -1) {
        selectedObjects.value.push(object);
      } else {
        selectedObjects.value.splice(index, 1);
      }
    } else {
      selectedObjects.value = [object];
    }
  } else {
    selectedObjects.value = [];
  }
}

4. Vue3实战:交互式3D展厅
4.1 项目结构
src/
  ├── components/
  │    ├── ExhibitionViewer.vue   // 3D展厅主组件
  │    ├── ExhibitInfo.vue        // 展品信息面板
  │    ├── Toolbar.vue            // 操作工具栏
  │    └── ExhibitThumbnails.vue  // 展品缩略图列表
  └── App.vue
4.2 展厅主组件
<!-- ExhibitionViewer.vue -->
<template>
  <div class="exhibition-viewer">
    <canvas ref="canvasRef"></canvas>
    <ExhibitInfo :exhibit="selectedExhibit" />
    <Toolbar @mode-change="setMode" />
    <ExhibitThumbnails :exhibits="exhibits" @select="selectExhibit" />
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue';
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { TransformControls } from 'three/addons/controls/TransformControls.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

// 展品数据
const exhibits = reactive([
  { id: 1, name: '雕塑', model: 'sculpture.gltf', position: [0, 0, 0] },
  { id: 2, name: '花瓶', model: 'vase.gltf', position: [2, 0, -1] },
  // 更多展品...
]);

const selectedExhibit = ref(null);
const interactionMode = ref('view'); // 'view' or 'edit'

// 初始化展厅
const initExhibition = async () => {
  const loader = new GLTFLoader();
  
  // 加载所有展品
  for (const exhibit of exhibits) {
    const gltf = await loader.loadAsync(`models/${exhibit.model}`);
    const model = gltf.scene;
    model.position.set(...exhibit.position);
    model.userData = { exhibitId: exhibit.id };
    scene.add(model);
  }
};

// 选择展品
const selectExhibit = (exhibit) => {
  // 通过射线检测或缩略图点击选择
  selectedExhibit.value = exhibit;
  
  // 定位相机到展品
  if (exhibit) {
    const model = scene.children.find(m => m.userData.exhibitId === exhibit.id);
    cameraControls.value.fitToObject(model, true);
  }
};

// 设置交互模式
const setMode = (mode) => {
  interactionMode.value = mode;
  if (mode === 'edit') {
    transformControls.visible = true;
  } else {
    transformControls.visible = false;
  }
};

// 保存展品位置
const saveExhibitPositions = () => {
  exhibits.forEach(exhibit => {
    const model = scene.children.find(m => m.userData.exhibitId === exhibit.id);
    if (model) {
      exhibit.position = [model.position.x, model.position.y, model.position.z];
    }
  });
};
</script>
4.3 展品信息面板
<!-- ExhibitInfo.vue -->
<template>
  <div v-if="exhibit" class="info-panel">
    <h2>{{ exhibit.name }}</h2>
    <p>{{ exhibit.description }}</p>
    <button v-if="editMode" @click="removeExhibit">移除</button>
  </div>
</template>

<script setup>
defineProps(['exhibit']);
const emit = defineEmits(['remove']);

const removeExhibit = () => {
  emit('remove', exhibit.id);
};
</script>
4.4 工具栏组件
<!-- Toolbar.vue -->
<template>
  <div class="toolbar">
    <button :class="{ active: mode === 'view' }" @click="setMode('view')">查看</button>
    <button :class="{ active: mode === 'edit' }" @click="setMode('edit')">编辑</button>
    <button @click="addExhibit">添加展品</button>
    <button @click="saveLayout">保存布局</button>
  </div>
</template>

<script setup>
const emit = defineEmits(['mode-change', 'add-exhibit', 'save-layout']);

const mode = ref('view');

const setMode = (newMode) => {
  mode.value = newMode;
  emit('mode-change', newMode);
};

const addExhibit = () => {
  emit('add-exhibit');
};

const saveLayout = () => {
  emit('save-layout');
};
</script>
4.5 展品缩略图列表
<!-- ExhibitThumbnails.vue -->
<template>
  <div class="thumbnails">
    <div v-for="exhibit in exhibits" 
         :key="exhibit.id"
         class="thumbnail"
         :class="{ active: exhibit === selected }"
         @click="select(exhibit)">
      <img :src="exhibit.thumbnail" :alt="exhibit.name">
      <span>{{ exhibit.name }}</span>
    </div>
  </div>
</template>

<script setup>
defineProps({
  exhibits: Array,
  selected: Object
});

const emit = defineEmits(['select']);

const select = (exhibit) => {
  emit('select', exhibit);
};
</script>

5. 触摸屏适配
5.1 触摸事件处理
// 添加触摸事件
canvas.addEventListener('touchstart', onTouchStart);
canvas.addEventListener('touchmove', onTouchMove);
canvas.addEventListener('touchend', onTouchEnd);

function onTouchStart(event) {
  event.preventDefault();
  
  // 获取第一个触摸点
  const touch = event.touches[0];
  
  // 模拟鼠标事件
  const mouseEvent = new MouseEvent('mousedown', {
    clientX: touch.clientX,
    clientY: touch.clientY
  });
  
  onMouseDown(mouseEvent);
}

function onTouchMove(event) {
  event.preventDefault();
  
  const touch = event.touches[0];
  const mouseEvent = new MouseEvent('mousemove', {
    clientX: touch.clientX,
    clientY: touch.clientY
  });
  
  onMouseMove(mouseEvent);
}

function onTouchEnd(event) {
  event.preventDefault();
  const mouseEvent = new MouseEvent('mouseup');
  onMouseUp(mouseEvent);
}
5.2 手势识别
// 双指缩放
let initialDistance = 0;

function handlePinch(event) {
  if (event.touches.length === 2) {
    const dx = event.touches[0].clientX - event.touches[1].clientX;
    const dy = event.touches[0].clientY - event.touches[1].clientY;
    const distance = Math.sqrt(dx * dx + dy * dy);
    
    if (initialDistance === 0) {
      initialDistance = distance;
    } else {
      const zoomFactor = distance / initialDistance;
      camera.zoom = Math.max(0.1, Math.min(5, initialZoom * zoomFactor));
      camera.updateProjectionMatrix();
    }
  } else {
    initialDistance = 0;
  }
}

6. 性能优化
6.1 交互物体分组
// 创建交互组
const interactiveGroup = new THREE.Group();
scene.add(interactiveGroup);

// 添加可交互物体
exhibits.forEach(exhibit => {
  exhibit.model.userData.interactive = true;
  interactiveGroup.add(exhibit.model);
});

// 检测时只检查该组
raycaster.intersectObjects(interactiveGroup.children);
6.2 空间分割优化
// 使用八叉树加速检测
import { Octree } from 'three/addons/math/Octree.js';

const octree = new Octree();
octree.fromGraphNode(scene);

function raycast() {
  // 使用八叉树检测
  const intersects = octree.raycast(raycaster);
  // ...
}
6.3 GPU拾取技术
// 创建离屏渲染目标
const pickingTexture = new THREE.WebGLRenderTarget(1, 1);

// 给每个物体分配唯一ID
let objectId = 1;
scene.traverse(obj => {
  if (obj.isMesh) {
    obj.userData.id = objectId++;
  }
});

// 渲染ID到纹理
function renderPicking() {
  const material = new THREE.MeshBasicMaterial({
    color: new THREE.Color().setHex(objectId)
  });
  
  renderer.setRenderTarget(pickingTexture);
  scene.overrideMaterial = material;
  renderer.render(scene, camera);
  scene.overrideMaterial = null;
  renderer.setRenderTarget(null);
}

// 读取像素获取ID
function getObjectId(x, y) {
  const pixelBuffer = new Uint8Array(4);
  renderer.readRenderTargetPixels(
    pickingTexture,
    x, y, 1, 1,
    pixelBuffer
  );
  
  // 将RGB转换为ID
  return (pixelBuffer[0] << 16) | (pixelBuffer[1] << 8) | pixelBuffer[2];
}

7. 常见问题解答

Q1:射线检测不到物体怎么办?

  1. 确认物体在相机视锥内
  2. 检查物体是否被其他物体遮挡
  3. 确认物体已添加到检测数组中
  4. 增加raycaster的far参数

Q2:拖拽时物体跳动?

  • 使用offset补偿交点与物体中心的偏移
  • 确保在同一个平面上移动
  • 使用物理引擎稳定位置

Q3:移动端如何优化交互?

  1. 增加触摸区域
  2. 使用防抖减少事件频率
  3. 提供视觉反馈(如按钮高亮)
  4. 简化复杂交互

8. 总结

通过本文,你已掌握:

  1. 射线检测原理与实现
  2. 基础交互:悬停、点击、拖拽
  3. 高级交互:变换控制、碰撞检测
  4. Vue3集成3D交互的完整流程
  5. 触摸屏适配与手势识别
  6. 交互性能优化技术
  7. 交互式3D展厅的实现

核心原理:Three.js的交互系统基于射线检测技术,通过从相机发射射线并计算与物体的交点,实现精确的3D拾取操作。


下一篇预告

第九篇:调试工具:Three.js Inspector使用
你将学习:

  • 浏览器控制台调试技巧
  • Three.js Inspector安装与使用
  • 场景结构可视化分析
  • 性能指标监控
  • 实时属性调整
  • Vue3集成调试工具

准备好成为Three.js调试大师了吗?让我们揭开场景优化的秘密!