ThreeJS搭配手势识别实现简易 VR运动

1,682 阅读5分钟

大家好,我是王大傻。我又来了,随着对ThreeJS的学习,这次给大家带来的是手势识别+ThreeJS实现的简单的人物运动。由于前端库的局限性,暂时找的这个库只识别了人脸以及手的张开闭合,所以做了一个简单的转体运动。起因是因为在群里看到了其他小伙伴做的飞机飞行的demo,当时以为是手势,后来知道是陀螺仪。

项目准备

项目依然是放在了GIthub上,在上一篇文章的基础上加了人物的模型,以及人物的骨骼动画。还有就是新增了一个shader的大海材质。(PS:原本想写个3D的神庙逃亡来着)

项目技术栈

  • Vite
  • Vue3
  • ThreeJS
  • Gsap
  • HandTrack(手势库)

设计初衷

  • 人物的移动
  • 人物模型+骨骼动画 (跑步、走路、跳跃、静止时左顾右盼)
  • 碰撞检测

准备工作

人物模型,链接放在这里,可以根据自己的喜好以及动画配置自己的人物模型。

image.png

项目开发

首先是上期我们讲过的,人物是用一个胶囊来替代了,这期我们需要把他做成真正的人。首先我们先让capsule为一个3d的模型,也就是初始化操作。然后我们通过GLTFLoader来对模型进行载入,在载入后我们还需要加入一个半球光源来使机器人的颜色能给我们看到。话不多说代码如下:

// 载入机器人
// 载入机器人模型
// 添加半球光源照亮机器人
export const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0xffffff, 1);
const capsule = new THREE.Object3D();
const loader = new GLTFLoader();
loader.load("./models/RobotExpressive.glb", (gltf) => {
  const robot = gltf.scene;
  robot.scale.set(0.4, 0.4, 0.4);
  robot.position.set(0, -0.85, 0);
  capsule.add(robot);
  })

image.png 加载完基础部分,我们需要设置一个动作的混合器,以供我们不断的调用模型的骨骼动画来和我们做的动作进行适配。那么上述代码将变为如下

// 载入机器人
// 载入机器人模型
const capsule = new THREE.Object3D();
const loader = new GLTFLoader();
//  设置动作混合器
export let mixer: THREE.AnimationMixer;
let actions: { [key: string]: THREE.AnimationAction } = {};
// 设置激活动作
let activeAction: THREE.AnimationAction;
loader.load("./models/RobotExpressive.glb", (gltf) => {
  const robot = gltf.scene;
  robot.scale.set(0.4, 0.4, 0.4);
  robot.position.set(0, -0.85, 0);
  capsule.add(robot);
  mixer = new THREE.AnimationMixer(robot);
  for (let i = 0; i < gltf.animations.length; i++) {
    let name = gltf.animations[i].name;
    actions[name] = mixer.clipAction(gltf.animations[i]);

    if (name !== "Idle" && name !== "Running" && name !== "Walking") {
      actions[name].clampWhenFinished = true; // 结束自动暂停 保留最后一帧
      actions[name].loop = THREE.LoopOnce;
    } else {
      actions[name].clampWhenFinished = false;
      actions[name].loop = THREE.LoopRepeat;
    }
  }
  activeAction = actions["Idle"];
  activeAction.play();
})

在这块我们着重要处理的动画细节就是:跑步,这个要不停运动,所以是重复这个动画。而例如die这些肯定不能多次重复,所以就设置一次。在设置完动作后我们开始更改我们之前的运动了以及一些键盘控制的逻辑,首先是我们要写一个更改动画的函数。

export const fadeToAction = (actionName: string) => {
  let prevAction = activeAction;
  activeAction = actions[actionName];
  if (prevAction != activeAction) {
    prevAction.fadeOut(0.3);
    activeAction
      .reset()
      .setEffectiveTimeScale(1)
      .setEffectiveWeight(1)
      .fadeIn(0.3)
      .play();
    mixer.addEventListener("finished", () => {
      let prevAction = activeAction;
      activeAction = actions["Idle"];
      prevAction.fadeOut(0.3);
      activeAction
        .reset()
        .setEffectiveTimeScale(1)
        .setEffectiveWeight(1)
        .fadeIn(0.3)
        .play();
    });
  }
};

