WebGL Series, Part 7: Adding a Bloom-Filter | Hendrik Erz

Abstract: In the second-to-last installment of my series on WebGL, I explain how a Bloom filter works and how I added it into the processing-pipeline of the iris indicator.


Last week was all about preparing the code to run post-processing, but aside from some simple tone-mapping, we haven’t done anything that actually requires this elaborate setup. Today’s article changes that. To read up on last week’s article, click here.

View the demo-page

Extracting Luminance

After adjusting the code last week, you should still see the rendered texture, but behind the scenes we write it to a “hidden” frame buffer, and only then copy this information to the canvas. In between these two steps, we can now add our post-processing. So let’s write a simple bloom filter. Remember that bloom consists of extracting a brightness map, blurring it, and combining the result with the original image. Let’s first write a way for the fragment shader to extract brightness information:

if (v_pass == FRAGMENT_PASS_BRIGHTNESS) {
  fragColor = texture(u_texture, v_texcoord);
  float l = luminance(fragColor.rgb);
  fragColor = l > 1.0 ? fragColor : vec4(0.0, 0.0, 0.0, 0.0);
}

The luminance function is simple:

float luminance (vec3 c) {
  return dot(c, vec3(0.2126, 0.7152, 0.0722));
}

This just computes a luminance value from the red, green, and blue values of a color. These magic numbers are floating around the internet in many places. They sometimes differ a bit, but you can find variations of these numbers all across the internet. I unfortunately forgot where I got these particular ones from.

But this is now finally the explanation why we use HDR colors and textures that exceed a brightness of 1.0. If we used regular colors, the luminance would be harder to calculate, and it would be more difficult to extract brightness information. By using bright colors, we can just check if the luminance is $>1.0$, and not worry if we may accidentally extract colors that aren’t supposed to shine.

Applying Gaussian Blur

Once the brightness information is there, we also need a way to apply blur to this. I have essentially just copied the function from LearnOpenGL, because it worked very nice. Read their explainer for how the blur filter works. What I did change is the blur weights. I added some more and modified some numbers because I felt it looked nicer. Also, this blur function works with alpha values (stay tuned for that!).

uniform bool u_blur_horizontal;
float blur_weight[7] = float[7] (0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216, 0.007, 0.002);
const int repeats = 7;
vec4 blur () {
  vec2 texel = vec2(1.0, 1.0) / vec2(textureSize(u_texture, 0));
  vec4 result = texture(u_texture, v_texcoord) * blur_weight[0];
  if (u_blur_horizontal) {
    for (int i = 1; i < repeats; i++) {
      result += texture(u_texture, v_texcoord + texel * vec2(i, 0.0)) * blur_weight[i];
      result += texture(u_texture, v_texcoord - texel * vec2(i, 0.0)) * blur_weight[i];
    }
  } else {
    for (int i = 1; i < repeats; i++) {
      result += texture(u_texture, v_texcoord + texel * vec2(0.0, i)) * blur_weight[i];
      result += texture(u_texture, v_texcoord - texel * vec2(0.0, i)) * blur_weight[i];
    }
  }

  return result;
}

The u_blur_horizontal is another uniform we need (see the explainer I linked above), but I’m not going to repeat the same information for how to set this here.

Allow the fragment shader to perform the blur by adding another condition:

if (v_pass == FRAGMENT_PASS_BLUR) {
  fragColor = blur();
}

And finally, for compositing the blurred image with the original image, another conditional:

if (v_pass == FRAGMENT_PASS_COMPOSITE) {
  vec4 originalColor = texture(u_texture, v_texcoord);
  vec4 blurColor = texture(u_blurTexture, v_texcoord);
  fragColor = originalColor + blurColor;
}

Adjusting the Rendering Code

With the shaders at hand, we can add a bloom pass function. Here, we pass the rendered rays as well as some number of how many bloom passes we want (this is the bloom intensity setting). The bloom function is relatively complex, and it makes use of the “ping-pong” buffer. We create the ping-pong buffers like the scenetarget – see the code to verify that it’s essentially the same setup code.

