Implementing a Dissolve Effect with Shaders and Particles in Three.js

Learn how to create an emissive dissolve effect, a popular technique in games for smoothly fading or transforming objects.

I got the initial idea of making this dissolve effect from a game that I was playing. In it zombies dissolve into thin air upon defeat. Since I was already experimenting with Three.js shaders and particles I thought of recreating something similar.

In this tutorial, I’ll walk you through the process of creating this cool dissolve effect, providing code snippets wherever necessary highlighting the key parts. Also each step is linked to a specific commit, so you can check out the corresponding commit to view and run the project at that stage.

Here’s a brief overview of the major parts that we’ll be going through

  1. We’ll start by setting up the Environment and Lighting deciding on what kind of lighting and material we want, keeping in mind how the boom effect may alter it.
  2. Next we’ll work on crafting the Dissolve Effect where, we’ll modify the already existing material by injecting our own shader snippets
  3. After that we’ll create our Particle System so that we can have particles emitted from the edge of dissolving mesh.
  4. Finally we’ll apply Selective Unreal Bloom effect over our mesh, making the edges and particles glow as they dissolve.

Setting up the Environment

Deciding on the material and lighting setup

When setting up the environment for our effect, we need to consider the type of material and lighting we want to create. Specifically, we will be working with a material where one part glows while the other parts reflect the surroundings. To achieve this, we will apply the bloom effect to this specific material. However, if there are intense reflections on the non-glowing parts, they may cause unwanted brightness.

Since multiple environment lighting options are available, we need to choose the one that best suits our effect while considering how bloom might influence the material.

HDRI or CubeMaps?

The intensity of HDRI lighting can cause excessive reflections when the bloom effect is applied to the mesh, resulting in an uncontrolled appearance. In contrast, CubeMaps simulate environmental lighting and surface reflections without introducing direct bright light sources. They allow for the capture of surrounding details without intense illumination, making CubeMaps ideal for applying bloom without creating overexposed highlights.

With bloom applied to the object, HDRI creates overblown reflections (left), while CubeMaps deliver more controlled reflections (right).

Creating Dissolve Effect

Perlin Noise

To create our dissolve pattern, we will use Perlin noise. Unlike standard noise algorithms that generate completely random values, Perlin noise produces continuous output, creating natural-looking transitions.

float perlinNoise2D(vec3 p) is a noise function that takes a 3D vector (vec3 p) as input and returns a noise value as a floating-point number b/w range of -1, 1 approximately.

How Amplitude and Frequency affects the noise output

float perlinNoise2D(vec3 p * frequency) * amplitude; 
  1. Amplitude: Controls the intensity of the noise effect, determining how high or low the noise output can be. For example, if the noise function outputs values approximately in the range [-1,1] and you set the amplitude to 10, the output will then range approximately between [-10,10].
  2. Frequency: The higher the frequency, the more detailed the noise pattern becomes.

In our case, we only need to focus on frequency to control the level of detail in the dissolve pattern.

Perlin noise with high frequency (left) compared to Perlin noise with low frequency (right).

Using Perlin noise to guide the dissolve effect

We can now generate a noise value for each fragment or pixel of our object. Based on these values, we’ll determine which portions should dissolve. This process involves addressing three key aspects:

  1. Which portion of the object should dissolve.
  2. Which portion should be considered the edge.
  3. How much of the object should remain unchanged, retaining its original material color.

Determine which portion to dissolve

To achieve this, we can create a uniform uProgress, which will serve as a threshold for each pixel or fragment. If, at a given pixel or fragment, noiseValue < uProgress, we simply discard it. It’s important to keep in mind that the range of uProgress should be slightly greater than the noise output range. This ensures that we can completely dissolve the object or fully reverse the dissolve effect.

Define the edge portion of the object

Next, let’s consider the edges. We can introduce another uniform, uEdge, to control the width of the edge. For any given fragment, if its corresponding noise value lies between uProgress and uProgress + uEdge, we will fill that pixel with the edge color.

