Olha Stefanishyna
← Back to home

Stateful Rendering with the Ping-Pong Technique

A cover image showing the initial state of the fade effect implemented in the practical example for this article.
A cover image showing the initial state of the fade effect implemented in the practical example for this article.

Table of Content

...

The previous article on rendering to textures covered the foundation of multi-pass rendering using Framebuffer Objects (FBOs). It demonstrated how to render to a texture and then use it for post-processing, but only for effects that transform each frame independently.

This limitation means we can't create iterative effects that rely upon previous frame data over time. Fluid simulations, for example, need this ability to reference past state.

In this article, we will explore the ping-pong rendering technique—a pattern that addresses this limitation and creates feedback loops in GPU programming. We will build upon the code from the previous article.

In the fluid simulations we're working toward, hundreds of iterations per second are executed, so every performance improvement matters. Let's first optimize the post-processing shader from the previous article.

The problem with branching in shaders

The post-processing shader from the previous article used an if statement to conditionally apply the effect:

glsl
1if (uEnableEffect && vUV.x > uSplitPosition) {
2 float grayscale = dot(sceneColor, vec3(0.299, 0.587, 0.114));
3 outColor = vec4(vec3(grayscale), 1.0);
4} else {
5 outColor = vec4(sceneColor, 1.0);
6}

Branches in shaders can hurt performance. GPUs execute shaders in groups called warps (NVIDIA) or wavefronts (AMD), typically containing 32 or 64 threads, where all threads must execute the same instruction. When threads within a warp take different branch paths, the GPU must serialize execution—running each path separately and some threads wait while others execute. It's called divergence.

Although this shader won't be used in the current article's example, let's examine how it would look without branching—because while if statements are common in most programming languages, shader programming often requires different approaches to maintain performance. And we'll use the techniques later.

This branching can be eliminated by using built-in GLSL functions.

The Solution: Branchless Approach

We can eliminate the if statement and maintain the same visual result:

glsl
1#version 300 es
2precision highp float;
3
4in vec2 vUV;
5out vec4 outColor;
6
7uniform sampler2D sceneTexture;
8uniform float splitPosition;
9uniform bool enableEffect;
10
11void main() {
12 // Flip Y coordinate for correct sampling
13 vec2 flippedUV = vec2(vUV.x, 1.0 - vUV.y);
14 vec3 sceneColor = texture(sceneTexture, flippedUV).rgb;
15
16 // Calculate grayscale using luminance formula
17 float grayscale = dot(sceneColor, vec3(0.299, 0.587, 0.114));
18 vec3 grayscaleColor = vec3(grayscale);
19
20 // Create a mask: 0.0 before split position, 1.0 after
21 float mask = step(splitPosition, vUV.x) * float(enableEffect);
22
23 // Blend between original and grayscale
24 vec3 finalColor = mix(sceneColor, grayscaleColor, mask);
25
26 outColor = vec4(finalColor, 1.0);
27}

The step(edge, x) function returns 0.0 when x < edge (original color) and 1.0 when x >= edge (grayscale color)

The mix() function interpolates between the two colors based on this factor.

The float() - Type conversion function that converts other data types to float.

The dot() - Calculates product of two vectors by multiplying corresponding components and summing them.

Coefficients vec3(0.299, 0.587, 0.114) are the Luminance Formula, it represents how human eyes perceive brightness. These are standard coefficients for sRGB (Rec. 709).

This eliminates branching and maintains the same visual result, ensuring all threads in a warp execute the same instructions for optimal GPU performance. This solution does more computation overall, but it avoids divergence and often runs faster on GPUs. In fluid simulations where shaders run hundreds of times per iteration, these improvements really matter.

Now let's turn to the main challenge: creating effects that evolve over time. This requires a special rendering pattern called a feedback loop.

Creating a Feedback Loop

A feedback loop in GPU programming is a rendering pattern where each frame's output depends on the previous frame's state. The computed result feeds back into the next iteration, creating a continuous evolution of data over time.

Many GPU effects rely on feedback loops:

  • Motion blur: Blend current frame with accumulated previous frames
  • Fluid simulation: Update velocity based on previous velocity and pressure
  • Heat diffusion: Spread temperature using previous temperature distribution
  • Particle trails: Accumulate particle positions that persist and fade

To create a feedback loop, we need to use the previous frame's texture as input while generating a new frame. But WebGL prohibits reading from a texture while rendering to it. Attempting to bind a texture as both input and render target results in undefined behavior. The OpenGL specification explicitly forbids this configuration, and implementations may produce corrupted output, driver crashes, or hardware exceptions.

Ping-Pong Technique

