Implement a particle system from 0 to 1

Keywords: Front-end github Javascript TypeScript Attribute

Recently, a small program has been developed that addresses the need for dynamic effects. We originally planned to use a gif diagram to achieve this dynamic effect, but the gif diagram has three drawbacks:

  1. The gif map of high quality kinetics performance is at least 2MB in size.
  2. Too many dynamic details make it difficult to achieve a seamless cycle.
  3. Technical b is not high:).

So the author started to use canvas to achieve dynamic effect, the first and most important step is to open github and search particle.After a five-minute search, it was found that implementing a particle system was an unprecedented pioneer. em.... Let me give you a few reasons to explain this mindset of repeating wheels:

  1. Most repositories do not achieve what we want.
  2. Some repositories seem to be able to do what we want with demo, but there's nothing more than demo; just say what to do with it.
  3. Cannot be compatible with applets: Core code contains DOM API or only supports webgl mode.

Before we start repeating the wheel work, explain the origin of this wheel. I have finished a JavaScript version before developing the particle system below, but when I look back at the code I think the API design is unreasonable and not flexible enough, so I decided to rewrite one with TypeScript and read it during that time. egret-libs After the code, API design and particle launch control are optimized. Project Address

Object-oriented

Briefly, let's talk about the two things we need to abstract, ParticleSystem and Article.ParticleSystem borrows the world concept from the physical engine, which is the space in which particles exist, assuming that there are two properties in space, vertical acceleration of gravity and horizontal acceleration (horizontal wind).Particle is an object that exists in space. Objects have properties such as size, mass, speed, position, rotation angle, etc.

Particle class

Start simply by building a Particle class

class Particle {
  // life cycle
  public lifespan: number
  // speed
  public velocityX: number
  public velocityY: number
  // position
  public x: number
  public y: number
  // Time elapsed
  public currentTime: number
  // Particle size
  private _startSize: number

  // Scale
  public scale: number
  // End rotation angle
  public endRotation: number
  // Aspect ratio
  private ratio: number

  // Input image width and height
  private _width: number
  private _height: number
  // Particle texture
  public texture: CanvasImageSource

  set startSize (size: number) {
    this._startSize = size;

    this._width = size;
    this._height = size / this.ratio;
  }

  // Get Particle Size
  get startSize (): number {
    return this._startSize;
  }

  // Setting particle texture and texture width and height information
  public setTextureInfo (texture: CanvasImageSource, config: {
    width: number,
    height: number
  }) {
    this.texture = texture;
    this.ratio = config.width / config.height;
  }
}

For length reasons, the code above shows the vast majority of the most important information.In fact, in the development process, the particle's attribute definition is not all-in-one, some attributes need to be filled up later, and some attributes find that the functions implemented are duplicated and need to be streamlined. Particle has many public properties and methods, but this is not open to developers. In fact, the entire Particle class is not developed for external use, and there is no need to manually instantiate this class when using it, because the entire system is designed to be highly coupled with Particle and ArticleSystem. There is a member method in the particle class, setTextureInfo, which sets the texture and width information of the particle, the first parameter of ctx.drawImage(..), which will be mentioned again later.The need to set width and height manually is based on compatibility considerations, although all compatibility can be written out here, it is ultimately decided that DOM API s should not be included as much as possible in the entire particle system, and that the best compatibility would be to leave the operation of obtaining picture attributes to the developer by passing in width and height information, focusing on core functions, and not implementing compatibility issues:).

ParticleSystem class

ParticleSystem class is undoubtedly the core of the particle system. The next step is to analyze its important functions.

constructor

Initialize the particle system with the incoming parameters

constructor (
  texture: CanvasImageSource,
  textureInfo: {
    width: number,
    height: number
  },
  config: string | any,
  ctx?: CanvasRenderingContext2D,
  canvasInfo?: {
    width: number,
    height: number
  }
) {
  if (canvasInfo) {
    this.canvasWidth = canvasInfo.width;
    this.canvasHeight = canvasInfo.height;
  }
  // Save canvas canvas
  this.ctx = ctx;
  // Save texture information
  this.changeTexture(texture, textureInfo);
  // Resolve and save configuration information
  this.changeConfig(config);
  // Create Particle Object Pool
  this.createParticlePool();
}

You can see from the constructor's parameters how to design the initialization API, and the reason why textureInfo was designed is explained above.ctx is optional, which is case-by-case, which is required when the particle system is required to complete the drawing of the canvas, and it is not necessary to pass in ctx when only the particle system is required to provide the drawing data.canvasInfo is also optional, and its function is the data needed by the particle system to empty the canvas, which is actually a compatibility parameter, which will be mentioned later.

