threejs 控制3D物体移动与转向

lxf2023-05-19 01:21:42

原文参考我的公众号文章 threejs 之控制 3D 物体移动

实现点哪儿,物体移动到哪儿,且正面朝向点击位置。(看演示图:gif录制fps只有8,页面正常跑起来时流畅的)!

threejs 控制3D物体移动与转向

threejs 控制3D物体移动与转向

1.鼠标控制移动、转向

控制器选择

这里是基于OrbitControls轨道控制器实现,并通过设置最大控制距离controls.minDistance = controls.maxDistance = 20;controls.target.copy(cube.position)实时更新控制器位置,来实现物体视角跟随。

未选择以下两种控制器 PointerLockControls指针锁定控制器无鼠标,主要是用于键盘操纵移动; FirstPersonControls第一人称控制器,适合第一人称游戏,鼠标+键盘操纵;

点击移动需要解决的两个问题

  1. 更新物体位置
  2. 更新物体朝向

通过 Raycaster 获取需要移动的目标位置,解决 1

// 鼠标事件,射线碰撞检测获取目标点空间坐标
let raycaster = new THREE.Raycaster();
let mouseVector = new THREE.Vector3();
let MTCtrl = new MoveAndTurnControl(gsap);
function onMouseDown(evt) {
  // 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1)
  mouseVector.x = (evt.clientX / width) * 2 - 1; //-1,1
  mouseVector.y = -((evt.clientY / height) * 2 - 1); //-1,1
  mouseVector.z = 1;

  // 通过摄像机和鼠标位置更新射线(将平面坐标转为世界坐标)
  raycaster.setFromCamera(mouseVector, camera);

  // 计算物体和射线的焦点
  let raycasters = raycaster.intersectObjects(scene.children, false);
  if (raycasters.length > 0) {
    let intersectObj = raycasters[0];
    let { point, distance } = intersectObj;

    MTCtrl.go({
      target: cube,
      destPosition: point,
      onStart: () => {
        controls.enabled = false;
      },
      onComplete: () => {
        controls.enabled = true;
      },
    });
  }
}
window.addEventListener("mousedown", onMouseDown, false);

通过四元数解决物体朝向问题,解决 2

/**
 * animate:gsap动画库
 */
class MoveAndTurnControl {
  constructor(animate) {
    this.animate = animate;
    this.isMoving = false;
    this.createTL();
  }

  /**
   * 更新位置和朝向
   * @param {*} param0
   * param0.target 被控制的物体对象 <模型 | object3D | group | mesh对象>
   * param0.destPosition 被控物体要移动到的三位坐标 <THREE.Vector3>
   * param0.speed 移动速度
   * param0.turnSpeed 物体转向速度
   * param0.fixedY <Number> 设置被控物体的Y轴高度,0代表不控制,则物体的Y会等于destPosition.y + offsetY
   * param0.offsetY <Number> Y轴偏移量,fixedY==0时有效
   * param0.onStart 动作开始时回调
   * param0.onUpdate 动作执行过程中回调
   * param0.onComplete 动作结束时回调
   */
  go({
    target,
    destPosition,
    speed = 5,
    turnSpeed = 0.1,
    fixedY = 0,
    offsetY = 0.5,
    onStart,
    onUpdate,
    onComplete,
  }) {
    // 如果正在移动,结束当前动画,从新定义新的timeline
    if (this.isMoving) {
      this.timeline.kill();
      this.createTL();
    }

    let targetPosition = target.position.clone();
    let offsetAngle = 0; // 2 * Math.PI 等于 0 //目标移动时的朝向偏移
    console.log("移动速度:", speed);

    // 计算物体移动的距离
    let moveDistance = Math.sqrt(
      Math.pow(destPosition.x - targetPosition.x, 2) +
        Math.pow(destPosition.z - targetPosition.z, 2)
    );
    console.log("移动距离:", moveDistance);
    let t = moveDistance / speed;
    console.log("移动时间:", moveDistance);

    // 以下代码在多段路径时可重复执行
    let mtx = new THREE.Matrix4(); //创建一个4维矩阵
    mtx.lookAt(targetPosition, destPosition, target.up); // 设置朝向
    mtx.multiply(
      new THREE.Matrix4().makeRotationFromEuler(
        new THREE.Euler(0, offsetAngle, 0)
      )
    );
    let toTurn = new THREE.Quaternion().setFromRotationMatrix(mtx); // 计算出需要进行旋转的四元数值

    // 动画:移动位置+旋转朝向
    onStart && onStart();
    this.isMoving = true;
    this.timeline.to(target.position, {
      duration: t,
      x: destPosition.x,
      y: fixedY == 0 ? fixedY : destPosition.y + offsetY,
      z: destPosition.z,
      onUpdate: () => {
        // 边移动边旋转
        target.quaternion.slerp(toTurn, turnSpeed);
        onUpdate && onUpdate(target.position, toTurn);
      },
      onComplete: () => {
        this.isMoving = false;
        onComplete && onComplete(target);
      },
    });
  }

