手把手带你玩转有趣的粒子特效

3,334 阅读4分钟

前言

大家好,这里是 CSS 兼 WebGL 魔法使——alphardex。

之前在练习写粒子特效的时候发现有些特效的实现跟物理知识有很大的关系(比如烟花特效),后来又碰巧发现了一本神书——The Nature of Code,它教你如何运用物理数学知识来模拟实现一些自然界的特效,读完后我的创作之魂被彻底激发,于是就有了这篇文章,它将带你从零开始实现一个由物理驱动的粒子特效。

以下是最终实现的效果图

hTmZCt.gif

让我们开始吧!

准备工作

笔者的 p5.js 模板,点击右下角可以 fork 一份

创作开始

微粒类

在某个位置画一个圆点,就这么简单~

class Particle {
  s: p5;
  position: p5.Vector; // 位置
  constructor(s: p5, position = s.createVector(0, 0)) {
    this.s = s;
    this.position = position.copy();
  }
  // 显示
  display() {
    this.s.circle(this.position.x, this.position.y, 6);
  }
  // 运行
  run() {
    this.display();
  }
}

微粒系统类

多画几个点,把它们存到数组里,就成了所谓的微粒系统

class ParticleSystem {
  s: p5;
  particles: Particle[]; // 该系统下的所有微粒
  origin: p5.Vector; // 系统原点
  constructor(s: p5, origin = s.createVector(0, 0)) {
    this.s = s;
    this.particles = [];
    this.origin = origin;
  }
  // 添加单个微粒
  addParticle() {
    const particle = new Particle(this.s, this.origin);
    this.particles.push(particle);
  }
  // 添加多个微粒
  addParticles(count = 1) {
    for (let i = 0; i < count; i++) {
      this.addParticle();
    }
  }
  // 运行
  run() {
    for (const particle of this.particles) {
      particle.run();
    }
  }
}

创建微粒

在画面上画上 100 个微粒

const sketch = (s: p5) => {
  let ps: ParticleSystem;

  const setup = () => {
    s.createCanvas(s.windowWidth, s.windowHeight);

    ps = new ParticleSystem(s, s.createVector(s.width / 2, s.height / 2));

    ps.addParticles(100);
  };

  const draw = () => {
    s.background(0);
    s.blendMode(s.ADD);

    ps.run();

    s.blendMode(s.BLEND);
  };

  s.setup = setup;
  s.draw = draw;
};

hfRxqe.png

100 个微粒都重叠在了画面中央,位置应该打乱一下

打乱微粒位置

给所有微粒赋予随机的坐标即可

class ParticleSystem {
  ...
  // 打乱微粒位置
  shuffle() {
    this.particles.forEach((p) => {
      const x = this.s.random(0, this.s.width);
      const y = this.s.random(0, this.s.height);
      const randPos = this.s.createVector(x, y);
      p.position = randPos;
    });
  }
}

const sketch = (s: p5) => {
  ...

  const setup = () => {
    ...
    ps.shuffle();
  };
};

hfWQGq.png

100 个微粒分散在了画面各处

微粒漫游

全部静止肯定不行,给它们一个随机的速度,就能动起来了

class Particle {
  ...
  velocity: p5.Vector; // 速度
  constructor(s: p5, position = s.createVector(0, 0)) {
    ...
    this.velocity = this.s.createVector(0, 0);
  }
  // 更新状态
  update() {
    this.position.add(this.velocity);
  }
  // 运行
  run() {
    this.update();
    this.display();
  }
}

class ParticleSystem {
  ...
  // 让微粒随机漫游
  wander() {
    this.particles.forEach((p) => {
      const x = this.s.random(-1, 1);
      const y = this.s.random(-1, 1);
      const randVel = this.s.createVector(x, y);
      p.velocity = randVel;
    });
  }
}

const sketch = (s: p5) => {
  ...

  const setup = () => {
    ...
    ps.wander();
  };
};

hTMJwF.gif

此时微粒会在画面上四处飘动

对微粒施加力

物理课上老师教过我们,力能改变物体的运动状态,根据牛二定律,我们能给物体施加一个力,增加物体的加速度,从而增加物体的速度

尝试让微粒受重力的影响下落

class Particle {
  ...
  acceleration: p5.Vector; // 加速度
  topSpeed: number; // 速度上限
  constructor(s: p5, position = s.createVector(0, 0)) {
    ...
    this.acceleration = this.s.createVector(0, 0);
    this.topSpeed = 12;
  }
  // 更新状态
  update() {
    this.velocity.add(this.acceleration);
    this.velocity.limit(this.topSpeed);
    this.position.add(this.velocity);
    this.acceleration.mult(0);
  }
  // 施加力
  applyForce(force: p5.Vector) {
    // 牛顿第二定律: F = ma
    const mass = 1; // 这里设质量为单位1
    const acceleration = p5.Vector.div(force, mass);
    this.acceleration.add(acceleration);
  }
}

