需求
最近要做一个可视化大屏,大屏中间需要有一个大楼的3D模型,并且希望这个模型能够进行交互, 以此为目标开始对选用vue+threejs的方案进行预研。
实现过程
编码前的准备
在编码前,有一些准备工作要做。首先是准备模型,然后是引入所需的依赖,在这两个地方都有踩一些坑。
第一个坑:模型存放的位置
不管是使用obj模型还是gltf模型,它们都要放到public目录下,不然在经过webpack打包后,模型文件就读不出来了。
第二个坑:模型内的引用路径
如果引用的是gltf,文件内的引用文件路径一定要记得改
关于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;
}
}
点击模型产生交互
点击模型产生交互,涉及到模型的选取。鼠标点击到的地方是否存在图形,可以看做从鼠标所处位置发出一根射线,这根射线会返回一个对象数组。
如果它什么也没射中,返回回来的就是个空数组。如果射中了一个模型,返回回来的数组里就会多一个对象。如果多个模型堆叠,它会层层穿透,返回回来的数组里就会有它穿过的所有模型对象,这些对象会按顺序排列在数组里。
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;
}
总结
虽然模型很简陋,但是基本的业务场景应该是能覆盖了,主要是要搞清楚想做的效果的实现原理,根据文档和别人的优秀例子摸索一下,最后也能轻松上手。