第八篇:交互入门:鼠标拾取物体
引言
交互是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 核心代码实现
<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:射线检测不到物体怎么办?
Q2:拖拽时物体跳动?
- 使用offset补偿交点与物体中心的偏移
- 确保在同一个平面上移动
- 使用物理引擎稳定位置
Q3:移动端如何优化交互?
8. 总结
通过本文,你已掌握:
核心原理:Three.js的交互系统基于射线检测技术,通过从相机发射射线并计算与物体的交点,实现精确的3D拾取操作。
下一篇预告
第九篇:调试工具:Three.js Inspector使用
你将学习:
- 浏览器控制台调试技巧
- Three.js Inspector安装与使用
- 场景结构可视化分析
- 性能指标监控
- 实时属性调整
- Vue3集成调试工具
准备好成为Three.js调试大师了吗?让我们揭开场景优化的秘密!
评论前必须登录!
注册