Crafting a Dreamy Particle Effect with Three.js and GPGPU

Learn how to create an interactive particle effect using Three.js, GPGPU, and shaders.

shining particles mask by dominik fojcik

Hi! I’m Dominik, a creative developer based in Wroclaw, Poland. Currently I’m at Huncwot.

In this tutorial, I’ll guide you through creating a dreamy, interactive particle effect using Three.js, shaders, and the powerful GPGPU technique. Together, we’ll explore how to use GPU computation to bring thousands of particles to life with seamless motion, glowing highlights, and dynamic interactivity.

Here’s what we’ll do:

  • Setting up GPGPU for lightning-fast particle calculations
  • Creating mouse-responsive animations
  • Adding extra shine with post-processing effects

To follow this tutorial, a solid understanding of Three.js and shaders is recommended.

Ready to get started?

So let’s dive in!

What’s GPGPU?

GPGPU stands for General-Purpose Computation on Graphics Processing Units. Typically, GPUs are used to create graphics and render images, but they can also handle other types of computations. By offloading certain tasks from the CPU to the GPU, processes can be completed much faster. GPUs excel at performing many operations simultaneously, making them ideal for tasks like moving thousands of particles efficiently. This approach significantly boosts performance and enables complex effects that would be too slow for a CPU to manage on its own.

You can learn more about GPGPU here:

Setting Up GPGPU

To harness the power of the GPU, we need to create two textures to store our data. Think of these textures as arrays, where each pixel represents the position of a single particle. To simplify this process, we’ll create a GPGPUUtils class to streamline the GPGPU setup.

GPGPUUtils.js

import * as THREE from 'three';
import { MeshSurfaceSampler } from 'three/examples/jsm/math/MeshSurfaceSampler.js';

  

export default class GPGPUUtils {
	constructor(mesh, size) {
	
	this.size = size;
	
	this.number = this.size * this.size;
	
	this.mesh = mesh;
	
	this.sampler = new MeshSurfaceSampler(this.mesh).build();
	
	  
	
	this.setupDataFromMesh();
	this.setupVelocitiesData();
}

  
	setupDataFromMesh() {
		const data = new Float32Array(4 * this.number);
		const positions = new Float32Array(3 * this.number);
		const uvs = new Float32Array(2 * this.number);
		
		this._position = new THREE.Vector3();
		
		for (let i = 0; i < this.size; i++) {
			for (let j = 0; j < this.size; j++) {
				const index = i * this.size + j;
				
				// Pick random point from Mesh
				
				this.sampler.sample(this._position);


				// Setup for DataTexture
				
				data[4 * index] = this._position.x;
				data[4 * index + 1] = this._position.y;
				data[4 * index + 2] = this._position.z;


				// Setup positions attribute for geometry
				
				positions[3 * index] = this._position.x;
				positions[3 * index + 1] = this._position.y;
				positions[3 * index + 2] = this._position.z;
				
				  
				
				// Setup UV attribute for geometry
				
				uvs[2 * index] = j / (this.size - 1);
				uvs[2 * index + 1] = i / (this.size - 1);
			}
		}
	  
	
		const positionTexture = new THREE.DataTexture(data, this.size, this.size, THREE.RGBAFormat, THREE.FloatType);
		
		positionTexture.needsUpdate = true;
		
		this.positions = positions;
		
		this.positionTexture = positionTexture;
		
		this.uvs = uvs;
	}
	  
	
	setupVelocitiesData() {
		const data = new Float32Array(4 * this.number);
		
		data.fill(0);
		
		let velocityTexture = new THREE.DataTexture(data, this.size, this.size, THREE.RGBAFormat, THREE.FloatType);
		
		velocityTexture.needsUpdate = true;
		
		this.velocityTexture = velocityTexture
	}
	  
	
	getPositions() {
		return this.positions;
	}
	
	  
	getUVs() {
		return this.uvs;
	}
	
	  
	getPositionTexture() {
		return this.positionTexture;
	}
	 
	
	getVelocityTexture() {
		return this.velocityTexture;
	}
}

GPGPU.js

import * as THREE from 'three';
import GPGPUUtils from './utils';
  

