WebGL 系列——(七)three.js 实践

506 阅读7分钟

前言:对于使用类似于 cesium 和 three.js 这种高度封装的 WebGL 库,其 api 非常多且每个名字取得非常长,使用场景也非常广泛,在实践中按需学习才是进步最快的方式。没有项目或者需求牵引,哪怕一时学会了也会很快忘记或者记得没那么深刻。建议的学习方式是从头到尾过一遍所有概念、功能和api,形成一个初步印象,然后在实际使用中不断地去查阅和熟悉,附一个官网:threejs.org/ ,Three.js中文网:www.webgl3d.cn/

下面以我们某项目中部分片段为例,理一下我们学习 three.js 的方法。

一、项目需求

该项目是想做一个机器修理车间的数字孪生系统,在大屏上用作展示和动态监控,分为 2D 部分和 3D 部分。

(一)2D 部分

  • 需求1:展示一张待维修机器的平面图,平面图上会有各个关键部位的标记和高亮展示;
  • 需求2:以轮播的方式展示当前维修车间的工作人员,包括工号、姓名、年龄、工龄、擅长内容等信息;
  • 需求3:以轮播的方式展示当前维修车间内所有送过来的机器的信息,点击某台机器,可以在 2D 的视角上看到该机器的平面图以及待维修内容。

(二)3D 部分

  • 需求1:在轮播信息板块点击某台机器,可以在 2D 的视角上看到该机器的平面图以及待维修内容,在 3D 视角上同步展示该型号机器的模型;
  • 需求2:可以对该机器进行拆分,便于维修人员观看其内部结构,辅助其维修;
  • 需求3:可以对该机器进行透视,便于维修人员观看其内部结构,辅助其维修;
  • 需求4:可以对该机器进行放大缩小,任意角度旋转观看,辅助维修人员维修。
  • 需求5:自由发挥,完成其他视觉效果,这里想到的是粒子化处理,自动动画,自动旋转展示等。

二、需求分析及资料查阅

(一)2D 部分

需求分析:这一块的目的就是展示和好看,所以需要各种好看的切图来堆砌,这部分主要靠 UI 来实现,前端程序员来还原,唯一一个轮播的部分用到 Swiper 库。

import { Swiper, SwiperSlide } from 'swiper/vue';
import { Autoplay } from 'swiper';
import 'swiper/css';
const onSwiper = swiper => {
swiper.el.onmouseover = function () {
  swiper.autoplay.stop();
};
swiper.el.onmouseleave = function () {
  swiper.autoplay.start();
};
};
  
     <swiper
        :modules="[Autoplay]"
        :loop="true"
        :slides-per-view="10"
        :space-between="10"
        direction="vertical"
        :speed="1500"
        @swiper="onSwiper"
        :autoplay="{
          delay: 100,
        }"
      >
        <swiper-slide v-for="(item, index) in uavList" :key="item" @click="detail(item, index)">
          <div class="uav_list" :class="{ navigationItemSel: curSelectNavigation === index }">
            <div class="uav_list_id">
              <img :src="item.image" alt="" />
              <div>
                <p style="font-family: BigYoungMediumGB20">{{ item.name }}</p>
                <span>{{ item?.id }}</span>
              </div>
            </div>
            <p class="show">{{ item?.lng?.toFixed(2) || '暂无' }}</p>
            <p class="show">{{ item?.lat?.toFixed(2) || '暂无' }}</p>
            <p class="show">{{ item?.height }}</p>
            <p class="show" :class="EStatus[item.type].class">{{ EStatus[item.type].text }}</p>
          </div>
        </swiper-slide>
      </swiper>

(二)3D 部分

需求分析:首先要完善三要素(上篇文章已经讲过)-->然后要导入模型-->然后要对模型播放动画、做拆解和还原、自动旋转,大概就这些,所以去查阅文档,发现需要用到 gltf 模型加载器GLTFLoader.js,于是开始学习。

① 学习导入模型

第一步 引入GLTFLoader.js

在three.js官方文件的 examples/jsm/子文件loaders/ 目录下,可以找到一个文件GLTFLoader.js,这个文件就是three.js的一个扩展库,专门用来加载gltf格式模型加载器。

// 引入gltf模型加载库GLTFLoader.js
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

第二步 gltf加载器new GLTFLoader()

执行new GLTFLoader()就可以实例化一个gltf的加载器对象。

// 创建GLTF加载器对象
const loader = new GLTFLoader();

第三步 gltf加载器方法.load()

通过gltf加载器方法.load()就可以加载外部的gltf模型。执行方法.load()会返回一个gltf对象,作为参数2函数的参数,改gltf对象可以包含模型、动画等信息。

loader.load( 'gltf模型.gltf', gltf => {
  // 返回的场景对象gltf.scene插入到threejs场景中
  scene.add( gltf.scene );
})

② 学习播放模型动画

three.js中动画也是很重要的一环。在创建模型时,一般都会创建模型的动画用于在开发中使用。 AnimationMixer动画混合器是用于场景中特定对象的动画的播放器。当场景中的多个对象独立动画时,每个对象都可以使用同一个动画混合器。rootObject 混合器播放的动画所属的对象,常用参数和属性如下:

  • .time 全局的混合器时间。
  • .clipAction(AnimationClip) 返回所传入的剪辑参数的AnimationAction对象。AnimationAction用来调度存储在AnimationClip中的动画。
  • AnimationClip 动画剪辑,是一个可重用的关键帧轨道集,它代表动画。
  • .getRoot() 返回混合器的根对象。
  • .update() 推进混合器时间并更新动画。在渲染函数中调用更新动画。