Object pool

There are many "particle objects" when running a particle system, and a pool of objects is created to reduce the cost of creating and destroying particles during operation. Strictly speaking, object pools should be independent of ParticleSystems, but there is no need for reuse and you are too lazy to think about how separate systems should be designed, so you write object pools as built-in features of ParticleSystem. A simple object pool has three key properties and methods. pool:Array<Particle>Collection of available Particle objects addOneParticle(): Remove a particle from the object pool to join the rendered particle collection RemoveOneParticle: Removes a particle from the rendered particle collection and recycles it to the object pool ParticleList: Array<Particle>renders a collection of particles, which should not be included in a separate object pool design.

addOneParticle

private addOneParticle () {
  let particle: Particle;

  if (this.pool.length) {
    particle = this.pool.pop();
  } else {
    particle = new Particle;
  }

  particle.setTextureInfo(this.texture, {
    width: this.textureWidth,
    height: this.textureHeight
  })
  // Initialize newly removed particles
  this.initParticle(particle);

  this.particleList.push(particle);
}

removeOneParticle

private removeOneParticle (particle: Particle) {
  let index: number = this.particleList.indexOf(particle);

  this.particleList.splice(index, 1);
  // Clear Texture Reference
  particle.texture = null;

  this.pool.push(particle);
}

Particle State Initialization and Update

For the particle system to be more expressive, some properties of particles should be random, and in conjunction with API design, we encapsulate a function randRange(range) to obtain random data.

function randRange (range: number): number {
  range = Math.abs(range);
  return Math.random() * range * 2 - range;
}

Simple physical knowledge is used for initializing and updating particle states, mainly calculating particle velocities and moving distances.

initParticle(particle)

The particle state initialization method sets the initial state of a particle, in which the randRange method described above is used to represent the random variation of individual particles.

private initParticle (particle: Particle): Particle {
  /* Other parameter initialization is omitted */

  let angle = this.angle + randRange(this.angleVariance);

  // Velocity decomposition
  particle.velocityX = this.speed * Math.cos(angle);
  particle.velocityY = this.speed * Math.sin(angle);

  particle.startSize = this.startSize + randRange(this.startSizeVariance);

  // Scale ratio, which is used in subsequent calculations
  particle.scale = particle.startSize / this.startSize;
}

updateParticle(particle)

public updateParticle (particle: Particle, dt: number) {
    // Time interval between uploading update status and this update
    dt = dt / 1000;

    // Speed and location updates
    particle.velocityX += this.gravityX * particle.scale * dt;
    particle.velocityY += this.gravityY * particle.scale * dt;
    particle.x += particle.velocityX * dt;
    particle.y += particle.velocityY * dt;
  }

Update Method

Defines an update method to control whether particles in a particle system should be added, deleted, and updated.

public update (dt: number) {
  // Need new particles
  if (!this.$stopping) {
    this.frameTime += dt;

    // this.frameTime records the difference between the time since the last particle was emitted and the interval between particle emission
    while (this.frameTime > 0) {
      if (this.particleList.length < this.maxParticles) {
        this.addOneParticle()
      }

      this.frameTime -= this.emissionRate;
    }
  }

  // Update particle status or remove particles
  let temp: Array<Particle> = [...this.particleList];

  temp.forEach((particle: Particle) => {
    // Update the state of a particle if its life cycle is not over
    // Remove a particle if its life cycle has ended
    if (particle.currentTime < particle.lifespan) {
      this.updateParticle(particle, dt);
      particle.currentTime += dt;
    } else {
      this.removeOneParticle(particle);

      if (this.$stopping && this.particleList.length === 0) {
        this.$stopped = true;

        // Callback after complete stop of particle system
        // Later added functionality that may not be considered when first developed
        this.onstopped && this.onstopped();
      }
    }
  })
}

The update method only involves data updates and is public, so it is designed to allow developers to control the particle drawing process by themselves after updating the drawing data, thereby embedding the particle system into existing programs.

Rendering and redrawing

Rendering here refers to the process of "drawing" data from a particle system onto a canvas canvas canvas canvas, and there are not many canvas API s available. If you want to involve texture rotation, you need to first understand what transform s are about the canvas canvas canvas canvas. Look here Portal.

public render (dt: number) {
  this.update(dt);
  this.draw();
  // Compatible applets
  (<any>this.ctx).draw && (<any>this.ctx).draw();
}