export default class GPGPU {
	constructor({ size, camera, renderer, mouse, scene, model, sizes }) {
		this.camera = camera; // Camera
		this.renderer = renderer; // Renderer
		this.mouse = mouse; // Mouse, our cursor position
		this.scene = scene; // Global scene
		this.sizes = sizes; // Sizes of the scene, canvas, pixel ratio
		this.size = size; // Amount of GPGPU particles
		this.model = model; // Mesh from which we will sample the particles
		
		
		this.init();
	}
	
	
	init() {
		this.utils = new GPGPUUtils(this.model, this.size); // Setup GPGPUUtils
	}
}

Integrating GPUComputationRenderer

We’ll use GPUComputationRenderer from Three.js to save particle positions and velocities inside textures.

This is how our GPGPU class should look like so far:

import * as THREE from 'three';
import GPGPUUtils from './utils';

import { GPUComputationRenderer } from 'three/examples/jsm/misc/GPUComputationRenderer.js';
  

export default class GPGPU {
	constructor({ size, camera, renderer, mouse, scene, model, sizes }) {
		this.camera = camera; // Camera
		this.renderer = renderer; // Renderer
		this.mouse = mouse; // Mouse, our cursor position
		this.scene = scene; // Global scene
		this.sizes = sizes; // Sizes of the scene, canvas, pixel ratio
		this.size = size; // Amount of GPGPU particles, ex. 1500
		this.model = model; // Mesh from which we will sample the particles
		
		
		this.init();
	}
	
	
	init() {
		this.utils = new GPGPUUtils(this.model, this.size); // Setup GPGPUUtils

		this.initGPGPU();
	}
	

	initGPGPU() {
		this.gpgpuCompute = new GPUComputationRenderer(this.sizes.width, this.sizes.width, this.renderer);
	}
}

Now we need to pass two textures containing data into our GPUComputationRenderer:

  • positionTexture: Texture with positions of particles.
  • velocityTexture: Texture with velocities of particles.

Thanks to GPGPUUtils, we can easily create them:

const positionTexture = this.utils.getPositionTexture();
const velocityTexture = this.utils.getVelocityTexture();

Now that we have the textures, we need to create two shaders for the GPUComputationRenderer:

simFragmentVelocity

This shader calculates the velocity of our particles (makes particles move).

simFragmentVelocity.glsl

uniform sampler2D uOriginalPosition;

void main() {
    vec2 vUv = gl_FragCoord.xy / resolution.xy;

    vec3 position = texture2D( uCurrentPosition, vUv ).xyz;
    vec3 original = texture2D( uOriginalPosition, vUv ).xyz;
    vec3 velocity = texture2D( uCurrentVelocity, vUv ).xyz;

    gl_FragColor = vec4(velocity, 1.);
}

simFragment

Inside this shader, we update the current particle position based on its velocity.

simFragment.glsl

void main() {
    vec2 vUv = gl_FragCoord.xy / resolution.xy;

    vec3 position = texture2D( uCurrentPosition, vUv ).xyz;
    vec3 velocity = texture2D( uCurrentVelocity, vUv ).xyz;

    position += velocity;

    gl_FragColor = vec4( position, 1.);
}

As you’ve probably noticed, we are not creating uniforms for uCurrentPosition and uCurrentVelocity. This is because these textures are automatically passed to the shader by GPUComputationRenderer.

Now let’s pass these shaders and data textures into the GPUComputationRenderer as follows:

this.positionVariable = this.gpgpuCompute.addVariable('uCurrentPosition', simFragmentPositionShader, positionTexture);

this.velocityVariable = this.gpgpuCompute.addVariable('uCurrentVelocity', simFragmentVelocityShader, velocityTexture);


this.gpgpuCompute.setVariableDependencies(this.positionVariable, [this.positionVariable, this.velocityVariable]);

this.gpgpuCompute.setVariableDependencies(this.velocityVariable, [this.positionVariable, this.velocityVariable]);

Next, let’s set up the uniforms for the simFragmentVelocity and simFragmentPosition shaders.

this.uniforms = {
    positionUniforms: this.positionVariable.material.uniforms,
    velocityUniforms: this.velocityVariable.material.uniforms
}


this.uniforms.velocityUniforms.uMouse = { value: this.mouse.cursorPosition };
this.uniforms.velocityUniforms.uMouseSpeed = { value: 0 };
this.uniforms.velocityUniforms.uOriginalPosition = { value: positionTexture }
this.uniforms.velocityUniforms.uTime = { value: 0 };

And finally we can initialize our GPUComputationRenderer

