Progressive Blur Effect using WebGL with OGL and GLSL Shaders

Learn how to create an interesting progressive blur effect using WebGL, OGL, and GLSL shaders.

Hey everyone, I’m Jorge Toloza, Freelance Creative Developer based in Colombia, It’s a pleasure for me to have the chance to inspire, teach and learn in collaboration with Codrops.

In this tutorial, we’ll create a progressive gradient blur effect that dynamically changes based on the position of images. We’ll use CSS to position the elements and then obtain the coordinates for the objects in WebGL.

Most of the logic is on the shader, so you can translate this effect to any other WebGL library like Three.js, or even achieve it in vanilla WebGL.

HTML structure

First we need to define our image container, It’s pretty simple; we have a <figure> and an <img> tag.

<figure class="media">
	<img src="/img/8.webp" alt="tote bag">
</figure>

Styles

The styles are really simple too, we are hiding our images because they will be rendered on the canvas.

.media {
  img {
    width: 100%;
    visibility: hidden;
  }
}

GL Class

Now in the JS, we have a couple of important classes. The first one is GL.js, which contains most of the logic to render our images using OGL.

import { Renderer, Camera, Transform, Plane } from 'ogl'
import Media from './Media.js';

export default class GL {
  constructor () {
    this.images = [...document.querySelectorAll('.media')]
    
    this.createRenderer()
    this.createCamera()
    this.createScene()

    this.onResize()

    this.createGeometry()
    this.createMedias()

    this.update()

    this.addEventListeners()
  }
  createRenderer () {
    this.renderer = new Renderer({
      canvas: document.querySelector('#gl'),
      alpha: true
    })

    this.gl = this.renderer.gl
  }
  createCamera () {
    this.camera = new Camera(this.gl)
    this.camera.fov = 45
    this.camera.position.z = 20
  }
  createScene () {
    this.scene = new Transform()
  }
  createGeometry () {
    this.planeGeometry = new Plane(this.gl, {
      heightSegments: 50,
      widthSegments: 100
    })
  }
  createMedias () {
    this.medias = this.images.map(item => {
      return new Media({
        gl: this.gl,
        geometry: this.planeGeometry,
        scene: this.scene,
        renderer: this.renderer,
        screen: this.screen,
        viewport: this.viewport,
        $el: item,
        img: item.querySelector('img')
      })
    })
  }
  onResize () {
    this.screen = {
      width: window.innerWidth,
      height: window.innerHeight
    }

    this.renderer.setSize(this.screen.width, this.screen.height)

    this.camera.perspective({
      aspect: this.gl.canvas.width / this.gl.canvas.height
    })

    const fov = this.camera.fov * (Math.PI / 180)
    const height = 2 * Math.tan(fov / 2) * this.camera.position.z
    const width = height * this.camera.aspect

    this.viewport = {
      height,
      width
    }
    if (this.medias) {
      this.medias.forEach(media => media.onResize({
        screen: this.screen,
        viewport: this.viewport
      }))
      this.onScroll({scroll: window.scrollY})
    }
  }
  onScroll({scroll}) {
    if (this.medias) {
      this.medias.forEach(media => media.onScroll(scroll))
    }
  }
  update() {
    if (this.medias) {
      this.medias.forEach(media => media.update())
    }

    this.renderer.render({
      scene: this.scene,
      camera: this.camera
    })
    window.requestAnimationFrame(this.update.bind(this))
  }
  addEventListeners () {
    window.addEventListener('resize', this.onResize.bind(this))
  }
}

For humans

We import some important objects, get all the images from the DOM, then create the Media objects for each image. Finally, we update the medias and the renderer in the update method using requestAnimationFrame.

Media class

This is the cool one. First, we set all the options, then we set the shader and wait for the texture to set the uImageSize uniform. The mesh will be a Plane. In the onResize method, we set the position of the media in the canvas based on the position we have in the CSS. I’m using the same approach from Bizarro’s tutorial; you can take a look if you want to know more about the positioning and the cover behavior for the images.

import { Mesh, Program, Texture } from 'ogl'
import vertex from '../../shaders/vertex.glsl';
import fragment from '../../shaders/fragment.glsl';

