canvas动画之粒子效果

在《canvas动画系列》前两章分别介绍了基础知识和绘制动画的思路,最近在某个项目活动中,需要使用canvas绘制酷炫的爆炸效果,因此这里整理canvas绘制粒子动画,作为canvas动画系列的第三篇。

<!--more-->

参考

1. 什么是粒子效果

关于粒子效果的定义,可以参考这里的维基百科。 之前看见过许多酷炫的canvas动画,大部分都是通过粒子效果实现的。比如这里canvas粒子生成人物面部轮廓7款让人惊叹的HTML5粒子动画特效等。

粒子效果可以绘制精致的动画效果。

2. 代码实现

下面让我们看看实现粒子动画需要的两个步骤:获取粒子和根据粒子制作动画。

2.1. 获取粒子

粒子效果可以简单理解为就是画布上一个个像素点。canvas提供了getImageData接口,用于获取画布某个区域的像素数据。

该接口返回一个imageData对象,其中的data属性为一个Uint8ClampedArray数组,其值类似于

let data = [0,0,0,1,255,255,255,1]

我们知道,一个像素对主要的特性就是其颜色值,包含RED、GREEN、BLUE三色相和透明度四个参数值。将imageData.data数据以四个长度为一组进行分组,表示某个像素点的RGBA属性,上面的数据表示图形包含一个rgba(0,0,0,1)和一个rgba(255,255,255,1)两个像素点。

换个思路,

  • 先使用drawImage接口将图片绘制在画布上,
  • 然后使用getImageData,就可以获取该图片的像素数据了

这样就完成了从图形到像素的转变,而像素,就可以看做是静止的粒子了。下面是一个简单的demo,将一个图片模糊处理成像素图片。

let canvas = document.getElementById("myCanvas");
let ctx = canvas.getContext("2d");
let img = document.getElementById("testImg");

let app = {
    init() {
        this.img = img;
        this.drawImage();

        this.drawParticals();
    },
    drawImage() {
        let { width, height } = this.img;
        ctx.drawImage(img, 0, 0, width, height);
    },
    getImageData() {
        let { width, height } = this.img;
        let imgData = ctx.getImageData(0, 0, width, height);

        return imgData;
    },
    getParticals(reductionFactor) {
        let { data } = this.getImageData();

        let praticales = [];
        let { width, height } = this.img;

        let count = 0;
        for (let x = 0; x < width; ++x) {
            for (let y = 0; y < height; ++y) {
                count++;
                // 控制粒子生成的数量
                if (count % reductionFactor !== 0) {
                    continue;
                }

                let index = (y * width + x) * 4; // 获取当前位置像素点的颜色信息
                let rgbaColorArr = data.slice(index, index + 4);

                let pratical = { 
                    x, 
                    y, 
                    rgbaColorArr,
                    radius: 4};
                praticales.push(pratical);
            }
        }

        return praticales;
    },
    clear() {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
    },
    drawParticals() {
        let praticales = this.getParticals(50);
        this.clear();
        praticales.forEach(p => {
            ctx.beginPath();
            ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);

            ctx.fillStyle =
                "rgba(" +
                p.rgbaColorArr[0] +
                "," +
                p.rgbaColorArr[1] +
                "," +
                p.rgbaColorArr[2] +
                ", 1)";
            ctx.fill();
        });
    }
};

window.onload = function(){
    app.init();
}

上面代码展示了一个粒子具备的基础属性,包括

  • 初始位置
  • 颜色

有了这两个属性,我们就可以在画布上绘制出对应的粒子了。

2.2. 制作动画

在上一章我们了解到了canvas动画绘制的基本思路,实际上制作粒子动画也是类似的。下面是一个爆炸动画的粒子类

