Confetti 五彩纸屑动画效果,基于 p5.js、向量、粒子系统的创意编程实现

最后更新于 2023-05-12

本教程我们将实现一个 Confetti 五彩纸屑动画效果,类似于飞书文档的点赞效果

主要涉及到粒子系统 (Particle System) 的实现、向量、坐标系的旋转等知识

## 作品分析

首先,我们细细观察一下上面这个五彩纸屑动画效果,看看它是怎么形成的,对它进行初步的分解。

五彩纸屑的动画效果有如下几个特点:

  1. 动画效果是由很多的彩色纸屑运动而成
  2. 单个纸屑的运动效果是先向上快速运动,再缓缓下落,在下落的过程中,纸屑还会发生旋转和形状变化
  3. 纸屑发射出去的时候,是有一个特定的运动范围的,在一定的夹角内发射出去
  4. 五彩纸屑,顾名思义是很多颜色,在下落的过程中,纸屑的透明度发生了变化

根据上面分析的特点,本教程将会从如下几个方面来一步一步地实现

  1. 单个粒子的实现,即单个纸屑的运动效果模拟
  2. 粒子系统的实现,即多个纸屑同时运动的效果模拟
  3. 粒子发射的角度优化

## 粒子系统的介绍

在这之前,我们需要知道为什么要基于粒子系统实现这个效果。

在这个五彩纸屑的效果中,有很多的纸屑在同时运动,每个纸屑的运动规律都是相似的,他们都是从同一个点被发射出来,在初始的推力下向上运动,之后再缓慢地落下,直到最后消失。基于粒子系统,我们可以很好地控制和管理纸屑粒子的运动。

粒子系统是一种在计算机图形学和游戏开发中广泛使用的技术,通常用于模拟复杂的动态效果,如爆炸、烟雾、火焰、水流、雪花、流星轨迹等。粒子系统通过生成大量小型、独立的粒子来模拟这些效果,每个粒子都具有自己的属性(如位置、速度、加速度、颜色、大小和生命周期等)。

我们可以用“和而不同”来概括粒子系统的特点,从整体上来说,粒子系统是由具有统一规律特征的个体而组成,而从个体上来说,每个粒子由随机表现出不同的特征。

  1. 统一性,整体上的统一规律
  2. 随机性,个体上的不同特征
  3. 群体性,群聚组织

以下是粒子系统的一些关键概念:

接下来我们看看如何实现这样的一个粒子系统,最终完成我们的五彩纸屑效果。

## 单个粒子的实现

### 粒子定义

对于我们的纸屑粒子,它具有如下属性

我们用代码来实现这样一个纸屑粒子

上面就是我们的纸屑粒子,可以看到画板上显示了一个长方形。我们来认识一下粒子实现的基础代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Particle {
    constructor(pos, vel, size) {
        this.pos = pos;
        this.vel = vel;
        this.acc = createVector(0, 0);
        this.size = size;
        this.life = 300;
        this.lifetime = 300;  
        this.color = color('black');
    }
    
    draw() {
        fill(this.color)
        rect(this.pos.x, this.pos.y, this.size, this.size * 2);
    }

    update() {
        this.vel.add(this.acc);
        this.pos.add(this.vel);
        this.acc.mult(0);
        this.lifetime--
    }

    applyForce(force) {
        this.acc.add(force);
    }

    isAlive() {
        return this.lifetime > 0
    }
}

class Particle {} 是 JavaScript 里定义类的语法,类是实现代码复用的比较有用的方式,我们可以基于类来实例化许多具有相同属性和方法的对象,这正好契合我们的粒子系统的特点。

我们定义了一个 Particle 类,包含了粒子的位置 pos、速度 vel、加速度 acc、大小 size、颜色 color 和生命周期 lifetime 剩余存活时间life 寿命 等属性。同时我们 Particle 类定义了几个常用的方法,如 applyForce()(应用力)、update()(更新粒子状态)、isAlive()(检查粒子是否存活)和 draw()(绘制粒子)。

