保姆级教学 three.js+vue实现模型交互展示和镜头切换

3,988 阅读7分钟

需求

最近要做一个可视化大屏,大屏中间需要有一个大楼的3D模型,并且希望这个模型能够进行交互, 以此为目标开始对选用vue+threejs的方案进行预研。

实现过程

编码前的准备

在编码前,有一些准备工作要做。首先是准备模型,然后是引入所需的依赖,在这两个地方都有踩一些坑。

第一个坑:模型存放的位置

不管是使用obj模型还是gltf模型,它们都要放到public目录下,不然在经过webpack打包后,模型文件就读不出来了。

image.png

第二个坑:模型内的引用路径

如果引用的是gltf,文件内的引用文件路径一定要记得改

image.png

关于three.js中加载不同格式的模型及动画可以看看这一篇:www.jianshu.com/p/906072e60…

编码

资源引入

首先,引入相关资源,OrbitControls 用于镜头轨道控制;FBXLoader 用于加载 fbx 格式模型;TWEEN 用来生成补间动画。

import * as Three from 'three'
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader";
import { TWEEN } from "three/examples/jsm/libs/tween.module.min.js";

页面结构

页面主要由两个部分构成,container用于渲染模型场景,fool部分是交互点,通过点击交互点和模型产生互动。

<div id="container" ref="container">
    <div id="container" ref="container"></div>
    <div class="point-box point point-0" ref="fool">
        <div className="label label-0" data-id="0">大楼</div>
        <div className="label label-0" data-id="1">1楼</div>
        <div className="label label-1" data-id="2">2楼</div>
        <div className="label label-2" data-id="3">顶楼</div>
    </div>
</div>

初始化方法

在初始化方法中定义好场景scene、相机camera、渲染器renderer、控制器controls,然后创建坐标系、设置视角、创建光源。之后就可以加载fbx了,因为要加载多个模型进去,所以单独写了modeLoad方法。最后给模型容器和交互点dom都绑上点击事件。

init() {
    let container = document.getElementById("container");
    this.scene = new Three.Scene();
    this.camera = new Three.PerspectiveCamera( 75, container.clientWidth / container.clientHeight, 0.1, 1000 );
    this.renderer = new Three.WebGLRenderer();
    this.renderer.setSize( container.clientWidth, container.clientHeight );
    container.appendChild( this.renderer.domElement );
    this.controls = new OrbitControls(this.camera, this.renderer.domElement);

    // 创建x、y、z轴
    let axisHelper = new Three.AxesHelper(250);
    this.scene.add(axisHelper);

    // 设置镜头的z轴距离,调整视角大小
    this.camera.position.z = 50;

    // 创建光源
    let spotLight = new Three.SpotLight(0xffffff,1)
    spotLight.position.set(20,50,20)
    this.scene.add(spotLight)

    // 加载fbx模型
    let url = 'mode3/1L.fbx'
    let url2 = 'mode3/2L.fbx'
    let url3 = 'mode3/louding.fbx'
    this.modeLoad(url);
    this.modeLoad(url2);
    this.modeLoad(url3);

    this.$refs.fool.addEventListener('click',this.foolClick);
    this.$refs.container.addEventListener('click',this.modelClick);
}

载入模型

在载入模型的时候有几个点需要注意:

  • url模型的地址不用加public,直接写public内的路径mode3/louding.fbx就行了。
  • 注意返回的object对象是什么格式,比如导入gltf格式的模型时,模型对象是object.scene,而使用fbx格式的模型时,object直接就是模型对象了。
  • 给模型加上模型阴影、自发光,并创建环境光。防止模型无纹理无颜色的情况下,载入进来一片漆黑或是一片灰的情况。
modeLoad(url) {
    let fbxLoader = new FBXLoader();
    fbxLoader.load(url, (object) => {
        // 遍历该父场景中的所有子物体来执行回调函数
        object.traverse( function ( child ) {
            if ( child.isMesh ) {
                child.frustumCulled = false;
                // 模型阴影
                child.castShadow = true;
                // 模型自发光
                child.material.emissive =  child.material.color;
                child.material.emissiveMap = child.material.map ;
            }
        } );
        this.scene.add(object);
        // 把载入的模型对象存一份在temp数组中
        this.temp.push(object);
        console.log("模型对象",object)

    }, function (xhr) {
        console.log((xhr.loaded / xhr.total * 100) + '% loaded')
    }, function (error) {
        console.error(error, 'load error!')
    })
}

持续渲染

按上面的步骤,我们初始化了配置,导入了模型,但这个时候运行还看不到模型。
我们已经把模型对象添加到了场景scene里,相机camera也设置好了,但场景和相机都没有进行渲染,所以此时看不到预期中的模型。

因为模型交互后会产生变化,所以我们需要循环渲染这个模型,这里使用requestAnimationFrame来完成循环操作。

requestAnimationFrame 比起 setTimeout、setInterval的优势主要有两点:

1、requestAnimationFrame 会把每一帧中的所有DOM操作集中起来,在一次重绘或回流中就完成,并且重绘或回流的时间间隔紧紧跟随浏览器的刷新频率,一般来说,这个频率为每秒60帧。

2、在隐藏或不可见的元素中,requestAnimationFrame将不会进行重绘或回流,这当然就意味着更少的的cpu,gpu和内存使用量。

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

