From our sponsor: Meco is a distraction-free space for reading and discovering newsletters, separate from the inbox.
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.