Tutorial · Advanced · ~55 min
WebGL 2 · Transform Feedback · GLSL · GPU Particles

WebGL Transform Feedback — GPU Particle Systems

Transform Feedback captures vertex shader outputs back into GPU buffers with zero CPU involvement. This enables particle simulations of millions of objects where physics, spawn logic, and respawn are all handled in GLSL — the CPU only issues drawArrays calls.

1Get a WebGL 2 context

Transform Feedback is a WebGL 2 feature. Get the context explicitly and confirm availability:

const canvas = document.querySelector('canvas'); const gl = canvas.getContext('webgl2'); if (!gl) throw new Error('WebGL 2 not supported — upgrade your browser.'); // Confirm transform feedback by checking the object constructor const tf = gl.createTransformFeedback(); gl.deleteTransformFeedback(tf); console.log('Transform Feedback available');
All modern desktop browsers support WebGL 2. On mobile, Safari added WebGL 2 support in iOS 15. Check caniuse.com/webgl2 for the latest coverage matrix.

2Create ping-pong VAOs and buffers

Two buffer pairs hold particle state. Each frame, one is read and the other is written. Then they swap.

const N = 500_000; // Layout: [x, y, z, vx, vy, vz, age, lifespan] 8 floats per particle const STRIDE = 8; function makeParticleBuffer() { const data = new Float32Array(N * STRIDE); for (let i = 0; i < N; i++) { const b = i * STRIDE; data[b] = (Math.random() - 0.5) * 4; // x data[b+1] = (Math.random() - 0.5) * 4; // y data[b+2] = (Math.random() - 0.5) * 4; // z data[b+3] = (Math.random() - 0.5) * 0.02; // vx data[b+4] = Math.random() * 0.02; // vy (upward drift) data[b+5] = (Math.random() - 0.5) * 0.02; // vz data[b+6] = Math.random() * 500; // age (staggered) data[b+7] = 300 + Math.random() * 200; // lifespan } const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_COPY); return buf; } const bufA = makeParticleBuffer(); const bufB = makeParticleBuffer(); let [readBuf, writeBuf] = [bufA, bufB]; // One transform feedback object per write buffer const tfA = gl.createTransformFeedback(); const tfB = gl.createTransformFeedback(); function bindTF(tf, buf) { gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, tf); gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, buf); gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null); } bindTF(tfA, bufA); bindTF(tfB, bufB); let [readTF, writeTF] = [tfA, tfB];

3Update shader with transform feedback varyings

const updateVS = /* glsl */ `#version 300 es in vec3 a_pos; in vec3 a_vel; in float a_age; in float a_lifespan; out vec3 v_pos; out vec3 v_vel; out float v_age; out float v_lifespan; // LCG pseudo-random seeded by particle data float rand(float seed) { return fract(sin(seed * 127.1 + 311.7) * 43758.5453); } void main() { float t = a_age / a_lifespan; if (a_age >= a_lifespan) { // Respawn at origin with random velocity float s = a_age + a_pos.x * 13.0; v_pos = vec3(0.0); v_vel = vec3(rand(s)*0.04-0.02, rand(s+1.0)*0.04+0.01, rand(s+2.0)*0.04-0.02); v_age = 0.0; v_lifespan = 300.0 + rand(s+3.0) * 200.0; } else { // Gravity + age v_vel = a_vel + vec3(0.0, -0.00003, 0.0); v_pos = a_pos + v_vel; v_age = a_age + 1.0; v_lifespan = a_lifespan; } } `; const updateFS = `#version 300 es precision highp float; void main() {}`; // fragment shader is never run (rasterize disabled) // Compile and link — critical: declare transform feedback varyings BEFORE linking const updateProg = gl.createProgram(); gl.attachShader(updateProg, compileShader(gl, gl.VERTEX_SHADER, updateVS)); gl.attachShader(updateProg, compileShader(gl, gl.FRAGMENT_SHADER, updateFS)); gl.transformFeedbackVaryings( updateProg, ['v_pos', 'v_vel', 'v_age', 'v_lifespan'], gl.INTERLEAVED_ATTRIBS // single interleaved buffer, not separate ); gl.linkProgram(updateProg);
Order matters: call gl.transformFeedbackVaryings() before gl.linkProgram(). The driver needs to know the capture layout at link time.

4Run the update pass (rasterization disabled)

