第八篇:交互入门:鼠标拾取物体_jigged objects
第八篇:交互入门:鼠标拾取物体
引言
交互是3D应用的核心灵魂,它让用户从旁观者变为参与者。Three.js提供了强大的射线检测(Raycaster)功能,可实现物体拾取、拖拽等交互效果。本文将深入解析交互技术原理,并通过Vue3实现一个交互式3D展厅,让你掌握用户与3D世界沟通的桥梁技术。
1. 射线检测(Raycaster)原理
1.1 射线检测流程
#mermaid-svg-raDpuwrVw0SKt1TI {font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-raDpuwrVw0SKt1TI .error-icon{fill:#552222;}#mermaid-svg-raDpuwrVw0SKt1TI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-raDpuwrVw0SKt1TI .edge-thickness-normal{stroke-width:2px;}#mermaid-svg-raDpuwrVw0SKt1TI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-raDpuwrVw0SKt1TI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-raDpuwrVw0SKt1TI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-raDpuwrVw0SKt1TI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-raDpuwrVw0SKt1TI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-raDpuwrVw0SKt1TI .marker.cross{stroke:#333333;}#mermaid-svg-raDpuwrVw0SKt1TI svg{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-raDpuwrVw0SKt1TI .label{font-family:\"trebuchet ms\",verdana,arial,sans-serif;color:#333;}#mermaid-svg-raDpuwrVw0SKt1TI .cluster-label text{fill:#333;}#mermaid-svg-raDpuwrVw0SKt1TI .cluster-label span{color:#333;}#mermaid-svg-raDpuwrVw0SKt1TI .label text,#mermaid-svg-raDpuwrVw0SKt1TI span{fill:#333;color:#333;}#mermaid-svg-raDpuwrVw0SKt1TI .node rect,#mermaid-svg-raDpuwrVw0SKt1TI .node circle,#mermaid-svg-raDpuwrVw0SKt1TI .node ellipse,#mermaid-svg-raDpuwrVw0SKt1TI .node polygon,#mermaid-svg-raDpuwrVw0SKt1TI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-raDpuwrVw0SKt1TI .node .label{text-align:center;}#mermaid-svg-raDpuwrVw0SKt1TI .node.clickable{cursor:pointer;}#mermaid-svg-raDpuwrVw0SKt1TI .arrowheadPath{fill:#333333;}#mermaid-svg-raDpuwrVw0SKt1TI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-raDpuwrVw0SKt1TI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-raDpuwrVw0SKt1TI .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-svg-raDpuwrVw0SKt1TI .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-svg-raDpuwrVw0SKt1TI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-raDpuwrVw0SKt1TI .cluster text{fill:#333;}#mermaid-svg-raDpuwrVw0SKt1TI .cluster span{color:#333;}#mermaid-svg-raDpuwrVw0SKt1TI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-raDpuwrVw0SKt1TI :root{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;}鼠标点击屏幕标准化设备坐标相机发射射线检测与物体交点返回最近交点
1.2 核心代码实现
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);}
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 悬停高亮效果
// 当前悬停的物体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; }});
2.2 点击选择物体
{{ selectedObject.name }}
位置: {{ selectedObject.position.toArray() }}
const selectedObject = ref(null);function onClick() { if (intersectedObjects.value.length > 0) { selectedObject.value = intersectedObjects.value[0]; } else { selectedObject.value = null; }}
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)
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(); }});
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 展厅主组件
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]; } });};
4.3 展品信息面板
{{ exhibit.name }}
{{ exhibit.description }}
defineProps([\'exhibit\']);const emit = defineEmits([\'remove\']);const removeExhibit = () => { emit(\'remove\', exhibit.id);};
4.4 工具栏组件
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\');};
4.5 展品缩略图列表
{{ exhibit.name }} defineProps({ exhibits: Array, selected: Object});const emit = defineEmits([\'select\']);const select = (exhibit) => { emit(\'select\', exhibit);};
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);// 给每个物体分配唯一IDlet 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);}// 读取像素获取IDfunction 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:射线检测不到物体怎么办?
- 确认物体在相机视锥内
- 检查物体是否被其他物体遮挡
- 确认物体已添加到检测数组中
- 增加raycaster的far参数
Q2:拖拽时物体跳动?
- 使用offset补偿交点与物体中心的偏移
- 确保在同一个平面上移动
- 使用物理引擎稳定位置
Q3:移动端如何优化交互?
- 增加触摸区域
- 使用防抖减少事件频率
- 提供视觉反馈(如按钮高亮)
- 简化复杂交互
8. 总结
通过本文,你已掌握:
- 射线检测原理与实现
- 基础交互:悬停、点击、拖拽
- 高级交互:变换控制、碰撞检测
- Vue3集成3D交互的完整流程
- 触摸屏适配与手势识别
- 交互性能优化技术
- 交互式3D展厅的实现
核心原理:Three.js的交互系统基于射线检测技术,通过从相机发射射线并计算与物体的交点,实现精确的3D拾取操作。
下一篇预告
第九篇:调试工具:Three.js Inspector使用
你将学习:
- 浏览器控制台调试技巧
- Three.js Inspector安装与使用
- 场景结构可视化分析
- 性能指标监控
- 实时属性调整
- Vue3集成调试工具
准备好成为Three.js调试大师了吗?让我们揭开场景优化的秘密!