专栏首页腾讯Bugly的专栏一起用 HTML5 Canvas 做一个简单又骚气的粒子引擎

一起用 HTML5 Canvas 做一个简单又骚气的粒子引擎

前言

好吧,说是“粒子引擎”还是大言不惭而标题党了,离真正的粒子引擎还有点远。废话少说,先看 Demo:http://ol.weixin.qq.com/public/users/jationhuang/grains/demos/demo1/index.html (请使用微信打开体验更好),点击屏幕有惊喜哦...

本文将教会你做一个简单的 Canvas 粒子制造器(下称引擎)。

世界观

这个简单的引擎里需要有三种元素:世界 (World)、发射器 (Launcher)、粒子 (Grain)。总得来说就是:发射器存在于世界之中,发射器制造粒子,世界和发射器都会影响粒子的状态,每个粒子在经过世界和发射器的影响之后,计算出下一刻的位置,把自己画出来。

世界 (World)

所谓 “世界”,就是全局影响那些存在于这这个 “世界” 的粒子的环境。一个粒子如果选择存在于这个 “世界” 里,那么这个粒子将会受到这个 “世界” 的影响。

发射器 (Launcher)

用来发射粒子的单位。他们能控制粒子生成的粒子的各种属性。作为粒子们的爹妈,发射器能够控制粒子的出生属性:出生的位置、出生的大小、寿命、是否受到 “World” 的影响、是否受到 "Launcher" 本身的影响等等……

除此之外,发射器本身还要把自己生出来的已经死去的粒子清扫掉。

粒子 (Grain)

最小基本单位,就是每一个骚动的个体。每一个个体都拥有自己的位置、大小、寿命、是否受到同名度的影响等属性,这样才能在 Canvas 上每时每刻准确描绘出他们的形态。

粒子绘制主逻辑

上面就是粒子绘制的主要逻辑。

我们先来看看世界需要什么。

创造一个世界

不知道为什么我理所当然得会想到世界应该有 重力加速度。但是光有重力加速度不能表现出很多花样,于是这里我给他增加了另外两种影响因素:热气和风。重力加速度和热气他们的方向是垂直的,风影响方向是水平的,有了这三个东西,我们就能让粒子动得很风骚了。

一些状态(比如粒子的存亡)的维护需要有时间标志,那么我们把时间也加入到世界里吧,这样方便后期做时间暂停、逆流的效果。

define(function(require, exports, module) {
   var Util = require('./Util');
   var Launcher = require('./Launcher');

   /**
    * 世界构造函数
    * @param config
    *  backgroundImage 背景图片
    *  canvas        canvas引用
    *  context       canvas的context
    *
    *  time          世界时间
    *
    *  gravity       重力加速度
    *
    *  heat          热力
    *  heatEnable    热力开关
    *  minHeat       随机最小热力
    *  maxHeat       随机最大热力
    *
    *  wind          风力
    *  windEnable    风力开关
    *  minWind       随机最小风力
    *  maxWind       随机最大风力
    *
    *  timeProgress    时间进步单位,用于控制时间速度
    *  launchers       属于这个世界的发射器队列
    * @constructor
    */
    function World(config){

    //太长了,略去细节

    
    }
    World.prototype.updateStatus = function(){};
    World.prototype.timeTick = function(){};
    World.prototype.createLauncher = function(config){};
    World.prototype.drawBackground = function(){};
    module.exports = World;
 });

大家都知道,画动画就是不断得重画,所以我们需要暴露出一个方法,提供给外部循环调用:

 /**
  * 循环触发函数
  * 在满足条件的时候触发
  * 比如RequestAnimationFrame回调,或者setTimeout回调之后循环触发的
  * 用于维持World的生命
  */

World.prototype.timeTick = function(){

    //更新世界各种状态
    this.updateStatus();

    this.context.clearRect(0,0,this.canvas.width,this.canvas.height);
    this.drawBackground();

    //触发所有发射器的循环调用函数
    for(var i = 0;i<this.launchers.length;i++){
       this.launchers[i].updateLauncherStatus();
       this.launchers[i].createGrain(1);
       this.launchers[i].paintGrain();
    }
 };

