vue+threejs写控件:双摄相机,显示第一人称视角

244 阅读2分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第29天,点击查看活动详情


效果

20221027_122908.gif

前言

以上是双摄相机,右上角显示第一人称(小机器人)视角的演示gif,本文使用vue+three.js实现该效果,以下是实现逻辑和完整代码。

实现逻辑

首先,是整个场景的创建和渲染,通过initScene()方法创建场景。

通过initCamera()方法创建相机,一共创建了2个相机,一个camera,用于展示正常视角的场景,另一个是camera2,用于展示小窗口显示第一人称视角的场景。

通过initLight()方法创建灯光,创建了两个灯光,一个平行光,一个环境光,都添加到了场景中。

通过initRenderer()方法创建渲染器,并将渲染器dom节点添加到html中。

通过initControls()方法创建轨道控制器,用于鼠标控制场景缩放旋转平移,该控制器控制的是camera相机。

通过initGround()方法创建地面,就是上面的灰色方形地面。

通过initCurve()方法绘制运动路径,使用Catmull-Rom算法,从一系列的点创建一条平滑的三维曲线。

通过initModel()方法加载机器人模型,并且制定和激活机器人模型的动画,让机器人有走路的姿势。

最后在模型加载完成后(this.manager.onLoad)调用animate()方法,在该方法中执行动画并且调整机器人的位置和朝向。同时更改小窗口的相机的位置和朝向,让它们在小机器人的视角上this.camera2.position.set(position.x, position.y + 0.5, position.z);this.camera2.lookAt(lookAtVec);,并且隐藏小机器人this.Robot.visible = false,在小窗口视图渲染完成后再显示小机器人,因为我们小窗口用的是第一人称视角,即小机器人视角,所以就不需要显示小机器人了。

代码中有进行更详细的注释说明。有兴趣可能查看代码进一步研究。

完整代码

<template>
  <div class="item">
    <div id="THREE70"></div>
    <div class="inset_box"></div>
  </div>
</template>

<script>
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

