写一个烟花送给自己吧。

743 阅读5分钟

开始前的废话

转眼间2022已走过了一半,但是今年注定是不平凡的一年。

各位小伙伴的心态是不是多少有些emo?

但是,也请坚持走下去!

乾坤未定,你我皆是黑马!

笔者这里送各位一场烟花,请继续相信未来可期!!!

正文开始

创建一张全屏的 canvas 画布

烟花使用 canvas 绘制的,那么我们就需要去创建一张全屏的 canvas 画布。这里就不做太多赘述,直接贴代码。

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const canvasWidth = window.innerWidth;
const canvasHeight = window.innerHeight;
// canvas全屏
canvas.width = canvasWidth;
canvas.height = canvasHeight;

模拟发射烟花效果

烟花首先应该有个发射上升的过程,然后到达一定高度之后开始💥 。这里先实现发射的这个过程:

  • 第一步应该是发射烟花
  • 第二步烟花到达顶部,开始爆炸。

发射烟花

发射烟花,我们应该先确认一下发射点位置,这里我选择在屏幕中间底部的位置作为烟花的发射点。以它作为起点开始向上移动烟花。实现烟花移动的功能需要通过 window.requestAnimationFrame 方法不断调用 renderCanvas 方法重新绘制图形。

// 设置发射点
const shootPoint = {
    x: Math.ceil(canvasWidth / 2),
    y: canvasHeight
};
const point = _.cloneDeep(shootPoint);
function renderCanvas() {
    ctx.fillRect(0, 0, canvasWidth, canvasHeight);
    // 开始绘制
    ctx.beginPath();
    ctx.moveTo(point.x, point.y);
    ctx.lineTo(point.x, point.y - 10);
    ctx.strokeStyle = 'red'; // 仅亮度会变化
    ctx.lineWidth = 2;
    ctx.stroke();
    point.y = point.y - 10;
    requestAnimationFrame(renderCanvas);
}
renderCanvas();

效果如下:

为了方便看清楚具体的效果,我将线的宽度设置到40px。可以看到已经初步实现了烟火的移动效果,但是这个效果并不是理想的效果,它还是差点意思。主要欠缺如下细节:

  • 烟花向上走速度应该是愈来愈慢的,速度逐步递减直至无法再上升,也就是匀减速。
  • 应该有拖尾效果,烟火效果更加逼近实际的效果。
  • 发射多个烟花,角度随机。

细节1:烟火加速度递减优化

烟花在发射之后,烟花在上升的过程是一个匀减速的过程,所以我们需要模拟一个初始的速度,然后通过位移计算公式来控制其位置。但是,实际上我们只能获取到屏幕宽度(也即是能移动的最大高度),因此我们需要去根据公式反推出一个初始速度:

// 匀减速 速度计算公式
Vt=Vo-gt;
// 位移计算公式
h=Vot-gt²
// 初始速度计算公式
Vo² = 2gh

因此我们需要 mock 一个初始的速度,具体的思路如下:先随机生成一个烟花终点位置,然后借用 Vo² = 2gh 公式来计算出初始的烟花速度,接下来用 Vt=Vo-gt 速度计算公式得到下一次渲染的速度。

const friction = 0.98; // 向上空气的阻力,其实也是模拟重力
// 获取范围内的随机数
function randomRange(min, max) {
    return Math.random() * (max - min) + min;
}
// 模拟初始的速度
function mockOriginalSpeed(h) {
    return Math.floor(Math.sqrt(2 * friction * h));
}
let speed = mockOriginalSpeed(randomRange(canvasHeight / 2, canvasHeight));
// 位移计算
speed *= friction;
point.y -= speed;

细节2:烟花移动加拖尾效果

烟花的拖尾效果需要canvas的 globalCompositeOperation API来实现,通过每次清除画布之前设置为destination-out 来绘制不重叠部分,但是注意清除完画布之后要还原到 lighter

点击查看globalCompositeOperation不同值效果

// 模拟hsla颜色
let hue = getHue();
let brightness = randomRange(60, 70);
let alpha = 1;
let alphaDecay = randomRange(0.015, 0.03);
// 现有内容保持在新图形不重叠的地方
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
ctx.globalCompositeOperation = 'lighter';
// 设置线条颜色
ctx.strokeStyle = `hsla(${hue}, 100%, ${brightness}%,${alpha})`;

细节3:创建多个方向的烟花

只有一个烟花显然很孤单,所以我们可以考虑创建类来实现多个烟花。这里定义一个叫 Firework 类,它需要传入四个参数(出发点位置以及终点位置),发射点和终点用于计算运动初始速度以及运动路径。类包含两个核心方法update() 和 draw() 方法,其中 update() 方法主要实现匀速递减、绘制位置变更以及判断运动状态功能,draw()方法则用于调用canvas api绘制一次raf的画布内容。

Firework类代码如下:

class Firework {
    constructor(sx, sy, tx, ty) {
        // 当前坐标
        this.x = sx;
        this.y = sy;
        // 起始点坐标
        this.sx = sx;
        this.sy = sy;
        // 目标点坐标
        this.tx = tx;
        this.ty = ty;
        // 起始点到目标点的距离
        this.totalDistance = this.calcPointsDistance(sx, sy, tx, ty);
        // 用于判断是否走完路程
        this.distanceTraveled = 0;
        // 随机弧度
        this.angle = Math.atan2(ty - sy, tx - sx);
        // 向上空气的阻力,其实也是模拟重力
        this.friction = 0.98;
        // 随机速度
        this.speed = this.mockOriginalSpeed(sy - ty);
        // 透明度
        this.alpha = 1;
        // 透明度衰减度
        this.alphaDecay = randomRange(0.015, 0.03);
        // 随机hue
        this.hue = getHue();
        // 随机亮度
        this.brightness = randomRange(60, 70);
        // 烟花的轨迹坐标
        this.coords = [[this.x, this.y]];
    }
    calcPointsDistance(sx, sy, tx, ty) {
        return Math.sqrt(Math.pow(tx - sx, 2) + Math.pow(ty - sy, 2));
    }
    // 模拟初始速度
    mockOriginalSpeed(h) {
        return Math.floor(Math.sqrt(2 * this.friction * h));
    }
    update(index) {
        this.coords.pop();
        this.coords.unshift([this.x, this.y]);
        this.alpha -= this.alphaDecay;
        this.speed *= this.friction;
        const vx = Math.cos(this.angle) * this.speed;
        const vy = Math.sin(this.angle) * this.speed;
        // 计算出移动后的距离
        this.distanceTraveled = this.calcPointsDistance(this.sx, this.sy, this.x + vx, this.y + vy);
        if (this.distanceTraveled >= this.totalDistance) {
            const radius = randomRange(5, 15);
            for (let i = 0; i < 100; i++) {
                particles.push(new Particle(this.tx, this.ty, this.hue, radius));
            }
            fireworks.splice(index, 1);
        } else {
            this.x += vx;
            this.y += vy;
        }
    }
    draw() {
        if (this.speed < 0) {
            return;
        }
        ctx.beginPath();
        const [startX, startY] = this.coords[this.coords.length - 1];
        ctx.moveTo(startX, startY);
        ctx.lineTo(this.x, this.y);
        ctx.lineCap = 'round';
        ctx.strokeStyle = `hsla(${this.hue}, 100%, ${this.brightness}%,${this.alpha})`;         // 仅亮度会变化
        ctx.lineWidth = 2;
        ctx.stroke();
    }
}

修改完成后效果如下(烟花在向随机的角度发射):

QQ20220503-145844-HD.gif

实现烟花爆炸效果

烟花发射到达顶点之后就该💥 并且发射出多个粒子,并且💥 之后由于重力会开始下坠并且逐渐变淡直至消失。所以这里我们需要考虑如下几个点:

  • 一次爆炸要创建多少个粒子
  • 爆炸之后粒子的运行轨迹应该是一个弧度
  • 爆炸烟花颜色应该是在一个色系范围的

创建多个粒子

主要思路如下,在烟花发射到达顶端的时候,同时创建100个元素用于描述此次烟花💥 之后产生的粒子。假设我们创建一个类的话,就需要创建100个粒子实例。实例需要传入三个主要参数,💥 点的坐标点位以及和烟花保持一致的颜色。

具体代码如下:

for (let i = 0; i < 100; i++) {
    particles.push(new Particle(this.tx, this.ty, this.hue));
}

粒子运行轨迹弧度

在粒子下落的过程,正常来看看的话应该是一个弧度的,因此我们需要用到几何的正弦和余弦函数来计算下一个点的位置。在 x 轴上通过余弦来计算下一个点位,y 轴上使用正弦函数计算(如果想要更真实一点的话,可以加一个重力系数)。主要核心计算代码如下:

// 随机生成一个圆大小的角度
this.angle = randomRange(0, Math.PI * 2);
// 随机生成初始的烟花速度
this.speed = randomRange(1, 10);
// 百分比 不同材质的物体摩擦系数不同(有现成值)
this.friction = 0.95;
// 作用于y轴加速度 模拟往下坠
this.gravity = 1;
this.speed *= this.friction;
this.x += Math.cos(this.angle) * this.speed;
this.y += Math.sin(this.angle) * this.speed + this.gravity;

这里贴一下图来说明计算规则,都是高中知识就不再赘述了。

adjacent-opposite-hypotenuse.svg

烟花色系范围选择

因为我使用的是 hsla 来表示颜色,并且hue用到的是随机生成的值,那么粒子的颜色取值范围应该保持在这个值的附近,所以设置个浮动值即可。

// 随机色调(基础色调-20和+20之间)
this.hue = randomRange(hue - 20, hue + 20);
// 随机亮度
this.brightness = Math.floor(Math.random() * 21) + 50;
                    
                    
// hsla的颜色模式
ctx.strokeStyle = `hsla(${this.hue}, 100%, ${this.brightness}%, ${this.alpha}`;