现在万事俱备,只欠我们对运动的逻辑进行更改了。首先我们需要再updatePlayer函数,也就是速度变更函数中加入判定条件

export function updatePlayer(deltaTime: number) {
  let damping = -0.05;
  if (playerOnFloor) {
    playerVelocity.y = 0;

    keyStates.isDown || playerVelocity.addScaledVector(playerVelocity, damping);
  } else {
    playerVelocity.y += gravity * deltaTime;
  }

  // console.log(playerVelocity);
  // 计算玩家移动的距离
  const playerMoveDistance = playerVelocity.clone().multiplyScalar(deltaTime);
  playerCollider.translate(playerMoveDistance);
  // 将胶囊的位置进行设置
  playerCollider.getCenter(capsule.position);
  // 进行碰撞检测
  playerCollisions();

  // 如果有水平的运动 则设置运动的动作
  if (
    Math.abs(playerVelocity.x) + Math.abs(playerVelocity.z) > 0.1 &&
    Math.abs(playerVelocity.x) + Math.abs(playerVelocity.z) <= 3
  ) {
    fadeToAction("Walking");
  } else if (Math.abs(playerVelocity.x) + Math.abs(playerVelocity.z) > 3) {
    fadeToAction("Running");
  } else {
    fadeToAction("Idle");
  }
}

同样在我们的键盘事件里面也要进行一定的判定条件

 document.addEventListener(
        "keyup",
        (event) => {
            // @ts-ignore
            keyStates[event.code] = false;
            keyStates.isDown = false;
            if (event.code === "KeyV") {
                if (!isV.value) {
                    camera.position.set(0, .8, 0)
                    isV.value = true
                } else {
                    camera.position.set(0, 2, -5)
                    isV.value = false
                }
            }
            if (event.code === "KeyQ") {
                createFireworks(scene)
            }
            if (event.code === "KeyT") {
                fadeToAction('Wave')
            }
            if (event.code === "KeyW") {
                playerVelocity.z = 0
            }
            if (event.code === "KeyS") {
                playerVelocity.z = 0
            }
            if (event.code === "KeyA") {
                playerVelocity.x = 0
            }
            if (event.code === "KeyD") {
                playerVelocity.x = 0
            }
            if (event.code === 'ShiftLeft' || event.code === 'ShiftRight') {
                QuickSpeed.length = 0
                modifyQuick(false)
            }


        },
        false
    );

    document.addEventListener("keydown", (event) => {
        if (event.code === "KeyQ") {
            createFireworks(scene)
        }
        if (event.code === "Space") {
            fadeToAction('Jump')
        }
        if (event.code === 'KeyW') {
            if (QuickSpeed.indexOf('Shift') === 0) {
                modifyQuick(true)
                playerDirection.z = 5;
                //获取胶囊的正前面方向
                const capsuleFront = new THREE.Vector3(0, 0, 0);
                capsule.getWorldDirection(capsuleFront);

                // 计算玩家的速度
                playerVelocity.add(capsuleFront.multiplyScalar(delta * 10));

            }
        }
        if (event.code === 'ShiftLeft' || event.code === 'ShiftRight') {
            if (QuickSpeed.indexOf('Shift') === -1) {
                QuickSpeed.push('Shift')
            }
        }

    })

相信细心的朋友也看到了,这块做了双键的操作,同时按下shift+w可以快速进入跑步状态。 在完成这些工作后,我们也开始正式进入我们的手势操作环节。

手势操作

首先介绍下我们今天重中之重的‘嘉宾’---handTrack。github介绍是一款前端的支持摄像头识别的手势库。他总共可以支持七种形式