export default {
  data() {
    return {
      camera: null,
      camera2: null,
      scene: null,
      renderer: null,
      gltfLoader: null,
      controls: null,
      manager: null,
      clock: null,
      mixer: null, // 混合动画器
      Robot: null, // 机器人模型
      curve: null, // 运动轨迹
      insetWidth: 200,
      insetHeight: 200,
    };
  },
  mounted() {
    this.manager = new THREE.LoadingManager();
    this.gltfLoader = new GLTFLoader(this.manager);
    this.clock = new THREE.Clock();
    this.initScene();
    this.initCamera();
    this.initLight();
    this.initRenderer();
    this.initControls();
    this.initGround();
    this.initCurve();
    this.initModel(); // 加载模型

    this.manager.onLoad = () => {
      this.animate();
      console.log("Loading complete!");
    };
  },
  methods: {
    initScene() {
      this.scene = new THREE.Scene();
    },
    initCamera() {
      this.camera = new THREE.PerspectiveCamera(
        50,
        (window.innerWidth - 201) / window.innerHeight,
        1,
        1000
      ); // 透视相机
      this.camera.position.set(2, 2, 4); // 设置相机位置

      // 创建透视相机
      this.camera2 = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
      this.camera2.position.set(0, 2, 4); // 设置相机位置
      this.camera2.lookAt(0, 0, 0);
    },
    initLight() {
      const light = new THREE.DirectionalLight(0xffffff); // 平行光
      light.position.set(0.5, 1.0, 0.5).normalize(); // 设置平行光的方向,从(0.5, 1.0, 0.5)->target一般(0, 0, 0)
      this.scene.add(light); // 将灯光添加到场景中

      const ambLight = new THREE.AmbientLight(0xf0f0f0, 0.1); // 环境光
      this.scene.add(ambLight);
    },
    initRenderer() {
      this.renderer = new THREE.WebGLRenderer({ antialias: true });
      this.renderer.outputEncoding = THREE.sRGBEncoding;
      this.renderer.setPixelRatio(window.devicePixelRatio);
      this.renderer.setSize(window.innerWidth - 201, window.innerHeight);
      document.getElementById("THREE70").appendChild(this.renderer.domElement);
    },
    initControls() {
      this.controls = new OrbitControls(this.camera, this.renderer.domElement);
    },
    initGround() {
      const ground = new THREE.Mesh(
        new THREE.BoxGeometry(4, 0.0015, 4),
        new THREE.MeshPhongMaterial({
          color: 0x999999,
          depthWrite: false,
          transparent: true,
          opacity: 1,
        })
      );
      ground.receiveShadow = true;
      ground.position.y = -0.0015;
      this.scene.add(ground);
    },
    initCurve() {
      this.curve = new THREE.CatmullRomCurve3([
        new THREE.Vector3(1, 0, -1),
        new THREE.Vector3(1, 0, 1),
        new THREE.Vector3(-1, 0, 1),
        new THREE.Vector3(-1, 0, -1),
      ]);
      this.curve.curveType = "centripetal"; // 曲线的类型
      this.curve.closed = true; // 曲线是否闭合
      const points = this.curve.getPoints(50); // 获取点列表,50为要将曲线划分为的分段数
      const line = new THREE.LineLoop(
        new THREE.BufferGeometry().setFromPoints(points),
        new THREE.LineBasicMaterial({ color: 0x0000ff })
      ); // 一条头尾相接的连续的线(参数说明:顶点列表,材质)
      this.scene.add(line); // 将曲线添加到场景中
    },
    initModel() {
      this.gltfLoader.load(
        "./models/models/gltf/RobotExpressive/RobotExpressive.glb",
        (gltf) => {
          gltf.scene.scale.set(0.1, 0.1, 0.1);
          this.Robot = gltf.scene;
          this.scene.add(gltf.scene);

          const animations = gltf.animations;
          this.mixer = new THREE.AnimationMixer(this.Robot); // 动画混合器
          let actions = {};
          for (let i = 0; i < animations.length; i++) {
            const clip = animations[i];
            const action = this.mixer.clipAction(clip);
            actions[clip.name] = action;
          }
          // 制定动画
          actions["Walking"]
            .reset() // 重置动作
            .play(); // 让混合器激活动作
        }
      );
    },
    animate() {
      requestAnimationFrame(this.animate);

      // 执行动画
      const dt = this.clock.getDelta();
      if (this.mixer) {
        this.mixer.update(dt);
      }

      // 调整走路位置和朝向
      const loopTime = 25 * 1000; // 定义循环一圈所需的时间
      let time = Date.now();
      let t = (time % loopTime) / loopTime; // 计算当前时间进度百分比
      const position = this.curve.getPointAt(t); // 参数t表示当前点在线条上的位置百分比,该方法根据传入的百分比返回在曲线上的位置
      this.Robot.position.copy(position); // 更新机器人的位置
      const tangent = this.curve.getTangentAt(t); // 该方法根据传入的百分比返回在曲线上的位置的切线
      const lookAtVec = tangent.add(this.Robot.position); // 位置向量和切线向量相加即为所需朝向的点向量
      this.Robot.lookAt(lookAtVec); // 更新机器人朝向

      // 创建双摄相机
      this.renderer.setClearColor(0x000000, 1); // 设置颜色及其透明度
      this.renderer.setViewport(
        0,
        0,
        window.innerWidth - 201,
        window.innerHeight
      ); // 设置视口大小
      this.renderer.render(this.scene, this.camera);

      this.renderer.setClearColor(0x444444, 1); // 设置颜色及其透明度
      this.renderer.clearDepth(); // 清除深度缓存。相当于调用.clear( false, true, false )
      this.renderer.setScissorTest(true); // 启用或禁用剪裁检测. 若启用,则只有在所定义的裁剪区域内的像素才会受之后的渲染器影响。
      this.renderer.setScissor(
        window.innerWidth - 416,
        window.innerHeight - 215,
        this.insetWidth,
        this.insetHeight
      ); // 设置裁剪区域
      this.renderer.setViewport(
        window.innerWidth - 416,
        window.innerHeight - 215,
        this.insetWidth,
        this.insetHeight
      ); // 设置视口大小
      this.camera2.position.set(position.x, position.y + 0.5, position.z);
      this.camera2.lookAt(lookAtVec);
      this.Robot.visible = false
      this.renderer.render(this.scene, this.camera2);
      this.renderer.setScissorTest(false);
      this.Robot.visible = true
    },
  },
};
</script>

<style lang="less" scoped>
.inset_box {
  position: absolute;
  top: 10px;
  right: 10px;
  width: 200px;
  height: 200px;
  overflow: hidden;
  border-radius: 3px;
  border: 5px solid rgba(255, 255, 255, 0.8);
}
</style>

结语

以上即是完整代码和实现逻辑。