Creating Smoke Particle System using custom shaders and particle effects in ThreeJS

In this article, we’ll explore how to create a dynamic smoke particle system using Three.js. Particle systems are a common technique used in computer graphics to simulate fuzzy phenomena like fire, smoke, or clouds. We’ll be focusing on smoke, with the flexibility to customize it to your liking.

Creating the Shaders:

To start, we need to create our custom vertex and fragment shaders, which are essential for controlling how particles are rendered. Here’s how they’re done.

Vertex Shader:

uniform float pointMultiplier;

attribute float size;
attribute float angle;
attribute vec4 aColor;

varying vec4 vColor;
varying vec2 vAngle;

void main() {
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);

  gl_Position = projectionMatrix * mvPosition;
  gl_PointSize = size * pointMultiplier / gl_Position.w;

  vAngle = vec2(cos(angle), sin(angle));
  vColor = aColor;
}

Fragment Shader:

uniform sampler2D diffuseTexture;

varying vec4 vColor;
varying vec2 vAngle;

void main() {
    vec2 coords = (gl_PointCoord - 0.5) * mat2(vAngle.x, vAngle.y, -vAngle.y, vAngle.x) + 0.5;
    gl_FragColor = texture2D(diffuseTexture, coords) * vColor;
}

Setting Up the Shader Loader

Then, we need to load our custom vertex and fragment shaders. Here’s a utility function to load shaders asynchronously:

import * as THREE from "three";
let loadShader = async (path) => {
  let response = await fetch(path);
  return await response.text();
};

This function takes the path to the shader file and returns the shader code as a string, ready to be used in our material.

Loading Shaders and Initializing Variables

Next, we’ll load our vertex (vertShader.glsl) and fragment (fragShader.glsl) shaders and declare some global variables:

let FShader, VShader;
let vFactor;
VShader = await loadShader("/Shaders/vertShader.glsl");
FShader = await loadShader("/Shaders/fragShader.glsl");

vFactor will be used later to control the velocity of the particles.

Creating a Linear Spline for Interpolation

To animate our particles smoothly, we’ll use a linear spline function that interpolates between points:

let getLinearSpline = (lerp) => {
  let points = [];
  let _lerp = lerp;
  let addPoint = (t, d) => {
    points.push([t, d]);
  };
  let getValueAt = (t) => {
    let p1 = 0;
    for (let i = 0; i < points.length; i++) {
      if (points[i][0] >= t) break;
      p1 = i;
    }

    let p2 = Math.min(points.length - 1, p1 + 1);
    if (p1 == p2) return points[p1][1];
    return _lerp(
      (t - points[p1][0]) / (points[p2][0] - points[p1][0]),
      points[p1][1],
      points[p2][1]
    );
  };
  return { addPoint, getValueAt };
};

This utility allows us to define splines for different attributes like size, color, and alpha (transparency), which will be used to control how these attributes change over the life of a particle.

Building the Smoke Particle System

Now, let’s get to the heart of the system—creating the particle system itself:

let getSmokeParticleSystem = (params) => {
  let { camera, emitter, parent, rate, texture, maxHeight, offset } = params;
  let uniforms = {
    diffuseTexture: {
      value: new THREE.TextureLoader().load(texture),
    },
    pointMultiplier: {
      value: window.innerHeight / (2.0 * Math.tan((30.0 * Math.PI) / 180.0)),
    },
  };

  let _material = new THREE.ShaderMaterial({
    uniforms: uniforms,
    vertexShader: VShader,
    fragmentShader: FShader,
    depthTest: true,
    depthWrite: false,
    transparent: true,
    vertexColors: true,
  });

  let _particles = [];
  let geometry = new THREE.BufferGeometry();
  geometry.setAttribute("position", new THREE.Float32BufferAttribute([], 3));
  geometry.setAttribute("size", new THREE.Float32BufferAttribute([], 1));
  geometry.setAttribute("aColor", new THREE.Float32BufferAttribute([], 4));
  geometry.setAttribute("angle", new THREE.Float32BufferAttribute([], 1));

  let _points = new THREE.Points(geometry, _material);
  parent.add(_points);

Here, we set up the particle material using our shaders and assign attributes for position, size, color, and rotation angle. The particles are then added to the parent object in the scene.

Defining Splines for Particle Attributes

Next, we define splines for alpha, color, and size to control how these attributes change over time:

 let alphaSpline = getLinearSpline((t, a, b) => a + t * (b - a));
  alphaSpline.addPoint(0.0, 0.0);
  alphaSpline.addPoint(0.6, 1.0);
  alphaSpline.addPoint(1.0, 0.0);

  let colorSpline = getLinearSpline((t, a, b) => {
    let c = a.clone();
    return c.lerp(b, t);
  });
  colorSpline.addPoint(0.0, new THREE.Color(0xffffff));

  let sizeSpline = getLinearSpline((t, a, b) => a + t * (b - a));
  sizeSpline.addPoint(0.0, 0.0);
  sizeSpline.addPoint(0.8, 0.8);

These splines ensure that particles gradually fade in, grow, and change color as they move through the scene.

Adding and Updating Particles

We then create functions to add and update particles:

 let _AddParticles = (timeElapsed) => {
    let posOffset = emitter.position.clone().add(offset);
    if (emitter.position.y >= maxHeight) {
      vFactor = new THREE.Vector3(0.0, 0.0, 4.0);
    } else {
      return;
    }
    gdfsghk += timeElapsed;
    let n = Math.floor(gdfsghk * rate);
    gdfsghk -= n / rate;

    for (let i = 0; i < n; i += 1) {
      let life = (Math.random() * 0.75 + 0.25) * maxLife;
      _particles.push({
        position: new THREE.Vector3(
          (Math.random() * 2 - 1) * radius,
          (Math.random() * 2 - 1) * radius,
          (Math.random() * 2 - 1) * radius
        ).add(posOffset),
        size: (Math.random() * 0.5 + 0.5) * maxSize,
        colour: new THREE.Color(),
        alpha: 1.0,
        life: life,
        maxLife: life,
        rotation: Math.random() * 2.0 * Math.PI,
        rotationRate: Math.random() * 0.01 - 0.005,
        velocity: vFactor,
      });
    }
  };

This function adds new particles to the system at the emitter’s position, with a random lifespan, size, and velocity.

Updating the Geometry

To reflect the changes in the particle system, the geometry must be updated:

 let _UpdateGeometry = () => {
    let positions = [];
    let sizes = [];
    let colours = [];
    let angles = [];

    for (let p of _particles) {
      positions.push(p.position.x, p.position.y, p.position.z);
      colours.push(p.colour.r, p.colour.g, p.colour.b, p.alpha);
      sizes.push(p.currentSize);
      angles.push(p.rotation);
    }

    geometry.setAttribute(
      "position",
      new THREE.Float32BufferAttribute(positions, 3)
    );
    geometry.setAttribute("size", new THREE.Float32BufferAttribute(sizes, 1));
    geometry.setAttribute(
      "aColor",
      new THREE.Float32BufferAttribute(colours, 4)
    );
    geometry.setAttribute("angle", new THREE.Float32BufferAttribute(angles, 1));

    geometry.attributes.position.needsUpdate = true;
    geometry.attributes.size.needsUpdate = true;
    geometry.attributes.aColor.needsUpdate = true;
    geometry.attributes.angle.needsUpdate = true;
  };
  _UpdateGeometry();

Updating Particles Over Time

Finally, we update the particles over time, adjusting their attributes based on their life cycle:

 let _UpdateParticles = (timeElapsed) => {
    for (let p of _particles) {
      p.life -= timeElapsed;
    }

    _particles = _particles.filter((p) => p.life > 0.0);

    for (let p of _particles) {
      let t = 1.0 - p.life / p.maxLife;
      p.rotation += p.rotationRate;
      p.alpha = alphaSpline.getValueAt(t);
      p.currentSize = p.size * sizeSpline.getValueAt(t);
      p.colour.copy(colorSpline.getValueAt(t));

      p.position.add(p.velocity.clone().multiplyScalar(timeElapsed));

      let drag = p.velocity.clone();
      drag.multiplyScalar(timeElapsed * 0.2);
      drag.x =
        Math.sign(p.velocity.x) *
        Math.min(Math.abs(drag.x), Math.abs(p.velocity.x));
      drag.y =
        Math.sign(p.velocity.y) *
        Math.min(Math.abs(drag.y), Math.abs(p.velocity.y));
      drag.z =
        Math.sign(p.velocity.z) *
        Math.min(Math.abs(drag.z), Math.abs(p.velocity.z));
      p.velocity.sub(drag);
    }
    _particles.sort((a, b) => {
      let d1 = camera.position.distanceTo(a.position);
      let d2 = camera.position.distanceTo(b.position);
      return d1 > d2 ? -1 : d1 < d2 ? 1 : 0;
    });
  };

This function ensures that particles are updated correctly over time, taking into account their velocity, drag, and distance from the camera.

Final Integration

We integrate everything into a main update loop that handles the entire particle system:

 let update = (timeElapsed) => {
    _AddParticles(timeElapsed);
    _UpdateParticles(timeElapsed);
    _UpdateGeometry();
  };
  return { update };
};
export { getSmokeParticleSystem };

The update function handles adding new particles, updating existing ones, and refreshing the geometry. This makes the particle system dynamic, with particles that evolve and eventually fade away. This same system can be tweaked as per our needs to create a particle system of our own.

Leave a comment

Your email address will not be published. Required fields are marked *