this.gpgpuCompute.init();

That’s how our class should look like:

import * as THREE from 'three';  
import simFragmentPositionShader from './shaders/simFragment.glsl';
import simFragmentVelocityShader from './shaders/simFragmentVelocity.glsl';
import { GPUComputationRenderer } from 'three/examples/jsm/misc/GPUComputationRenderer.js';
import GPGPUUtils from './utils';


export default class GPGPU {
    constructor({ size, camera, renderer, mouse, scene, model, sizes }) {
        this.camera = camera; // Camera
        this.renderer = renderer; // Renderer
        this.mouse = mouse; // Our cursor position
        this.scene = scene; // Global scene
        this.sizes = sizes; // window width & height

        this.size = size; // Amount of GPGPU particles
        this.model = model; // Mesh from which we will sample the particles


        this.init();
    }


    init() {
        this.utils = new GPGPUUtils(this.model, this.size);

        this.initGPGPU();  
    }


    initGPGPU() {
        this.gpgpuCompute = new GPUComputationRenderer(this.sizes.width, this.sizes.width, this.renderer);

        const positionTexture = this.utils.getPositionTexture();
        const velocityTexture = this.utils.getVelocityTexture();

        this.positionVariable = this.gpgpuCompute.addVariable('uCurrentPosition', simFragmentPositionShader, positionTexture);

        this.velocityVariable = this.gpgpuCompute.addVariable('uCurrentVelocity', simFragmentVelocityShader, velocityTexture);

        this.gpgpuCompute.setVariableDependencies(this.positionVariable, [this.positionVariable, this.velocityVariable]);

        this.gpgpuCompute.setVariableDependencies(this.velocityVariable, [this.positionVariable, this.velocityVariable]);

        this.uniforms = {
        positionUniforms: this.positionVariable.material.uniforms,
        velocityUniforms: this.velocityVariable.material.uniforms
        }

        this.uniforms.velocityUniforms.uMouse = { value: this.mouse.cursorPosition };
        this.uniforms.velocityUniforms.uMouseSpeed = { value: 0 };
        this.uniforms.velocityUniforms.uOriginalPosition = { value: positionTexture };
        this.uniforms.velocityUniforms.uTime = { value: 0 };


        this.gpgpuCompute.init();
    }


    compute(time) {
        this.gpgpuCompute.compute();

        this.uniforms.velocityUniforms.uTime.value = time;
    }
}

Perfect! After the GPUComputationRenderer is set up and ready to perform calculations, we can proceed to create our particles.

Creating Particles

Let’s start by creating the material for our particles. We will need two shaders to update the particles’ positions based on the data computed by the GPGPU.

vertex.glsl

varying vec2 vUv;
varying vec3 vPosition;

uniform float uParticleSize;
uniform sampler2D uPositionTexture;



void main() {
    vUv = uv;

    vec3 newpos = position;

    vec4 color = texture2D( uPositionTexture, vUv );



    newpos.xyz = color.xyz;

    vPosition = newpos;

    vec4 mvPosition = modelViewMatrix * vec4( newpos, 1.0 );

    gl_PointSize = ( uParticleSize / -mvPosition.z );

    gl_Position = projectionMatrix * mvPosition;
}

fragment.glsl

varying vec2 vUv;

uniform sampler2D uVelocityTexture;



void main() {
    float center = length(gl_PointCoord - 0.5);

    vec3 velocity = texture2D( uVelocityTexture, vUv ).xyz * 100.0;
    float velocityAlpha = clamp(length(velocity.r), 0.04, 0.8);

    if (center > 0.5) { discard; }

    gl_FragColor = vec4(0.808, 0.647, 0.239, velocityAlpha);
}

Now let’s setup ShaderMaterial for particles.

// Setup Particles Material

this.material = new THREE.ShaderMaterial({
    uniforms: {
        uPositionTexture: { value: this.gpgpuCompute.getCurrentRenderTarget(this.positionVariable).texture },
        uVelocityTexture: { value: this.gpgpuCompute.getCurrentRenderTarget(this.velocityVariable).texture },
        uResolution: { value: new THREE.Vector2(this.sizes.width, this.sizes.height) },
        uParticleSize: { value: 2 }
    },
    vertexShader: vertexShader,
    fragmentShader: fragmentShader,
    depthWrite: false,
    depthTest: false,
    blending: THREE.AdditiveBlending,
    transparent: true
});

