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

Apr 26, 2025 - 09:52
 0
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