image.png 详细大家可以通过看官网的案例。 那么既然我们要通过摄像头识别手势,那么首先我们要做的就是创建一个video用来对我们摄像头读取的视频流进行播放。在

视频准备

我们初始化Video标签后,我们需要再Onmounted中进行视频流的读取。

    // 获取用户媒体,包含视频和音频
    navigator.mediaDevices
        .getUserMedia({ video: true, audio: true })
        .then((stream) => {
            video.value!.srcObject = stream 
            // 将捕获的视频流传递给video  放弃window.URL.createObjectURL(stream)的使用
});

这里我们调用了浏览器的底层navigator方法来获取我们的视频流,并将其传给我们创建的Video标签。

image.png

手势库使用

在做完视频后我们就开始我们的手势库的使用了。首先我们封装个方法,因为我们需要不断的进行摄像头图像捕捉,所以我们这个方法不需要返回任何内容。

  const runDetection = () => {
    const context = myCanvas.value.getContext("2d");
    handModel.detect(video).then((predictions: any) => {
      if (predictions.length > 0) {
        status =
          predictions[0].label === undefined
            ? predictions[1]
              ? predictions[1].label
              : predictions[0].label
            : predictions[0].label;
      }
      handModel.renderPredictions(predictions, myCanvas.value, context, video);
      requestAnimationFrame(runDetection);
    });

而这里面的例如video canvas之类的参数则是需要我们在视频加载完成时候给我们库。那么我们的视频加载函数将会变成这样:

    // 获取用户媒体,包含视频和音频
    navigator.mediaDevices
        .getUserMedia({ video: true, audio: true })
        .then((stream) => {
            video.value!.srcObject = stream // 将捕获的视频流传递给video  放弃window.URL.createObjectURL(stream)的使用
            // video.value!.play() //  播放视频
        }).then(res => {
            handTrack.startVideo(document.getElementById('video') as HTMLVideoElement).then(function (data: { status: boolean }) {
                if (data.status) {
                    handTrack.load(modelParams).then((model: any) => {
                        initMytack(model, myCanvas as Ref<HTMLCanvasElement>, document.getElementById('video') as HTMLVideoElement)
                    })
                }
            });
        })
});

image.png 在这个过程中我们通过调用,handTrack库的startVideo方法来判断我们是否正确读取到视频,我们读取到后通过load 方法加载基础配置,关于配置信息呢,在源码中也可以看到:

image.png

//  手势库
const modelParams = {
    flipHorizontal: true, // 翻转摄像头
    maxNumBoxes: 20, // 最大检测数量
    iouThreshold: 0.5, // 阈值
    scoreThreshold: 0.6,
    labelMap: {
        1: "open",
        2: "closed",
        3: "pinch",
        4: "point",
        6: "pointtip",
        7: "pinchtip",
    },
    modelType: "ssd320fpnlite",
};

我们用到的这几个经过大傻的仔细揣摩后,标出了对应的含义,同样的 我们需要传入具体的数据,因此刚才的捕捉函数就是:

export const initMytack = (
  handModel: any,
  myCanvas: Ref<HTMLCanvasElement>,
  video: HTMLVideoElement
) => {
  const runDetection = () => {
    const context = myCanvas.value.getContext("2d");
    handModel.detect(video).then((predictions: any) => {
      if (predictions.length > 0) {
        status =
          predictions[0].label === undefined
            ? predictions[1]
              ? predictions[1].label
              : predictions[0].label
            : predictions[0].label;
      }
      handModel.renderPredictions(predictions, myCanvas.value, context, video);
      requestAnimationFrame(runDetection);
    });
  };
  runDetection();
};

至此为止我们的函数已经完成,让我们来看下效果吧:

test.gif 当我们的手心打开时候 场景渲染,我们握拳时候场景停止。感兴趣的小伙伴可以在文章开头的地方下载后自行查看。 看完的朋友记得一键三连哦!~

image.png