class ParticleSystem {
  ...
  // 对每个微粒施加力
  applyForce(force: p5.Vector) {
    this.particles.forEach((p) => p.applyForce(force));
  }
}

const sketch = (s: p5) => {
  ...
  const draw = () => {
    ...

    ps.run();

    const gravity = s.createVector(0, 0.05);
    ps.applyForce(gravity);

    ...
  };
};

hfhjMV.gif

吸引体类

新增一个 Attractor 类,负责对微粒施加吸引力,这样微粒就都会朝它那边跑了

class Attractor extends Particle {
  attractForceMag: number; // 吸引力大小
  radius: number; // 半径
  id: number; // id标识
  constructor(s: p5, position = s.createVector(0, 0), radius = 16, id = 0) {
    super(s, position);
    this.attractForceMag = 0.05;
    this.radius = radius;
    this.id = id;
  }
  // 显示
  display() {
    this.s.circle(this.position.x, this.position.y, this.radius * 2);
  }
  // 施加引力
  applyAttractForce(p: Particle) {
    const attractForce = p5.Vector.sub(this.position, p.position);
    attractForce.setMag(this.attractForceMag);
    p.applyForce(attractForce);
  }
}

class ParticleSystem {
  ...
  // 对每个微粒应用吸引力
  applyAttractor(attractor: Attractor) {
    this.particles.forEach((p) => attractor.applyAttractForce(p));
  }
}

const sketch = (s: p5) => {
  ...
  let attractor: Attractor;

  const setup = () => {
    ...

    attractor = new Attractor(s, s.createVector(s.width / 2, s.height / 2));
  };

  const draw = () => {
    ...

    // const gravity = s.createVector(0, 0.05);
    // ps.applyForce(gravity);
    attractor.run();
    ps.applyAttractor(attractor);

    ...
  };
};

hf4jfA.gif

点击添加吸引体

通过点击鼠标,我们能在画面上动态添加吸引体

const sketch = (s: p5) => {
  ...
  // let attractor: Attractor;
  let attractors: Attractor[] = [];
  let currentAttractorId = 0;
  let mousePos: p5.Vector;

  const setup = () => {
    ...
    // attractor = new Attractor(s, s.createVector(s.width / 2, s.height / 2));
  };

  const draw = () => {
    mousePos = s.createVector(s.mouseX, s.mouseY);

    ...

    // const gravity = s.createVector(0, 0.05);
    // ps.applyForce(gravity);
    // attractor.run();
    // ps.applyAttractor(attractor);

    // 吸引体吸引微粒
    attractors.forEach((attractor) => {
      attractor.run();
      ps.applyAttractor(attractor);
    });

    ...
  };

  // 每点击1次,添加1个吸引体
  const mousePressed = () => {
    const attractor = new Attractor(s, mousePos, 16, currentAttractorId);
    attractors.push(attractor);
    currentAttractorId += 1;
  };

  ...
  s.mousePressed = mousePressed;
};

hf573n.gif

吸引体相互吸引

如果吸引体之间也能相互吸引,那就更有趣了

const sketch = (s: p5) => {
  ...
  const draw = () => {
    ...

    // 吸引体相互之间的动作
    for (let i = 0; i < attractors.length; i++) {
      for (let j = 0; j < attractors.length; j++) {
        if (i !== j) {
          const attractorA = attractors[j];
          const attractorB = attractors[i];

          // 吸引体相互吸引
          attractorA.applyAttractForce(attractorB);
        }
      }
    }

    ...
  };
};

hfvNjg.gif

吸引体靠近时合并

但光吸引肯定是不够的,不如试着让它们大鱼吃小鱼般地融合起来呢?

class Attractor extends Particle {
  // 是否跟另一个靠得很近
  isNearAnother(attractor: Attractor) {
    const distAB = p5.Vector.dist(this.position, attractor.position);
    const distMin = (this.radius + attractor.radius) * 0.8;
    const isNear = distAB < distMin;
    return isNear;
  }
  // 吸收
  absorb(attractor: Attractor) {
    this.attractForceMag += attractor.attractForceMag;
    this.radius += attractor.radius;
    this.velocity = this.s.createVector(0, 0);
  }
}

const sketch = (s: p5) => {
  ...
  const draw = () => {
    ...

    // 吸引体相互之间的动作
    for (let i = 0; i < attractors.length; i++) {
      for (let j = 0; j < attractors.length; j++) {
        if (i !== j) {
          const attractorA = attractors[j];
          const attractorB = attractors[i];

          // 吸引体相互吸引
          attractorA.applyAttractForce(attractorB);

          // 两个吸引体靠的太近,则吸引体A吸收吸引体B
          if (attractorA.isNearAnother(attractorB)) {
            attractorA.absorb(attractorB);
            attractors = attractors.filter(
              (attractor) => attractor.id !== attractorB.id
            );
          }
        }
      }
    }

    ...
  };
};

注意这里把 attractor 的 radius 调小了

hh8llV.gif