第一步 创建全局参数初始化动画相关对象

let actions = [] // 所有的动画数组 
let mixer = null // AnimationMixer 对象

第二步 解析挂载并执行动画

 import {AnimationMixer} from 'three';
 import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
 
 const gltfLoader = new GLTFLoader();
    gltfLoader.load(uri, glb => {
      mixer = new AnimationMixer(glb.scene);//准备场景的动画混合器
      //在该模型上使用动画混合器挂在所有动画
      const clips = glb.animations;
      clips.forEach(item => {
        actions.push(mixer.clipAction(item));
      });
      
 //播放动画或者暂停
 actions.forEach(item => {
       if (item.isRunning()) {
         item.stop();
       } else {
         item.play();
       }
     });

③ 学习拆解、还原、透视、线框化模型

思考一下,为什么会把这几个需求写在一起,因为本质上它们无非都是对模型的 child 进行操作而已,比如拆解和还原就是把模型的每个 child 拿到,改变下位置;再比如透视就是把模型的每个child 拿到,改变下透明度;再比如线框化就是把模型的每个child 拿到,改变下材质。因此我们自然而然地联想到需要一个方法来遍历模型,拿到它的每一个 child。

递归遍历方法.traverse(),three.js 层级模型就是一个树结构,可以通过递归遍历的算法去遍历 three.js 一个模型对象的所有后代。 为了更顺滑一点,我们需要使用动画,也就是前几篇文章提到的 gsap 库。

// 拆解和还原模型
  const disassemble = (mesh: Object3D<Event> | Group) => {
    if (isFlat.value) {
      mesh.traverse((child: any) => {
        if (child.isMesh) {
          gsap.to(child.position, {
            x: child.oldPosition.x,
            y: child.oldPosition.y,
            z: child.oldPosition.z,
            duration: 1,
          });
        }
      });
      isFlat.value = false;
    } else {
      mesh.traverse((child: any) => {
        if (child.isMesh) {
          child.oldPosition = child.position.clone();
          gsap.to(child.position, EModel[child.name].animation);
        }
      });
      isFlat.value = true;
    }
  };
 // 透视和还原模型
  const transparency = (mesh: Object3D<Event> | Group) => {
    mesh.traverse(child => {
      if (child.material) {
        if (!child.material.transparent) child.material.transparent = true;
        if (child.material.opacity === 1) {
          gsap.to(child.material, {
            opacity: 0.3,
            duration: 1,
          });
        } else {
          gsap.to(child.material, {
            opacity: 1,
            duration: 1,
          });
        }
      }
    });
  };
 // 线框化和还原模型
const wireFrame = () => {
    planeModel.traverse(child => {
      if (child.isMesh) {
        if (state.wireFrame) {
          child.material.wireframe = false;
        } else {
          child.material.wireframe = true;
        }
      }
    });
    state.wireFrame = !state.wireFrame;
  };

④ 学习手动和自动旋转、缩放模型

所谓模型缩放与旋转,在 three.js 中,是使用轨道控制器(OrbitControls)使得相机围绕目标进行轨道运动实现的。OrbitControls 本质上就是改变相机的参数,比如相机的位置属性,改变相机位置也可以改变相机拍照场景中模型的角度,实现模型的360度旋转预览效果,改变透视投影相机距离模型的距离,就可以改变相机能看到的视野范围。

// 引入轨道控制器扩展库OrbitControls.js
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

// 设置相机控件轨道控制器OrbitControls
const controls = new OrbitControls(camera, renderer.domElement);

// 如果OrbitControls改变了相机参数,重新调用渲染器渲染三维场景
controls.addEventListener('change', function () {
    renderer.render(scene, camera); //执行渲染操作
    console.log('camera.position',camera.position);// 浏览器控制台查看相机位置变化
});

OrbitControls 会有很多属性和方法,比如 autoRotate:Boolean,将其设为 true,以自动围绕目标旋转。请注意,如果它被启用,你必须在你的动画循环里调用 update()。同时设置 autoRotateSpeed:Float,当 autoRotate 为 true 时,围绕目标旋转的速度将有多快,默认值为2.0,相当于在60fps时每旋转一周需要30秒。同样,如果它被启用,你必须在你的动画循环里调用update()。

const displayModel = () => {
    controls.autoRotate = true;
    controls.autoRotateSpeed = 3;

    !camera.basePosition && (camera.basePosition = camera.position.clone());
    !planeModel.baseRotation && (planeModel.baseRotation = planeModel.rotation.clone());
    if (state.realism) {
      gsap.to(camera.position, {
        x: 0.15,
        y: 0.15,
        z: 0.15,
        duration: 1,
      });
      gsap.to(planeModel.rotation, {
        y: 0.8,
        duration: 1,
      });
    } else {
      gsap.to(camera.position, {
        x: camera.basePosition.x,
        y: camera.basePosition.y,
        z: camera.basePosition.z,
        duration: 1,
      });
      gsap.to(planeModel.rotation, {
        y: planeModel.baseRotation.y,
        duration: 1,
      });
    }
  };
  
// 渲染函数
function render() {
  renderer.render(scene, camera);//执行渲染操作
  controls.update();//更新控制器
  mixer && mixer.update(clock.getDelta());//更新模型动画的状态,如果不放动画可以不要这一行
  window.requestAnimationFrame(render)//直接这样写也可以 requestAnimationFrame(render)
}
render();