这个 timeTick 方法在外部循环调用时,每次都做着这几件事:

  1. 更新世界状态
  2. 清空画布重新绘制背景
  3. 轮询全世界所有发射器,并更新它们的状态,创建新的粒子,绘制粒子

那么,世界的状态到底有哪些要更新?

显然,每一次都要让时间往前增加一点是容易想到的。其次,为了让粒子尽可能动得风骚,我们让风和热力的状态都保持不稳定——每一阵风和每一阵热浪,都是你意识不到的~

World.prototype.updateStatus = function(){
    this.time+=this.timeProgress;
    this.wind = Util.randomFloat(this.minWind,this.maxWind);
    this.heat = Util.randomFloat(this.minHeat,this.maxHeat);
};

世界造出来了,我们还得让世界能造粒子发射器呀,要不然怎么造粒子呢~

World.prototype.createLauncher = function(config){
    var _launcher = new Launcher(config);
    this.launchers.push(_launcher);
};

好了,做为上帝,我们已经把世界打造得差不多了,接下来就是捏造各种各样的生灵了。

捏出第一个生物:发射器

发射器是世界上的第一种生物,依靠发射器才能繁衍出千奇百怪的粒子。那么发射器需要具备什么特征呢?

首先,它是属于哪个世界的得搞清楚(因为这个世界可能不止一个世界)。

其次,就是发射器本身的状态:位置、自身体系内的风力、热力,可以说:发射器就是一个世界里的小世界。

最后就是描述一下他的“基因”了,发射器的基因会影响到他们的后代(粒子)。我们赋予发射器越多的“基因”,那么他们的后代就会有更多的生物特征。具体看下面的良心注释代码吧~

define(function (require, exports, module) {
   var Util = require('./Util');
   var Grain = require('./Grain');

   /**
    * 发射器构造函数
    * @param config
    *  id              身份标识用于后续可视化编辑器的维护
    *  world           这个launcher的宿主
    *
    *  grainImage      粒子图片
    *  grainList       粒子队列
    *  grainLife       产生的粒子的生命
    *  grainLifeRange  粒子生命波动范围
    *  maxAliveCount   最大存活粒子数量
    *
    *  x               发射器位置x
    *  y               发射器位置y
    *  rangeX          发射器位置x波动范围
    *  rangeY          发射器位置y波动范围
    *
    *  sizeX           粒子横向大小
    *  sizeY           粒子纵向大小
    *  sizeRange       粒子大小波动范围
    *
    *  mass            粒子质量(暂时没什么用)
    *  massRange       粒子质量波动范围
    *
    *  heat            发射器自身体系的热气
    *  heatEnable      发射器自身体系的热气生效开关
    *  minHeat         随机热气最小值
    *  maxHeat         随机热气最小值
    *
    *  wind            发射器自身体系的风力
    *  windEnable      发射器自身体系的风力生效开关
    *  minWind         随机风力最小值
    *  maxWind         随机风力最小值
    *
    *  grainInfluencedByWorldWind      粒子受到世界风力影响开关
    *  grainInfluencedByWorldHeat      粒子受到世界热气影响开关
    *  grainInfluencedByWorldGravity   粒子受到世界重力影响开关
    *
    *  grainInfluencedByLauncherWind   粒子受到发射器风力影响开关
    *  grainInfluencedByLauncherHeat   粒子受到发射器热气影响开关
    *
    * @constructor
    */

   function Launcher(config) {
       //太长了,略去细节
   }

   Launcher.prototype.updateLauncherStatus = function () {};
   Launcher.prototype.swipeDeadGrain = function (grain_id) {};
   Launcher.prototype.createGrain = function (count) {};
   Launcher.prototype.paintGrain = function () {};

   module.exports = Launcher;

});

发射器要负责生孩子啊,怎么生呢:

Launcher.prototype.createGrain = function (count) {
       if (count + this.grainList.length <= this.maxAliveCount) {
           //新建了count个加上旧的还没达到最大数额限制
       } else if (this.grainList.length >= this.maxAliveCount &&
           count + this.grainList.length > this.maxAliveCount) {
           //光是旧的粒子数量还没能达到最大限制
           //新建了count个加上旧的超过了最大数额限制
           count = this.maxAliveCount - this.grainList.length;
       } else {
           count = 0;
       }
       for (var i = 0; i < count; i++) {
           var _rd = Util.randomFloat(0, Math.PI * 2);
           var _grain = new Grain({/*粒子配置*/});
           this.grainList.push(_grain);
       }
   };

生完孩子,孩子死掉了还得打扫……(好悲伤,怪内存不够用咯)

Launcher.prototype.swipeDeadGrain = function (grain_id) {
    for (var i = 0; i < this.grainList.length; i++) {
        if (grain_id == this.grainList[i].id) {
            this.grainList = this.grainList.remove(i);//remove是自己定义的一个Array方法
            this.createGrain(1);
            break;
        }
    }
};

生完孩子,还得把孩子放出来玩:

Launcher.prototype.paintGrain = function () {
    for (var i = 0; i < this.grainList.length; i++) {
        this.grainList[i].paint();
    }
};

自己的内部小世界也不要忘了维护呀~(跟外面的大世界差不多)

Launcher.prototype.updateLauncherStatus = function () {
    if (this.grainInfluencedByLauncherWind) {
        this.wind = Util.randomFloat(this.minWind, this.maxWind);
    }
    if(this.grainInfluencedByLauncherHeat){
        this.heat = Util.randomFloat(this.minHeat, this.maxHeat);
    }
};

好了,至此,我们完成了世界上第一种生物的打造,接下来就是他们的后代了(呼呼,上帝好累)

子子孙孙,无穷尽也

出来吧,小的们,你们才是世界的主角!

作为世界的主角,粒子们拥有各种自身的状态:位置、速度、大小、寿命长度、出生时间当然必不可少

define(function (require, exports, module) {
    var Util = require('./Util');

    /**
     * 粒子构造函数
     * @param config
     *  id              唯一标识
     *  world           世界宿主
     *  launcher        发射器宿主
     *
     *  x               位置x
     *  y               位置y
     *  vx              水平速度
     *  vy              垂直速度
     *
     *  sizeX           横向大小
     *  sizeY           纵向大小
     *
     *  mass            质量
     *  life            生命长度
     *  birthTime       出生时间
     *
     *  color_r
     *  color_g
     *  color_b
     *  alpha           透明度
     *  initAlpha       初始化时的透明度
     *
     *  influencedByWorldWind
     *  influencedByWorldHeat
     *  influencedByWorldGravity
     *  influencedByLauncherWind
     *  influencedByLauncherHeat
     *
     * @constructor
     */
    function Grain(config) {
        //太长了,略去细节
    }

    Grain.prototype.isDead = function () {};
    Grain.prototype.calculate = function () {};
    Grain.prototype.paint = function () {};
    module.exports = Grain;
});

粒子们需要知道自己的下一刻是怎样子的,这样才能把自己在世界展现出来。对于运动状态,当然都是初中物理的知识了:-)

Grain.prototype.calculate = function () {
    //计算位置
    if (this.influencedByWorldGravity) {
        this.vy += this.world.gravity+Util.randomFloat(0,0.3*this.world.gravity);
    }
    if (this.influencedByWorldHeat && this.world.heatEnable) {
        this.vy -= this.world.heat+Util.randomFloat(0,0.3*this.world.heat);
    }
    if (this.influencedByLauncherHeat && this.launcher.heatEnable) {
        this.vy -= this.launcher.heat+Util.randomFloat(0,0.3*this.launcher.heat);
     }
     if (this.influencedByWorldWind && this.world.windEnable) {
         this.vx += this.world.wind+Util.randomFloat(0,0.3*this.world.wind);
     }
     if (this.influencedByLauncherWind && this.launcher.windEnable) {
        this.vx += this.launcher.wind+Util.randomFloat(0,0.3*this.launcher.wind);
    }
    this.y += this.vy;
    this.x += this.vx;
    this.alpha = this.initAlpha * (1 - (this.world.time - this.birthTime) / this.life);

    //TODO 计算颜色 和 其他

};

