Threejs实现鼠标控制相机+键盘控制模型+点击指定点控制模型移动

1.前言

Threejs实现鼠标控制相机功能,键盘控制模型功能,点击指定点控制模型移动功能

键盘使用WASD控制模型移动效果图:

鼠标移动可控制相机的位置+控制模型移动到指定点效果图:

2.功能拆分

根据以上效果图,可以得到以下三个主要实现的功能

  1. 鼠标移动可以使相机跟随
  2. 通过键盘的wasd按键可控制模型中人物的移动
  3. 可在threejs场景中任意选择一个点控制人物移动过去

3.使用到的技术栈

Vue3 + Vite + Threejs(GLTFLoader,DRACOLoader,Stats,CSS2DObject,CSS2DRenderer) + Element-Plus + GSAP

4.实现功能

1.首先创建一个基础的Vue3+Threejs模板

  1. 创建对应的工程(这里不做详细介绍了,只需要创建一个Vue3的模板就行了)

  2. 下载对应的第三方模块

    js 复制代码
    // 需要以下三个依赖
    yarn add threejs gsap element-plus
  3. 挂载element-plus

    js 复制代码
    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')
  4. 在项目中导入对应的资源

    js 复制代码
    import { onMounted, ref } from 'vue';
    import { ElMessage } from 'element-plus';
    // 导入threejs
    import * as THREE from 'three';
  5. 初始化场景,相机,渲染器等等

    js 复制代码
    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);
  6. 创建一个threejs的渲染函数

    js 复制代码
    // 渲染函数
    function render() {
      // 更新渲染器
      renderer.render(scene, camera);
    
      // 获取时钟从创建到当前所使用的时间
      let delta = clock.getDelta();
    
      // 根据时间差来控制动画的播放
      mixer && mixer.update(delta);
    
      // 重新渲染
      requestAnimationFrame(render);
    }
    
    onMounted(() => {
      render();
    })
  7. 创建地板网格与场景中的环境光,平行等等

    js 复制代码
    // 地板网格
    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);
    });
  8. 导入模型,并且设置模型的贴图与材质等等

    js 复制代码
    // 加载模型
    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;
      });
    };
  9. 实现相机视角跟随鼠标功能

    js 复制代码
    // 创建一个二维向量,存储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
      );
  10. 实现按下WASD控制模型的移动功能

    js 复制代码
    // 每次移动的距离
    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();
        }
      });
  11. 实现控制模型移动到点击的地方

    js 复制代码
    // 监听点击事件,获取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.结尾

以上就是本次分享的全部内容了,如果有疑问或者好的意见欢迎私信😁😁😁

相关推荐
码农小白19 小时前
qt学习:linux监听键盘alt+b和鼠标移动事件
学习·计算机外设
那就举个栗子!19 小时前
多传感器融合slam过程解析【大白话版】
数码相机
计算机毕设孵化场20 小时前
计算机毕设-基于springboot的多彩吉安红色旅游网站的设计与实现(附源码+lw+ppt+开题报告)
vue.js·spring boot·后端·计算机外设·课程设计·计算机毕设论文·多彩吉安红色旅游网站
xy189901 天前
相机触发模式
数码相机
kejijianwen1 天前
沸蛇鼠标,多功能智慧AI,重新定义生产力
人工智能·计算机外设
一秒美工助手1 天前
鼠标经过遮罩效果 详情页阿里巴巴国际站外贸跨境电商装修运营 详情页装修无线端装修手机装修设计代码证书滚动特效效果代码,自定义内容代码模板模块设计设置装修
前端·javascript·html·计算机外设
yunfanleo2 天前
代替Spinnaker 的 POINTGREY工业级相机 FLIR相机 Python编程案例
c++·python·数码相机
摆烂仙君3 天前
《Camera-free Image to PanoramaGeneration with Diffusion Model》论文解析——CamFreeDiff
人工智能·数码相机·计算机视觉
weixin_Todd_Wong20103 天前
【海思Hi3519DV500】双目网络相机套板硬件规划方案
数码相机
isyoungboy3 天前
普通单片机为什么无法直接驱动dcmi接口相机
单片机·嵌入式硬件·数码相机