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.结尾

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

相关推荐
李小白杂货铺5 小时前
显示器最佳分辨率设置
计算机外设·显示器·内置显示器·独立显示器·最佳分辨率
高效办公能手12 小时前
图片翻译器,分享四款直接翻译图片的软件!
数码相机
狂睡GG爆13 小时前
开放式耳机伤耳朵吗?分享四款不伤耳的开放式蓝牙耳机
计算机外设
有梦想的鱼21 小时前
杂牌鼠标侧键设置
计算机外设
shuxianshrng1 天前
鹰眼降尘系统怎么样
大数据·服务器·人工智能·数码相机·物联网
wow2ok1 天前
天融信把桌面explorer.exe删了,导致开机之后无windows桌面,只能看到鼠标解决方法
windows·计算机外设
鬼臾区1 天前
展锐平台手机camera 软硬件架构
数码相机·智能手机·硬件架构
光学测量小菜鸡1 天前
线结构光测量系统标定--导轨
数码相机·算法·3d
哲伦贼稳妥2 天前
一天认识一个硬件之显示器
经验分享·其他·计算机外设
培林将军2 天前
C51单片机-单按键输入识别,键盘消抖
单片机·嵌入式硬件·计算机外设