From our partner: The AI visual builder for Next.js & Tailwind:
"It's like Framer and v0 had a baby"
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
- 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.
- Next we’ll work on crafting the Dissolve Effect where, we’ll modify the already existing material by injecting our own shader snippets
- After that we’ll create our Particle System so that we can have particles emitted from the edge of dissolving mesh.
- 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.

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;
- 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].
- 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.

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:
- Which portion of the object should dissolve.
- Which portion should be considered the edge.
- 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);
`);
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:
- Use
modelViewMatrix
to transform the position vector intoviewPosition
, representing the particle’s position relative to the camera (with the camera as the origin). - Set
gl_PointSize = uBaseSize / -viewPosition.z
, making the particle size inversely proportional to its z-axis distance, whereuBaseSize
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:
- Noise value <
uProgress
- 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);
}
`;
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, whileinitPosition
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:
initializeAttributes()
– Runs once. Declares the attribute arrays, loops over them, and initializes their values.updateAttributes()
– Called on each game loop iteration to update the existing attributes.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()
}
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;
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);
}
`;
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.
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!