private bloomPass (sourceTexture: WebGLTexture, nPasses = 32): WebGLTexture {
    const gl = this.gl
    const { cWidth, cHeight } = this.textureSize()

    this.setFramebufferRectangle(cWidth, cHeight)

    this.setFramebuffer(this.pingpong[1].fb, cWidth, cHeight)
    gl.bindTexture(gl.TEXTURE_2D, sourceTexture)
    gl.uniform1f(this.passUniformLocation, FRAGMENT_PASS_BRIGHTNESS)
    gl.drawArrays(gl.TRIANGLES, 0, 6)
    gl.bindTexture(gl.TEXTURE_2D, this.pingpong[1].rbuf)

    gl.uniform1f(this.passUniformLocation, FRAGMENT_PASS_BLUR)

    for (let pass = 0; pass < nPasses * 2; pass++) {
      gl.uniform1i(this.blurHorizontalUniformLocation, pass % 2)
      this.setFramebuffer(this.pingpong[pass % 2]!.fb, cWidth, cHeight)
      gl.drawArrays(gl.TRIANGLES, 0, 6)
      gl.bindTexture(gl.TEXTURE_2D, this.pingpong[pass % 2]!.rbuf)
    }

    this.setFramebuffer(this.pingpong[1]!.fb, cWidth, cHeight)

    gl.uniform1f(this.passUniformLocation, FRAGMENT_PASS_COMPOSITE)

    gl.bindTexture(gl.TEXTURE_2D, sourceTexture)
    gl.activeTexture(gl.TEXTURE0 + 1)
    gl.bindTexture(gl.TEXTURE_2D, this.pingpong[lastActiveTexture]!.rbuf)

    gl.drawArrays(gl.TRIANGLES, 0, 6)

    gl.bindTexture(gl.TEXTURE_2D, null)
    gl.activeTexture(gl.TEXTURE0)
    gl.bindTexture(gl.TEXTURE_2D, null)
    gl.bindFramebuffer(gl.FRAMEBUFFER, null)

    return this.pingpong[1]!.rbuf
}

Let’s unpack this function. First, I ensure that there is our full-screen rectangle in the buffer. Then, I tell the shaders to run a brightness pass. I bind, as a source, the original rendered image (scenetarget.scene), write that into the second ping-pong frame buffer, and let the shaders do their thing by drawing the two triangles.

Then, I immediately bind the ping-pong buffer’s texture, which now contains the brightness information. I then switch the shaders to perform blur-passes instead, and enter a loop that progressively applies more and more and more blur to the image. This is why I wrote the brightness information into the second ping-pong: Because the loop can then just start at 0, write the first blur result in the first ping-pong buffer and then simply re-use the second one to do the second blur pass.

We always do 2 passes, one of which applies horizontal blur, and one of which applies vertical blur. Whenever I call drawArrays, this will take the blurred image and apply even more blur to it.

Finally, a composite pass. The final image is now in the second ping-pong buffer, so I bind its texture. Now we actually need two textures, so we have to switch the texture slot we are working with to the second one. We bind the source image to the first texture slot and the blurred image to the second texture slot. Then, we tell our fragment shader to run a composite pass (which only adds the colors from the two images), and make sure to reset the state accordingly. Finally, I return the texture. This allows me to conditionally enable or disable the blooming:

let outputTexture = this.bloomEnabled
  ? this.bloomPass(this.scenetarget.scene, this.nBloomPasses)
  : this.scenetarget.scene

If bloom is disabled, it will simply use the unmodified scene as a source to draw onto the canvas. But if you enable bloom, and pass that output texture to your final draw-to-canvas pass, it should show you a bloom effect. Hooray!

Moving the Tone-Mapping into its own Shader Pass

One additional thing we now want to do is move the tone-mapping around. Until now, we just applied this to the color, but the issue is that we want to retain the HDR colors for as long as possible so that the bloom really pops.

So we should move the tone mapping into its own fragment shader pass and make sure to only adjust the colors just short before drawing to the canvas. The fragment shader change is minimal and at this point self-explanatory:

if (v_pass == FRAGMENT_PASS_TONEMAP) {
  vec4 result_color = texture(u_texture, v_texcoord);
  fragColor = vec4(tonemap(result_color.rgb), result_color.a);
}

In our WebGL engine, we then only have to run the outputTexture from the bloom pass through the tonemapping once:

gl.bindTexture(gl.TEXTURE_2D, outputTexture)
this.setFramebuffer(this.pingpong[1].fb, cWidth, cHeight)
gl.uniform1f(this.passUniformLocation, FRAGMENT_PASS_TONEMAP)
gl.drawArrays(gl.TRIANGLES, 0, 6)
outputTexture = this.pingpong[1].rbuf
gl.bindTexture(gl.TEXTURE_2D, null)

As you can see, I bind the output texture, but instead of writing to the canvas, I use one of the two ping-pong frame buffers as a target. Then, I tell the fragment shader that it should perform tone-mapping, and commence the actual shader run by drawing the two triangles once more. Then, I overwrite the outputTexture to use the now-tone-mapped one from the ping-pong.

If you run this version of the code, the colors will pop and the bloom should look adequate. This means we’re almost done!

Final Thoughts

The animation should now look almost like the demo page.

But there’s just one issue: Depending on your display, you may have noticed that the edges of the rays somehow look very ragged, especially if you disable the bloom effect. Why is that, and how can we fix this?

This is something you might’ve noticed in the last article as well, and which I didn’t address in this one. So stay tuned for the final article in this series that completes the animation by implementing antialiasing!

Suggested Citation

Erz, Hendrik (2026). “WebGL Series, Part 7: Adding a Bloom-Filter”. hendrik-erz.de, 20 Feb 2026, https://www.hendrik-erz.de/post/webgl-series-part-7-adding-a-bloom-filter.

Send a Tip on Ko-Fi

Did you enjoy this article? Send a tip on Ko-Fi

← Return to the post list