export default class Media {
  constructor ({ gl, geometry, scene, renderer, screen, viewport, $el, img }) {
    this.gl = gl
    this.geometry = geometry
    this.scene = scene
    this.renderer = renderer
    this.screen = screen
    this.viewport = viewport
    this.img = img
    this.$el = $el
    this.scroll = 0

    this.createShader()
    this.createMesh()

    this.onResize()
  }
  createShader () {
    const texture = new Texture(this.gl, {
      generateMipmaps: false
    })

    this.program = new Program(this.gl, {
      depthTest: false,
      depthWrite: false,
      fragment,
      vertex,
      uniforms: {
        tMap: { value: texture },
        uPlaneSize: { value: [0, 0] },
        uImageSize: { value: [0, 0] },
        uViewportSize: { value: [this.viewport.width, this.viewport.height] },
        uTime: { value: 100 * Math.random() },
      },
      transparent: true
    })

    const image = new Image()

    image.src = this.img.src
    image.onload = _ => {
      texture.image = image

      this.program.uniforms.uImageSize.value = [image.naturalWidth, image.naturalHeight]
    }
  }
  createMesh () {
    this.plane = new Mesh(this.gl, {
      geometry: this.geometry,
      program: this.program
    })

    this.plane.setParent(this.scene)
  }
  onScroll (scroll) {
    this.scroll = scroll
    this.setY(this.y)
  }
  update () {
    this.program.uniforms.uTime.value += 0.04
  }
  setScale (x, y) {
    x = x || this.$el.offsetWidth
    y = y || this.$el.offsetHeight
    this.plane.scale.x = this.viewport.width * x / this.screen.width
    this.plane.scale.y = this.viewport.height * y / this.screen.height

    this.plane.program.uniforms.uPlaneSize.value = [this.plane.scale.x, this.plane.scale.y]
  }
  setX(x = 0) {
    this.x = x
    this.plane.position.x = -(this.viewport.width / 2) + (this.plane.scale.x / 2) + (this.x / this.screen.width) * this.viewport.width
  }
  setY(y = 0) {
    this.y = y
    this.plane.position.y = (this.viewport.height / 2) - (this.plane.scale.y / 2) - ((this.y - this.scroll) / this.screen.height) * this.viewport.height
  }
  onResize ({ screen, viewport } = {}) {
    if (screen) {
      this.screen = screen
    }

    if (viewport) {
      this.viewport = viewport
      this.plane.program.uniforms.uViewportSize.value = [this.viewport.width, this.viewport.height]
    }
    this.setScale()

    this.setX(this.$el.offsetLeft)
    this.setY(this.$el.offsetTop)
  }
}

The Vertex

Very simple implementation, we are getting the UV and position to render the images.

precision highp float;

attribute vec3 position;
attribute vec2 uv;

uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;

varying vec2 vUv;

