第八篇:交互入门:鼠标拾取物体
第八篇:交互入门:鼠标拾取物体
引言
交互是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转换为IDreturn (pixelBuffer[0] << 16) | (pixelBuffer[1] << 8) | pixelBuffer[2];
}
7. 常见问题解答
Q1:射线检测不到物体怎么办?
- 确认物体在相机视锥内
- 检查物体是否被其他物体遮挡
- 确认物体已添加到检测数组中
- 增加raycaster的far参数
Q2:拖拽时物体跳动?
- 使用offset补偿交点与物体中心的偏移
- 确保在同一个平面上移动
- 使用物理引擎稳定位置
Q3:移动端如何优化交互?
- 增加触摸区域
- 使用防抖减少事件频率
- 提供视觉反馈(如按钮高亮)
- 简化复杂交互
8. 总结
通过本文,你已掌握:
- 射线检测原理与实现
- 基础交互:悬停、点击、拖拽
- 高级交互:变换控制、碰撞检测
- Vue3集成3D交互的完整流程
- 触摸屏适配与手势识别
- 交互性能优化技术
- 交互式3D展厅的实现
核心原理:Three.js的交互系统基于射线检测技术,通过从相机发射射线并计算与物体的交点,实现精确的3D拾取操作。
下一篇预告
第九篇:调试工具:Three.js Inspector使用
你将学习:
- 浏览器控制台调试技巧
- Three.js Inspector安装与使用
- 场景结构可视化分析
- 性能指标监控
- 实时属性调整
- Vue3集成调试工具
准备好成为Three.js调试大师了吗?让我们揭开场景优化的秘密!