  createTL() {
    this.timeline = this.animate.timeline();
  }
}

// 使用
let MTCtrl = new MoveAndTurnControl(gsap);
MTCtrl.go({
  target: cube,
  destPosition: clickPoint,
  onStart: () => {
    controls.enabled = false;
  },
  onComplete: () => {
    controls.enabled = true;
  },
});

对于更新物体朝向个人认为是个难点,一开始想了很多方式,在完整代码中能够体现我的尝试。 最后在网上找到了一个运行起来比较完美的解决方案「Quaternion-四元数」。 四元数这个东西比较复杂,对于刚入门 threejs 的我来说暂不深入了解,只要知道它能够更好的更新物体在 3D 世界中的旋转状态,比 Eluar 角好。 最后我封装了一个控制类MoveAndTurnControl,提供了多个可控参数和详细的参数说明,以下是完整代码。

点击移动-完整代码

<template>
  <div class="container" ref="container"></div>
</template>

<script setup>
import { onMounted, onUnmounted, ref } from "vue";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import gsap from "gsap";

let controls = null;
let container = ref(null);
const width = window.innerWidth;
const height = window.innerHeight;

// 创建场景
const scene = new THREE.Scene();

// 创建相机
const cameraY = 15;
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);
camera.position.set(0, cameraY, 10);
camera.lookAt(0, 0, 0);

// 创建渲染器
const renderer = new THREE.WebGL1Renderer();
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor("#262837");

// 环境光
const ambientLight = new THREE.AmbientLight("#ffffff", 0.5);
scene.add(ambientLight);

// 月光
const moonLight = new THREE.DirectionalLight("#f5f5f5", 0.5);
moonLight.position.set(2, 7, -4);
scene.add(moonLight);

// 地板
const floor = new THREE.Mesh(
  new THREE.PlaneGeometry(50, 50),
  new THREE.MeshStandardMaterial({ color: "#ffffff", side: THREE.DoubleSide })
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.01;
scene.add(floor);

// 球体
const ball = new THREE.Mesh(
  new THREE.SphereGeometry(2, 32, 32),
  new THREE.MeshBasicMaterial({ color: "#777777" })
);
ball.position.set(4, 5, 0);
scene.add(ball);

// 添加立方体
let backMaterial = new THREE.MeshPhysicalMaterial({
  map: new THREE.TextureLoader().load("/images/texture/fty_logo.jpg"),
  side: THREE.DoubleSide,
});
let plainMaterial = new THREE.MeshBasicMaterial({
  color: 0xff0000,
  transparent: true,
  opacity: 0.4,
});
const boxMaps = [
  plainMaterial,
  plainMaterial,
  plainMaterial,
  plainMaterial,
  plainMaterial,
  backMaterial,
]; // 创建纹理数组,给某个面特殊处理,方便观察移动朝向是否正确
const cube = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), boxMaps);
cube.position.set(0, 0.5, 0);
scene.add(cube);

/**
 * animate:gsap动画库
 */
class MoveAndTurnControl {
  constructor(animate) {
    this.animate = animate;
    this.isMoving = false;
    this.createTL();
  }

