本教程我们将实现一个 Confetti 五彩纸屑动画效果,类似于飞书文档的点赞效果
主要涉及到粒子系统 (Particle System) 的实现、向量、坐标系的旋转等知识
首先,我们细细观察一下上面这个五彩纸屑动画效果,看看它是怎么形成的,对它进行初步的分解。
五彩纸屑的动画效果有如下几个特点:
根据上面分析的特点,本教程将会从如下几个方面来一步一步地实现
在这之前,我们需要知道为什么要基于粒子系统实现这个效果。
在这个五彩纸屑的效果中,有很多的纸屑在同时运动,每个纸屑的运动规律都是相似的,他们都是从同一个点被发射出来,在初始的推力下向上运动,之后再缓慢地落下,直到最后消失。基于粒子系统,我们可以很好地控制和管理纸屑粒子的运动。
粒子系统是一种在计算机图形学和游戏开发中广泛使用的技术,通常用于模拟复杂的动态效果,如爆炸、烟雾、火焰、水流、雪花、流星轨迹等。粒子系统通过生成大量小型、独立的粒子来模拟这些效果,每个粒子都具有自己的属性(如位置、速度、加速度、颜色、大小和生命周期等)。
我们可以用“和而不同”来概括粒子系统的特点,从整体上来说,粒子系统是由具有统一规律特征的个体而组成,而从个体上来说,每个粒子由随机表现出不同的特征。
以下是粒子系统的一些关键概念:
接下来我们看看如何实现这样的一个粒子系统,最终完成我们的五彩纸屑效果。
对于我们的纸屑粒子,它具有如下属性
我们用代码来实现这样一个纸屑粒子
上面就是我们的纸屑粒子,可以看到画板上显示了一个长方形。我们来认识一下粒子实现的基础代码。
|
|
class Particle {}
是 JavaScript 里定义类的语法,类是实现代码复用的比较有用的方式,我们可以基于类来实例化许多具有相同属性和方法的对象,这正好契合我们的粒子系统的特点。
我们定义了一个 Particle
类,包含了粒子的位置 pos
、速度 vel
、加速度 acc
、大小 size
、颜色 color
和生命周期 lifetime 剩余存活时间
、life 寿命
等属性。同时我们 Particle
类定义了几个常用的方法,如 applyForce()
(应用力)、update()
(更新粒子状态)、isAlive()
(检查粒子是否存活)和 draw()
(绘制粒子)。
在 Particle 的构造函数 constructor()
中,我们初始化了粒子的基本属性。pos
、vel
和 acc
都是向量类型的数据,因为它们都具有方向和大小的属性。
draw
方法中,我们通过调用 p5.js 内置的 fill
方法来设置渲染的颜色,同时使用 rect
方法在点 pos
的位置绘制了一个矩形。
update
方法中,我们基于加速度更新了速度、位置,最后将加速度重置为 0,同时将生命值减少 1。
在我们的运动模型中,速度、加速度、位置有如下的关系
速度 = 速度 + 加速度
位置 = 位置 + 速度
applyForce
方法是用来接受外部的施加力的,它接受一个向量作为参数,通过外部的力改变了加速度。
isAlive
方法是用来检查粒子是否存活的,如果 lifetime 小于或者等于 0,说明粒子已经消亡了,这个时候粒子系统需要将该粒子移除。
有了 Particle
类,就可以基于其生成具体的粒子了,我们可以通过 JavaScript 的语法 new Particle()
来实例化一个具体的粒子。
我们来看看在 p5.js 中如何使用这个粒子代码。
|
|
setup
和 draw
方法同一个层级下通过 JavaScript 的 let
语法创建了一个 confetti
变量。然后在 setup 中实例化了一个 Particle 对象并赋值给了 confetti
变量。这样我们就可以在接下来的 draw 函数中使用这个 confetti 对象了。draw
方法中,我们调用了 confetti.update
和 confetti.draw
两个方法。confetti.update
方法会更新粒子的速度和位置,confetti.draw
方法用来在画板上绘制粒子。
setup
和draw
方法都属于 p5.js 生命周期函数
setup()
是用来做初始化的,只会执行一次,而draw()
方法则会一遍一遍的执行我们不能在
confetti
定义的时候就实例化Particle
对象,因为这个时候 p5.js 相关代码还没有初始化。
上面的实现中,我们看到的是一个静态的长方形,一动不动地。这是因为我们还没有给 confetti
施加力,自然就不会动了。接下来我们让单个粒子运动起来,我们先假设粒子的运动轨迹都是垂直升空再垂直慢慢降落的,在后面我们会讲到如何对粒子初始发射的角度做优化。
要让粒子从 A 点运动到 B,它在坐标系上的位置会发生什么变化?
我们首先需要分析一下粒子的受力情况,当粒子从 A 点发出运动到 B 点的时候,它首先会受到一个初始向上的推力 bootForce,bootForce 只会在粒子产生的时候才会有,也就是 bootForce 只会施加一次给粒子。
在 bootForce 初始推力的作用下,粒子开始上升,在这个过程中,粒子一直受到重力的作用,让它的速度慢慢减少,最后粒子就会停止上升最后在重力的作用下开始下降。
使用向量 bootForce 来表示初始推力,初始推力的方向向上,具有一定的大小,我们假设 bootForce = createVector(0, -15)
,即方向向上,大小为 15。
使用向量 gravity 来表示重力,重力的方向一直向下,我们假设 gravity = createVector(0, 0.5)
,即方向向下,大小为 0.5。
在 p5.js 坐标系中,正常情况下
x 轴(横轴)方向上,向右方向为正,向左方向为负
y 轴(纵轴)方向上,向下方向为正,向上方向为负
来看看加上受力后,我们的粒子是怎么运动的。
可以看到我们的粒子已经运动起来了,在代码中,我们增加了两个力,一个是粒子初始化时给它施加了一个向上的推力,另一个是重力。在这两个力的作用下,粒子开始上升,在这个过程中,粒子一直受到向下的重力作用,它的速度慢慢减少,最后粒子就停止上升并在重力的作用下开始下降。
现在粒子基本实现了我们的运动轨迹要求,但是存在一个问题,粒子在下降的时候由于重力的作用,速度越来越快,而我们是期望它能缓缓下降的。这就需要我们引入一个新的力,摩擦力。
在粒子系统中,摩擦力通常表示为一个与速度方向相反的阻力,它的大小与速度成正比,可以通过以下公式计算出来:
$friction = -1 * coefficient * velocity$
其中,coefficient
是摩擦系数,表示摩擦力的大小,velocity
是粒子的速度向量。由于摩擦力的方向与速度方向相反,所以我们需要将其乘以-1。
知道了摩擦力的计算公式,来看看如何给粒子施加摩擦力,让它的运动更加自然。
首先我们来定义一个摩擦系数的变量,为了简单起见,我们将其值设置为 0.05,即 const coefficient = 0.05
由上面的摩擦力公式,我们可以得到每次渲染时对应的摩擦力大小,即
|
|
上述代码中,有两点需要注意
particle.vel.copy()
不能直接操作 particle.vel,因为向量的运算会修改向量自身,所以我们需要一份拷贝
vel.multmult(-coefficient)
使用 p5.js mult
向量标量乘法来实现摩擦力的计算。
p5.js 提供了向量运算的静态操作函数,我们可以使用 p5.Vector.mult() 的形式来调用
向量的标量乘法,即向量和数值相乘,运算规则为向量的每个分量分别和数值相乘
$a * (x, y) = (a * x, a * y)$
我们给粒子 Particle 类增加了一个 applyFriction() 方法,用于对粒子施加摩擦力,添加了摩擦力的模拟后,我们的单个粒子运动就基本上完成了
我们的粒子目前是黑色的,与五彩纸屑还有一定的差距。接下来,我们将为粒子添加颜色。
在之前的教程中,我们主要依靠颜色模式来动态生成颜色。然而,在这个教程中,我们将引入色盘的概念。
色盘,正如其名,是一个预先调整好的颜色列表。当需要使用时,我们只需按照一定规则选择其中一个颜色即可。
我们可以定义一个数组变量来存储色盘中的颜色,一般情况下,一个一维数组足矣,假如你有多个色盘的话,那么可能就要使用一个二维数组了。
下面我们使用一个一维数组来实现我们的色盘。
|
|
使用色盘也很简单,当要用到颜色时,我们使用最简单的随机策略从数组中选取一个,即
|
|
当 random 的参数是一个数组时,random 函数会随机返回一个该数组中的元素,这正好满足的我们的目的。
下面我们看看基于色盘添加颜色后的粒子效果,点击运行后可以看到不同的颜色粒子。
多色盘
当我们有多个色盘时,可以用二维数组来表示,如我们有春夏秋冬四个色盘,每个色盘有一系列的颜色,就可以用如下的代码来实现。
const pallete = [
['春天', ['#26ccff', '#a25afd']],
['夏天', ['#ff5e7e', '#88ff5a']],
['秋天', ['#fcff42', '#ffa62d']],
['冬天', ['#ff36ff']],
]
或者用一维数组来表示,数组里的每一项存储一个色盘信息
const pallete = [
{name: '春天', colors: ['#26ccff', '#a25afd']},
{name: '夏天', colors: ['#ff5e7e', '#88ff5a']},
{name: '秋天', colors: ['#fcff42', '#ffa62d']},
{name: '冬天', colors: ['#ff36ff']},
]
当我们有多个色盘时,我们最好像这样给色盘取一个名字,如此方便我们对颜色的调整。
有了单个粒子实现的基础,我们接下来实现一个简单的粒子系统 ParticleSystem,对粒子进行统一的管理,包括粒子的生成、粒子的消亡、以及对粒子施加力的作用。
要完成粒子的管理任务,ParticleSystem 需要有如下几个主要的属性和方法:
下面我们重点看看发射粒子和粒子发射容器形状的实现
|
|
在每次发射粒子的时候,我们都根据 onceCount 来发射特定数量的粒子,同时增加 emittedCount 和 hasEmitted 的值。
|
|
我们可以通过调整 emitRadianStart 和 emitRadianEnd 这两个角度参数来控制发射器的形状。当发射器形状为圆形时,emitRadianStart 为 0,emitRadianEnd 为 Math.PI * 2。
为了控制粒子发射的角度,需要从开始和结束角度之间获取一个随机值,并将其转换成长度为 1 的单位向量。这将作为粒子初始推力方向,进一步决定粒子的运动方向。
为了让粒子的运动更自然,需要为每个粒子设置不同的初始推力大小,以确保它们的速度和最终上升的高度各不相同。通过使用随机值和向量标量乘法,可以轻松实现这一点。在此示例中,我们使用介于 4 到 25 之间的随机数,但你也可以根据实际情况进行调整。
粒子的发射范围见下图
我们目前实现的版本和开篇的 demo 版本有些许不一样,缺少了粒子的旋转和变形。而这通过 p5.js 的 rotate 和 scale 的内置方法可以很方便地实现。
为了让粒子旋转的更加自然,我们给粒子增加了一个旋转角度的属性 rotate,以及一个旋转速度的属性 rotateSpeed。
旋转角度 rotate 在粒子初始化时随机获取了一个 0 到 Math.PI * 2 之间的值,而 rotateSpeed 我们选取了 -0.05 到 0.05 之间的随机值。当然你也可以设置为其他的值,看看效果如何。
有了旋转角度 rotate,我们只需要在粒子渲染 draw 时,将坐标系进行响应的旋转即可,需要注意的是我们需要调用 push 和 pop 来复原绘图现场。
来具体看看旋转飘落的实现代码
|
|
在上述代码中,我们首先基于旋转速度更新当前粒子的旋转角度,然后调用 push 来保存绘图现场,在旋转之前,我们先调用 translate 方法,将坐标系的原点移动到了当前粒子的位置,这样进行旋转的时候,就是针对当前粒子进行操作了。
在旋转的同时,我们将当前旋转角度映射到变形参数上面,通过 p5.js 的 scale 方法对粒子进行变形操作,这样让粒子看起来更自然一些。
对坐标系进行操作时,我们通常都应该调用 p5.js 的 push 和 pop 方法来保存和还原绘图现场,以免对后续的渲染造成非预期的结果
通过 translate 移动原点到当前粒子位置,这是关键的一个步骤,否则默认情况下坐标系将以左上角原点为中心进行旋转,这可能不是我们想要的效果
下面是我们最终的五彩纸屑效果,你可以试着修改一下代码,看看效果如何
本教程我们基于简单的粒子系统、向量知识和坐标系旋转等知识实现了一个类似于飞书文档点赞的五彩纸屑效果。
你还可以基于本教程的代码做一些其他的修改,如下是一些可能的点: