I wanted to experiment with creating my own visualisation using particle effects for this site. It uses ThreeJS with custom vertex and fragment shaders.
The process involves loading a model, extracting its vertex positions, and then using those positions to drive a THREE.Points
object. The real transformation happens within custom GLSL shaders assigned via a THREE.ShaderMaterial
.
This post covers the core logic within the vertex and fragment shaders, explaining how they work together to position, size, and colour each particle, turning static geometry into a dynamic visualization. We’ll assume necessary uniforms
(like u_time
, u_size
) and attributes
(like the per-particle a_pulseOffset
) are correctly passed from the main JavaScript code to the shaders.
The Shaders: Step-by-Step Breakdown
Shaders run on the GPU, processing each vertex (vertex shader) and then each pixel (fragment shader).
Vertex Shader: Preparing Each Particle
The vertex shader determines where each particle appears on screen and how large it should be. It also calculates values to pass down to the fragment shader for colouring.
1. Calculating a Per-Particle Pulse
To make the particles feel dynamic and individual, we calculate a “pulse” value for each one. This uses the global u_time
uniform (which increases frame by frame) and a random a_pulseOffset
attribute assigned to each particle.
// --- Vertex Shader Snippet: Pulse Calculation ---
uniform float u_time;
attribute float a_pulseOffset; // Unique random value per particle
varying float v_pulse; // 'varying' passes this value to the fragment shader
// ... inside main() ...
v_pulse = 0.5 + 0.5 * sin(u_time + a_pulseOffset);
// Result: v_pulse smoothly oscillates between 0.0 and 1.0 over time,
// at a slightly different phase for each particle due to a_pulseOffset.
This v_pulse
value will be used later to affect both the size and brightness/opacity of the particle.
2. Calculating Distance and Adding Dynamic Shift
To provide a bit of variation in the visuals we want the particle’s colour to change based on its original position relative to the center of the model. We calculate this distance and then add a time-based variation to make the color transitions feel more alive.
// --- Vertex Shader Snippet: Distance Calculation ---
varying float v_distance; // Passed to fragment shader for colouring
// 'position' is the built-in attribute holding the particle's base position
// ... inside main() ...
// Calculate distance from the object's origin (0,0,0)
float distanceFromCenter = length(position);
// Clamp to a 0.0-1.0 range (assumes model is somewhat centered/normalized)
v_distance = clamp(distanceFromCenter, 0.0, 1.0);
// Introduce a slow, secondary wave affecting the distance value
float timeFactor = 0.5 + 0.5 * sin(v_pulse * 0.5 + u_time * 0.5);
// Mix the original distance with its inverse based on the time factor
v_distance = mix(v_distance, 1.0 - v_distance, timeFactor);
// Result: v_distance still primarily reflects distance, but subtly shifts
// over time, causing the colour gradient (set later) to "breathe".
The dynamic v_distance
is passed to the fragment shader to drive colour mixing.
3. Setting Particle Size with Perspective
The vertex shader outputs gl_PointSize
. To create this we combine our base size uniform (u_size
), the per-particle pulse (v_pulse
), and some perspective scaling.
// --- Vertex Shader Snippet: Particle Size ---
uniform float u_size; // Base size from JavaScript
// v_pulse calculated earlier
// modelViewMatrix is a built-in uniform from Three.js
// ... inside main() ...
// Calculate the particle's position in camera space
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
// Set size: Base size * pulse factor / depth
// Dividing by negative depth (-mvPosition.z) makes closer particles larger.
gl_PointSize = u_size * v_pulse / (-mvPosition.z);
4. Calculating Final Screen Position
Finally, the vertex shader must calculate the particle’s final 2D position on the screen using standard matrix transformations provided by Three.js.
// --- Vertex Shader Snippet: Final Position ---
// projectionMatrix is a built-in uniform from Three.js
// Apply projection matrix to get clip space coordinates
gl_Position = projectionMatrix * mvPosition;
Fragment Shader: Colouring Each Particle Pixel
The fragment shader determines the final colour (gl_FragColor
) for each pixel covered by a particle sprite. It receives the varying
values (like v_pulse
and v_distance
) interpolated from the vertex shader’s outputs.
1. Defining Colours and Interpolating
We define two colours and mix between them based on the v_distance
value received from the vertex shader. An easing function is used for a smoother visual transition.
// --- Fragment Shader Snippet: Colour Interpolation ---
precision mediump float; // Standard precision setting
varying float v_pulse; // Received from vertex shader
varying float v_distance; // Received from vertex shader
// Simple easing function
float easeInOut(float t) {
t = clamp(t, 0.0, 1.0);
return t < 0.5 ? 2.0 * t * t : 1.0 - pow(-2.0 * t + 2.0, 2.0) / 2.0;
}
// ... inside main() ...
vec3 colorCenter = vec3(0.5, 0.7, 1.0); // Light Blue
vec3 colorEdge = vec3(0.9, 0.5, 0.8); // Light Pink
// Apply easing to the distance value
float eased_distance = easeInOut(v_distance);
// Mix between center and edge colours based on the eased distance
vec3 finalColor = mix(colorCenter, colorEdge, eased_distance);
// Result: Particles closer to the original model center are bluer,
// those further out are pinker, with a smooth, eased gradient.
// The gradient itself shifts slowly due to the vertex shader's time logic.
2. Shaping the Particle (Alpha)
By default, points are rendered as squares. We can use the built-in gl_PointCoord
(which provides UV-like coordinates within the point sprite, from 0.0 to 1.0) to calculate an alpha value that creates a soft, circular shape.
// --- Fragment Shader Snippet: Particle Shape (Alpha) ---
// ... inside main() ...
// Calculate distance from the center (0.5, 0.5) of the point sprite coordinate system
float dist = length(gl_PointCoord - vec2(0.5));
// Use smoothstep to create a soft edge:
// Fully opaque (alpha=1) inside radius 0.3
// Fully transparent (alpha=0) outside radius 0.48
// Smooth transition between 0.3 and 0.48
float alpha = smoothstep(0.3, 0.48, 0.5 - dist);
// Result: alpha defines a soft circular mask for the particle.
3. Final Output Colour and Pulse Application
We combine the calculated colour and alpha, modulating both by the v_pulse
value to make the particles pulse in brightness and opacity.
// --- Fragment Shader Snippet: Final Output ---
// Modulate base colour by pulse for brightness pulsing.
// Modulate shape alpha by pulse for opacity pulsing.
gl_FragColor = vec4(finalColor * v_pulse, alpha * v_pulse);
// Result: The final pixel colour reflects the distance-based gradient,
// is shaped into a soft circle, and pulses in intensity and visibility.
Conclusion
The combination of the vertex shader to handle positioning and the fragment shader to determine the final colour and shape of each pixel takes a bit of getting your head around but is extremely powerful once you have the right working model. The strength of modern GPUs lies in their ability to perform an incredible number of parallel computations and so while writing shaders is very different from typical software programming, it’s very rewarding to see how a small amount of code can create interesting visualisations.