  /**
   * 更新位置和朝向
   * @param {*} param0
   * param0.target 被控制的物体对象 <模型 | object3D | group | mesh对象>
   * param0.destPosition 被控物体要移动到的三位坐标 <THREE.Vector3>
   * param0.speed 移动速度
   * param0.turnSpeed 物体转向速度
   * param0.fixedY <Number> 设置被控物体的Y轴高度,0代表不控制,则物体的Y会等于destPosition.y + offsetY
   * param0.offsetY <Number> Y轴偏移量,fixedY==0时有效
   * param0.onStart 动作开始时回调
   * param0.onUpdate 动作执行过程中回调
   * param0.onComplete 动作结束时回调
   */
  go({
    target,
    destPosition,
    speed = 5,
    turnSpeed = 0.1,
    fixedY = 0,
    offsetY = 0.5,
    onStart,
    onUpdate,
    onComplete,
  }) {
    // 如果正在移动,结束当前动画,从新定义新的timeline
    if (this.isMoving) {
      this.timeline.kill();
      this.createTL();
    }

    let targetPosition = target.position.clone();
    let offsetAngle = 0; // 2 * Math.PI 等于 0 //目标移动时的朝向偏移
    console.log("移动速度:", speed);

    // 计算物体移动的距离
    let moveDistance = Math.sqrt(
      Math.pow(destPosition.x - targetPosition.x, 2) +
        Math.pow(destPosition.z - targetPosition.z, 2)
    );
    console.log("移动距离:", moveDistance);
    let t = moveDistance / speed;
    console.log("移动时间:", moveDistance);

    let mtx = new THREE.Matrix4(); //创建一个4维矩阵
    mtx.lookAt(targetPosition, destPosition, target.up); // 设置朝向
    mtx.multiply(
      new THREE.Matrix4().makeRotationFromEuler(
        new THREE.Euler(0, offsetAngle, 0)
      )
    );
    let toTurn = new THREE.Quaternion().setFromRotationMatrix(mtx); // 计算出需要进行旋转的四元数值

    // 动画:移动位置+旋转朝向
    onStart && onStart();
    this.isMoving = true;
    this.timeline.to(target.position, {
      duration: t,
      x: destPosition.x,
      y: fixedY == 0 ? fixedY : destPosition.y + offsetY,
      z: destPosition.z,
      onUpdate: () => {
        // 边移动边旋转
        target.quaternion.slerp(toTurn, turnSpeed);
        onUpdate && onUpdate(target.position, toTurn);
      },
      onComplete: () => {
        this.isMoving = false;
        onComplete && onComplete(target);
      },
    });
  }

  createTL() {
    this.timeline = this.animate.timeline();
  }
}

// 鼠标事件,射线碰撞检测获取目标点空间坐标
let raycaster = new THREE.Raycaster();
let mouseVector = new THREE.Vector3();
let MTCtrl = new MoveAndTurnControl(gsap);
function onMouseDown(evt) {
  // 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1)
  mouseVector.x = (evt.clientX / width) * 2 - 1; //-1,1
  mouseVector.y = -((evt.clientY / height) * 2 - 1); //-1,1
  mouseVector.z = 1;

  // 通过摄像机和鼠标位置更新射线(将平面坐标转为世界坐标)
  raycaster.setFromCamera(mouseVector, camera);

  // 计算物体和射线的焦点
  let raycasters = raycaster.intersectObjects(scene.children, false);
  if (raycasters.length > 0) {
    let intersectObj = raycasters[0];
    let { point, distance } = intersectObj;

    // 方法1:controls.getAzimuthalAngle()获取控制器的水平旋转
    // distance = Math.min(intersectObj.distance, 10);
    // gsap.timeline().to(cube.position, {
    //     duration: distance/20,
    //     x: intersectObj.point.x,
    //     // y: intersectObj.point.y,
    //     z: intersectObj.point.z,
    //     onComplete: () => {
    //         console.log(controls.getAzimuthalAngle())
    //         cube.rotation.y = controls.getAzimuthalAngle()
    //     }
    // })

    // 方法2:根据物体目标点的x和z计算tan
    // let turnAngle = Math.atan(point.x / point.z)+Math.PI;
    // console.log('turnAngle:', turnAngle)

    // gsap.timeline().to(cube.rotation, {
    //     duration: 0.3,
    //     // y: controls.getAzimuthalAngle(),
    //     y: turnAngle,
    //     onComplete: () => {
    //         gsap.timeline().to(cube.position, {
    //             duration: t,
    //             x: intersectObj.point.x,
    //             // y: intersectObj.point.y,
    //             z: intersectObj.point.z,
    //         })
    //     }
    // })
    // return

    // 方法3:计算物体坐标和目标点坐标的空间向量夹角
    // let v1 = new THREE.Vector3(cube.position.x, cube.position.y, cube.position.z)
    // let v2 = new THREE.Vector3(point.x, point.y, point.z)
    // let turnAngle = v1.cross(v2)
    // console.log(turnAngle)
    // gsap.timeline().to(cube.rotation, {
    //     duration: 0.3,
    //     // x: v1.x,
    //     y: v1.y,
    //     // z: v1.z,
    // })
    // return

    // 方法4:四元数
    MTCtrl.go({
      target: cube,
      destPosition: point,
      onStart: () => {
        controls.enabled = false;
      },
      onComplete: () => {
        controls.enabled = true;
      },
    });
  }
}

