你还在开发传统大屏?这难道不是前端的觉醒年代?
本文使用threejs开发一款发电机拆解动画并通过交互展示零件详细信息数据交互的大屏开发,效果中规中矩,但是涵盖的知识点比较多,内容详细,也列举了众多开发中遇到的坑,内容比较长,手把手教大家写一个完整的大屏,包教包会,不收任何学费,收藏==学会。
视频讲解及源码见文末
技术栈
- three.js 0.165.0
- vite 4.3.2
- nodejs v18.19.0
效果图
加载模型
文中的模型使用的是gltf格式,在加载的时候,那就要用到threejs提供的GLTFLoader
,由于这个加载器并不是threejs内置的,所以必须使用显式引用。DRACOLoader
是处理压缩数据的,相对于那些需要解压缩的模型,如果不使用这个处理器的话,会报错,下面小结会讲到。 具体参考 # GLTF加载器
typescript
复制代码
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { DRACOLoader, GLTF } from 'three/examples/jsm/Addons.js'
// 创建解压缩器
const dracoLoader = new DRACOLoader();
// 解压缩处理的文件地址
dracoLoader.setDecoderPath(`${import.meta.env.VITE_ASSETS_URL}assets/draco/gltf/`);
const gltfLoader = new GLTFLoader();
// 加载gltf
export function loadGltf(url: string) {
gltfLoader.setDRACOLoader(dracoLoader);
return new Promise<GLTF>((resolve, reject) => {
gltfLoader.load(url, function (gltf: GLTF) {
resolve(gltf)
}, function (xhr) {
console.log(xhr);
console.log((xhr.loaded / xhr.total * 100) + '% loaded');
});
})
}
loader的第二个回调是加载结束的调用,封装一个promise,即可同步加载模型,第三个回调是进度,可以从这里看到模型的一些尺寸信息和加载进度,并且可以实时展示加载进度。
模型解压缩
你可以从threejs官网提供的模型下载地址market.pmnd.rs/ 获取到模型,但是这些模型都是压缩后的gltf,必须要使用DRACOLoader
,如果在加载的过程中没有解压缩工具,则会报错,并且在设置地址的时候,也需要注意,dracoLoader.setDecoderPath(${import.meta.env.VITE_ASSETS_URL}assets/draco/gltf/);
我这里用的是oss地址,方便部署,而本地的node_modules的位置在node_modules\three\examples\jsm\libs\draco\gltf\draco_decoder.js
这里。
下面展示一下不加解压缩器去加载被压缩的gltf看看会报什么错,在以后大家的开发中遇到的话不至于蒙圈
加载地址是https://vazxmixjsiawhamofees.supabase.co/storage/v1/object/public/models/ruins/model.gltf
大家也可以试一下,也可以从前面提到的网站复制地址,就是网站有点慢
获取模型信息
模型加载后,可以看到一些信息,比如scene
模型场景,animations
动画列表以及其他的信息,一般用不到就不展示了,拿到模型既然要交互,那就需要对模型信息进行处理,比如获取模型尺寸,位置,世界坐标等。可以通过box3
获取,在讲解外框制作的时候会详细介绍,拿到模型后,用traverse
api遍历对象,获取每一个对象的名称,用于之后的交互,也可以在userdata属性加入自己想要的内容,但是需要注意的是 千万不要用uuid作为唯一值,因为每次加载后uuid都不同,它只是在当前加载的所有模型中是唯一的。你可以像下面代码展示的,将box3的信息添加到userdata中。
typescript
复制代码
alternatorGltf.scene.traverse((mesh: Object3D<Object3DEventMap>) => {
if (mesh instanceof Mesh) {
const boxInfo = getBox3Info(mesh);
mesh.userData.boxInfo = boxInfo
}
})
模型与html的交互
从效果图中可以看到,右侧是所有模型信息的列表,那么我们将通过点击列表,获取模型信息,并添加外框,模型信息可以在加载模型的时候从后台获取并提前放在userdata中,这里为了展示获取模型的方法,就每次点击才获取信息。
动态添加li,mechanicalData这个数据是我提前录好的,源码中有的,数据结构是{"模型名称": '中文名称'}
,并在循环的时候,将模型名称作为数据分配到li的属性上去,方便获取,实际开发中可能需要从后台实时获取数据;
给li添加点击事件,为了不一个一个的绑定点击事件,我在UL上添加的绑定时间,并通过事件代理获取到li的数据
ts
复制代码
for (let key in mechanicalData) {
if (key && mechanicalData[key]) {
lis += `<li class="li-item" data-model_name="${key}">
<p>${mechanicalData[key]}</p>
<p>${key.indexOf('ab') === -1 ? '正常' : '异常'}</p>
</li>`
}
}
ul的事件代理
typescript
复制代码
if (dom['rightMenuPart']) {
dom['rightMenuPart'].addEventListener('click', (event: any) => {
const name = event.target?.dataset?.model_name
if (name) {
const model = scene.getObjectByName(name);
if (model) {
createBox(model)
}
}
})
}
上面的代码就是根据获得绑定在li上的model_name
信息,获取当前被点击的模型名称,并通过# .getObjectByName获取到场景中的模型,这个方法只返回第一个获取到的值,所以尽量保持场景中的模型唯一,或者如果有分组,可以用group.getObjectByName
,但也要确保组内的模型名称唯一,这里多提一下,除了通过名称获取,还可以通过自定义属性 # .getObjectByProperty获取模型。
动画
关于动画的详细内容可以参考之前的文章 # three.js——镜头跟踪,下面讲的是作为这篇的补充。
剪辑动画
先给大家看一下裁剪之前的动画效果,
完整的动画是从收起状态到展开状态再到收起,那么我们就可以将动画裁剪成2部分,第一部分是展开,观察效果图可以看出来,完整展开的时间大约是2s,第二部分是收起,动画的最开始就是收起状态,所以我们只裁剪0.1s的位置就可以,先上代码,一会解释
typescript
复制代码
// 定义一个动画器
export let motorAnimation: HandleAnimation
// 将模型加载到动画器中
motorAnimation = new HandleAnimation(alternatorGltf.scene, alternatorGltf.animations)
// 裁剪动画并命名为expand
motorAnimation.clipAnimation('Take 001', 2, 'expand')
// 裁剪动画并命名为retract
motorAnimation.clipAnimation('Take 001', 0.1, 'retract')
// 将这两个动画都设置为只播放一次
motorAnimation.once(['retract', 'expand'])
HandleAnimation
方法是作者封装的一个处理动画的基础类方法,包含动画播放、切换、绘制骨骼、裁剪、镜头跟踪等,在设置好动画后,还需要在render中调用upDate方法motorAnimation && motorAnimation.upDate()
,
着重讲一下clipAnimation
裁切方法,主要目的就是根据已有动画截取有效信息;
ts
复制代码
/**
*
* @param name 动画来源名称
* @param time 持续时长
* @param newName 新的动画名称
* @returns
*/
clipAnimation(name: string, time: number, newName: string) {
// 确保动画名称存在
if (!this.actions[name]) {
console.error(`Animation '${name}' does not exist.`);
return;
}
// 获取指定名称的动画
const action = this.actions[name];
// 获取动画的原始片段
const clip = action._clip;
// 动画总时长
const duration = clip.duration
if (time > duration) {
console.error(`Animation '${name}' solong.`);
return
}
// 剪辑动画
const slicedClip = new THREE.AnimationClip(newName, time, clip.tracks.slice(0, 30));
// 更新剪辑后的动画到 actions 中
const slicedAction = this.playerMixer.clipAction(slicedClip);
slicedAction.clampWhenFinished = false;
this.actions[newName] = slicedAction;
// 更新所有动画
this.getMovement();
}
# AnimationClip提供三个参数,第一个是新动画的名称,第二个是剪辑的持续时长,第三个是剪辑动画的来源,动画来源是只包含所有动画的# KeyframeTrack关键帧数组,这里包含时间线、动画信息等;
times是时间轴,当前动画所需的时间,values是在这段时间内的动作,这两个属性一般都是相对应的,比如变化动作是位置向量,则values是times的3倍,如果是旋转,可能是3-4倍,都是一一对应的,每一个tracks都对应着一个运动部分,而values则是这个运动部分的运动变化,这两个有本质的区别,做一个实验,将tracks截取一下,目前动画里有64的运动的目标,我们截取前30个看一下效果。
const slicedClip = new THREE.AnimationClip(newName, time, clip.tracks.slice(0,30));
;
从效果图中可以看到有一部分是不动的,所以一定要区分tracks截取和动画剪辑的区别,比如一个人,一个完整的动画是伸懒腰,而你只想要胳膊单独运动,你就可以找到胳膊的tracks去截取,而本文提到的是剪辑动画,所以控制的是第二个变量:持续时间,剪辑的持续时长不要超过总时长,而总时长是可以传负数的,这样AnimationClip会根据第三个参数动画合计的总时长进行计算得到,建议不要传负数,比较消耗性能。
动画播放
通过上面的代码裁切出两个动画一个是expand
展开动画,一个是retract
收缩复位动画,
可以看到我们的动画器中已经存在三种动画了
接下来就是根据按钮点击播放不同的动画。
typescript
复制代码
// 动画状态,用来控制阻止相同动画的播放
let animationState = ''
// 添加点击事件
if (dom['expand']) {
dom['expand'].addEventListener('click', () => {
// 删除上一个点击的模型外框
removeThatPart()
if (animationState !== 'expand') {
// 切换动画
animationState = expand(animationState)
changeCamera(cameraPos, lastLookat, new Vector3(), 1)
}
})
}
if (dom['retract']) {
dom['retract'].addEventListener('click', () => {
removeThatPart()
if (animationState !== 'retract') {
animationState = retract(animationState)
changeCamera(cameraPos, lastLookat, new Vector3(), 1)
}
})
}
export const expand = (animationState: string) => {
// 用于判断是否是第一次播放,如果是第一次播放直接播放,如果不是第一次播放,用切换动画来播放
if (animationState) {
motorAnimation.fadeToAction('expand', 1)
} else {
motorAnimation.play('expand', false)
}
return 'expand'
}
export const retract = (animationState: string) => {
if (animationState) {
motorAnimation.fadeToAction('retract', 1)
} else {
motorAnimation.play('retract', false)
}
return 'retract'
}
从代码中可以看到加了一个限制,就是animationState是否为空,如果是空则代表需要直接播放动画,如果不是则代表已经播放过了,所以采用切换动画的方式来播放,播放和切换动画有本质的区别
typescript
复制代码
/**
*
* @param name 播放动画 名称
* @param restore 在播放完当前动画是否执行上一次动画,如果不执行则保持当前动画最后一帧
*/
play(name: string, restore = true) {
this.playerActiveAction = this.actions[name];
this.playerActiveAction.play();
this.restore = restore
if (restore) {
this.playerMixer.addEventListener('finished', this.restoreState.bind(this))
} else {
this.playerMixer.removeEventListener('finished', this.restoreState.bind(this))
}
this.thatState = name
}
/**
*
* @param name 下一个动画名称
* @param duration 过度时间
*/
fadeToAction(name: string, duration = 0.5) {
const index = this.onceAni.findIndex((once: string) => once === name)
if (index === -1) this.thatState = name
this.previousAction = this.playerActiveAction;
this.playerActiveAction = this.actions[name];
if (this.previousAction !== this.playerActiveAction) {
this.previousAction.fadeOut(duration);
}
this.playerActiveAction
.reset()
.setEffectiveTimeScale(1)
.setEffectiveWeight(1)
.fadeIn(duration)
.play();
}
播放动画.play只能播放一次,如果再调用play则需要先调用reset,官网是这样形容的 # .play () : this
makefile
复制代码
让混合器激活动作。此方法可链式调用。
说明: 激活动作并不意味着动画会立刻开始: 如果动作在此之前已经完成(到达最后一次循环的结尾),或者如果已经设置了延时 启动(通过 startAt),则必须先执行重置操作(reset)。 一些其它的设置项也可以阻止动画的开始。
所以切换动画添加的reset方法,并设置的过渡时间,能够保证两个动画之间的衔接不那么突兀。
这都是源码中封装好的方法,开箱即用。
摒弃传统setInterval
传统的setInterval在某种情况下会导致内存泄漏,每次调用都会占用一部分内存空间,既然threejs的更新都是基于# requestAnimationFrame的循环调用,那么我们就可以利用这个api,自己封装一个interval循环调用的方法,至于这个api具体怎么用,可以去看一下官网,源码中封装了一个IntervalTime
方法,原理就是通过第一次调用时获取高精度时间,和第二次调用的时间相比,如果符合传入的第二个参数“间隔”判断当前是需要执行callback的,方法如下:
typescript
复制代码
export class IntervalTime {
private intervals: { callback: () => void, time: number, lastTime: number, remainingIterations: number }[] = [];
constructor() {}
interval(callback: () => void, time: number, iterations: number = Infinity) {
this.intervals.push({ callback, time, lastTime: 0, remainingIterations: iterations });
}
update() {
let now = performance.now(); // 使用 performance.now() 获取高精度时间
for (let i = 0; i < this.intervals.length; i++) {
const { callback, time, lastTime, remainingIterations } = this.intervals[i];
let deltaTime = now - lastTime;
if (deltaTime > time) {
// 执行一秒内需要做的事情
callback();
// 更新剩余执行次数
this.intervals[i].remainingIterations--;
if (this.intervals[i].remainingIterations === 0) {
// 移除该interval
this.intervals.splice(i, 1);
i--; // 调整索引以正确处理移除元素后的下一个元素
} else {
// 重置时间
this.intervals[i].lastTime = now;
}
}
}
}
clearIntervals() {
this.intervals = [];
}
}
该方法支持回调,间隔执行时长,执行次数,清除缓存等方法。使用起来也是老少皆宜
typescript
复制代码
const intervalTime = new IntervalTime();
// 更新时间
intervalTime.interval(() => {
upDateTime()
}, 1000)
// 更新图表
intervalTime.interval(() => {
echarts2Draw()
}, 1000 * 5)
// 更新所有序列
intervalTime.update()
intervalTime.update()
方法需要在requestAnimationFrame
方法中调用,让序列统一更新
页面中页头上面的时间更新和左侧的echarts更新都用到了该方法。
交互
使用模型加载大屏,交互是必不可少的,前文讲到的是从2d选中数据与3d进行交互,现在要说一说从3d模型上的交互,要做的是选中一个模型,让当前选中的模型突出,并展示当前模型的数据(css2d),那么我们一步一步来。
选择模型
模型交互使用threejs提供的# 光线投射Raycaster,原理就是从摄像机位置(也是near投射近点)到鼠标点击位置,需要将鼠标点击位置转化为3d坐标系中的位置(也是far投射远点)时产生一条射线,并且检测这条射线所经过的所有的被检测物体,并返回一个检测数据列表Array<Intersection<TIntersected>>
,
typescript
复制代码
import * as THREE from 'three'
import { camera, controlsMoveFlag, renderer } from './scene';
let mouse = new THREE.Vector2(); //鼠标位置
var raycaster = new THREE.Raycaster();
export function ray(children: THREE.Object3D[], callback?: (raylist: THREE.Intersection<THREE.Object3D<THREE.Object3DEventMap>>[]) => void) {
renderer.domElement.addEventListener("click", (event) => {
if(controlsMoveFlag) {
mouse.x = (event.clientX / document.body.offsetWidth) * 2 - 1;
mouse.y = -(event.clientY / document.body.offsetHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
var raylist = raycaster.intersectObjects(children, true);
callback && callback(raylist)
}
});
}
controlsMoveFlag
众所周知啊,click的执行顺序是会经过mouseup阶段,所以就会有一个问题,如果你想旋转模型,就需要将鼠标按下, 旋转完模型,鼠标抬起,在抬起的过程中就会调用click方法去检测,我们用一个方法验证一下
typescript
复制代码
let down = false
renderer.domElement.addEventListener("click", (event) => {
console.log('click');
});
renderer.domElement.addEventListener("mousedown", (event) => {
console.log("mousedown");
down = true
});
renderer.domElement.addEventListener("mouseup", (event) => {
console.log("mouseup");
down = false
});
renderer.domElement.addEventListener("mousemove", (event) => {
down && console.log("mousemove");
});
将鼠标动作打印出来,看一下执行顺序,在鼠标抬起时mouseup和click同时执行了,说明前面说的问题是存在的,具体看下面这张图,
在旋转的过程中鼠标抬起,会误触模型,导致click方法进行了检测事件,而controlsMoveFlag
这个变量就很好的解决了这个问题,这个变量是通过检测轨道控制器的start、end方法检测相机位置,进行判断鼠标是否移位,如果以为则controlsMoveFlag为false,不进行交互。
ts
复制代码
controls.addEventListener('start', () => {
controlsStartPos.copy(camera.position)
})
controls.addEventListener('end', () => {
controlsMoveFlag = controlsStartPos.distanceToSquared(camera.position) === 0
})
在在start方法中判断移动触发前相机的位置,在end的时候计算移动前的相机位置和当前相机位置的距离,如果为0则视为未移动。
raylist 结构
var raylist = raycaster.intersectObjects(children, true);
,intersectObjects为检测方法,第一个参数是被检测目标,THREE.Object3D<THREE.Object3DEventMap[]
类型,第二个参数是是否检测当前目标的子目标,如果只想检测当前目标,则设置为false或者不传。
射线检测返回值raylist
如果为空数组,则表示当前点击位置并未检测到模型或者目标,
检测到的内容一般包含以下内容,包含被检测到的模型面,模型geoment的法向量,模型本身,和与模型发生交互点的点位向量,目前我们需要做的是获取模型信息,也就是objects,一般检测到的模型为多个,索引值越小,则离镜头(近端面)越近,所以我们每次都获取第一个目标的object就好了。对了顺便提一嘴,一般射线检测的click事件都是挂在window上的,但是现在页面有html元素了,所以为了不影响html交互,则将事件挂在在renderer.domElement
上。
scss
复制代码
ray(alternatorGltf.scene.children, (raylist) => {
const obj = raylist?.[0]?.object
if (obj) {
createBox(obj)
}
})
调用点击事件并获取最近的第一个模型,进行其他操作,createBox接受一个模型,并创建一个基于模型尺寸的包围盒,和线框,用来突出当前选中的模型,将摄像头位置放置在交互模型的前面,让模型在画面中心位置,详细代码:
typescript
复制代码
// 创建模型包围盒
const createBox = async (model: Object3D<Object3DEventMap>) => {
removeThatPart()
const { size, center } = getBox3Info(model)
const cameraPosition = center.clone().addScalar(145)
const group = new Group()
let moreMesh = moreTrack(group)
const box = new BoxGeometry(size.x, size.y, size.z);
const mesh = new Mesh(box, bubbleMaterial)
let line2 = getLine2ByGeomentry(box)
moreMesh.add(mesh)
moreMesh.add(line2)
let partsObject = { ...mechanicalData, ...BoltMatData }
const partName = partsObject[model.name]
labelDom = drawPart2Dinfo({
name: partName
})
labelDom.position.setY(30);
group.add(labelDom)
scene.add(moreMesh)
moreMesh.position.set(center.x, center.y, center.z)
const lengthV3 = new Vector3().subVectors(lastLookat, center);
// 10是常数,根据不同模型大小或者自己想要的速度自定义
const time = lengthV3.length() * 10
changeCamera(cameraPosition, lastLookat, center, time)
lastLookat.copy(center)
}
ResourceTracker
ResourceTracker 方法是作者根据网上找到的信息封装的无痕删除模型的方法,可以删除贴图材质和顶点信息的内存残留。放心大胆使用。代码太多了这里就不贴了,感兴趣的同学可以看看源码。
scss
复制代码
// 使用方法
// 创建
const moreResMgr = new ResourceTracker();
const moreTrack = moreResMgr.track.bind(moreResMgr)
let moreMesh = moreTrack(new THREE.Mesh())
// 销毁
moreResMgr.dispose()
选中模型外框制作
外框是由白色的边缘线和半透明的盒子组成的,需要单独制作并且放到一个组里,将组的位置改为模型的中心位置
从box3获取模型信息
获取模型信息使用api为# Box3,获取模型的最大尺寸最小尺寸和中心位置,源码中有封装好的方法,
typescript
复制代码
export const getBox3Info = (mesh: THREE.Object3D) => {
const box3 = new THREE.Box3();
box3.setFromObject(mesh);
const size = new THREE.Vector3()
const center = new THREE.Vector3()
box3.getCenter(center)
box3.getSize(size)
const worldQuaternion = new THREE.Quaternion()
const worldPosition = new THREE.Vector3();
const worldDirection = new THREE.Vector3();
mesh.getWorldQuaternion(worldQuaternion)
mesh.getWorldPosition(worldPosition)
mesh.getWorldDirection(worldDirection)
return {
size, center, min: box3.min, max: box3.max, worldPosition, worldDirection, worldQuaternion, box3
}
}
除了box3的信息,还获取了模型的世界坐标世界四元数和距离,暂时用不到,这里提一下box3的几个属性
# containsPoint 检测点位信息是否在包围盒内,一般用于子弹的检测,可以用来检测子弹是否穿过敌人
# intersectsBox 检测两个包围盒是否相交,可以用作碰撞检测,比如主角身体是否碰撞到墙
这些都是比较常用的方法,可以参考之前的文章 # threejs——开发一款塔防游戏 中的检测功能
创建外框
arduino
复制代码
const box = new BoxGeometry(size.x, size.y, size.z);
const mesh = new Mesh(box, bubbleMaterial)
用# 立方缓冲几何体(BoxGeometry)创建一个和模型一样大的盒子,并添加到场景中,
moreMesh.position.set(center.x, center.y, center.z)
再将创建的组位置调整为模型的位置,因为每次创建盒子都是根据最新信息创建的,所以不会出现什么偏差,不管动画是否展开或者收起都会找到最新位置,这里就不需要像前文说的 提前将信息获取好放在userdata中,情况不同,处理方式也不同。
这样还是有一个问题模型和盒子重合的地方会出现这样的纹路,透明度渲染计算出现的问题,所以我们可以适当的将盒子调大一点,既然盒子尺寸是从box3来的,那我们就修改一下box3的信息,getBoxInfo返回了box3的实例,使用# .expandByScalar 将盒子扩大一点点,下面我们将getBoxInfo改造一下
typescript
复制代码
export const getBox3Info = (mesh: THREE.Object3D) => {
...
function getSize() {
box3.getSize(size)
}
return {
size, center, min: box3.min, max: box3.max, worldPosition, worldDirection, worldQuaternion, box3, getSize
}
}
createBox方法 获取size的方式也调整一下
typescript
复制代码
···
const boxInfo = getBox3Info(model)
boxInfo.box3.expandByScalar(1.1)
boxInfo.getSize()
const {size,center} = boxInfo
···
这样就可以保证盒子和模型之间不会粘连了
聚焦动画
当点击模型时候为了让模型在场景的中心,这时候需要调整的是镜头的位置,而不是模型的位置。
scss
复制代码
const lengthV3 = new Vector3().subVectors(lastLookat, center);
// 10是常数,根据不同模型大小或者自己想要的速度自定义
const time = lengthV3.length() * 10
const cameraPosition = center.clone().addScalar(145)
changeCamera(cameraPosition, lastLookat, center, time)
这里封装了一个镜头动画changeCamera
,用来修改position和lookat的,# .lookAt顾名思义就是物体朝向某个位置,属于在object3D的方法,而camera的基类也是object3D,关于位置的获取,首先记录一下初始的camera的lookat位置,位置为0的向量,每次点击模型,镜头的lookat就是模型的center位置,因为我们要将镜头朝向模型,而镜头的位置需要计算一下,想象一下,你俯身用眼睛盯着某一个物体,为了更好的看清物体,你的眼睛位置肯定是比物体的位置要高的,相机的位置计算也是如此:const cameraPosition = center.clone().addScalar(145)
,将物体位置的向量乘以一个常数 而这个常数需要你根据模型大小,展示的位置来调整的。
镜头动画
还有就是运动时间的问题,
ini
复制代码
const lengthV3 = new Vector3().subVectors(lastLookat, center);
// 10是常数,根据不同模型大小或者自己想要的速度自定义
const time = lengthV3.length() * 10
这段代码是根据上一个点击的物体到当前点击物体的位置进行计算的,当动画的运动时间和运动距离都成比例的变量,那运动速度相对是相同的,通过公式可以大概计算出来
将四个参数(cameraPosition, lastLookat, center, time)都传入changeCamera
中,接下来就是镜头的补间动画了。
typescript
复制代码
export const changeCamera = (endPos: Vector3, startLookat: Vector3, endLookat: Vector3, time: number) => {
return Promise.all([cameraPositionTween(endPos, time), cameraPositionLookAt(startLookat, endLookat, time)])
}
在这个方法中一共调用了两个补间动画,一个是镜头位置动画,另一个是镜头的lookat动画,
typescript
复制代码
export const cameraPositionTween = (endPos: Vector3, time: number) => {
return new Promise((res, reg) => {
let tween = new TWEEN.Tween(camera.position)
.to(endPos, time)
.start()
.onComplete(() => {
res({ tween })
})
})
}
export const cameraPositionLookAt = (startLookat: Vector3, endLookat: Vector3, time: number) => {
return new Promise((res, reg) => {
let tween = new TWEEN.Tween(startLookat)
.to(endLookat, time)
.start()
.onUpdate((lookAt) => {
camera.lookAt(lookAt.x, lookAt.y, lookAt.z)
})
.onComplete(() => {
res({ tween })
})
})
}
cameraPositionLookAt
这个方法就不讲了,就是两个不同的值进行补间动画,在更新回调中将摄像机的lookat调整一下,需要将的是镜头位置动画,可以看到并没有调用tween的onUpdate
方法去设置镜头位置,但是就很神奇的生效了,这里需要讲一下threejs的# 三维向量(Vector3),其实这是一个需要避坑的地方,在这个动画里正好利用了这个特性,就是不管设置向量的Vector3的任何一个值,都是修改原来的值,指针都不曾改变,所以可以看到我前面用size和center的时候用了那么多的clone
方法,就是想保持center的原始性,一旦被其他方法污染,center就不再是物体的位置了,镜头位置修改正是用了这个原理,在start调用后,每次修改camera.position都会改变原始向量,这就导致不需要调用onupdate方法即可修改镜头位置。所以在日后的开发中,一旦设定了某个向量,轻易不要去污染这个向量,尽量用clone生成一个新的向量后再进行计算。
删除上一个选中模型的外框
当选中一个模型的时候,再去选中另一个,则需要将第一个删除,前文提到的ResourceTracker
方法有可以直接删除模型的方法
typescript
复制代码
// 销毁
dispose() {
for (const resource of this.resources) {
if (resource instanceof THREE.Object3D) {
if (resource.parent) {
resource.parent.remove(resource);
}
}
if (resource.dispose) {
resource.dispose();
}
}
this.resources.clear();
}
即便你不用封装好的ResourceTracker
方法,也可以单独使用dispose,但是模型需要单独传入了
至于边界线,可以参考之前的文章 # threejs渲染高级感可视化涡轮模型,里面有详细的介绍
2d元素
交互的最终目的是为了展示,面板的展示基本上都用css2dObject,相比于css3dObject矩阵中少了方向的概念,所以不管场景怎么移动,都是修改矩阵中的位置信息
2d元素的处理
需要加载2d元素就需要 # CSS 2D渲染器(CSS2DRenderer),
typescript
复制代码
css2dRenderer: CSS2DRenderer,
css2dRenderer = new CSS2DRenderer();
css2dRenderer.setSize(window.innerWidth, window.innerHeight);
css2dRenderer.domElement.style.position = "absolute";
css2dRenderer.domElement.style.top = "0";
css2dRenderer.domElement.style.pointerEvents = "none";
css2dRenderer.domElement.style.zIndex = '10';
document.body.appendChild(css2dRenderer.domElement);
css2dRenderer.domElement需要将css属性设置为相对定位,让dom元素的层级高一些,至少要高于3d场景的canvas元素,css2d也是需要在render中调用的 css2dRenderer.render(scene, camera);
将场景和镜头传入render方法中。
而创建2d元素则需要css2dObject,这个官网没有介绍,也不是threejs自带的方法,需要显式引用
typescript
复制代码
import { CSS2DObject, Line2, LineGeometry } from 'three/examples/jsm/Addons.js';
export const drawPart2Dinfo = (info: {name:string}) => {
// 创建一个div元素
const moonMassDiv = document.createElement('div');
moonMassDiv.className = 'label';
moonMassDiv.innerHTML = `
<div class="part-name">
<p>${info.name}</p>
</div>
<div class="part-dec">
<p>通过点击事件,获取模型名称,并在数据库查找信息,得到信息后从这里展示</p>
</div>
`
const label = new CSS2DObject(moonMassDiv);
return label
}
将css加入到组中,组的位置信息之前设置为模型的center信息,
这样的话 就会导致2d元素覆盖在模型上,所以2d元素需要单独调整一下高度,只要比模型高就可以,多少根据元素和模型尺寸自定 labelDom.position.setY(30);
3d场景背景透明化
从这张图上可以看到,模型覆盖html元素,而右边的html元素又覆盖了模型,这除了调整几个元素的z-index以外还需要将3d场景的background透明化,
typescript
复制代码
renderer = new THREE.WebGLRenderer({
canvas: canvas[0],
antialias: true,
alpha: true,
powerPreference: 'high-performance' // 高性能
});
需要将alpha
属性设置为true,而scene场景的背景颜色不需要设置,这样一来,3d场景背景色就透明化了,再去控制html元素的zindex,就可以随心所欲了,如果有条件,还可以将效果设置为裸眼3d的效果。
2d元素的删除
前文提到的ResourceTracker
方法删除3d世界的元素是有效的,但是css2d元素并不可以在这里删除,所以就要用object3d的另一个api # .removeFromParent
typescript
复制代码
const removeThatPart = () => {
moreResMgr.dispose()
if (labelDom) {
labelDom.removeFromParent()
}
}
源码及视频
源码地址:www.aspiringcode.com/content?id=…
在线体验:display.aspiringcode.com:8888/html/171844…
历史文章
如有侵权请联系站点删除!
技术合作服务热线,欢迎来电咨询!