Transform Feedback

Updating Particles with Transform Feedback

A formula cannot know where a particle bounced. Transform feedback lets the vertex shader write its outputs into a buffer instead: the state lives in two buffers, read from one and written to the other each step, while RASTERIZER_DISCARD skips the fragment stage. The outputs are named with transformFeedbackVaryings() before linking:

Simulating 1,000 bouncing particles with transform feedbackJavaScript
const program = gl.createProgram();
gl.attachShader(program, GLH.shader(gl, gl.VERTEX_SHADER, `#version 300 es
  layout(location = 0) in vec3 aPosition;
  layout(location = 1) in vec3 aVelocity;
  out vec3 vPosition, vVelocity;                       // captured, not rasterized
  void main() {
    vVelocity = aVelocity + vec3(0.0, -1.5, 0.0) / 60.0;          // gravity over 1/60 s
    vPosition = aPosition + vVelocity / 60.0;                      // semi-implicit Euler
    if (vPosition.y < 0.0) { vPosition.y *= -1.0; vVelocity.y *= -0.5; }   // bounce
  }`));
gl.attachShader(program, GLH.shader(gl, gl.FRAGMENT_SHADER,
  '#version 300 es\nvoid main() {}'));                    // required, but writes nothing
gl.transformFeedbackVaryings(program, ['vPosition', 'vVelocity'], gl.INTERLEAVED_ATTRIBS);
gl.linkProgram(program); gl.useProgram(program);           // varyings are set before linking
const state = new Float32Array(6000);                       // 1,000 x (x y z vx vy vz)
for (let i = 0; i < 1000; i++) state.set([0, 0.5, 0, Math.cos(i), 1, Math.sin(i)], i * 6);
const buffers = [GLH.buffer(gl, state), GLH.buffer(gl, state)];
const vaos = buffers.map((buffer) => {
  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao); gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); gl.enableVertexAttribArray(1);
  return vao;
});
gl.bindVertexArray(null); gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.enable(gl.RASTERIZER_DISCARD);                           // vertex work only
for (let step = 1; step <= 120; step++) {                   // two seconds
  const [from, to] = [vaos[(step + 1) % 2], buffers[step % 2]];   // read one, write the other
  gl.bindVertexArray(from); gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, to);
  gl.beginTransformFeedback(gl.POINTS); gl.drawArrays(gl.POINTS, 0, 1000);
  gl.endTransformFeedback(); gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, null);
  if (step % 40) continue;
  const p = new Float32Array(6); gl.bindBuffer(gl.ARRAY_BUFFER, to);
  gl.getBufferSubData(gl.ARRAY_BUFFER, 0, p);
  gl.bindBuffer(gl.ARRAY_BUFFER, null);              // still bound, it would block feedback
  console.log(`step ${step}:`, Array.from(p, (v) => v.toFixed(3)).join(' '));
}
Output
step 40: 0.667 0.825 0.000 1.000 0.000 0.000
step 80: 1.333 0.483 0.000 1.000 -1.000 0.000
step 120: 2.000 0.174 0.000 1.000 0.362 0.000

Particle 0 peaks at 0.825, bounces at half speed and is rising again at step 120. Without the unbind, every later step failed: "A transform feedback buffer that would be written to is also bound to a non-transform-feedback target". Read-backs stall the GPU; a real page draws the buffers as instances.

1,000 bouncing particles simulated on the GPU with transform feedback, ping-ponging two buffers, then drawn from themHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  canvas { display: block; width: 100%; max-width: 600px; }
  p { max-width: 600px; margin: 4px 0; font-family: monospace; }
</style>
<canvas id="c" width="600" height="300"></canvas>
<p id="out"></p>
<script>
const gl = document.getElementById('c').getContext('webgl2');
function shader(type, src) {
  const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
  return s;
}
// The update program: its outputs are captured into a buffer, not rasterized.
const update = gl.createProgram();
gl.attachShader(update, shader(gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aVelocity;
out vec3 vPosition, vVelocity;
void main() {
  vVelocity = aVelocity + vec3(0.0, -1.5, 0.0) / 60.0;          // gravity over 1/60 s
  vPosition = aPosition + vVelocity / 60.0;                      // semi-implicit Euler
  if (vPosition.y < 0.0) { vPosition.y *= -1.0; vVelocity.y *= -0.7; }   // bounce off the floor
  if (abs(vPosition.x) > 1.4) vVelocity.x *= -1.0;               // and off the side walls
}`));
gl.attachShader(update, shader(gl.FRAGMENT_SHADER, '#version 300 es\nvoid main() {}'));   // required, writes nothing
gl.transformFeedbackVaryings(update, ['vPosition', 'vVelocity'], gl.INTERLEAVED_ATTRIBS); // before linking
gl.linkProgram(update);
// The draw program reads the same buffers as points.
const draw = gl.createProgram();
gl.attachShader(draw, shader(gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aVelocity; out vec3 vColor;
void main() {
  vColor = mix(vec3(0.12, 0.37, 0.55), vec3(0.88, 0.6, 0.06), clamp(length(aVelocity) / 2.0, 0.0, 1.0));
  gl_Position = vec4(aPosition.x * 0.65, aPosition.y * 1.1 - 0.9, 0.0, 1.0); gl_PointSize = 4.0;
}`));
gl.attachShader(draw, shader(gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; in vec3 vColor; out vec4 c;
void main() { if (length(gl_PointCoord - 0.5) > 0.5) discard; c = vec4(vColor, 1.0); }`));
gl.linkProgram(draw);

const COUNT = 1000;
const state = new Float32Array(COUNT * 6);                       // x y z vx vy vz
let seed = 11;
const random = () => (seed = seed * 16807 % 2147483647) / 2147483647;
for (let i = 0; i < COUNT; i++)                                  // a fountain: random speeds from one spout
  state.set([0, 0.2, 0, (random() - 0.5) * 1.6, 0.8 + random() * 1.4, (random() - 0.5)], i * 6);
const buffers = [0, 1].map(() => {
  const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b);
  gl.bufferData(gl.ARRAY_BUFFER, state, gl.DYNAMIC_COPY); return b;
});
const vaos = buffers.map((buffer) => {
  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao); gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); gl.enableVertexAttribArray(1);
  return vao;
});
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);                           // still bound, it would block feedback

let step = 0;
function frame() {
  // Simulate: read buffer A, write buffer B, fragment stage skipped.
  const from = step % 2, to = 1 - from;
  gl.useProgram(update);
  gl.enable(gl.RASTERIZER_DISCARD);
  gl.bindVertexArray(vaos[from]);
  gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, buffers[to]);
  gl.beginTransformFeedback(gl.POINTS);
  gl.drawArrays(gl.POINTS, 0, COUNT);
  gl.endTransformFeedback();
  gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, null);
  gl.disable(gl.RASTERIZER_DISCARD);
  // Draw the new state straight from buffer B.
  gl.clearColor(0.97, 0.96, 0.93, 1); gl.clear(gl.COLOR_BUFFER_BIT);
  gl.useProgram(draw);
  gl.bindVertexArray(vaos[to]);
  gl.drawArrays(gl.POINTS, 0, COUNT);
  gl.bindVertexArray(null);
  step++;
  document.getElementById('out').textContent = `step ${step}: ${COUNT} particles updated by transform feedback, no CPU copy`;
  if (step % 600 === 0) {                                        // start over every 10 seconds
    buffers.forEach((b) => { gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferSubData(gl.ARRAY_BUFFER, 0, state); });
    gl.bindBuffer(gl.ARRAY_BUFFER, null);
  }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>