粒子类

为了实现以上几点,我创建一个类来实现以上功能,主要思路也是有三个主要方法,一个用于更新粒子位置及状态的方法,一个负责绘制图形的方法。

class Particle {
    // 初始化时的x,y坐标
    constructor(x, y, hue, radius) {
        this.x = x;
        this.y = y;
        // 粒子坐标集合
        this.coords = [
            [x, y],
            [x, y],
            [x, y]
        ];
        // 随机弧度
        this.angle = randomRange(0, Math.PI * 2);
        // 随机基本速度,同时roll点,用于展示不同大小的烟花
        this.speed = randomRange(1, radius);
        // 摩擦系数、重力(减缓粒子速度、模拟抛物线下坠)
        this.friction = 0.95; // 百分比 不同材质的物体摩擦系数不同(有现成值)
        this.gravity = 1; // 作用于y轴加速度 模拟往下坠
        // 随机色调(基础色调-20和+20之间)
        this.hue = randomRange(hue - 20, hue + 20);
        // 随机亮度
        this.brightness = Math.floor(Math.random() * 21) + 50;
        // 初始透明度
        this.alpha = 1;
        // 随机的透明度衰变系数(透明度减淡)
        this.alphaDecay = randomRange(0.015, 0.03);
    }
    // 更新某个(索引)粒子属性
    update(index) {
        this.coords.pop();
        this.coords.unshift([this.x, this.y]);
        this.speed *= this.friction;
        this.x += Math.cos(this.angle) * this.speed;
        this.y += Math.sin(this.angle) * this.speed + this.gravity;
        // 透明度衰减
        this.alpha -= this.alphaDecay;
        // 当透明度小于最小衰减值 就把这个例子对象删除
        if (this.alpha < this.alphaDecay) {
            particles.splice(index, 1);
        }
    }
    // 绘制粒子(例子以line的方式)
    draw() {
        ctx.beginPath();
        // 从集合中最后一个项开始
        const [startX, startY] = this.coords[this.coords.length - 1];
        ctx.moveTo(startX, startY);
        ctx.lineTo(this.x, this.y);
        // hsla的颜色模式
        ctx.strokeStyle = `hsla(${this.hue}, 100%, ${this.brightness}%, ${this.alpha}`;
        ctx.lineWidth = 2;
        ctx.lineCap = 'round';
        ctx.stroke();
    }
}

一点点小细节优化

清理定时器副作用

因为我在代码中用到了定时器,但是如果没有及时销毁的话会有很大的问题。例如,当你切换到浏览器的其他tab页面的时候,这个页面的定时器并没有休眠掉,它仍然在不停地执行回调任务(只是会加大延时,Chrome是按照1s的延迟处理)。所以在休眠的这段时间,如果不做任何处理的话定时器就会不停地调用 mockFirework()创建烟花。但是可惜的是这个时间段内是没有任何函数来消费mock出来的烟花的(inactive tab期间requestAnimationFrame 是不会执行的),这样就导致了在重新激活tab时,requestAnimationFrame 的函数要消耗大量的烟花从而导致页面直接卡爆。

具体的解法还是需要调用到window的 visibilitychange 事件来实现(兼容性查下 caniuse )

具体代码如下:

// 处理定时器的问题
function handleVisibilityChange() {
    // tab 隐藏
    if (document.hidden) {
        clearInterval(fireworkTimer);
        fireworkTimer = 0;
        fireworks.length = 0;
    } else {
        fireworkTimer = setInterval(() => {
            mockFirework();
        }, 2000);
        mockFirework();
    }
}
window.addEventListener('visibilitychange', handleVisibilityChange);

resize视口监控

这个优化点就很简单了,当视口出现变化的时候咱们肯定要重置一下画布的大小去适配新的视口大小。代码也很简单直接贴了:

// resize method
function resizeCanvas() {
    canvasWidth = window.innerWidth;
    canvasHeight = window.innerHeight;
    canvas.width = canvasWidth;
    canvas.height = canvasHeight;
}
window.addEventListener('resize', resizeCanvas);

show code

结尾

总结一下:

写代码的过程中还是要想想如何抽象出你的逻辑,再总结一下整体的大致思路哇:

  • 负责定时 mock 烟花的方法
  • 负责消费 mock 出的烟花
  • 负责烟花发射效果的 Firework 类
  • 负责爆炸效果的 Particle 类

so,区分好每个阶段要做的事情,你写起来是不是更加流畅呢?

其实,这个烟花效果整体不算难,只是这个例子涉及到了高中的数学和物理知识点,这些才是我想要分享给大家的点。当然中间也会串一些开发过程的优化点,比如如何利用抽象类来实现功能,如何清除定时器副作用,还有这里没有用到的 canvas 一些优化手段(后续有机会会整理出来哇,埋一个坑)。

最后的最后,祝大家看的开心。