The positions of the particles calculated by the GPGPU are passed as a uniform via a texture stored in a buffer.

Let’s now create the geometry for our particles. The data of positions and UVs can be easily retrieved from the GPGPUUtils we created earlier. After that, we need to set these values as attributes for the geometry.

// Setup Particles Geometry

const geometry = new THREE.BufferGeometry();


// Get positions, uvs data for geometry attributes

const positions = this.utils.getPositions();
const uvs = this.utils.getUVs();


// Set geometry attributes

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));

Once we have our material and geometry, we can combine them with a THREE.Points function and add them into scene to display the particles.

createParticles() {

    // Setup Particles Material

    this.material = new THREE.ShaderMaterial({
        uniforms: {
            uPositionTexture: { value: this.gpgpuCompute.getCurrentRenderTarget(this.positionVariable).texture },
            uVelocityTexture: { value: this.gpgpuCompute.getCurrentRenderTarget(this.velocityVariable).texture },
            uResolution: { value: new THREE.Vector2(this.sizes.width, this.sizes.height) },
            uParticleSize: { value: 2 }
        },
        vertexShader: vertexShader,
        fragmentShader: fragmentShader,
        depthWrite: false,
        depthTest: false,
        blending: THREE.AdditiveBlending,
        transparent: true
    })


    // Setup Particles Geometry

    const geometry = new THREE.BufferGeometry();


    // Get positions, uvs data for geometry attributes

    const positions = this.utils.getPositions();
    const uvs = this.utils.getUVs();


    // Set geometry attributes

    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));


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

    this.scene.add(this.mesh);
}

Once everything is set up, we need to run the <code>GPUComputationRenderer computations on every frame so that the positions of the particles are updated.

GPGPU.js

compute() {
    this.gpgpuCompute.compute();
}

That’s our effect looks so far:

Now, let’s have a look at the next step where we will put the particles into motion on mouse move.

Mouse interaction

Once our particles are visible on the screen, we can create a mouse effect to push particles away from our cursor. For this, we’ll use the GPGPUEvents class to handle the Three.js Raycaster and three-mesh-bvh to sped up raycasting.

import * as THREE from 'three';
import { MeshBVH, acceleratedRaycast } from 'three-mesh-bvh';



