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).