function updatePass() { gl.useProgram(updateProg); bindAttribs(readBuf, updateProg); // a_pos, a_vel, a_age, a_lifespan // Disable rasterization — we only want the transformed data gl.enable(gl.RASTERIZER_DISCARD); gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, writeTF); gl.beginTransformFeedback(gl.POINTS); gl.drawArrays(gl.POINTS, 0, N); gl.endTransformFeedback(); gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null); gl.disable(gl.RASTERIZER_DISCARD); // Swap read ↔ write [readBuf, writeBuf] = [writeBuf, readBuf]; [readTF, writeTF] = [writeTF, readTF]; } function bindAttribs(buf, prog) { gl.bindBuffer(gl.ARRAY_BUFFER, buf); const bytes = Float32Array.BYTES_PER_ELEMENT; const stride = 8 * bytes; function attr(name, size, offset) { const loc = gl.getAttribLocation(prog, name); if (loc === -1) return; gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, size, gl.FLOAT, false, stride, offset * bytes); } attr('a_pos', 3, 0); attr('a_vel', 3, 3); attr('a_age', 1, 6); attr('a_lifespan', 1, 7); }

5Render pass: draw the updated particles

const renderVS = /* glsl */ `#version 300 es in vec3 a_pos; in float a_age; in float a_lifespan; uniform mat4 u_vp; uniform float u_pixelRatio; out float v_t; void main() { v_t = a_age / a_lifespan; gl_Position = u_vp * vec4(a_pos, 1.0); gl_PointSize = (1.0 - v_t) * 4.0 * u_pixelRatio; } `; const renderFS = /* glsl */ `#version 300 es precision highp float; in float v_t; out vec4 fragColor; void main() { // Round point sprite vec2 uv = gl_PointCoord * 2.0 - 1.0; if (dot(uv, uv) > 1.0) discard; // Fire colour: white → orange → dark-red vec3 col = mix(vec3(1.0, 0.9, 0.5), vec3(0.8, 0.1, 0.0), v_t); float alpha = (1.0 - v_t) * 0.6; fragColor = vec4(col * alpha, alpha); } `; // In render loop: function renderPass(vp) { gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE); // additive blend for fire gl.useProgram(renderProg); gl.uniformMatrix4fv(vpLoc, false, vp); bindAttribs(readBuf, renderProg); gl.drawArrays(gl.POINTS, 0, N); gl.disable(gl.BLEND); }

6Spawn, death, and respawn in the shader

The respawn logic in the vertex shader (Step 3) handles the entire particle lifecycle on the GPU. No CPU reads are needed. To support emitter position or burst spawning, pass a uniform:

// JS — update emitter position uniform each frame const emitterLoc = gl.getUniformLocation(updateProg, 'u_emitter'); function updatePass() { gl.useProgram(updateProg); gl.uniform3f(emitterLoc, emitter.x, emitter.y, emitter.z); // ... rest of pass } // GLSL — use it in the respawn branch /* glsl */` uniform vec3 u_emitter; // in the respawn branch: v_pos = u_emitter + vec3(rand(s)*0.1-0.05, 0.0, rand(s+1.0)*0.1-0.05); ` // Main loop function loop() { updatePass(); // GPU physics, zero CPU renderPass(vpMatrix); // GPU draw requestAnimationFrame(loop); } requestAnimationFrame(loop);
Transform Feedback is the most efficient route for GPU-side particle updates in WebGL 2. For WebGPU use compute shaders instead — they offer more flexibility (random access, atomics, barrier synchronization).

Frequently Asked Questions

What will I learn in this tutorial?

Use WebGL Transform Feedback to update particle positions entirely on the GPU, eliminating CPU readback and enabling millions of particles at real-time framerates.

What topics are covered in this tutorial?

This tutorial covers: Get a WebGL 2 context, Create ping-pong VAOs and buffers, Update shader with transform feedback varyings, Run the update pass (rasterization disabled), Render pass: draw the updated particles, Spawn, death, and respawn in the shader.

What tools and technologies does this tutorial use?

This tutorial uses WebGL 2, Transform Feedback, GLSL, GPU Particles.

How long does this tutorial take?

This tutorial takes approximately 55 minutes to complete.

What prerequisites do I need before starting?

This is a Advanced-level tutorial — no special preparation beyond basic JavaScript is assumed.