So the condition becomes: if (noise > uProgress && noise < uProgress + uEdge)

If neither of the above two conditions is met, the material will remain unchanged, retaining its original color.

shader.fragmentShader = shader.fragmentShader.replace('#include <dithering_fragment>', `#include <dithering_fragment>        
    float noise = cnoise(vPos * uFreq) * uAmp; // calculate cnoise in fragment shader for smooth dissolve edges
    
    if(noise < uProgress) discard; // discard any fragment where noise is lower than progress
    
    float edgeWidth = uProgress + uEdge;
    if(noise > uProgress && noise < edgeWidth){
        gl_FragColor = vec4(vec3(uEdgeColor),noise); // colors the edge
    }
    
    gl_FragColor = vec4(gl_FragColor.xyz,1.0);
    
`);
Demonstration of the dissolve effect controlled by uniforms.

Particle system

We will create a points object to display particles using the same geometry from our previous mesh. To achieve fine control over each particle’s appearance and movement, we first need to set up a basic shader material.

Creating ShaderMaterial for particles

For the fragment shader, set fragColor to white.

For the vertex shader, we need to adjust the particle size based on the camera distance:

  1. Use modelViewMatrix to transform the position vector into viewPosition, representing the particle’s position relative to the camera (with the camera as the origin).
  2. Set gl_PointSize = uBaseSize / -viewPosition.z, making the particle size inversely proportional to its z-axis distance, where uBaseSize controls the base particle size.

Keeping particles on the edges and discarding the rest

We will follow our previous shader strategy with some modifications. The noise calculation will be moved to the vertex shader and passed to the fragment shader as a varying. To maintain consistent dissolve patterns, we will use the same uniforms and parameters (amplitude, frequency, edge width, and progress) from our previous shader.

Particle discard conditions:

  1. Noise value < uProgress
  2. Noise value > uProgress + uEdge
particleMat.fragmentShader = `
uniform vec3 uColor;
uniform float uEdge;
uniform float uProgress;

varying float vNoise;
 
 void main(){
    if( vNoise < uProgress ) discard;
    if( vNoise > uProgress + uEdge) discard;

    gl_FragColor = vec4(uColor,1.0);
 }
 `;
Dissolve effect on the particles. Retaining particles along the dissolving edge.

How do particle systems actually work?

Each particle typically has a few properties associated with it, such as lifespan, velocity, color, and size. During each iteration of the game loop or animation loop, we iterate over all the particles, update these properties, and render them on the screen. That’s essentially how a particle system works.

Three.js implements particles by binding them to geometries (such as buffer geometries or sphere geometries). These geometries contain attributes similar to particle properties, which can be modified in JavaScript and accessed via shaders. By default, any given geometry includes basic attributes like position and normal.

Giving velocity to particles and making them move

Now, let’s focus on a few key attributes and see how they change over time, building on the general workings of a particle system that we just discussed.

To make our particles move, we need two things: position and velocity. Then, we can simply update the position using the formula: new_position = position + velocity.

Defining the attributes: To achieve this, we can create four new attributes: currentPosition, initPosition, velocity, and maxOffset.

  • maxOffset: The maximum distance a particle can travel before resetting to its initial position.
  • currentPosition & initPosition: Copies of the position attribute. We will update currentPosition and use it in the shader to modify the particle’s position, while initPosition will be used to reset the position when needed.
  • velocity: Added to currentPosition in each iteration to update its position over time.

Then, we can create the following three functions:

  1. initializeAttributes() – Runs once. Declares the attribute arrays, loops over them, and initializes their values.
  2. updateAttributes() – Called on each game loop iteration to update the existing attributes.
  3. setAttributes() – Attaches the attributes to the geometry. This function will be called at the end of the previous two functions.