export default class GPGPUEvents {

constructor(mouse, camera, mesh, uniforms) {
    this.camera = camera;
    this.mouse = mouse;
    this.geometry = mesh.geometry;
    this.uniforms = uniforms;
    this.mesh = mesh;


    // Mouse

    this.mouseSpeed = 0;



    this.init();
}



init() {
    this.setupMouse();
}



setupMouse() {
    THREE.Mesh.prototype.raycast = acceleratedRaycast;

    this.geometry.boundsTree = new MeshBVH(this.geometry);

    this.raycaster = new THREE.Raycaster();

    this.raycaster.firstHitOnly = true;

    this.raycasterMesh = new THREE.Mesh(
        this.geometry,
        new THREE.MeshBasicMaterial()
    );


    this.mouse.on('mousemove', (cursorPosition) => {
        this.raycaster.setFromCamera(cursorPosition, this.camera);

        const intersects = this.raycaster.intersectObjects([this.raycasterMesh]);

        if (intersects.length > 0) {
            const worldPoint = intersects[0].point.clone();

            this.mouseSpeed = 1;

            this.uniforms.velocityUniforms.uMouse.value = worldPoint;
        }
    });
}


update() {
    if (!this.mouse.cursorPosition) return; // Don't update if cursorPosition is undefined

    this.mouseSpeed *= 0.85;

    this.mouseSpeed = Math.min(this.currentMousePosition.distanceTo(this.previousMousePosition) * 500, 1);

    if (this.uniforms.velocityUniforms.uMouseSpeed) this.uniforms.velocityUniforms.uMouseSpeed.value = this.mouseSpeed;
}

GPGPUEvents, as you can see, sends the current mouse position and speed to simFragmentVelocity as uniforms. This will be necessary later to make the particles repel when the mouse moves.

We can now initialize them inside the GPGPU class and add them to the compute() function to update on every tick.

init() {
    this.utils = new GPGPUUtils(this.model, this.size);

    this.initGPGPU();

    this.createParticles();

    this.events = new GPGPUEvents(this.mouse, this.camera, this.model, this.uniforms);
}


compute() {
    this.gpgpuCompute.compute();
    this.events.update();
}

Once GPGPUEvents are set up, we can move to the simFragmentVelocity shader to animate the particles based on mouse movement.

simFragmentVelocity.glsl

uniform sampler2D uOriginalPosition;
uniform vec3 uMouse;
uniform float uMouseSpeed;


void main() {
    vec2 vUv = gl_FragCoord.xy / resolution.xy;

    vec3 position = texture2D( uCurrentPosition, vUv ).xyz;
    vec3 original = texture2D( uOriginalPosition, vUv ).xyz;
    vec3 velocity = texture2D( uCurrentVelocity, vUv ).xyz;

    velocity *= 0.7; // velocity relaxation


    // particle attraction to shape force

    vec3 direction = normalize( original - position );

    float dist = length( original - position );

    if( dist > 0.001 ) velocity += direction * 0.0003;


    // mouse repel force

    float mouseDistance = distance( position, uMouse );
    float maxDistance = 0.1;


    if( mouseDistance < maxDistance ) {
        vec3 pushDirection = normalize( position - uMouse );
        velocity += pushDirection * ( 1.0 - mouseDistance / maxDistance ) * 0.0023 * uMouseSpeed;
    }


    gl_FragColor = vec4(velocity, 1.);
}

We can also make the particles shine brighter when the velocity is high inside fragment.glsl.

fragment.glsl

varying vec2 vUv;

uniform sampler2D uVelocityTexture;


void main() {
    float center = length(gl_PointCoord - 0.5);

    vec3 velocity = texture2D( uVelocityTexture, vUv ).xyz * 100.0;

    float velocityAlpha = clamp(length(velocity.r), 0.04, 0.8);

    if (center > 0.5) { discard; }


    gl_FragColor = vec4(0.808, 0.647, 0.239, velocityAlpha);
}

And that’s how it looks so far. Lovely, right?

Post-processing

In the final step, we’ll set up post-processing to make our particles shine. The PostProcessing class does just that.

import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { MotionBloomPass } from './MotionBloomPass.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js';
import { Vector2 } from 'three';



export default class PostProcessing {
    constructor({ renderer, scene, camera, sizes, debug }) {
        this.renderer = renderer;
        this.scene = scene;
        this.camera = camera;
        this.sizes = sizes;
        this.debug = debug;

        this.params = {
            threshold: 0.2,
            strength: 0.8,
        }

        this.init();
    }


    static getInstance(args) {
        if (!PostProcessing.instance) {
            PostProcessing.instance = new PostProcessing(args);
        }

        return PostProcessing.instance;
    }


    // Init

    init() {
        this.setupEffect();
        this.setupDebug();
    }


    setupEffect() {
        const renderScene = new RenderPass(this.scene, this.camera.target);

        this.bloomPass = new MotionBloomPass(new Vector2(this.sizes.width, this.sizes.height), 1.5, 0.4, 0.85);
        this.bloomPass.threshold = this.params.threshold;
        this.bloomPass.strength = this.params.strength;
        this.bloomPass.radius = this.params.radius;

        const outputPass = new OutputPass();

        this.composer = new EffectComposer(this.renderer);
        this.composer.addPass(renderScene);
        this.composer.addPass(this.bloomPass); // <-- Our effect to make particles shine
        this.composer.addPass(outputPass);
    }  


    resize() {
        if (this.composer) {
            this.composer.setSize(this.sizes.width, this.sizes.height);
            this.composer.setPixelRatio(this.sizes.pixelRatio);
        }
    }  


    update() {
        if (this.composer) this.composer.render();
    }  
}

The Effect we are using here is modified the UnrealBloomPass from the Three.js library. You can find the code here.

For a post-processing implementation, check out:

And that’s it! Our final result is a dreamy, unreal effect:

And this is how it looks in motion:

Final Words

I hope you enjoyed this tutorial and learned something from it!

GPGPU is an advanced topic that could fill an entire article on its own. However, I hope this project will be a cool starting point for you to explore or experiment with this technique.

Dominik Fojcik

Crafting unique websites for founders and artists. Creative Technologist, UI & Motion Designer.

Stay in the loop: Get your dose of frontend twice a week

Fresh news, inspo, code demos, and UI animations—zero fluff, all quality. Make your Mondays and Thursdays creative!