1.前言
Threejs实现鼠标控制相机功能,键盘控制模型功能,点击指定点控制模型移动功能
键盘使用WASD控制模型移动效果图:
鼠标移动可控制相机的位置+控制模型移动到指定点效果图:
2.功能拆分
根据以上效果图,可以得到以下三个主要实现的功能
- 鼠标移动可以使相机跟随
- 通过键盘的wasd按键可控制模型中人物的移动
- 可在
threejs
场景中任意选择一个点控制人物移动过去
3.使用到的技术栈
Vue3 + Vite + Threejs(GLTFLoader,DRACOLoader,Stats,CSS2DObject,CSS2DRenderer) + Element-Plus + GSAP
4.实现功能
1.首先创建一个基础的Vue3+Threejs
模板
- 创建对应的工程(这里不做详细介绍了,只需要创建一个Vue3的模板就行了)
- 下载对应的第三方模块
// 需要以下三个依赖 yarn add threejs gsap element-plus
- 挂载
element-plus
import { createApp } from 'vue' import './style.css' import App from './App.vue' import ElementPlus from 'element-plus'; // 导入element-plus 组件库 import 'element-plus/dist/index.css'; // 导入element-plsu css文件 createApp(App).use(ElementPlus).mount('#app')
- 在项目中导入对应的资源
import { onMounted, ref } from 'vue'; import { ElMessage } from 'element-plus'; // 导入threejs import * as THREE from 'three';
- 初始化场景,相机,渲染器等等
const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 5000); camera.position.set(0, 83, 90); const renderer = new THREE.WebGL1Renderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setClearColor('#a9dfbe'); scene.add(camera);
- 创建一个threejs的渲染函数
// 渲染函数 function render() { // 更新渲染器 renderer.render(scene, camera); // 获取时钟从创建到当前所使用的时间 let delta = clock.getDelta(); // 根据时间差来控制动画的播放 mixer && mixer.update(delta); // 重新渲染 requestAnimationFrame(render); } onMounted(() => { render(); })
- 创建地板网格与场景中的环境光,平行等等
// 地板网格 const gridHelper = new THREE.GridHelper(1000, 10); scene.add(gridHelper); // 环境光 const light1 = new THREE.AmbientLight(0xffffff, 2); // 柔和的白光 scene.add(light1); // 循环生成平行光 const lightArr = [ { x: 10, y: 100, z: 10, }, { x: 20, y: 100, z: 20, }, { x: 30, y: 100, z: 30, }, { x: 40, y: 100, z: 40, }, { x: 50, y: 100, z: 50, }, { x: -10, y: 100, z: -10, }, { x: -20, y: 100, z: -20, }, { x: -30, y: 100, z: -30, }, { x: -40, y: 100, z: -40, }, { x: -50, y: 100, z: -50, }, ]; lightArr.forEach(({ x, y, z }) => { const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5); directionalLight.position.set(x, y, z); scene.add(directionalLight); });
- 导入模型,并且设置模型的贴图与材质等等
// 加载模型 const loadModel = () => { // 实例化解析器 const gltfLoader = new GLTFLoader(); const dracoLoader = new DRACOLoader(); // 设置draco解压glft模型的解析器路径 dracoLoader.setDecoderPath('./draco/gltf/'); // 给gltf模型解析器设置解压解析器 gltfLoader.setDRACOLoader(dracoLoader); // 导入人物模型 gltfLoader.load('./character.glb', model => { // 获取模型 character = model; // 设置相机跟随模型 camera.lookAt(model.scene.position); // 调整模型的尺寸以及位置 character.scene.scale.set(35, 35, 35); // 添加名称在模型上方 addModelName(character.scene); // 模型导入场景中 scene.add(character.scene); // 根据模型实例化动画 mixer = new THREE.AnimationMixer(model.scene); // 模型支持的动画列表 animations = { 等: mixer.clipAction(character.animations[0]), 跑: mixer.clipAction(character.animations[1]), 停: mixer.clipAction(character.animations[2]), 走: mixer.clipAction(character.animations[3]), }; // 默认首先执行等待动画 animations['等'].play(); // 修改当前执行的动画标识为等待 currentAnimation = '等'; }); // 导入问号模型 gltfLoader.load('./question.glb', model => { // 保存模型数据 question = model.scene; // 设置模型的垂直位置 question.position.y = 20; // 设置模型的大小 question.scale.set(0.05, 0.05, 0.05); // 循环设置模型的材质 question.traverse(function (gltf) { // 问号的边框 if (gltf.name === 'questionmark_05_-_Default_0') gltf.material = new THREE.MeshStandardMaterial({ color: '#1E88E5' }); // 问号的主体 if (gltf.name === 'questionmark_03_-_Default_0') { // 导入色彩贴图 let map = textureLoader.load('./color.jpg'); // 设置色彩贴图重复的模式 // 水平镜像重复 map.wrapS = THREE.RepeatWrapping; // 垂直镜像重复 map.wrapT = THREE.MirroredRepeatWrapping; // 导入金属贴图 let metalness = textureLoader.load('./metalness.png'); // 设置金属贴图重复的模式 // 水平镜像重复 metalness.wrapS = THREE.RepeatWrapping; // 垂直镜像重复 metalness.wrapT = THREE.MirroredRepeatWrapping; // 设置问号主体模型的材质 gltf.material = new THREE.MeshStandardMaterial({ // 默认贴图 map, // 金属程度 metalness: 1, // 金属贴图 metalnessMap: metalness, }); } }); // 模型添加到场景中 scene.add(question); // 设置模型自动旋转 setInterval(() => { if (question.rotation.y >= Math.PI * 2) question.rotation.y = 0; else question.rotation.y += 0.1; }, 30); // 默认隐藏模型,当触发点击行为时,问号将会出现在点击的位置 question.visible = false; }); };
- 实现相机视角跟随鼠标功能
// 创建一个二维向量,存储xy const mouse = new THREE.Vector2(); // 监听鼠标移动事件 window.addEventListener( 'mousemove', e => { // 非鼠标控制视图模式不触发 if (controlType.value === 'target_point' || controlType.value === 'control') return; // 获取鼠标位置 mouse.x = ((e.clientX / window.innerWidth) * 2 - 1) * 100; mouse.y = (-(e.clientY / window.innerHeight) * 2 + 1) * 100; // 将相机聚焦到这个点 camera.lookAt(mouse.x + camera.position.x, mouse.y + camera.position.y, camera.position.z - 50); // 修改当前的控制状态为鼠标跟随状态 controlType.value = 'mouse_follow'; }, false );
- 实现按下WASD控制模型的移动功能
// 每次移动的距离 const move_distance = 0.5; // 各个key的启用状态 let keyStatus = { w: false, a: false, s: false, d: false, }; // 相机更新定时器 let cameraTimer = null; // 监听全局的鼠标按下方法 window.addEventListener('keydown', ({ key }) => { // 模型未加载成功不处理 if (!character) return; // 将key转换为小写,避免大写的WASD无法操作 key = key.toLowerCase(); // 判断是否点击了wasd if (!'wasd'.includes(key)) return; // 判断当前是否为移动到指定点状态 if (controlType.value === 'target_point') return ElMessage.warning('当前正在移动到指定点,无法使用键盘控制'); // 保存当前模型的坐标 let position = { ...character.scene.position }; // 设置控制状态为wasd移动状态 controlType.value = 'control'; // 当点击w时,自动取消掉s的移动 key === 'w' && (keyStatus['s'] = false); // 当点击a时,自动取消掉d的移动 key === 'a' && (keyStatus['d'] = false); // 当点击d时,自动取消掉a的移动 key === 's' && (keyStatus['w'] = false); // 当点击s时,自动取消掉w的移动 key === 'd' && (keyStatus['a'] = false); // 获取当前按下的按钮移动状态,如果当前按钮的按钮正在移动时,则不允许再次启动移动 if (keyStatus[key]) return; // 将当前按下的按钮的状态改为true keyStatus[key] = true; // 首先清除一次相机更新定时器 clearInterval(cameraTimer); // 添加相机更新 cameraTimer = setInterval(() => { // 开启w按钮定时器 keyStatus['w'] && (position.z -= move_distance); keyStatus['a'] && (position.x -= move_distance); keyStatus['d'] && (position.x += move_distance); keyStatus['s'] && (position.z += move_distance); // 解构获取移动后的坐标 let { x, y, z } = position; // 更新模型的朝向(这里character.scene.position还没更新,所以还是之前的位置) character.scene.rotation.y = calculateAngle(character.scene.position.x, -character.scene.position.z, position.x, -position.z) - Math.PI / 2; // 更新位置 character.scene.position.set(x, y, z); // 获取模型的位置 const modelPosition = character.scene.position; // 设置相机的位置在模型的上方 camera.position.set(modelPosition.x, 83, modelPosition.z + 90); // 确保相机始终朝向模型 camera.lookAt(modelPosition); }); // 执行走动画 animations['走'].reset().setEffectiveTimeScale(1).setEffectiveWeight(1).fadeIn(0.2).play(); }); // 监听键盘抬起事件 window.addEventListener('keyup', ({ key }) => { // 判断当前是否为移动到指定点状态 if (controlType.value === 'target_point') return; // 模型未加载成功不处理 if (!character) return; // 将key转换为小写,避免大写的WASD无法操作 key = key.toLowerCase(); // 设置对应按钮可继续操作 keyStatus[key] = false; // 所有按钮都被松开 if (!keyStatus.w && !keyStatus.a && !keyStatus.s && !keyStatus.d) { // 设置控制状态为鼠标跟随状态 controlType.value = 'mouse_follow'; // 清除相机更新定时器 clearInterval(cameraTimer); // 停止走/跑运动 animations['走'].fadeOut(0.3); // 执行等待动画 animations['等'].reset().setEffectiveWeight(1).fadeIn(0.1).play(); } });
- 实现控制模型移动到点击的地方
// 监听点击事件,获取threejs中点击的坐标 renderer.domElement.addEventListener('click', pickupObjects); // 拾取对象 function pickupObjects(event) { // wasd控制状态无法移动到指定点 if (controlType.value === 'control') return ElMessage.warning('当前处于键盘控制状态,无法移动到指定点'); // 点击屏幕创建光线投射用于进行鼠标拾取 var raycaster = new THREE.Raycaster(); // 将鼠标位置归一化为设备坐标. x 和 y 方向的取值范围是 (-1 to +1) var vector = new THREE.Vector2(); vector.x = (event.clientX / window.innerWidth) * 2 - 1; vector.y = -(event.clientY / window.innerHeight) * 2 + 1; // 创建平面 设置平面法向量Vector3 和 原点到平面距离(这里表示无限平面以Y轴为标准平铺在threejs场景中的) var groundplane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // 通过相机和鼠标位置更新射线投射器 raycaster.setFromCamera(vector, camera); // 接受射线和平面的交点(也就是鼠标点击的3D坐标) var intersectionPoint = new THREE.Vector3(); // 计算射线和平面的交点 raycaster.ray.intersectPlane(groundplane, intersectionPoint); // intersectionPoint 就是点击位置的世界坐标 currentAnimation === '等' && modelMoveTarget(character.scene, intersectionPoint); // 设置控制状态为移动到指定点 controlType.value = 'target_point'; } // 模型移动动画 const modelMoveTarget = (model, target) => { // 当前位置 let originPositon = new THREE.Vector3(model.position.x, 0, model.position.z); // 目标位置 let targetPositon = new THREE.Vector3(target.x, 0, target.z); // 计算移动距离 let distance = originPositon.distanceTo(targetPositon); // 当前距离与目标距离之间的距离差值小于等于1则目标点在当前点的位置,不需要移动 if (distance <= 1) return; // 添加当前点与目标点的线条 let line = addLine(originPositon, targetPositon); // 旋转模型到目标点的方向(指定目标点相对于当前目标点的相对角度) model.rotation.y = calculateAngle(originPositon.x, -originPositon.z, targetPositon.x, -targetPositon.z) - Math.PI / 2; // 显示问号模型 question.visible = true; // 设置问号模型的位置 question.position.set(targetPositon.x, 20, targetPositon.z); // 距离小于300时执行走动画,大于300时执行跑动画 const isFar = distance > 300; // 记录当前的动画类型 currentAnimation = isFar ? '跑' : '走'; // 将等待动画权重降低 animations['等'].fadeOut(0.2); // 执行跑/走动画 animations[isFar ? '跑' : '走'] .reset() .setEffectiveTimeScale(isFar ? 1 : 2) .setEffectiveWeight(1) .fadeIn(0.2) .play(); // 使用gsap动画执行运动动画 gsap.to(model.position, { ...targetPositon, // 这里配置运动时间与距离挂钩, 距离越远, 运动时间越长 duration: parseInt(distance) / (isFar ? 50 : 25), // 动画轨迹为线性轨迹 esas: 'none', // 动画运动回调 onUpdate() { // 更新线条的距离 line = addLine(new THREE.Vector3(model.position.x, 0, model.position.z), targetPositon); // 当执行的整体进度大于(跑时大于0.95,走时大于0.85)时,则执行动画执行完成回调,这样会有较好的过渡效果 if (this.progress() >= (isFar ? 0.95 : 0.85) && currentAnimation !== '等') { // 停止走/跑运动 animations[isFar ? '跑' : '走'].fadeOut(0.3); // 执行等待动画 animations['等'].reset().setEffectiveWeight(1).fadeIn(0.1).play(); // 隐藏问号模型 question.visible = false; // 删除距离线条 scene.remove(line); // 设置控制状态为鼠标控制状态 controlType.value = 'mouse_follow'; } else { // 设置相机的位置在模型的上方 camera.position.set(model.position.x, 83, model.position.z + 90); } }, // 监听动画执行完成 onComplete() { // 修改当前执行的动画 currentAnimation = '等'; }, }); }; // 计算坐标2相对于坐标1的角度 function calculateAngle(x1, y1, x2, y2) { // 计算坐标差值 const dx = x2 - x1; const dy = y2 - y1; // 计算角度(弧度值) let angleRad = Math.atan2(dy, dx); return angleRad; // 直接返回弧度值,即正负π之间的值 }
5.关键点与流程解释
1. 鼠标移动相机视角跟随功能:
- 通过监听鼠标移动事件获取到当前鼠标在浏览器视图中的2D坐标
- 之后计算转为
Threejs
场景中的3D的坐标(这里只有3D的x坐标与y坐标,z坐标默认为固定值) - 获取之后再原先相机的位置基础上加上计算得出的xy坐标即可实现此功能
2. 通过WASD控制人物模型移动功能
- 首先这里需要两个标识变量,分别为
keyStatus
用于标识WASD的启用状态,cameraTimer
用来更新相机的位置 - 之后分别监听键盘按下与松开方法,判断按下的是哪个键,就设置对应键的
keyStatus
为true,表示在此方向移动模型 - 这里注意,当前监听到按下W键时,应该自动将S键改为false,即使当前正在按着S键,这是为了避免W键与S键冲突,让后面按下的键可以覆盖之前按下的键的行为,并且按下S,按下A或者D时,都应该控制对应的键的状态为false
- 然后获取移动之前的模型的坐标,注意这里使用了浅拷贝,将原先模型的xyz拷贝到一个新的对象中,避免直接修改影响模型(直接修改模型的各个方向的坐标容易导致模型闪烁)
- 根据按下的WASD对浅拷贝出来的坐标进行修改
- 然后创建一个定时器(
cameraTimer
)用于更新模型坐标以及模型的朝向,并且控制执行模型动画 - 监听按键松开事件,判断松开的是什么按键,调整对应按键的
keyStatus
值为false - 在监听按钮松开事件中判断是否所有的
keyStatus
都为false,这表示WASD按键都松开了,这里处理将更新相机坐标与模型坐标定时器(cameraTimer
)清除,并且停止模型的走
动画,执行停止动画
3. 指定点控制模型移动过去
- 首先监听渲染器挂载的dom的点击时间,这里注意不要监听全局的点击时间,因为可能点击了其他了区域,并非Threejs的场景区域也会导致触发这里的点击
- 监听到点击后通过点击的xy坐标计算出Threejs场景中的xy坐标
- 设置点击的xy坐标为目标坐标,当前的xy为当前坐标,计算得出距离
- 判断距离是否超出预设值,超出预设值则执行
跑
动画,未超出则执行走
动画 - 使用gsap更新模型的坐标到目标坐标
6.结尾
以上就是本次分享的全部内容了,如果有疑问或者好的意见欢迎私信😁😁😁