前不久热剧《权利的游戏》在万众期待中大结局了,很多人对它的大结局不满意,甚至网上有人联名请愿重拍大结局。但不论如何,这陪伴我们8年的美剧终究还是完结了。

 

纪念人和事的方式有很多种:文字、音乐、光和影,而代码也是其中一种。所以,我们可以通过写代码去和这部我们追了多年的剧说再见。

 

大家应该都知道该剧改编自美国作家乔治·R·R·马丁的奇幻小说《冰与火之歌》系列。正巧大佬Jason Labbe写了一个和“冰与火”相关的作品,效果如下。

 

看完大家可能会觉得,你这不是骗我吗?这明明只有火,哪来的冰呀。确实,这个作品的名字就Fire Brush(https://www.openprocessing.org/sketch/415191),和冰毫无关系。但是,大家请稍安勿躁,我在这个作品的基础上做了一点点修改,完成了Ice and Fire(https://www.openprocessing.org/sketch/725409)。

 

效果如下,这样就是名副其实的冰与火之歌了!

 

Fire Brush这个作品是用P5.js写的,用粒子系统去模拟了火焰和烟雾的效果。同时作品里面有很多小技巧值得我们去学习。

 

 

接下来我就给大家介绍一下它的Processing版本,之后介绍如何修改,让它变成名副其实的"冰与火之歌":凛冬之后,火影摇曳。

 

 

粒子系统

 

粒子系统就是一系列独立对象的集合,这些对象通常用简单的图形或者点来表示。为什么它很重要?毫无疑问,粒子系统可以用来模拟各种自然现象,比如接下来我们要模拟的火焰、烟雾。

 

实际上,它的作用不仅如此,如果我们要用代码对自然界中的各种事物建模,你会发现我们面对的都是多个对象的系统。比如一堆弹球的运动、鸟群的繁殖以及生态系统的演化,这些研究对象都是由复数组成的系统。

 

要用代码对自然界中的各种事物建模,我们必须得先知道,一个粒子它有什么最基本的特征。我在这里给大家介绍两个很重要的:运动方式和生命周期

 

1.运动方式

 

自然界的各种事物有一个很明显的特征,就是它们是会运动的:鸟会飞,植物会随风摇曳。俗话说得好:“生命在于运动”。那么接下来我们就先来看看如何描述一个粒子的运动方式。

 

平移

第一种运动方式是平移,常见的平移有匀速直线运动、自由落体、抛体运动等。这种运动的特点就是在运动过程中,物体上任何两点的连线都是平行的。比如下面就是一个自由落体运动的小球。

 

 

描述一个粒子的平动,我们需要三个向量:速度,加速度,位置。同时需要根据以下的公式来更新一个粒子的位置。

 

 

转动

 

第二种运动方式是转动,就是物体绕着一个点旋转,这个点可以在物体内部,也可以在物体外部。比如下图中两个正方形一个绕着自己中心点旋转,一个绕着一个点旋转。

 

描述一个粒子的转动,我们需要三个标量:角速度,角加速度,角度。分别对应物体平移的速度、角速度、位置。同们也有一个公式用来指导我们更新一个粒子的角度。

 

 

2. 生命周期

 

自然界的万事万物都有生命周期,没有一样东西可以永远存在下去。所以我们需要用一个变量来表示粒子的剩余的生命长度,然后在每次更新的时候不断减小这个变量,同时粒子的外貌也可以随着这个变量的改变而改变。但这个变量小于0的时候,将其删除。

 

 

下面就是一个透明度度随生命流失而增大的小球,最后当生命剩余长度为0的时候就消失不见。

 

 

 

继承和多态

 

大家之前可能接触过继承和多态的概念,毕竟,它们是面向对象编程3个基本特性中的两个(还有一个是封装),但大家可能没有真正实践过。少了这两个特性,我们对粒子和粒子系统的建模能力会大打则扣,所以下面我们先来简单了解一些继承和多态

 

1.继承

 

一个类是由一系列成员变量和成员函数构成的,分别对应它们的特征和行为。继承就是子类继承父类的特征和行为,使得子类对象(实例)具有父类的实例域和方法,或子类从父类继承方法,使得子类具有父类相同的行为。这么说过于抽象,我们先来看一下下面这个简单的例子。

 

假如我们要对狗、猫、鸟建模,它们有一些共同特点:

- 它们都有一个名字,出生的时候需要取名字,并且在出生的时候会说出自己的名字。

- 它们都能吃食物,在吃东西的时候会先说"I can eat food."。

 

但是另一方面它们又有不同:

- 狗在吃食物的时候还会说出自己最喜欢的食物。

- 猫除了拥有名字以外,还拥有一个外号,同样也是一出生就获得。

- 鸟最近在减肥,如果我们让它吃食物,它会告诉我们"I can't eat food.",同时它还会飞。

 

用代码将上面的模型翻译过来如下。

 

Dog dog = new Dog("Dog"); 

dog.eat(); 

 

Cat cat = new Cat("Cat", "tomcat"); 

cat.eat(); 

 

Bird bird =  new Bird("Bird"); 

bird.eat(); 

bird.fly(); 

 

 

我们希望上述代码的结果输出如下图。

 

 

显然为了达到以上的效果,我们可以定义三个毫不相关的类,但是这样会有很多重复代码,不简洁优雅。所以接下来我们就用继承的方法来定义这三个类。

 

首先我们根据狗、猫、鸟的共同特点来定义一个父类Animal如下。

 

class Animal{

String name;

Animal(String _name){

  name = _name;

  println("My name is: ", name); 

}

 

void eat(){

println("I can eat food.");

}

}

 

然后根据狗的特点来定义Dog这个类,代码如下。

 

class Dog extends Animal{

Dog(String _name){

  super(_name);

}

 

void eat(){

  super.eat();

  println("My favorite food is bone.");

}

}

 

这段代码用了两个关键字:

 

extends :该关键字指出当前类的父类。一个类只能继承自一个父类,但是父类可以是其它类的子类。所以Dog这个类就继承自Animal类,就拥有天生拥有name这个成员变量。

 

super() :调用父类的构造函数,换句话说,父类的构造函数做了什么,子类的构造函数也会做同样的事情。所以初始化Dog的时候它会像Animal一样打印出它的名字。

 

在Dog的eat函数中的super指向它的父类Animal,所以在该函数中首先调用了Animal的eat函数输出"I can eat food.",接着再输出"My favorite food is bone."。

 

接下来我们来看Cat这个类,我们给它增加了一个外号作为成员变量,并且在构造函数中对该成员变量进行了初始化。

 

class Cat extends Animal{

String nickname;

Cat(String _name, String _nickname){

  super(_name);

  nickname = _nickname;

}

}

 

在Bird这个类中,我们重写了Bird的eat方法,它会把父类Animal的同名函数覆盖掉,然后给鸟增加了一个飞翔的方法。

 

class Bird extends Animal{

Bird(String _name){

  super(_name);

}

 

void eat(){

  println("I can't eat food.");

}

 

void fly(){

  println("I can fly.");

}

}

 

 

2.多态

 

下面来给大家介绍一下多态。多态(来自希腊语polymorphos,表示多种形态)指的是把一个实例对象当做多种形态。一个Dog对象肯定是Dog类的实例,由于Animal类是Dog类的父类,所以Dog对象也可以当做Animal类的实例。在代码中,我们可以同时用下面的方式引用对象。

 

Dog dog1 = new Dog("Dog1");

Animal dog2 = new Dog("Dog2");

dog2.eat(); //仍然调用Dog的eat方法

/*输出:

I can eat food.

My favorite food is bone.

*/

 

在数组或ArrayList中,这种多态规则是非常有用的,大家看以下的代码和输出。

 

ArrayList<Animal> animals = new ArrayList();

animals.add(new Dog("Dog")); 

animals.add(new Cat("Cat", "tomcat")); 

animals.add(new Bird("Bird")); 

for(Animal a : animals){

a.eat();

 

}

 

 

分析Fire Brush作品我们可以获得如下发现。

 

该作品由两种元素构成,火焰和烟雾。拖动鼠标,会增加火焰。火焰会沿着鼠标拖动的方向移动,同时也会向下运动,过一段时间后会消失。在火焰移动的过程中会有烟雾出现,它们会向两边跳动,给人一种喷发的感觉,最后也会慢慢消失。

 

 

火焰是由一个个圆构成,每个大圆里还有一个小圆,并且它们的透明度不同。每一个烟雾是一个黑色的圆,它们的透明度也不一样。

 

还有一个细节就是作品背景的颜色会随着火焰数量的变化而变化。那么接下来我们就来一起看看如何实现以上的效果。

 

大概流程

 

我们在setup函数中初始化了我们的粒子系统,然后在draw函数中不断更新背景的颜色、粒子的状态,以及监听按下鼠标左键事件,判断是否需要加入新的火焰。

 

注意这里颜色的模式不是RGB,而是HSB。并且按下一次鼠标左键会调用多次addFire函数,因为按下鼠标不是一瞬间的事情,这段时间会执行多次draw函数。

 

ParticleSystem ps;

 

void setup() {

  size(600, 600);

  colorMode(HSB, 255);

  ps = new ParticleSystem();

}

 

void draw() {

  //根据火焰粒子的数量来确定背景颜色的饱和度

  background(255, ps.getSize(), 255);

  ps.run();

 

  if (mousePressed && mouseButton == LEFT) {

    //按一次鼠标会执行多次这里的函数

    ps.addFire(mouseX, mouseY);

  }

}

 

class ParticleSystem {}

class Particle {}

class Fire extends Particle{}

class Smoke extends Particle{}

 

接下来来看ParticleSystem这个类,看它是如何存储粒子、管理粒子的行为。我们用一个ArrayList来存储所有的粒子,并且每次更新的时候需要删除生命走到尽头的粒子。

 

class ParticleSystem {

  ArrayList<Particle> plist;

 

  ParticleSystem() {

    plist = new ArrayList<Particle>();

  }

 

  void run() {

    for (int i = plist.size() - 1; i >= 0; i--) {

      Particle p = plist.get(i);

      p.run();

 

  //删除生命走到尽头的粒子

      if (p.isDead()) {

        plist.remove(i);

      }

    }

  }

 

  void addFire(float x, float y) {

    plist.add(new Fire(x, y));

  }

 

  void addSmoke(float x, float y, float size) {

    plist.add(new Smoke(x, y, size));

  }

 

  //返回粒子系统的类型是Fire的粒子的数量

  int getSize() {

    int cnt = 0;

    for(Particle p: plist){

    //如果这粒子是属于Fire类型的,它的type为1,否者为0

        cnt += p.type;

    }

    return cnt;

  }

}

 

 

父类:Particle

 

我们现在来看看父类Particle。每一个粒子有一些变量来描述它的运动情况,一些变量来描述它的生命周期,还有一些来描述它的基本属性。在update函数中根据之前提到的公式会对其位置不断进行更新。在display函数中首先让粒子做转动,然后再进行平移,接着根据剩余生命的长度来进行放缩。

 

基本的Particle类默认只做抛体运动,也就是加速度大小和方向都固定的运动。

 

class Particle {

  PVector location, velocity, acceleration, origin;

  float angle, aVelocity, aAcceleration;

  float lifespan, lifeRate, maxLifespan;

  int type, hue;

 

  Particle(float x, float y) {

    //默认物体绕着自己中心点旋转

    origin = new PVector(x, y);

    

    //初始化位置为(0,0)

    location = new PVector();

    

    //一个向下的加速度,模拟重力的效果

    acceleration = new PVector(0, 0.05);

    

    //返回一个任意的方向、大小为1的速度

    velocity = PVector.random2D();

    lifespan = maxLifespan = 50;

    

    //用于控制生命流逝的快慢

    lifeRate = random(0.35, 1);

    hue = 20;

    type = 1;

  }

  

  //根据粒子在生命周期内移动的距离计算它的速度大小

  float getSpeed(float s){

    float t = maxLifespan / lifeRate;

    return s / t;

  }

 

  void run() {

    update();

    display();

  }

  

  void update() {

    //平移

    velocity.add(acceleration);

    location.add(velocity);

    

    //转动

    aVelocity += aAcceleration;

    angle += aVelocity;

    

    //生命流逝

    lifespan -= lifeRate;

  }

 

  boolean isDead() {

    if (lifespan < 0.0) {

      return true;

    } else {

      return false;

    }

  }

 

  void display() {

    pushMatrix();

    translate(origin.x, origin.y);

    rotate(radians(angle));

    translate(location.x, location.y);

    scale(map(lifespan, 0, maxLifespan, 0, 1));

    drawShape();

    popMatrix();

  }

 

  void drawShape() {

    stroke(hue, 255, 255);

    strokeWeight(30);

    point(0, 0);

  }

}

 

现在大家如果把PartcleSystem里面的addFire函数修改如下的话,可以得到如下图的效果:

 

void addFire(float x, float y) {

    plist.add(new Particle(x, y));

 

}

 

 

 

子类:Fire

 

一个简单的粒子系统其实已经完成了,我们现在就需要在这个基础上进行完善,以达到火焰的效果。

 

Fire类和它的父类主要有以下几个不同:

 

- 由三个粒子构成:Fire粒子的包含三个在其附近的粒子,拥有不同的位置和色调。

- 初速度不同:多了一个从上一帧鼠标位置指向当前鼠标位置的速度,用来达到火焰跟随鼠标移动的效果。

- update函数不同:在每一次update的时候,会有概率向粒子系统添加烟雾粒子。

- drawShape函数不同:绘制每一个粒子时会绘制两个大小不同、透明度不同的圆形。

- 多了一个spwan函数:用来向粒子系统添加烟雾粒子。

 

知道了Fire类和其父类的不同之处,我们就能很容易理解以下的代码了。

 

class Fire extends Particle {

  //存储包含的粒子

  ArrayList<PVector> plist;

  Fire(float x, float y) {

    super(x, y);

 

//设置初速度

    PVector v = new PVector(mouseX - pmouseX, mouseY - pmouseY);

    v.mult(0.1);

    velocity.mult(getSpeed(100)).add(v);

 

//初始化需要绘制的三个粒子

    plist = new ArrayList();

    for (int i = 0; i < 3; i++) {

      float xOffset = random(-10, 10);

      float yOffset = random(-10, 10);

      float hue = random(50);

      

      //向量的每个维度分别表示该粒子的:x坐标、y坐标、色调

      plist.add(new PVector(xOffset, yOffset, hue));

    }

  }

 

  void update() {

    super.update();

 

//添加烟雾粒子

    if (int(random(5)) == 0) {

      int spawnCount = int(random(3))+1;

      for (int i = 0; i < spawnCount; i++) {

        spawn();

      }

    }

  }

 

  void spawn() {

    //根据当前火焰粒子的生命长度来确定烟雾粒子的大小

    float size = random(25, 50)*map(lifespan, maxLifespan, 0, 1, 0);

    if (size > 0) {

      //添加一个烟雾粒子

      ps.addSmoke(location.x + origin.x , location.y + origin.y, size);

    }

  }

 

  void drawShape() {

    for (PVector p : plist) {

      //绘制大圆

      stroke(p.z, 255, 255, 20);

      strokeWeight(80);

      point(p.x, p.y);

 

  //绘制小圆

      stroke(p.z, 255, 255, 100);

      strokeWeight(30);

      point(p.x, p.y);

    }

  }

}

 

 

子类:Smoke

 

了解完火焰,我们再来看看烟雾。烟雾和它父类的主要区别如下:

 

- 运动形式多了转动:该转动是匀速转动,角加速度为0。

- 平移是匀速直线运动:加速度为0。

- 生命周期不一样:生命周期要短一点。

- drawShape函数不同:每一个圆的颜色为黑色,且其透明度是随机的。

 

同样根据以上不同点,理解下面的代码也不是难事。这样,Fire Brush我们就完成了。

 

class Smoke extends Particle {

  float size, alpha;

  Smoke(float x, float y, float _size) {

    super(x, y);

    size = _size;

    alpha = random(10, 150);

    lifespan = maxLifespan = 30;

    lifeRate = random(0.4, 1.25);

    

    //初始化转动的角度和初速度

    angle = random(-45, 45);

    aVelocity = random(-2, 2);

    

    //设置初速的和角速度

    velocity.set(0, getSpeed(random(-100, -100)));

    acceleration.mult(0);

    type = 0;

  }

  

  void drawShape() {

    stroke(0, 0, 0, alpha);

    strokeWeight(size);

    point(0, 0);

  }

}

 
 
 

我在FireBrush上做的改动非常的简单。首先我修改了颜色各个通道的取值范围,以达到之后粒子颜色渐变更柔和的效果。

 

void setup() {

  //..

  colorMode(HSB, 360);

  //..

}

 

void draw() {

  background(300, ps.getSize(), 360);

  //..

}

 

接下来我对Fire类改做如下的修改。让每一个粒子的色调不是固定的,是其随着该粒子剩余的生命长度改变。

 

class Fire extends Particle {

  //..

  Fire(float x, float y) {

//..

    hue = 165;

  }

  //..

  void drawShape() {

    for (PVector p : plist) {

      stroke(hue + lifespan * 1.5, 360, 360, 50);

      strokeWeight(80);

      point(p.x, p.y);

 

      stroke(hue + lifespan * 1.5, 360, 360, 180);

      strokeWeight(30);

      point(p.x, p.y);

    }

  }

}

 

最后我修改Smoke类。除了修改颜色,还将每个粒子从圆形变成了三角形。并且在绘制的时候对其旋转的一个角度。

 

class Smoke extends Particle {

  //..

  float theta;

  Smoke(float x, float y, float _size) {

    //..

    theta = random(TAU);

    hue = 10;

  }

 

  void drawShape() {

    rotate(theta);

    fill(hue + lifespan * 1.2, 360, 360, alpha);

    noStroke();

    triangle(-size / 2, 0, size / 2, 0, 0, size * sqrt(3) / 2);

  }

}

 

其实到了现在大家应该发现,借助于继承和多态,我们很容易对粒子系统进行拓展。我们只用修改它们的运动状态,渲染方式(绘制的图形、颜色等),就能获得一个新的作品。

 

大家如果有兴趣看以去看看Fire Brush的作者的另一个作品Frozen Brush(https://www.openprocessing.org/sketch/413567),这个作品效果非常的惊艳。

 

 

但其实原理也很简单,它也是一个粒子系统。该粒子系统每个粒子的运动方式很基础,就是随机一个方向做匀速直线运动,但是它的渲染的方式却很有意思。

 

该作品不是一个个粒子的渲染,而是选择其中三个粒子,以这三个粒子为顶点绘制一个三角形。而选择哪三个粒子作者使用的Delaunay三角剖分算法,简单的来说,就是把平面上一些点连成如下的效果:

 

 

如果大家感兴趣的话,我有机会可以给大家详细介绍一下这个作品。

 

 

在看完这篇文章后,大家完全可以自己实现“愤怒的小鸟”这个前几年很火的游戏。

 

该游戏中主要就两种粒子,鸟和柱子。鸟只是做简单的自由落体运动,只不过在玩家按下向上键的时候,小鸟会获得一个向上的速度。柱子做的是匀速直线运动,方向向左。唯一有点难度的地方是如何做碰撞检测,就是如何判断鸟和柱子相撞了。

 

我之前用Python写过这个游戏,给小鸟增加了射击功能,让游戏变得简单了一点,效果如下。