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:
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.
<!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>