canvas做烟花效果,已经烂大街了。可搬别人的代码,不如自己动手更有意思。
虽然自己做的效果并不是很出众,但完成这一特效整个过程,也算是一种收获。
在做烟花特效之前,我还做了
【canvas】网易云音乐鲸云特效『水晶音波』的简单实现
【canvas】网易云音乐鲸云动效『孤独星球』的简单实现
相比之下,烟花特效要复杂一点。
爆炸
刚开始,从简单的来,先做爆炸的单个碎片。
同样,在这过程中,我会先把重复用到的常量定义好,减少重复计算。
const PI2 = Math.PI * 2;
const PI7_8 = Math.PI * 7 / 8;
const PI15_32 = Math.PI * 15 / 32;
const PI_16 = Math.PI / 16;
先定义“碎片”
为了构造一个碎片。需要知道
参数 | 含义 |
---|---|
size | 碎片的大小 |
lightness | 碎片的亮度 |
color | 碎片的颜色 |
angle | 碎片移动的方向(角度) |
speed | 碎片移动的速度 |
shape | 碎片所"遵循" 的形状,用于修正碎片的速度 |
frame | 帧数 |
帧数,没错。你没有看错,这里我用了帧数,目的是为了告诉一个碎片最多只能渲染多少次(帧)。可以理解为碎片
Shard
的生存周期。
同时,烟花爆炸的位置,烟花一爆炸,碎片Shard
开始执行render()
函数绘制。所以我另外写了一个函数bomb(x, y)
。以下是全部代码。
class Shard {
constructor(context, size, lightness, color, angle, speed, shape, frame) {
this.ctx = context;
this.maxSize = size;
this.maxLightness = lightness;
this.color = color;
this.angle = angle;
this.shape = shape;
this.speed = speed;
this.frame = frame;
this.speedX = speed * shape(angle) * Math.cos(angle); // 极坐标转直角坐标的公式 x = r * cosθ
this.speedY = speed * shape(angle) * Math.sin(angle); // 极坐标转直角坐标的公式 y = r * sinθ
}
__linear(src, dst, coeff) {
return src + (dst - src) * coeff;
}
__update() {
this.index++;
if (this.index >= this.frame) {
this.feedback && this.feedback('finish');
} else {
const coeff = this.index / this.frame;
const param1 = coeff * coeff;
const param2 = Math.sin(this.__linear(0, PI7_8, coeff));
this.x += this.__linear(this.speedX, 0, param1);
this.y += this.__linear(this.speedY, 0, param1);
this.lightness = Math.floor(this.__linear(0, this.maxLightness, param2));
this.size = Math.floor(this.__linear(0, this.maxSize, param2));
}
}
bomb(x, y) {
this.feedback && this.feedback('exploding');
this.x = x;
this.y = y;
this.lightness = 0;
this.size = 0;
this.index = 0;
}
render() {
this.ctx.fillStyle = `hsl(${this.color}, 100%, ${this.lightness}%)`;
this.ctx.beginPath();
this.ctx.arc(this.x, this.y, this.size, 0, PI2);
this.ctx.fill();
this.__update();
}
}
代码解读
为了让烟花爆炸看起来不那么单调,碎片亮度、大小,移动速度,理应做成非线性的。
所以首先,我先是撸了一个线性函数__linear(src, dst, coeff)
。中学数学,不难理解。
__linear(src, dst, coeff) {
return src + (dst - src) * coeff;
}
然后,让碎片的移动由快到慢,最后为零,当然,是非线性的,所以用初中就学过的二元一次方程y = x^2
。
让大小和亮度由0变大,然后变小,所以用三角函数,大概是这样的一个过程。
const coeff = this.index / this.frame;
const param1 = coeff * coeff;
const param2 = Math.sin(this.__linear(0, PI7_8, coeff));
this.x += this.__linear(this.speedX, 0, param1);
this.y += this.__linear(this.speedY, 0, param1);
this.lightness = Math.floor(this.__linear(0, this.maxLightness, param2));
this.size = Math.floor(this.__linear(0, this.maxSize, param2));
升空
升空就简单多了。
class Rocket {
constructor(context, range, altitude, shardNum) {
this.ctx = context;
this.range = range;
this.altitude = altitude;
this.shardNum = shardNum;
this.restart();
this.shards = [];
const maxSize = 2.5;
const maxLightness = 40;
const speed = 2;
const { func, scope } = shapeSet.random();
const frame = 50;
for (let i = 0; i < this.shardNum; ++i) {
const angle = scope[0] + scope[1] * i / this.shardNum;
this.shards.push(new Shard(this.ctx, maxSize, maxLightness, this.color, angle, speed, func, frame));
}
this.shards[this.shards.length - 1].feedback = this.__guard.bind(this);
}
restart() {
const speed = 10 + Math.random() * 6;
const angle = PI15_32 + Math.random() * PI_16;
this.xSpeed = Math.cos(angle) * speed;
this.ySpeed = -Math.sin(angle) * speed;
this.color = Math.floor(Math.random() * 360);
this.size = 2;
this.x = this.range[0] + Math.floor(Math.random() * (this.range[1] - this.range[0]));
this.y = this.altitude;
this.state = 'waiting';
}
__guard(state) {
this.state = state;
}
__update() {
if (this.ySpeed < 0) {
this.x += this.xSpeed;
this.y += this.ySpeed;
this.ySpeed += 0.15;
this.size -= 0.01;
}
else if (this.state == 'finish')
this.restart();
else if (this.state == 'waiting')
this.shards.forEach(shard => shard.bomb(this.x, this.y));
}
render = () => {
if (this.state == 'exploding') {
this.shards.forEach(Shard => Shard.render());
}
else {
this.ctx.fillStyle = `hsl(${this.color}, 100%, 40%)`;
this.ctx.beginPath();
this.ctx.arc(this.x, this.y, this.size, 0, PI2);
this.ctx.fill();
}
this.__update();
}
}
代码解读
升空的Rocket
类中,我定义了一个状态state
,有三种情况
state | 含义 |
---|---|
exploding | 爆炸中,此时Rocket 的render() 函数只绘制碎片Shard |
waiting | 升空中,此时Rocket 的render() 函数只绘制自身` |
finish | 爆炸完了,执行restart() 从头开始。 |
值得注意的是,一个火箭会生成多个碎片,但我们只需要关注其中一个碎片,这个碎片反馈是否完成爆炸即可。
this.shards[this.shards.length - 1].feedback = this.__guard.bind(this);
在Shard
中也可以看到if (this.index >= this.frame) { this.feedback && this.feedback('finish'); }
烟花爆炸形状
在上述Rocket
中,可以看到一段const { func, scope } = shapeSet.random();
,这是我把所有形状的函数放到了shapeSet
类中管理,不复杂。
class Shape {
constructor() { this.Set = []; }
push(func) { this.Set.push(func); }
random() { return this.Set[Math.floor(Math.random() * this.Set.length)]; }
};
然后,延续我之前的风格,定义个“场景”,然后跑场景就行。
class Scene {
constructor(canvas) {
this.cvs = canvas;
this.ctx = this.cvs.getContext('2d');
const range = [this.cvs.width / 2, this.cvs.width / 2];
const altitude = this.cvs.height;
const rocketNum = 1;
const shardNum = 50;
this.firework = [];
for (let i = 0; i < rocketNum; ++i) {
this.firework.push(new Rocket(this.ctx, range, altitude, shardNum));
}
}
render() {
this.ctx.fillStyle = "rgba(0, 0, 0, .4)";
this.ctx.fillRect(0, 0, this.cvs.width, this.cvs.height);
this.firework.forEach(rocket => rocket.render());
}
run() {
if (this.ctx) {
this.timer = setInterval(this.render.bind(this), 10);
}
}
stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = 0;
}
}
};
接下来,我就只需解决形状就行了。
简单形状
- 圆形
function circle(θ) {
return 1; // 半径为1
}
function cardioid(θ) {
return 1 + Math.sin(θ);
}
- 玫瑰曲线
或者
const rose = [];
function roseCurve(θ, n = 3) {
rose[n] || (rose[n] = {});
rose[n][θ] || (rose[n][θ] = Math.sin(n * θ));
return rose[n][θ];
};
玫瑰曲线有个重要特性:闭合曲线。
这里就不全部论述,仅说用到的。
- 当
n
为整数时,
- 若
n
为奇数,玫瑰线的叶子数为n
,闭合周期为 π。- 当
n
为偶数,玫瑰线的叶子数为2n
,闭合周期为 2π。
- 当
n
不是整数,而是n = L/W
的有理数时,
- 当
L
或W
中有一个为偶数时,玫瑰线的叶子数为2L
,闭合周期为2Wπ
。比如:六叶玫瑰线
闭合曲线为4π
。
- 伯努利双扭线
const lemn = {};
function lemniscate(θ) {
lemn[θ] || (lemn[θ] = Math.sqrt(Math.cos(2 * θ)));
return lemn[θ];
}
复杂形状
const butterfly = {};
function butterflyCurve(θ) {
butterfly[θ] || (butterfly[θ] = Math.exp(Math.cos(θ)) - 2 * Math.cos(4 * θ) + Math.pow(Math.sin(θ / 12), 5));
return butterfly[θ];
}
其他形状
- 六叶玫瑰线的闭合曲线为
4π
,但如果我们只绘制[0, π/2]
,就是开头的6
了
完成,下面是最终效果。
在线演示:codepen
来源:CSDN
作者:三生翰旋醉梦
链接:https://blog.csdn.net/qq_24380063/article/details/103893627