作者简介 Coco 蚂蚁金服·数据体验技术团队
3D 粒子动画效果如下:
本示例所构成并不复杂,主要分成以下几部分:
- 渐变的背景
- 不断切换的 3D 粒子模型(入场的散点也是粒子模型的一种形态)
- 同时切换的文字
实现主要是基于 threejs 做的,接下来我会分别讲解各部分的实现,不过不会介绍基础。基础内容可以去官网找到~
scene 的 background 可以接收 Color、Texture 或 CubeTexture。在本示例中,我们使用 Texture 就可以达到渐变效果。Texture 可以接收 canvas 或图片,此处我们使用 canvas 画出渐变效果,具体代码如下:
const canvas = document.createElement('canvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const context = canvas.getContext('2d');
const gradient = context.createLinearGradient(0, 0, width, 0);
gradient.addColorStop(0, '#4e22b7');
gradient.addColorStop(1, '#3292ff');
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
const texture = new THREE.Texture(canvas);
texture.needsUpdate = true;
const scene = new THREE.Scene();
scene.background = texture;
接下来给大家介绍的是一个黑科技——减少模型粒子数。通常情况下设计师会给你符合设计稿内所需的模型,粒子数不会比你所需的多,但凡是有例外的时候。另外,粒子的多少是影响性能的关键之一,在这个示例中我测试过 1W 个粒子就使动画有卡顿感(当然这还跟运行的本本性能有很大的关系)。如果你正好有这方面的烦恼,那这个方法或许对你有用。
实现思路:在 X,Y,Z 轴上分别计算相邻点之间的距离,如果大于距离小于设定值则删除。具体代码如下:
// pos为粒子的数据,单项数据分别含x,y,z坐标值 {x, y, z}
const all = [];
const yObjs = {};
const xObjs = {};
const zObjs = {};
// 对所有点按照x,y,z值进行分组,分组后的数据分别存在xObjs,yObjs,zObjs
for (var i = pos.length-1; i>=0; i--) {
const p = pos[i];
const yKey = getKey(p.y);
const xKey = getKey(p.x);
const zKey = getKey(p.z);
if (!yObjs[yKey]) { yObjs[yKey] = []; }
if (!xObjs[xKey]) { xObjs[xKey] = []; }
if (!zObjs[zKey]) { zObjs[zKey] = []; }
const item = {x, y, z, r, g, b};
yObjs[yKey].push(item);
xObjs[xKey].push(item);
zObjs[zKey].push(item);
all.push(item);
}
// 对x,y,z值进行排序
const xKeys = orderAscKeys(xObjs);
const yKeys = orderAscKeys(yObjs);
const zKeys = orderAscKeys(zObjs);
// 提取在x,y,z轴上需删除的坐标值
const xDels = getDelKeys(xKeys, 4); // 4为点与点的间距
const yDels = getDelKeys(yKeys, 4);
const zDels = getDelKeys(zKeys, 4);
const result = [];
for (var i = all.length-1; i>=0; i--) {
const item = all[i];
if ( !(xDels.indexOf(getKey(item.x))>-1 || yDels.indexOf(getKey(item.y))>-1 || zDels.indexOf(getKey(item.z))>-1 )) {
result.push(item);
}
}
return result;
function getKey(x) {
let r = x.toFixed(1);
if (r==='-0.0') {
r = '0.0'
}
return r;
}
function orderAscKeys (obj) {
return Object.keys(obj).sort((a, b)=>{
return Number(a) - Number(b);
});
}
function getDelKeys(keys, d) {
const dels = [];
keys.reduce((prev, curr, idx)=>{
let res = curr;
if (curr-prev < d) {
dels.push(curr);
res = prev;
}
return res;
});
return dels;
}
注:此方法只有对粒子排序比较整齐的模型,如果模型本身的粒子排序混乱使用此方法的效果可能会不理想。
在整个动画效果中,粒子通过变换位置流畅的组合着各个模型,如何做到这一点呢? 如果你给每个模型都 new Geometry 出来,这回让你手忙脚乱,而且效果不理想。比较好的处理方式是将这 5 个模型用一个 Geometry,通过改变 vertices 属性来达到模型的变换。不过有一点需要注意,vertices 属性可以改变里面每一项的值,但不能在定义之后再新增项的数量。这就要求我们在最初定义 Geometry 的 vertices 的值个个数必须是几个模型中点数最多的那个。
const config = this.config;
const textureLoader = new THREE.TextureLoader();
textureLoader.crossOrigin = '';
const mapDot = textureLoader.load('img/gradient.png'); // 圆点
this.mapDot = mapDot;
const geometry = new THREE.Geometry();
const count = config.totalCount; // 四个模型中最多个粒子总数值
for (let i = 0; i < count; i++) {
let x = 0, y = 0, z = 0;
x = Math.random() * 2000 - 1000;
y = Math.random() * 2000 - 1000;
z = Math.random() * 8000 - 4000;
geometry.vertices.push(new THREE.Vector3(x, y, z));
geometry.colors.push(new THREE.Color(1, 1, 1));
}
const material = new THREE.PointsMaterial({
size: 20,
map: mapDot,
depthTest: true,
alphaTest: .1,
opacity: 1,
side: THREE.DoubleSide,
transparent: !0,
vertexColors: THREE.VertexColors,
});
const points = new THREE.Points(geometry, material);
// 调整模型姿势
points.rotation.z = Math.PI;
points.rotation.y = Math.PI;
points.rotation.x = -Math.PI * .3;
points.position.y = 240;
points.position.x = 100;
points.position.z = 240;
this.scene.add(points);
this.points = points;
webGL 采用右手坐标系,而 canvas 二维坐标系是 x 轴往右延伸、y 轴往下延伸,因为存在这样的差异直接通过图片生成的 3 维模型会是倒的。在实际处理的时候我们可以通过模型的整理做调整,也可以换算每个点的坐标值。具体做法可以根据实际想要的效果来选择。
整个动画过程中有以下几种状态:
- 入场前奏动画(即散点状态),用于数据准备和动画过渡效果
- 模型切换动画进行时
- 模型切换间隔休息时
状态的管理与切换具体代码如下:
// 一切模型都已经准备妥当
if (this.objectsData && this.points && this.index !== undefined) {
const config = this.config;
const len = config.objs.length;
const idx = this.index % len; // 当前模型序号
const item = this.objectsData[idx];
const geometry = this.points.geometry;
this.points.material.needsUpdate = true;
geometry.verticesNeedUpdate = true;
geometry.colorsNeedUpdate = true;
// 前奏因数据准备时间不较长,不额外加延迟时间
const delay = (this.index === 0) ? config.delay * 60 * 0 : config.delay * 60;
if (item.waiting < delay) {
// 等待时间,包含了前奏(1)和切换间隔时间(3)
item.waiting++;
} else if (item.waiting >= delay && item.count < config.totalCount - 1) {
// 动画进行时(2)
let nd;
if (item.count === 0) {
config.onLeave && typeof config.onLeave === 'function' && config.onLeave();
nd = idx;
const prevIdx = (idx - 1 > -1) ? idx - 1 : len - 1;
const prev = this.objectsData[prevIdx];
if (prev) {
// 执行下一个模型切换动画时,将前一个的执行时计数和休息时计数都归零
prev.count = 0;
prev.waiting = 0;
}
}
// 执行动画
this.particleAnimation(this.points, item, nd);
} else if (item.waiting >= delay && item.count >= config.totalCount - 1) {
// 切换到下一个模型
this.index++;
}
}
模型切换动画执行时时按批执行的,并不是所有粒子一起开始运动的。 这样做的好处是:
- 避免出现因单位时间(16.67ms)内因计算量过大而出现画面卡顿感;单位时间是由 requestAnimationFrame 执行的频率决定的。
- 分组动画会让动画更有层次感
/**
* points: 粒子对象
* item: 当前模型数据
* idx: 当前模型序号
*/
particleAnimation(points, item, idx) {
const geometry = points.geometry;
const vertices = geometry.vertices;
const colors = geometry.colors;
const len = vertices.length;
if (item.count >= len) { return; }
if (!item.vTween) {
item.vTween = [];
}
const config = this.config;
let isOdd = this.index % 2===0;
// 每组动画执行的粒子个数
const cnt = 1000;
for (let j = item.count, l = item.count + cnt; j < l && j < len - 1; j++) {
const n = j % item.length;
const p = item.data[n];
if (!p) { return; }
// TWEEN只new一次,减少多次实例化的开销
if (!item.vTween[j] && vertices[j]) {
item.vTween.push(new TWEEN.Tween(vertices[j])
.easing(TWEEN.Easing.Exponential.In)
);
}
item.vTween[j].stop();
// 奇偶序号的模型位置不一样,调整x坐标数据
const x = (isOdd) ? p.x : p.x-400;
item.vTween[j].to({ x, y: p.y, z: p.z }, config.speed).start();
}
item.count = item.count + cnt;
}
TWEEN 是一个补间动画库,使用方法也很简单,详情参见说明文档。TWEEN 提供了以下几种方式,可以自行替换使用,如图:
文字部分的动画是用 css3 实现的,网络上已经很多有关 css3 动画相关的文档就不再详述,这里就简单列出相关样式代码以作参考。
.leaving {
transition: transform .7s, opacity .7s;
transform: translate3d(0px, -205%, 0);
opacity: 0;
}
.entering {
transition: opacity .7s;
opacity: 1;
}
至此整个动画效果中可能遇到的问题都已经一一阐述,有兴趣的同学可以自动动手尝试一下。如有更好的实现方式欢迎提出来共享。