Three.js: Particles with a Custom Particle System

In this article, we are going to create a custom particle system that emits and destroys textured particles in Three.js. Like the particles examples on the Three.js website, our particles will be drawn using GL_POINTS. However, we will be using a custom BufferGeometry and ShaderMaterial to get the job done.

Step 1: Template to Start

As you will notice in the video tutorial above, I decided to mimic a Minecraft torch for the example use case of the particle system. This tutorial assumes some basic Three.js knowledge, so I’m providing a template that includes the torch, textures, and code for rendering the torch and orbit controls. You can download the package, including the code, below. If you just want the code, it’s available below the download link. Be aware the code contains references to textures that will be missing, so you will need to replace the textures or replace the names with those of your own.

Download here from Google Drive.

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>three.js webgl - particles - sprites</title>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0"
    />
    <style>
      body {
        margin: 0;
        padding: 0;
      }
    </style>
  </head>
  <body>
    <script type="module">
      import * as THREE from "../three.module.js";
      import { OrbitControls } from "./OrbitControls.js";

      let camera,
        scene,
        renderer,
        controls;


      init();
      animate();

      function init() {
        camera = new THREE.PerspectiveCamera(
          75,
          window.innerWidth / window.innerHeight,
          1,
          2000
        );
        camera.position.z = 10;

        scene = new THREE.Scene();
        scene.fog = new THREE.FogExp2(0x000000, 0.005);

        // Create our minecraft torch
        const loader = new THREE.TextureLoader();
        loader.setPath("textures/");
        const side = new THREE.MeshBasicMaterial({
          map: loader.load("side.png"),
        });
        const top = new THREE.MeshBasicMaterial({
          map: loader.load("top.png"),
        });
        const bottom = new THREE.MeshBasicMaterial({
          map: loader.load("bottom.png"),
        });

        const textures = [side, side, top, bottom, side, side];

        const torchGeometry = new THREE.BoxGeometry(0.5, 2.5, 0.5);
        const torch = new THREE.Mesh(torchGeometry, textures);

        scene.add(torch);

        renderer = new THREE.WebGLRenderer();
        renderer.setClearColor(new THREE.Color(0xffffff));
        renderer.setPixelRatio(window.devicePixelRatio);
        renderer.setSize(window.innerWidth, window.innerHeight);
        document.body.appendChild(renderer.domElement);

        controls = new OrbitControls(camera, renderer.domElement);

        //

        window.addEventListener("resize", onWindowResize);
      }

      function onWindowResize() {
        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();

        renderer.setSize(window.innerWidth, window.innerHeight);
      }
      //

      function animate() {
        requestAnimationFrame(animate);

        render();
      }

      function render() {

        renderer.render(scene, camera);
      }
    </script>
  </body>
</html>

Step 2: ParticleSystem Class