class ExplodingParticle {
    constructor(x, y, rbaArray) {
        this.startX = x
        this.tartY = y
        this.rgbArray = rbaArray

        // 设置想要的粒子动画的时长
        this.animationDuration = 500; // in ms
        // 设置粒子的速度
        this.speed = {x: -5 + Math.random() * 10, y: -5 + Math.random() * 10};
        // 粒子大小
        this.radius = 5 + Math.random() * 5;
        // 为粒子设定一个最大的生存时间
        this.life = 30 + Math.random() * 10;
        this.remainingLife = this.life;
    }

    // 判断粒子是否还存活
    isAlive() {
        return this.remainingLife > 0 && this.radius > 0
    }

    // 这个函数稍后将会调用动画相关的逻辑
    draw(ctx) {
        let p = this;
        if (this.isAlive()) {
            // 在当前位置绘制一个圆
            ctx.beginPath();
            ctx.arc(p.startX, p.startY, p.radius, 0, Math.PI * 2);
            ctx.fillStyle = "rgba(" + this.rgbArray[0] + ',' + this.rgbArray[1] + ',' + this.rgbArray[2] + ", 1)";
            ctx.fill();
            // 更新粒子的位置和生命
            p.remainingLife--;
            p.radius -= 0.25;
            p.startX += p.speed.x;
            p.startY += p.speed.y;
        }
    }
}

只需要在每一帧中调用draw方法,更新粒子的属性,然后清除画布重新绘制即可。 上面的draw方法实现的动画比较简陋,因为只是线性修改了粒子的位置,还可以扩展思路,比如

  • 根据粒子存在时间修改其透明度
  • 为粒子位移添加物理效果,如重力加速度、碰撞等

3. 遇见的一些问题

下面是在开发过程中遇见的一些问题。

3.1. 画布污染

由于canvas部分接口存在跨域限制,其中就包括了getImageData,在调用ctx.getImageData生成粒子时,如果图片资源与页面域名不一致,会报跨域错误,

在chrome中会提示如下错误

Uncaught SecurityError: Failed to execute 'getImageData' on 'CanvasRenderingContext2D': The canvas has been tainted by cross-origin data

解决办法是使用内联的base64图片,代替网络图片资源。

// img.js
module.exports = {
  bigImg: `data:image/png;base64, VBORw0KGgoAAAANS...` 
}

// game.js
let imgData = require('./img.js')

let img = new Image()
img.onload = ()=>{}
img.src = imgData.bigImg

通过base64形式,相当于直接加载了本地图片资源,不存在跨域问题,因此也可以顺利解决getImageData存在的跨域问题。这种解决方式对于绘制静态图片的例子效果较有帮助,但是如果是动态的第三方图片,则最好的解决办法是将图片下载到当前域名下

下面是使用php下载远程图片的代码(所以说PHP是世界上...)

$pic = 'http://avatar.csdn.net/7/5/0/1_molaifeng.jpg';
$arr = getimagesize($pic);
$pic = "data:{$arr['mime']};base64," . base64_encode(file_get_contents($pic));
?>

<img src="<?php echo $pic ?>" />

参考:

3.2. getImageData兼容问题

在iOS低版本(8.2)及部分Android机器上,粒子效果未生效的兼容性bug,后面排查发现原因是,ctx.getImageData接口获取到的data像素数据,是一个Uint8ClampedArray类型的数组,在低版本手机上没有slice方法,导致生成的粒子数目为0,使用Array.prototype.slice.call可以解决

let colorData = ctx.getImageData(this.x, this.y, width, height).data

// 低版本的手机上 Uint8ClampedArray 对象没有实现slice方法
let rgbaColorArr = Array.prototype.slice.call(colorData, index, index + 4)

4. 小结

了解粒子效果的实现原理之后,就可以发挥想象力,绘制各种酷炫的动画。

当然,还有一个非常重要的问题需要解决:canvas动画的性能问题。如果性能不达标,则再酷炫的动画也没有实际意义了。关于canvas的性能优化问题,涉及方面过多,那么就让我们在《canvas动画系列》的下一章继续。