window.addEventListener("mousedown", onMouseDown, false);

function animate() {
  camera.position.y = cameraY; // 视角高度锁定

  controls.update(); // 更新控制器
  controls.target.copy(cube.position); //(物体视角跟随)

  renderer.render(scene, camera);
  window.requestAnimationFrame(animate);
}

const reRender = () => {
  // 修改相机的参数,宽高比
  camera.aspect = window.innerWidth / window.innerHeight;
  // 更新投影的变换矩阵
  camera.updateProjectionMatrix();
  // 重新设置渲染器尺寸
  renderer.setSize(window.innerWidth, window.innerHeight);
};

// 挂载之后获取dom
onMounted(() => {
  // 添加轨道控制器,并锁定视角到被控物体上
  controls = new OrbitControls(camera, container.value);
  controls.enableDamping = true;
  controls.enablePan = false;
  controls.enableRotate = false;
  controls.maxDistance = 20;
  controls.minDistance = 20;

  container.value.appendChild(renderer.domElement);
  animate();

  window.addEventListener("resize", () => reRender);
});

onUnmounted(() => {
  window.removeEventListener("resize", reRender);
});
</script>
<style lang="less" scoped>
.container {
  width: 100vw;
  height: 100vh;
  border: solid 4px hsla(160, 100%, 37%, 1);
}
</style>

2.【WASD】键盘控制移动、转向

let isUp = false,
  isDown = false,
  isLeft = false,
  isRight = false,
  isJump = false;

// 键盘事件
window.addEventListener("keydown", (e) => {
  console.log(e.code);
  switch (e.code) {
    case "KeyW":
      console.log("前进");
      isUp = true;
      break;
    case "KeyS":
      console.log("后退");
      isDown = true;
      break;
    case "KeyA":
      console.log("左转");
      isLeft = true;
      break;
    case "KeyD":
      console.log("右转");
      isRight = true;
      break;
    case "Space":
      console.log("跳跃");
      cube.translateY(2);
      isJump = true;
      break;
  }
});

window.addEventListener("keyup", (e) => {
  console.log(e.code);
  switch (e.code) {
    case "KeyW":
      isUp = false;
      break;
    case "KeyS":
      isDown = false;
      break;
    case "KeyA":
      isLeft = false;
      break;
    case "KeyD":
      isRight = false;
      break;
    case "Space":
      isJump = false;

      cube.position.set(0, 0.5, 0);
      // cube.rotation.set(0, 0, 0)
      cube.quaternion.set(0, 0, 0, 0);

      robotModel.position.set(0, 0.5, 0);
      // robotModel.rotation.set(0, 0, 0)
      robotModel.quaternion.set(0, 0, 0, 0);

      camera.position.set(0, 5, 10);
      camera.lookAt(0, 0, 0);
      break;
  }
});

const objMove = () => {
  if (isUp) {
    cube.translateZ(-0.1); //不用.position.z,无法模拟真实效果
  }
  if (isDown) {
    cube.translateZ(+0.1);
  }
  if (isLeft) {
    cube.rotateY(THREE.MathUtils.degToRad(1)); //不用.position.y,无法模拟真实效果
  }
  if (isRight) {
    cube.rotateY(-THREE.MathUtils.degToRad(1));
  }
};

function animate() {
  objMove();

  renderer.render(scene, camera);
  window.requestAnimationFrame(animate);
}

animate();
本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!