From our sponsor: Meco is a distraction-free space for reading and discovering newsletters, separate from the inbox.
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.