Building a Custom GPU-Accelerated Particle System with WebGL and GLSL Shaders
Canvas 2D is fine for a few hundred particles, but when you want thousands — even millions — of particles moving independently, you need the GPU. In this article, we’ll build a custom particle system using raw WebGL and GLSL shaders to handle the heavy lifting. Fully hardware-accelerated, buttery smooth. Why WebGL for Particles? WebGL allows: Rendering 10,000+ particles at 60fps Parallel computation via vertex shaders Full control over physics, color, size, and trails Step 1: Set Up a Basic WebGL Context Start by creating a WebGL rendering context: const canvas = document.getElementById('glcanvas'); const gl = canvas.getContext('webgl'); if (!gl) { alert("WebGL not supported"); } Step 2: Define the Vertex Shader for Particle Positions Each particle is just a single point processed by the GPU: const vertexShaderSource = attribute vec2 a_position; uniform float u_time; void main() { float moveX = sin(u_time + a_position.y) * 0.2; float moveY = cos(u_time + a_position.x) * 0.2; gl_Position = vec4(a_position + vec2(moveX, moveY), 0, 1); gl_PointSize = 2.0; } ; Step 3: Create the Fragment Shader for Coloring The fragment shader controls how each particle looks: const fragmentShaderSource = precision mediump float; void main() { gl_FragColor = vec4(1, 0.5, 0.0, 1); // Orange particles } ; Step 4: Initialize Buffers and Animate Load particle positions into a buffer and draw them each frame: function createShader(gl, type, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); return shader; } function createProgram(gl, vertexShader, fragmentShader) { const program = gl.createProgram(); gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); return program; } const vertices = new Float32Array(10000 * 2).map(() => Math.random() * 2 - 1); const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); const vShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource); const fShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource); const program = createProgram(gl, vShader, fShader); gl.useProgram(program); const positionLocation = gl.getAttribLocation(program, "a_position"); const timeLocation = gl.getUniformLocation(program, "u_time"); gl.enableVertexAttribArray(positionLocation); gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0); function render(time) { gl.clear(gl.COLOR_BUFFER_BIT); gl.uniform1f(timeLocation, time * 0.001); gl.drawArrays(gl.POINTS, 0, 5000); requestAnimationFrame(render); } requestAnimationFrame(render); Pros and Cons ✅ Pros Extreme performance for huge particle counts Highly customizable physics and visuals Direct control over GPU rendering ⚠️ Cons Steeper learning curve than Canvas 2D Debugging shaders can be tedious Cross-browser quirks, especially on mobile
Canvas 2D is fine for a few hundred particles, but when you want thousands — even millions — of particles moving independently, you need the GPU. In this article, we’ll build a custom particle system using raw WebGL and GLSL shaders to handle the heavy lifting. Fully hardware-accelerated, buttery smooth.
Why WebGL for Particles?
WebGL allows:
- Rendering 10,000+ particles at 60fps
- Parallel computation via vertex shaders
- Full control over physics, color, size, and trails
Step 1: Set Up a Basic WebGL Context
Start by creating a WebGL rendering context:
const canvas = document.getElementById('glcanvas');
const gl = canvas.getContext('webgl');
if (!gl) {
alert("WebGL not supported");
}
Step 2: Define the Vertex Shader for Particle Positions
Each particle is just a single point processed by the GPU:
const vertexShaderSource =
attribute vec2 a_position;
uniform float u_time;
void main() {
float moveX = sin(u_time + a_position.y) * 0.2;
float moveY = cos(u_time + a_position.x) * 0.2;
gl_Position = vec4(a_position + vec2(moveX, moveY), 0, 1);
gl_PointSize = 2.0;
}
;
Step 3: Create the Fragment Shader for Coloring
The fragment shader controls how each particle looks:
const fragmentShaderSource =
precision mediump float;
void main() {
gl_FragColor = vec4(1, 0.5, 0.0, 1); // Orange particles
}
;
Step 4: Initialize Buffers and Animate
Load particle positions into a buffer and draw them each frame:
function createShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
return shader;
}
function createProgram(gl, vertexShader, fragmentShader) {
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
return program;
}
const vertices = new Float32Array(10000 * 2).map(() => Math.random() * 2 - 1);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
const vShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
const fShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
const program = createProgram(gl, vShader, fShader);
gl.useProgram(program);
const positionLocation = gl.getAttribLocation(program, "a_position");
const timeLocation = gl.getUniformLocation(program, "u_time");
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
function render(time) {
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform1f(timeLocation, time * 0.001);
gl.drawArrays(gl.POINTS, 0, 5000);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
Pros and Cons
✅ Pros
- Extreme performance for huge particle counts
- Highly customizable physics and visuals
- Direct control over GPU rendering
⚠️ Cons
- Steeper learning curve than Canvas 2D
- Debugging shaders can be tedious
- Cross-browser quirks, especially on mobile