在 Particle 的构造函数 constructor() 中,我们初始化了粒子的基本属性。posvelacc 都是向量类型的数据,因为它们都具有方向和大小的属性。

draw 方法中,我们通过调用 p5.js 内置的 fill 方法来设置渲染的颜色,同时使用 rect 方法在点 pos 的位置绘制了一个矩形。

update 方法中,我们基于加速度更新了速度、位置,最后将加速度重置为 0,同时将生命值减少 1。

在我们的运动模型中,速度、加速度、位置有如下的关系

速度 = 速度 + 加速度

位置 = 位置 + 速度

applyForce 方法是用来接受外部的施加力的,它接受一个向量作为参数,通过外部的力改变了加速度。

isAlive 方法是用来检查粒子是否存活的,如果 lifetime 小于或者等于 0,说明粒子已经消亡了,这个时候粒子系统需要将该粒子移除。

### 生成单个粒子

有了 Particle 类,就可以基于其生成具体的粒子了,我们可以通过 JavaScript 的语法 new Particle() 来实例化一个具体的粒子。

我们来看看在 p5.js 中如何使用这个粒子代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
let confetti

function setup() {
    createCanvas(windowWidth, windowHeight)
    confetti = new Particle(
        createVector(width / 2, height / 2),
        createVector(0, 0),
        10
    )
}

function draw() {
    confetti.update()
    confetti.draw()
}
  1. 我们首先在 setupdraw 方法同一个层级下通过 JavaScript 的 let 语法创建了一个 confetti 变量。然后在 setup 中实例化了一个 Particle 对象并赋值给了 confetti 变量。这样我们就可以在接下来的 draw 函数中使用这个 confetti 对象了。
  2. draw 方法中,我们调用了 confetti.updateconfetti.draw 两个方法。confetti.update 方法会更新粒子的速度和位置,confetti.draw 方法用来在画板上绘制粒子。

setupdraw 方法都属于 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

由上面的摩擦力公式,我们可以得到每次渲染时对应的摩擦力大小,即

1
2
3
4
5
// 获取粒子当前速度的一份拷贝
const vel = particle.vel.copy()

// 向量的乘运算,实现摩擦力的计算
const friction = vel.mult(-coefficient)

上述代码中,有两点需要注意

  1. particle.vel.copy()

    不能直接操作 particle.vel,因为向量的运算会修改向量自身,所以我们需要一份拷贝

  2. vel.multmult(-coefficient)

    使用 p5.js mult 向量标量乘法来实现摩擦力的计算。

p5.js 提供了向量运算的静态操作函数,我们可以使用 p5.Vector.mult() 的形式来调用

向量的标量乘法,即向量和数值相乘,运算规则为向量的每个分量分别和数值相乘

$a * (x, y) = (a * x, a * y)$

我们给粒子 Particle 类增加了一个 applyFriction() 方法,用于对粒子施加摩擦力,添加了摩擦力的模拟后,我们的单个粒子运动就基本上完成了

### 五颜六色的粒子

我们的粒子目前是黑色的,与五彩纸屑还有一定的差距。接下来,我们将为粒子添加颜色。

在之前的教程中,我们主要依靠颜色模式来动态生成颜色。然而,在这个教程中,我们将引入色盘的概念。

色盘,正如其名,是一个预先调整好的颜色列表。当需要使用时,我们只需按照一定规则选择其中一个颜色即可。

我们可以定义一个数组变量来存储色盘中的颜色,一般情况下,一个一维数组足矣,假如你有多个色盘的话,那么可能就要使用一个二维数组了。

下面我们使用一个一维数组来实现我们的色盘。

1
2
3
4
5
6
7
8
9
const pallete = [
    '#26ccff',
    '#a25afd',
    '#ff5e7e',
    '#88ff5a',
    '#fcff42',
    '#ffa62d',
    '#ff36ff'
];