In this second step, let’s add in the ParticleSystem base class and shaders. This is the base class that will be used by any ParticleSystem. It takes care of spawning, destroying, and rendering particles. It doesn’t have any default animation, so particles will just sit there until they die. To include various types of animation, we can create sub-classes.

      const vertexShader = `
        uniform float pointMultiplier;
        attribute float scale;
        attribute float alpha;

        varying float alphaToFrag;

        void main() {
          vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
          gl_Position = projectionMatrix * mvPosition;
          gl_PointSize = pointMultiplier * 1500.0 * scale / gl_Position.w;

          alphaToFrag = alpha;
        }
      `;

      const fragmentShader = `
        uniform sampler2D diffuseTexture;

        varying float alphaToFrag;

        void main() {
          gl_FragColor = texture2D(diffuseTexture, gl_PointCoord) * vec4(1.0, 1.0, 1.0, alphaToFrag);
        }
      `;

      class ParticleSystem {
        constructor (texture, emit_every, particle_life) {
          this.texture = texture;
          this.emit_every = emit_every;
          this.particle_life = particle_life;
          this.last_emission = 0;

          this.geometry = new THREE.BufferGeometry();
          this.particles = [];
          this.material = new THREE.ShaderMaterial({
            uniforms: {
              diffuseTexture: { value: texture },
              pointMultiplier: { value: window.innerHeight / window.innerWidth }
            },
            vertexShader,
            fragmentShader,
            blending: THREE.NormalBlending,
            depthTest: true,
            depthWrite: false,
            transparent: true,
            vertexColors: true,
          });

          this.mesh = new THREE.Points(this.geometry, this.material);
          this.clock = new THREE.Clock();
        }

        setPosition(position) {
          this.mesh.position.x = position.x;
          this.mesh.position.y = position.y;
          this.mesh.position.z = position.z;
        }

        getMesh() {
          return this.mesh;
        }

        updateAspect() {
          this.material.uniforms.pointMultiplier.value = window.innerHeight / window.innerWidth;
        }

        spawn() {
          this.particles.push({
            position: [0, 0, 0],
            scale: 1,
            alpha: 1,
            spawnTime: this.clock.elapsedTime,
          });

          this.last_emission = this.clock.elapsedTime;
        }

        update() {
          const elapsedTime = this.clock.getElapsedTime();

          this.particles = this.particles.filter((particle) => elapsedTime - particle.spawnTime < this.particle_life);

          if (elapsedTime - this.last_emission >= this.emit_every) {
            this.spawn();
          }

          this.geometry.setAttribute("position", new THREE.Float32BufferAttribute(this.particles.map((particle) => particle.position).flat(), 3));
          this.geometry.setAttribute("scale", new THREE.Float32BufferAttribute(this.particles.map((particle) => particle.scale).flat(), 1));
          this.geometry.setAttribute("alpha", new THREE.Float32BufferAttribute(this.particles.map((particle) => particle.alpha).flat(), 1));
          this.geometry.attributes.position.needsUpdate = true;
          this.geometry.attributes.scale.needsUpdate = true;
        }
      }

Step 3: Animated Particle Classes

We’ll create two types of particle systems to get our Minecraft torch going. One for the smoke, which will rise up and fade. The second one for the flames, which will gradually get bigger before disappearing. You could create any animation you want in your sub-classes, or expand the base class to create a greater variety of possible appearances and animations.

      class GrowParticleSystem extends ParticleSystem {
        update() {
          for (let i = 0; i < this.particles.length; i++) {
            this.particles[i].position[1] += 0.001;
            this.particles[i].scale += 0.001;
          }

          super.update();
        }
      }

      class SmokeParticleSystem extends ParticleSystem {
        spawn() {
          super.spawn();
          this.particles[this.particles.length - 1].dartX = Math.random() * 0.005 * (Math.random() > 0.5 ? 1 : -1 );
          this.particles[this.particles.length - 1].dartZ = Math.random() * 0.005 * (Math.random() > 0.5 ? 1 : -1 );
        }

        update() {
          for (let i = 0; i < this.particles.length; i++) {
            this.particles[i].position[0] += this.particles[i].dartX;
            this.particles[i].position[1] += 0.005;
            this.particles[i].position[2] += this.particles[i].dartZ;
            this.particles[i].scale -= 0.001;
            this.particles[i].alpha -= 0.01;
          }

          super.update();
        }
      }

Step 4: Adding and Updating the Particle Systems

...
      function init() {
        ...

        const flame = loader.load("flame.png");
        flame.flipY = false;

        const smoke = loader.load("smoke.png");
        smoke.flipY = false;

        flameParticles = new GrowParticleSystem(flame, 1, 2.5);
        flameParticles.setPosition(new THREE.Vector3(0, 2.5 / 2 + 0.25, 0));

        smokeParticles = new SmokeParticleSystem(smoke, 2, 2.5);
        smokeParticles.setPosition(new THREE.Vector3(0, 2.5 / 2 + 0.5, 0));
        scene.add(flameParticles.getMesh());
        scene.add(smokeParticles.getMesh());
        ...

      }

      function onWindowResize() {
        ...

        flameParticles.updateAspect();
        smokeParticles.updateAspect();

        ...
      }

      function animate() {
        ...
        // Whether your function is called render, update, animate, or something else, make sure these get called each frame.
        flameParticles.update();
        smokeParticles.update();

        ...
      }

...

Conclusion

That’s it! You should now have a working particle system. There’s lots of areas to expand this system with a large variety of features, but it’s a solid start that should help you get where you’re going.