void main() {
  vUv = uv;

  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

The Fragment

Ok, here is the final file; we are almost there.

precision highp float;

uniform vec2 uImageSize;
uniform vec2 uPlaneSize;
uniform vec2 uViewportSize;
uniform float uTime;
uniform sampler2D tMap;

varying vec2 vUv;

/*
  by @arthurstammet
  https://shadertoy.com/view/tdXXRM
*/
float tvNoise (vec2 p, float ta, float tb) {
  return fract(sin(p.x * ta + p.y * tb) * 5678.);
}
vec3 draw(sampler2D image, vec2 uv) {
  return texture2D(image,vec2(uv.x, uv.y)).rgb;   
}
float rand(vec2 co){
  return fract(sin(dot(co.xy, vec2(12.9898, 78.233))) * 43758.5453);
}
/*
  inspired by https://www.shadertoy.com/view/4tSyzy
  @anastadunbar
*/
vec3 blur(vec2 uv, sampler2D image, float blurAmount){
  vec3 blurredImage = vec3(0.);
  float d = smoothstep(0.8, 0.0, (gl_FragCoord.y / uViewportSize.y) / uViewportSize.y);
  #define repeats 40.
  for (float i = 0.; i < repeats; i++) { 
    vec2 q = vec2(cos(degrees((i / repeats) * 360.)), sin(degrees((i / repeats) * 360.))) * (rand(vec2(i, uv.x + uv.y)) + blurAmount); 
    vec2 uv2 = uv + (q * blurAmount * d);
    blurredImage += draw(image, uv2) / 2.;
    q = vec2(cos(degrees((i / repeats) * 360.)), sin(degrees((i / repeats) * 360.))) * (rand(vec2(i + 2., uv.x + uv.y + 24.)) + blurAmount); 
    uv2 = uv + (q * blurAmount * d);
    blurredImage += draw(image, uv2) / 2.;
  }
  return blurredImage / repeats;
}


void main() {
  vec2 ratio = vec2(
    min((uPlaneSize.x / uPlaneSize.y) / (uImageSize.x / uImageSize.y), 1.0),
    min((uPlaneSize.y / uPlaneSize.x) / (uImageSize.y / uImageSize.x), 1.0)
  );

  vec2 uv = vec2(
    vUv.x * ratio.x + (1.0 - ratio.x) * 0.5,
    vUv.y * ratio.y + (1.0 - ratio.y) * 0.5
  );

  float t = uTime + 123.0;
  float ta = t * 0.654321;
  float tb = t * (ta * 0.123456);
  vec4 noise = vec4(1. - tvNoise(uv, ta, tb));

  vec4 final = vec4(blur(uv, tMap, 0.08), 1.0);

  final = final - noise * 0.08;

  gl_FragColor = final;
}

Let’s explain a little bit. First, we apply the crop in the image to keep the ratio:

vec2 ratio = vec2(
  min((uPlaneSize.x / uPlaneSize.y) / (uImageSize.x / uImageSize.y), 1.0),
  min((uPlaneSize.y / uPlaneSize.x) / (uImageSize.y / uImageSize.x), 1.0)
);

vec2 uv = vec2(
  vUv.x * ratio.x + (1.0 - ratio.x) * 0.5,
  vUv.y * ratio.y + (1.0 - ratio.y) * 0.5
);

Next, we play with the time to get TV noise for our images using the tvNoise function:

float t = uTime + 123.0;
float ta = t * 0.654321;
float tb = t * (ta * 0.123456);
vec4 noise = vec4(1. - tvNoise(uv, ta, tb));

For the blur, I’m using the blur function based on the implementation of the Gaussian Blur by @anastadunbar. We are basically getting the average relative to the current pixel and the repeats.

The important part is the gradient variable. We are using the gl_FragCoord and the uViewportSize to generate a fixed gradient at the bottom of the viewport so we can apply the blur based on the proximity of each pixel to the edge.

vec3 blur(vec2 uv, sampler2D image, float blurAmount){
  vec3 blurredImage = vec3(0.);
  float gradient = smoothstep(0.8, 0.0, (gl_FragCoord.y / uViewportSize.y) / uViewportSize.y);
  #define repeats 40.
  for (float i = 0.; i < repeats; i++) { 
    vec2 q = vec2(cos(degrees((i / repeats) * 360.)), sin(degrees((i / repeats) * 360.))) * (rand(vec2(i, uv.x + uv.y)) + blurAmount); 
    vec2 uv2 = uv + (q * blurAmount * gradient);
    blurredImage += draw(image, uv2) / 2.;
    q = vec2(cos(degrees((i / repeats) * 360.)), sin(degrees((i / repeats) * 360.))) * (rand(vec2(i + 2., uv.x + uv.y + 24.)) + blurAmount); 
    uv2 = uv + (q * blurAmount * gradient);
    blurredImage += draw(image, uv2) / 2.;
  }
  return blurredImage / repeats;
}

Then we can return the final color

vec4 final = vec4(blur(uv, tMap, 0.08), 1.);

final = final - noise * 0.08;
gl_FragColor = final;

You should get something link this:

And that’s it! Thanks for reading. I hope this tutorial was useful to you 🥰.

Photos by @jazanadipatocu.

Jorge Toloza

Creative Developer focused on motion and interaction.

Stay in the loop: Get your dose of frontend twice a week

Fresh news, inspo, code demos, and UI animations—zero fluff, all quality. Make your Mondays and Thursdays creative!