使用色盘也很简单,当要用到颜色时,我们使用最简单的随机策略从数组中选取一个,即

1
const color = random(pallete)

当 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 需要有如下几个主要的属性和方法:

下面我们重点看看发射粒子和粒子发射容器形状的实现

### 粒子发射的逻辑实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
emit() {
    if (!this.isLoop && this.hasEmitted) {
        return
    }
    for (let i = 0; i < this.onceCount; i++) {
        this.addParticle();
    }
    this.emittedCount += this.onceCount
    if (this.emittedCount == this.numParticles) {
        this.hasEmitted = true
    }
}
  1. isLoop 标记是否循环发射,如果为 true,那么会在每次渲染循环时发射粒子。
  2. hasEmitted 标记粒子是否发射完成,如果该粒子系统发射的粒子和粒子总数相等,那么就设置 hasEmited 为 true,表示已经发射完成了。
  3. emittedCount 是粒子系统已经发射的粒子数量。
  4. onceCount 是每次发射的粒子数量

在每次发射粒子的时候,我们都根据 onceCount 来发射特定数量的粒子,同时增加 emittedCount 和 hasEmitted 的值。

### 粒子的发射容器形状,即粒子发射的角度

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
addParticle() {
    // 基于开始和结束角度来决定例子发射时的方向
    const emitVel = p5.Vector.fromAngle(random(this.emitRadianStart, this.emitRadianEnd))
    const particle = new Particle(
        this.centerPos.copy(),
        createVector(), 
        random(4, 10),
    );
    // 施加不同的初始推力
    particle.applyForce(emitVel.mult(random(5, 25)))
    this.particles.push(particle);
}

我们可以通过调整 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 来复原绘图现场。

来具体看看旋转飘落的实现代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
draw() {
    this.rotate += this.rotateSpeed
    const currentColor = color(this.color)
    if (this.vel.y >= 0) {
        currentColor.setAlpha(this.lifetime / this.life * 100)
    }
    fill(currentColor)
    push()
    // 移动原点到当前粒子位置,这是关键的一个步骤,否则默认情况下坐标系将以左上角原点进行旋转,这可能不是我们想要的效果
    translate(this.pos.x, this.pos.y)
    rotate(this.rotate)
    const scaleX = cos(this.rotate)
    const scaleY = sin(this.rotate)
    scale(scaleX, scaleY)
    rect(0, 0, this.size, this.size * 2, 2);
    pop()
}

在上述代码中,我们首先基于旋转速度更新当前粒子的旋转角度,然后调用 push 来保存绘图现场,在旋转之前,我们先调用 translate 方法,将坐标系的原点移动到了当前粒子的位置,这样进行旋转的时候,就是针对当前粒子进行操作了。

在旋转的同时,我们将当前旋转角度映射到变形参数上面,通过 p5.js 的 scale 方法对粒子进行变形操作,这样让粒子看起来更自然一些。

对坐标系进行操作时,我们通常都应该调用 p5.js 的 push 和 pop 方法来保存和还原绘图现场,以免对后续的渲染造成非预期的结果

通过 translate 移动原点到当前粒子位置,这是关键的一个步骤,否则默认情况下坐标系将以左上角原点为中心进行旋转,这可能不是我们想要的效果

## 最终的效果

下面是我们最终的五彩纸屑效果,你可以试着修改一下代码,看看效果如何

## 更进一步

本教程我们基于简单的粒子系统、向量知识和坐标系旋转等知识实现了一个类似于飞书文档点赞的五彩纸屑效果。

你还可以基于本教程的代码做一些其他的修改,如下是一些可能的点:

  1. 基于本教程的粒子系统实现一个下雨的效果
  2. 基于本教程的粒子系统,修改粒子发射器的形状。在以鼠标点击的位置为圆心半径为 50 的圆环上发射粒子。
  3. 你能添加更多的纸屑图形么?现在我们显示的都是矩形,或许可以显示圆形,五角星,三角形或者其他自定义的图形,试着看看怎么实现。