Sunday, July 21, 2024

Progressive Blur Effect using WebGL with OGL and GLSL Shaders

Web DevelopmentProgressive Blur Effect using WebGL with 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 
  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. You can use the comments section if you have any questions. I hope this tutorial was useful to you 🥰.

Photos by @jazanadipatocu.

Digital meets Physical: Risograph Printing with WebGL



Check out our other content

Check out other tags:

Most Popular Articles