吸引体半径超限后坍塌

如果一不小心合得太多怎么办?就让它当场坍塌爆炸,boom shakalaka!

class Attractor extends Particle {
  ...
  isCollasping: boolean; // 是否正在坍塌
  isDead: boolean; // 是否坍塌完毕
  static RADIUS_LIMIT = 100; // 半径上限
  ...
  constructor(s: p5, position = s.createVector(0, 0), radius = 16, id = 0) {
    ...
    this.isCollasping = false;
    this.isDead = false;
  }
  // 坍塌
  collapse() {
    this.isCollasping = true;
    this.radius *= 0.75;
    if (this.radius < 1) {
      this.isDead = true;
    }
  }
}

const sketch = (s: p5) => {
  ...

  const draw = () => {
    ...

    // 吸引体吸引微粒
    attractors.forEach((attractor) => {
      ...

      // 当吸引体吸收过多微粒,半径超限后会坍塌
      if (
        attractor.radius >= Attractor.RADIUS_LIMIT ||
        attractor.isCollasping
      ) {
        attractor.collapse();
        if (attractor.isDead) {
          attractors = attractors.filter((item) => item.id !== attractor.id);
        }
      }
    });
    ...
  };
  ...
};

hTMxXV.gif

设置渐变背景色

画面黑色背景太单调了,设置个美丽的渐变色吧

const sketch = (s: p5) => {
  // https://p5js.org/examples/color-linear-gradient.html
  // 创建渐变色
  const setGradient = (
    x: number,
    y: number,
    w: number,
    h: number,
    c1: p5.Color,
    c2: p5.Color,
    axis: number
  ) => {
    s.strokeWeight(1);
    if (axis === 1) {
      // Top to bottom gradient
      for (let i = y; i <= y + h; i++) {
        let inter = s.map(i, y, y + h, 0, 1);
        let c = s.lerpColor(c1, c2, inter);
        s.stroke(c);
        s.line(x, i, x + w, i);
      }
    } else if (axis === 2) {
      // Left to right gradient
      for (let i = x; i <= x + w; i++) {
        let inter = s.map(i, x, x + w, 0, 1);
        let c = s.lerpColor(c1, c2, inter);
        s.stroke(c);
        s.line(i, y, i, y + h);
      }
    }
  };

  const draw = () => {
    ...

    s.background(0);
    s.blendMode(s.ADD);

    setGradient(
      0,
      0,
      s.width,
      s.height,
      s.color("#2b5876"),
      s.color("#4e4376"),
      1
    );

    ps.run();

    ...
  };
};

hoGXGQ.png

线条拖尾效果

记录微粒运动的上一个位置,并且与当前位置相连,就能得到拖尾轨迹

class Particle {
  ...
  lastPosition: p5.Vector; // 上一个位置(用来创建延迟拖尾效果)
  constructor(s: p5, position = s.createVector(0, 0)) {
    ...
    this.lastPosition = this.s.createVector(0, 0);
  }
  // 显示
  display() {
    // this.s.circle(this.position.x, this.position.y, 6);
    this.s.fill(255);
    this.s.stroke(255);
    this.s.strokeWeight(2);
    this.s.strokeJoin(this.s.ROUND);
    this.s.strokeCap(this.s.ROUND);
    this.s.line(
      this.position.x,
      this.position.y,
      this.lastPosition.x,
      this.lastPosition.y
    );
  }
  // 更新状态
  update() {
    this.lastPosition = this.position.copy();
    this.velocity.add(this.acceleration);
    this.velocity.limit(this.topSpeed);
    this.position.add(this.velocity);
    this.acceleration.mult(0);
  }
}

const sketch = (s: p5) => {
  ...

  const setup = () => {
    const canvas = s.createCanvas(s.windowWidth, s.windowHeight);

    // 场景半透明化,使线条的拖尾更加清晰
    const ctx = (canvas as any).drawingContext as CanvasRenderingContext2D;
    ctx.globalAlpha = 0.5;

    ...
  };
};

hoIanA.gif

吸引体振荡效果

通过sin正弦函数,我们能让吸引体的大小有规律地振荡起来

class Attractor extends Particle {
  ...
  oscillatingRadius: number; // 波动半径
  constructor(s: p5, position = s.createVector(0, 0), radius = 16, id = 0) {
    ...
    this.oscillatingRadius = 0;
  }
  // 显示
  display() {
    this.s.blendMode(this.s.BLEND);
    this.s.stroke(0);
    this.s.fill("#000000");
    this.oscillate();
    this.s.circle(this.position.x, this.position.y, this.oscillatingRadius * 2);
  }
  // 振荡
  oscillate() {
    const oscillatingRadius = this.s.map(
      this.s.sin(this.s.frameCount / 6) * 2,
      -1,
      1,
      this.radius * 0.8,
      this.radius
    );
    this.oscillatingRadius = oscillatingRadius;
  }
}

hTeu79.gif

项目地址

Attractor Particle System