// declare attributes 
let particleCount = meshGeo.attributes.position.count;
let particleMaxOffsetArr: Float32Array; // how far a particle can go from its initial position
let particleInitPosArr: Float32Array; // store the initial position of the particles
let particleCurrPosArr: Float32Array; // used to update the position of the particle
let particleVelocityArr: Float32Array; // velocity of each particle
let particleSpeedFactor = 0.02; // for tweaking velocity

function initParticleAttributes() {
    particleMaxOffsetArr = new Float32Array(particleCount);
    particleInitPosArr = new Float32Array(meshGeo.getAttribute('position').array);
    particleCurrPosArr = new Float32Array(meshGeo.getAttribute('position').array);
    particleVelocityArr = new Float32Array(particleCount * 3);

    for (let i = 0; i < particleCount; i++) {
        let x = i * 3 + 0;
        let y = i * 3 + 1;
        let z = i * 3 + 2;

        particleMaxOffsetArr[i] = Math.random() * 1.5 + 0.2;

        particleVelocityArr[x] = 0;
        particleVelocityArr[y] = Math.random() + 0.01;
        particleVelocityArr[z] = 0;
    }

    // Set initial particle attributes
    setParticleAttributes();
}

function updateParticleAttributes() {
    for (let i = 0; i < particleCount; i++) {
        let x = i * 3 + 0;
        let y = i * 3 + 1;
        let z = i * 3 + 2;

        particleCurrPosArr[x] += particleVelocityArr[x] * particleSpeedFactor;
        particleCurrPosArr[y] += particleVelocityArr[y] * particleSpeedFactor;
        particleCurrPosArr[z] += particleVelocityArr[z] * particleSpeedFactor;

        const vec1 = new THREE.Vector3(particleInitPosArr[x], particleInitPosArr[y], particleInitPosArr[z]);
        const vec2 = new THREE.Vector3(particleCurrPosArr[x], particleCurrPosArr[y], particleCurrPosArr[z]);
        const dist = vec1.distanceTo(vec2);

        if (dist > particleMaxOffsetArr[i]) {
            particleCurrPosArr[x] = particleInitPosArr[x];
            particleCurrPosArr[y] = particleInitPosArr[y];
            particleCurrPosArr[z] = particleInitPosArr[z];
        }
    }

    // set particle attributes after changes
    setParticleAttributes();
}

initParticleAttributes();

animationLoop(){
  updateParticleAttributes()
}
Particle movement driven by position and velocity attributes in geometry

Creating a wave-like motion for the particles

To create wave-like motion for the particles, we can use a sine wave function: y=sin(x⋅freq+time)⋅amplitude

This will generate a wave pattern parallel to the x-axis, where Amplitude controls how high or low the wave moves, and Frequency determines how often the oscillations occur. The time value causes the wave to move.

To apply a wavy motion to our particle movement, we can use the particle position as input to the sine function and create a wave offset that we can add to their velocity.

  • waveX = sin(pos_y) * amplitude
  • waveY = sin(pos_x) * amplitude

We can create a function to calculate the wave offset for the desired axis and apply it to the particle’s velocity. By combining multiple sine waves, we can generate a more natural and dynamic motion for the particles.

function calculateWaveOffset(idx: number) {
    const posx = particleCurrPosArr[idx * 3 + 0];
    const posy = particleCurrPosArr[idx * 3 + 1];

    let xwave1 = Math.sin(posy * 2) * (0.8 + particleData.waveAmplitude);
    let ywave1 = Math.sin(posx * 2) * (0.6 + particleData.waveAmplitude);

    let xwave2 = Math.sin(posy * 5) * (0.2 + particleData.waveAmplitude);
    let ywave2 = Math.sin(posx * 1) * (0.9 + particleData.waveAmplitude);

    return { xwave: xwave1+xwave2, ywave: ywave1+ywave2 }
}
// inside update attribute function
let vx = particleVelocityArr[idx * 3 + 0];
let vy = particleVelocityArr[idx * 3 + 1];

let { xwave, ywave } = calculateWaveOffset(idx);