private draw () {
  this.particleList.forEach((particle: Particle) => {
    let {
      texture,
      x,
      y,
      width,
      height,
      alpha,
      rotation
    } = particle;

    let halfWidth = width / 2,
      halfHeight = height /2;

    // Save Canvas State
    this.ctx.save();

    // Move the upper right corner of the canvas to the center of the texture
    this.ctx.translate(x + halfWidth, y + halfHeight);

    // Rotate canvas
    this.ctx.rotate(rotation);

    if (alpha !== 1) {
      this.ctx.globalAlpha = alpha;
      this.ctx.drawImage(texture, -halfWidth, -halfHeight, width, height);
    } else {
      this.ctx.drawImage(texture, -halfWidth, -halfHeight, width, height);
    }

    // Restore Canvas State
    this.ctx.restore();
  })
}

Repainting involves two steps, time control and canvas redrawing, while canvas redrawing involves clearing the canvas and calling render.

// dt indicates the time difference between circular calls
private circleDraw (dt: number) {
  if (this.$stopped) {
    return;
  }

  // This is also handled for compatibility with applets (look back at the constructor parameters above)
  let width: number, height: number;
  if (this.canvasWidth) {
    width = this.canvasWidth;
    height = this.canvasHeight;
  } else if (this.ctx.canvas) {
    width  = this.ctx.canvas.width;
    height = this.ctx.canvas.width;
  }

  // Canvas redrawing
  this.ctx.clearRect(0, 0, width, height);
  this.render(dt);

  // time control
  // With simple compatibility processing, the requestAnimationFrame has better performance advantages.
  // Use setTimeout instead when not supported
  if (typeof requestAnimationFrame !== 'undefined') {
    requestAnimationFrame(() => {
      let now = Date.now();
      // Calculate time difference
      this.circleDraw(now - this.lastTime);
      this.lastTime = now;
    })
  } else {
    // The disadvantage of setTimeout is that the program's callback into the background will still be executed
    setTimeout(() => {
      let now = Date.now();
      this.circleDraw(now - this.lastTime);
      this.lastTime = now;
    }, 17)
  }
}

Start and Stop

Starting is very simple, just call circleDraw to start.The render method requires an incoming time difference, so this.lastTime is needed here to save the start and last redraw timestamps.

public start () {
  this.$stopping = false;

  if (!this.$stopped) {
    return;
  }

  this.lastTime = Date.now();

  this.$stopped = false;
  this.circleDraw(0);
}

public stop () {
  this.$stopping = true;
}

Examples of usage

If you follow the steps above or have read the source code of the project or written it once by yourself, there is no difficulty in the usage section. Here is an example of the basic usage.

import ParticleSystem from '../src/ParticleSystem'

// Create canvas
const canvas: HTMLCanvasElement = document.createElement('canvas');
canvas.width = (<Window>window).innerWidth;
canvas.height = (<Window>window).innerHeight;
document.body.appendChild(canvas);

// Get Canvas Context
const ctx: CanvasRenderingContext2D = canvas.getContext('2d');

// Load Texture
const img: HTMLImageElement = document.createElement('img');
img.src = './test/texture.png';
img.onload = () => {
  // Create Particle System
  const particle = new ParticleSystem(
    // Texture Resources
    img,
    // Texture Size
    {
      width: img.width,
      height: img.height
    },
    // Particle System Parameters
    {
      gravity: {
        x: 10,
        y: 80
      },
      emitterX: 200,
      emitterY: -10,
      emitterXVariance: 200,
      emitterYVariance: 10,
      maxParticles: 1,
      endRotation: 2,
      endRotationVariance: 50,
      speed: 50,
      angle: Math.PI / 2,
      angleVariance: Math.PI / 2,
      startSize: 15,
      startSizeVariance: 5,
      lifespan: 5000
    },
    // Canvas Context
    ctx
  )

  particle.start();
}

On applet platforms, there may be performance issues that cause FPS to run at 15-60 There are large fluctuations between them.We can do this by separating computation from rendering.The general idea is that the particle system runs into a subthread worker, which is only responsible for the calculation of the particle position, sends the calculated data to the main thread, which calls the canvas-related API to complete the drawing of the canvas.You can try to do that.This idea has been used in the current project. FPS is 45-60 when the applet runs the particle system.

Look here demo

TODO

Adding "gravitational body" and "repulsive body" to a particle system can make the particle system more interactive by attracting and repelling the particle and changing its position at any time.Interested partners can try it out on their own.

[Author's introduction]: Ye Mao, Phragmites technology web front-end development engineer, representative works: Lipstick Challenge Net Red Mini game, service-side rendering of the official website.Good at website construction, public number development, WeChat applet development, games, public number development, focusing on front-end domain framework, interactive design, image drawing, data analysis and other research.Work side by side: yemao@talkmoney.cn Access www.talkmoney.cn Learn more

Posted by scrypted on Thu, 16 May 2019 01:31:39 -0700