粒子们怎么知道自己死了没?

Grain.prototype.isDead = function () {
    return Math.abs(this.world.time - this.birthTime)>this.life;
};

粒子们又该以怎样的姿态把自己展现出来?

Grain.prototype.paint = function () {
    if (this.isDead()) {
        this.launcher.swipeDeadGrain(this.id);
    } else {
        this.calculate();
        this.world.context.save();
        this.world.context.globalCompositeOperation = 'lighter';
        this.world.context.globalAlpha = this.alpha;
        this.world.context.drawImage(this.launcher.grainImage, this.x-(this.sizeX)/2, this.y-(this.sizeY)/2, this.sizeX, this.sizeY);
        this.world.context.restore();
    }
};

嗟乎。

后续

后续希望能够通过这个雏形,进行扩展,再造一个可视化编辑器供大家使用。

如果你觉得文章是良心出品, 就请扫描下方的二维码打赏作者并转发给身边的小伙伴们一起分享吧...

对了,代码都在这:https://github.com/jation/CanvasGrain


本文系腾讯Bugly独家内容,转载请在文章开头显眼处注明作者和出处“腾讯Bugly(http://bugly.qq.com)”

本文分享自微信公众号 - 腾讯Bugly(weixinBugly),作者:黄佳生

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2016-04-14

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 深入源码探索 ReactNative 通信机制

    本文从源码角度剖析 ReactNative 中 Java <> Js 的通信机制(基于最新的 ReactNative for Android Release 2...

    腾讯Bugly
  • 你知道android的MessageQueue.IdleHandler吗?

    前言 我们知道android是基于Looper消息循环的系统,我们通过Handler向Looper包含的MessageQueue投递Message, 不过我们...

    腾讯Bugly
  • 深入理解 ButterKnife,让你的程序学会写代码

    前言 话说我们做程序员的,都应该多少是个懒人,我们总是想办法驱使我们的电脑帮我们干活,所以我们学会了各式各样的语言来告诉电脑该做什么——尽管,他们有时候也会误会...

    腾讯Bugly
  • tcplayer 源码改造第二弹 -> 加入倍速播放

    由腾讯视频的官方文档可以知道,currentTime方法是暴露给用户,用于获取/设置当前时间的方法,同理,加入获取当前倍速的方法currentRate:

    大洼X
  • 【转】js中this用法

    只有一个对象调用了包含this函数时,this才被赋值,并且所赋的值只依赖于调用了包含this函数的对象

    前端博客 : alili.tech
  • 确认过眼神,你是喜欢Stream的人

    摘要:在学习Node的过程中,Stream流是常用的东东,在了解怎么使用它的同时,我们应该要深入了解它的具体实现。今天的主要带大家来写一写可读流的具体实现,就过...

    用户2145235
  • node.js + postgres 从注入到Getshell

    (最近你们可能会看到我发很多陈年漏洞的分析,其实这些漏洞刚出来我就想写,不过是没时间,拖延拖延,但该做的事迟早要做的,共勉)

    phith0n
  • VUE+WebPack游戏设计:欲望都市城市图层的设计

    望月从良
  • 小游戏入门

    极乐君
  • 实现小球在弹射前的拉伸特效和动态障碍物特效

    当前我们实现小球弹射时,会先用鼠标点击小球,然后移动鼠标,当松开鼠标时,小球会弹射向鼠标松开的位置。我们按住小球的时间越长,小球弹射的力度就越大,但有一个问题是...

    望月从良

扫码关注云+社区

领取腾讯云代金券