vx += xwave;
vy += ywave;
Using multiple sin waves for wave-like particle motion.

Adding distant-scaling, texture and rotation

To add more variation to the particles, we can make them scale down in size as their distance from the initial position increases. By calculating the distance between the initial and current positions, we can use this value to create a new attribute for each particle.

let particleDistArr: Float32Array; // declare a dist array

// inside the initialiseAttributes() function
particleDistArr[i] = 0.001;

// inside the updateAttributes() function
const vec2 = new THREE.Vector3(particleCurrPosArr[x], particleCurrPosArr[y], particleCurrPosArr[z]);
const dist = vec1.distanceTo(vec2);
particleDistArr[i] = dist;

// inside the particle vertex shader 
particleMat.vertexShader = `
...
    float size = uBaseSize * uPixelDensity;
    size = size  / (aDist + 1.0);
    gl_PointSize = size / -viewPosition.z;
... 
`

Now, let’s talk about rotation and texture. To achieve this, we create an angle attribute that holds a random rotation value for each particle. First, initialize the angleArray inside the initializeAttributes function using: angleArr[i] = Math.random() * Math.PI * 2;. This assigns a random angle to each particle. Then, in the updateAttributes function, we update the angle using angleArr[i] += 0.01; to increment it over time.

Next, we pass the angle attribute from the vertex shader to the fragment shader, create a rotation transformation matrix, and shift gl_PointCoord from the range [0,1] to [-0.5, 0.5]. We then apply the rotation transformation and shift it back to [0,1]. This shifting is necessary to set the pivot point for rotation at the center rather than the bottom-left corner.

Additionally, don’t forget to set the blending mode to additive blending and enable the transparency property by setting it to true.

particleMat.transparent = true;
particleMat.blending = THREE.AdditiveBlending;
// update particle fragment shader 
particleMat.fragmentShader = `
uniform vec3 uColor;
uniform float uEdge;
uniform float uProgress;
uniform sampler2D uTexture;

varying float vNoise;
varying float vAngle;

void main(){
    if( vNoise < uProgress ) discard;
    if( vNoise > uProgress + uEdge) discard;

    vec2 coord = gl_PointCoord;
    coord = coord - 0.5; // get the coordinate from 0-1 ot -0.5 to 0.5
    coord = coord * mat2(cos(vAngle),sin(vAngle) , -sin(vAngle), cos(vAngle)); // apply the rotation transformaion
    coord = coord +  0.5; // reset the coordinate to 0-1  

    vec4 texture = texture2D(uTexture,coord);

    gl_FragColor = vec4(uColor,1.0);
    gl_FragColor = vec4(vec3(uColor.xyz * texture.xyz),1.0);
 }
 `;
Particles with distant-scaling, texture and rotation.

Applying Selective Unreal Bloom

To apply the bloom effect only to the object and not the background or environment, we can create two separate renders using Effect Composer.

Before the first render, we set the background to black, apply the Unreal Bloom pass, and render it to an off-screen buffer.

Once that’s done, we reset the background environment. After that, we create a new render that takes the base texture of the scene (tDiffuse) and the bloom texture from the off-screen render, then combines them. This way, bloom is applied only to the object.

Final result after bloom is applied. Check out the final demo.

And there you have it—a dynamic dissolve effect complete with glowing particles! Feel free to experiment with the parameters: try different noise patterns, adjust the glow intensity, or modify particle behavior to create your own unique variations.

I hope you found this tutorial helpful! Keep experimenting and have fun with the effect!

Jatin Chopra

CS student exploring graphics and game development with WebGL and Three.js

The
New
Collective

🎨✨💻 Stay ahead of the curve with handpicked, high-quality frontend development and design news, picked freshly every single day. No fluff, no filler—just the most relevant insights, inspiring reads, and updates to keep you in the know.

Prefer a weekly digest in your inbox? No problem, we got you covered. Just subscribe here.