做到这一步,我们的模型就已经可以出现并用鼠标控制缩放和移动了。 但我们还希望实现点击某个模型,或点击某个按钮,镜头移动到这个模型上,并隐藏其它模型。

相机移动实现漫游等动画

怎么样实现漫游动画,其实就是控制相机移动。把相机从一个坐标,移动到另一个坐标,其间可以指定相机视角朝向。但从一个坐标切换到另一个坐标,画面会直接切过去,和我们希望的运镜移动的预期不符,这里使用到补间动画库TWEEN来实现漫游效果。

animateCamera(camera, controls, newP, time) {
    new TWEEN.Tween( camera.position )
    .to({
            x: newP.x,
            y: newP.y,
            z: newP.z
        },
        time
    )
    .easing(TWEEN.Easing.Quadratic.InOut) //.easing(TWEEN.Easing.Cubic.InOut);
    .onUpdate(() => {
        // onUpdate会在镜头移动到指定位置期间不停的循环调用
        // 使用lookAt,让镜头移动时始终看向场景
        camera.lookAt(this.scene.position);
    })
    .start();
    animate();
    function animate() {
        requestAnimationFrame(animate);
        TWEEN.update();
    }
}

点击按钮切换镜头

init()方法最后使用this.$refs.fool.addEventListener('click',this.foolClick)给几个div都绑上了点击事件,现在给这些点击事件添加行为。

foolClick(event){
    let clikDom = event.target;
    let clickId = clikDom.getAttribute('data-id')
    // 如果点1楼、2楼、顶楼,就先将所有的模型隐藏,再将选中的模型显示
    if(clickId !== '0'){
        this.temp.forEach((item,index) => {
            item.visible = false;
        });
        this.temp[clickId-1].visible = true;
    }
    // 如果点大楼整体,就将所有的模型显示
    else{
        this.temp.forEach((item,index) => {
            item.visible = true;
        });
    }
    // 选中不同的按钮,切换不同的视角
    switch (clickId) {
        case '1' :
            this.animateCamera(this.camera, this.controls, { x: 0, y: 19, z: 24 }, 1600); // 大楼整体
            break;
        case '2' :
            this.animateCamera(this.camera, this.controls, { x: 12, y: 22, z: 24 }, 1600); // 1楼
            break;
        case '3' :
            this.animateCamera(this.camera, this.controls, { x: 17, y: 30, z: 45 }, 1600); // 2楼
            break;
        default :
        this.animateCamera(this.camera, this.controls, { x: 19, y: 25, z: 50 }, 1600); // 顶楼
            break;
    }
}

点击模型产生交互

点击模型产生交互,涉及到模型的选取。鼠标点击到的地方是否存在图形,可以看做从鼠标所处位置发出一根射线,这根射线会返回一个对象数组。
如果它什么也没射中,返回回来的就是个空数组。如果射中了一个模型,返回回来的数组里就会多一个对象。如果多个模型堆叠,它会层层穿透,返回回来的数组里就会有它穿过的所有模型对象,这些对象会按顺序排列在数组里。

rau).jpg

modelClick(event) {
   let Sx = event.clientX; //鼠标单击位置横坐标
   let Sy = event.clientY; //鼠标单击位置纵坐标
   // 屏幕坐标转WebGL标准设备坐标
   // 通过鼠标点击位置,计算出 raycaster 所需点的位置,以屏幕为中心点,范围 -1 到 1
   // 这里的container就是画布所在的div,也就是说,这个是要拿整个scene所在的容器来界定的
   let container = document.getElementById("container");
   let getBoundingClientRect = container.getBoundingClientRect()
   let x = ((Sx - getBoundingClientRect .left) / container.offsetWidth) * 2 - 1; //WebGL标准设备横坐标
   let y = -((Sy - getBoundingClientRect .top) / container.offsetHeight) * 2 + 1; //WebGL标准设备纵坐标
   // 创建一个射线投射器`Raycaster`
   let raycaster = new Three.Raycaster();
   // 通过鼠标单击位置标准设备坐标和相机参数计算射线投射器`Raycaster`的射线属性.ray
   raycaster.setFromCamera(new Three.Vector2(x, y), this.camera);
   // 返回.intersectObjects()参数中射线选中的网格模型对象
   // 未选中对象返回空数组[],选中一个 数组一个元素,选中两个 数组两个元素
   let intersects = raycaster.intersectObjects(this.temp);
   console.log("射线投射器返回的对象", intersects);
   // console.log("射线投射器返回的对象 点point", intersects[0].point);
   // console.log("射线投射器返回的对象 几何体",intersects[0].object.geometry.vertices)
   // intersects.length大于0说明,说明选中了模型
   if (intersects.length > 0) {
       // 未选中的模型隐藏
       this.temp.forEach(item => {
           if(!(intersects[0].object.uuid === item.children[0].uuid)){
               item.visible = false;
           }
       });
   }
   else {
       this.temp.forEach(item => {
           item.visible = true;
       })
   }
}

最后

最后,把init()animate()挂载到mounted()里,注意别忘了给画布容器设宽高。

mounted() {
    this.init();
    this.animate();
}
......
#container {
    height: 800px;
    width: 1800px;
}

总结

虽然模型很简陋,但是基本的业务场景应该是能覆盖了,主要是要搞清楚想做的效果的实现原理,根据文档和别人的优秀例子摸索一下,最后也能轻松上手。