The ping-pong technique is the standard solution for implementing feedback loops on the GPU. Instead of trying to read and write to the same texture, it uses two textures that alternate between read and write roles each frame. This can be implemented in several ways, but the most common approach is to use two identical framebuffers (A and B) that swap roles: while one provides the previous state for reading, the other receives the new state. After each frame, they exchange roles—hence "ping-ponging" between them. This swap prevents read/write conflicts while maintaining the continuous state evolution needed for feedback loops.

Within this infrastructure, shaders act as the computational engine. Each frame, the shader reads from the current "read" texture, applies your simulation logic (fade, blur, physics, etc.), and writes the result to the "write" framebuffer. The ping-pong technique handles the state management, while your shader defines what actually happens to that state.

Diagram illustrating the ping-pong framebuffer technique across two frames
Diagram illustrating the ping-pong framebuffer technique across two frames

Implementing the Ping-Pong Technique

The implementation requires managing two FBOs and a mechanism to swap their roles. In WebGL terms, we need two framebuffers with identical configurations that can alternate between being the texture source and the render target.

Here's how to structure the ping-pong setup:

javascript
1function createPingPongFramebuffers(gl, width, height) {
2 const fboA = createFramebuffer(gl, width, height);
3 const fboB = createFramebuffer(gl, width, height);
4
5 return {
6 read: fboA,
7 write: fboB,
8 swap: function () {
9 [this.read, this.write] = [this.write, this.read];
10 },
11 };
12}
13
14const fboPair = createPingPongFramebuffers(gl, gl.canvas.width, gl.canvas.height);

This creates a pair of framebuffers with their attached textures (using the createFramebuffer function from the previous article). In the returned object read property points to the framebuffer containing the previous frame's texture, write property points to the framebuffer that will receive the current frame's output and swap method exchanges these references.

After rendering each frame, calling swap() prepares the system for the next iteration—what was just written becomes the source for the next read, and the old source becomes available for writing.

Example: An Iterative Fade Effect

Let's look at how to build a simple effect to demonstrate the technique. We will render an initial orange circle to one of the framebuffers. Then, in a loop, we will continuously read from the previous frame's texture and multiply it by 0.98, creating a gradual fade-to-black effect.

Shaders

We need two shaders: one for drawing the initial shape, and one for iterative processing.

glsl
1// 1. Initial Scene Shader (draws an orange circle)
2const initialSceneFragSrc = `#version 300 es
3 precision highp float;
4 in vec2 vUV;
5 out vec4 outColor;
6 void main() {
7 float dist = distance(vUV, vec2(0.5));
8 float circle = 1.0 - smoothstep(0.2, 0.21, dist);
9 outColor = vec4(circle * 1.0, circle * 0.5, circle * 0.1, 1.0);
10 }`;
11
12// 2. Feedback Shader (reads previous frame and fades it)
13const feedbackFragSrc = `#version 300 es
14 precision highp float;
15 in vec2 vUV;
16 out vec4 outColor;
17 uniform sampler2D uPreviousFrame; // Texture from the 'read' FBO
18 void main() {
19 vec4 previousColor = texture(uPreviousFrame, vUV);
20 // Slowly fade the color
21 outColor = previousColor * 0.98;
22 }`;

JavaScript Render Loop

The render loop orchestrates the process: binding the correct FBOs, drawing, swapping, and then finally rendering the result to the screen.

javascript
1// Setup (simplified)
2const fboPair = createPingPongFramebuffers(gl, width, height);
3
4// Draw initial scene once to fboPair.read
5// ...
6
7// Render loop - focusing on the ping-pong pattern
8function render() {
9 // --- Feedback Pass ---
10 gl.bindFramebuffer(gl.FRAMEBUFFER, fboPair.write.framebuffer);
11 gl.useProgram(feedbackProgram);
12
13 // Read from previous frame
14 gl.bindTexture(gl.TEXTURE_2D, fboPair.read.texture);
15 gl.uniform1i(previousFrameLocation, 0);
16
17 // Draw full-screen quad
18 gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
19
20 // --- Display Pass ---
21 gl.bindFramebuffer(gl.FRAMEBUFFER, null);
22 gl.bindTexture(gl.TEXTURE_2D, fboPair.write.texture);
23 gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
24
25 // Swap for next frame
26 fboPair.swap();
27
28 requestAnimationFrame(render);
29}

Running this code will show an orange circle (or ellipse on non-square canvases) that slowly fades to black. You can explore a full working example on GitHub.

Summary

We reviewed the ping-pong technique, a fundamental pattern in GPGPU programming that allows creating effects that rely on feedback loops.

In the next article, we will implement the advection step.


This is part of my series on implementing interactive 3D visualizations with WebGL 2.

Next article: Fluid Simulation in WebGL: The Advection Step

Let's talk