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()beforegl.linkProgram(). The driver needs to know the capture
layout at link time.
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).