three.js小白的学习之路。
爆炸图动画,就是看起来让模型像爆炸一样,从中心向四周进行移动,直到指定位置。
(鸡:在想我的事?)
之前实现的爆炸图动画是通过blender实现的。将模型导入到blender中,通过调整模型位置作为爆炸后的位置,然后通过关键帧动画实现。将原始位置作为动画起始帧,终止位置作为动画中间帧,再将起始位置作为结束帧,整个动画就完成了(或者,将起始和终止分别作为起始帧和结束帧,然后动画倒放也一样)。
将实现动画的模型重新导成glb/gltf模型,在three.js中进行加载,然后通过Animation相关的API加载动画,通过clip的play和pause等API控制动画的实现。
上述的方法针对一个模型里面是有少数几个部件或者分组是比较方便的,而且便于控制,可以自己控制动画的开始结束,并且模型在three.js中进行放缩、旋转等都不会影响动画的最终呈现。但对于模型的零部件很多,每一个都要单独控制的话,就会显得非常笨拙。
那这种情况应该怎么办呢,当然是借助向量系统啦。
分析分析原因。首先爆炸是从中心向四周的扩散,假如模型B位于中心点A的前方,那么爆炸后也不可能跑到中心点的后面去,因此这个方向基本是固定(不考虑与其他模型的碰撞哈)。
既然方向固定,怎么来获取呢?这就要用到一点点向量的知识了。向量,有大小有方向。如果想获取从中心点A指向模型B的向量,那么就需要 AB = B - A。如此这般,就可以得到一个向量,方向就是爆炸后的方向,大小就是爆炸前二者的距离。
其次,three.js中可以通过model.traverse方法很便捷的遍历每一个mesh,然后通过上述步骤计算其与中心点A的方向和距离。
接下来说一下具体的实现。
1.载入模型,获取中心点和向量信息
随便选一个模型,注意要是多个Mesh组成的一个Group,不然咋爆炸出来零部件。这里用的是鸡哥,毕竟再看一眼就会爆炸!
模型是下载的别人的,顺便推荐一个模型网站。
将模型载入到three.js的场景中,这里基础场景的搭建省去了。
let mesh: Three.Group;
const map: Map<
string,
{ vector: Three.Vector3; length: number; pos: Three.Vector3 }
> = new Map(); // 存储爆炸信息
let percentage= ref(0);
new GLTFLoader().load("/chicken.glb", (glb) => {
esh = glb.scene as Three.Group;
scene.add(glb.scene);
const main = glb.scene.getObjectByName("main"); // 保持不变的中心物体
const center = new Three.Vector3();
center.copy(main!.position); // 定义爆炸图的中心位置
mesh.traverse((item) => {
if (item.isMesh) {
const pos = new Three.Vector3().copy(item.position).sub(center);
map.set(item.uuid, {
vector: pos.clone().normalize(),
length: pos.length(),
pos: item.position.clone(),
});
}
});
});
traverse循环里面的pos就是当前模型到中心模型的向量了。
有了向量,那就好说,让模型的位置沿着向量的方向去移动就行了:
const func = () => {
mesh.traverse((item) => {
if (item.isMesh) {
const curMesh = map.get(item.uuid);
const pos = curMesh!.pos
.clone()
.add(
curMesh!.vector.clone().multiplyScalar(curMesh!.length * percentage.value / 20)
);
item.position.set(pos.x, pos.y, pos.z);
}
});
}
percentage变量随时间的变化从0开始增加,为了方便控制,这里添加了一个滑块组件:
<div class="slider">
<el-slider v-model="percentage" />
</div>
为了让模型的位置动态变化,需要在循环里面一直执行爆炸动画的函数:
const loop = () => {
renderer.render(scene, camera);
if (mesh) {
Func();
}
requestAnimationFrame(loop);
}
效果如下图所示:
2.变换模型位置,并且修改中心点坐标
目前来看,一切都已经OK了,但是我们将模型的位置变一下,再看一下效果:
glb.scene.scale.set(10, 10, 10);
glb.scene.position.set(100, 10, 10);
glb.scene.rotation.x = Math.PI / 3;
哎嘿嘿,好像也没有问题。原因是我们中心点坐标依然使用的是main模型的原点位置。
其实此时打印一下center,可以看出,模型的坐标并不是移动后的坐标,换而言之,就是依然是局部坐标,而非世界坐标。这就引申出一个问题,我想让移动后的坤坤爆炸的中心点在坐标原点(模型的坐标原点在两只露出的鸡脚中间附近),看看会发生什么事情:
const center = new Three.Vector3(); // 中心点置成原点
看出来问题了吗?我想的是世界坐标原点,爆炸图却是以模型的局部坐标原点爆炸的。这就和我想要的有些不一样了。当然如果你想实现的爆炸图就像用局部坐标实现,当然大部分条件下局部也够用了,那就不用往下看了。
此时我想说的是另一种情况:我不知道模型缩放前的大小,原点也不太清楚是在哪儿,所以不好控制爆炸时的位置,怎么办?
3.世界坐标下的爆炸图动画
基于上述问题,就不得不考虑局部坐标和世界坐标之间的转换了。
转换其实也不难,如果不想自己手写的(说的就是我),直接使用Three.js中Vectore3自带的方法:worldToLocal,该方法可以将世界坐标转换成相对于模型的局部坐标。
于是获取模型中心点的方法就要这么写:
center.copy(glb.scene.worldToLocal(new Three.Vector3(0, 0, 0)));
简单吧,看看效果:
完美,因为是针对坐标原点,所以会往左上方跑。而如果针对一开始的问题,我不知道模型的局部坐标系内的具体位置,可以使用Three.Box3包围盒来写:
const box = new Three.Box3();
box.expandByObject(glb.scene);
console.log(box);
box变量里面就是模型的缩放平移旋转后的大小信息,里面有center等方法,可以帮助我们快速定位模型位置!
4.总结
爆炸图动画,不想用blender而是通过three.js来做,就是向量的一种运用场景。需要一定的数学基础和空间想象力,不过说实话,没有也没啥所谓。然后通过一个变量的连续变化使模型的位置发生变化,产生爆炸的效果,目标达成。如果说补货是用blender等建模软件或其他原因导致无法获得模型的相关坐标信息,则使用世界坐标转成局部坐标的方式完成。
5.代码
展示一下全部的代码,将目标方法进行了封装:
<template>
<div id="boom" ref="boomRef"></div>
<div class="slider">
<el-slider v-model="percentage" />
</div>
</template>
<script lang="ts" setup>
import * as Three from "three";
import { ref, onMounted } from "vue";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/Addons.js";
const boomRef = ref();
const scene = new Three.Scene();
const camera = new Three.PerspectiveCamera(
80,
window.innerWidth / window.innerHeight,
0.1,
10000
);
camera.position.set(0, 40, 40);
camera.lookAt(0, 0, 0);
const renderer = new Three.WebGLRenderer({
antialias: true,
logarithmicDepthBuffer: true,
});
renderer.setPixelRatio(window.devicePixelRatio); // 设置设备像素比,避免渲染模糊问题
const percentage = ref(0);
const boom = (mesh, center, type = 'local') => {
const centerPos = new Three.Vector3(); // 储存爆炸中心位置
const map = new Map(); // 存储爆炸信息
// 如果传的是字符串,即模型的名字,就需要在mesh里面包含这个模型,此时一定是局部坐标系
if (typeof center === "string") {
const main = mesh.getObjectByName(center); // 保持不变的中心物体
centerPos.copy(main!.position);
} else {
if (type === "local") {
// 如果是相对于局部坐标系来说,那就直接赋值即可
centerPos.copy(center);
} else {
// 否则,就需要将这个坐标转化成相对于mesh的局部坐标
centerPos.copy(mesh.worldToLocal(center.clone()));
}
}
mesh.traverse((item) => {
if (item.isMesh) {
const pos = new Three.Vector3().copy(item.position).sub(centerPos);
map.set(item.uuid, {
vector: pos.clone().normalize(),
length: pos.length(),
pos: item.position.clone(),
});
}
});
const bloomFunc = (explosion: number) => {
mesh.traverse((item) => {
// @ts-ignore
if (item.isMesh) {
const curMesh = map.get(item.uuid);
const pos = curMesh!.pos
.clone()
.add(
curMesh!.vector.clone().multiplyScalar(curMesh!.length * explosion)
);
item.position.set(pos.x, pos.y, pos.z);
}
});
};
return bloomFunc;
};
let bloomFunc;
new GLTFLoader().load("/chicken.glb", (glb) => {
scene.add(glb.scene);
glb.scene.scale.set(10, 10, 10);
glb.scene.position.set(10, 10, 10);
glb.scene.rotation.x = Math.PI / 3;
const box = new Three.Box3();
box.setFromObject(glb.scene);
console.log(box);
const pos = [
(box.max.x + box.min.x) / 2,
box.min.y,
(box.max.z + box.min.z) / 2,
];
// bloomFunc = boom(glb.scene, new Three.Vector3(0, 0, 0));
bloomFunc = boom(glb.scene, new Three.Vector3(0, 0, 0), "world");
// bloomFunc = boom(glb.scene, new Three.Vector3(...pos), "world");
// bloomFunc = boom(glb.scene, "main");
});
const ambientLight = new Three.AmbientLight(0xffffff, 4);
scene.add(ambientLight);
const axes = new Three.AxesHelper(300);
scene.add(axes);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
const loop = () => {
renderer.render(scene, camera);
if (bloomFunc) {
bloomFunc(percentage.value / 20);
}
requestAnimationFrame(loop);
};
onMounted(() => {
renderer.setSize(boomRef.value.clientWidth, boomRef.value.clientHeight);
boomRef.value.appendChild(renderer.domElement);
loop();
});
</script>
<style lang="scss" scoped>
#boom {
width: 100vw;
height: 100vh;
}
.slider {
width: 100px;
position: absolute;
bottom: 50px;
right: 50%;
transform: translateX(50%);
}
</style>