Wednesday, January 22, 2025

WebGL Shader Techniques for Dynamic Image Transitions

Web DevelopmentWebGL Shader Techniques for Dynamic Image Transitions


WebGL shaders give us incredible control over 3D objects, allowing us to creatively color each pixel and position vertices in 3D space.

In this article, we’ll harness that power to create a unique image reveal effect. We’ll break down the math behind it into simple, manageable steps, building the final result piece by piece.

SDF of the circle

Let us start simple by simply drawing a circle on the screen. When we are dealing with shaders we want to think about ways to describe a circle using vectors.

We can use the vector equation of the circle, where r is any point in the circle, and c is the center of the circle.

What is more important than coding the equation, is to understand the intuition of why this works.

If we think about it, a circle can be described as a geometric object for which every distance starting from the center is equal. Since the distance (which is derived by Pythagorean theorem) is the magnitude of the r – c, we can use the length function that is provided to us on GLSL language.

Let’s use our UV coordinates to draw a circle. We want to draw our circle in the center of the screen, therefore we will offset by the center vector.

void main() { 
    vec3 bg = vec3(254.0/255.0, 149.0/255.0, 2.0/255.0);
    vec3 fg = vec3(250.0 / 255.0, 229.0 / 255.0, 214.0 / 255.0);

    vec2 ratio = vec2(uSize.x / uSize.y, 1.0);
    
    vec2 c = vec2(0.5, 0.5) * ratio;
    vec2 r = vec2(vUv.x, vUv.y) * ratio;

    float circle = length(r - c); 
    vec3 color = mix(bg, fg, circle);

    gl_FragColor = vec4(color,  1.0);
}

If we simply visualize this using color, we get the following radial gradient. The question is where is our circle hiding?

We observe a radial gradient that appears darker at the center and brighter towards the edges. This makes sense when you consider how colors work in GLSL as 0 represents black and 1 represents white. Closer distances to the center result in smaller values, which correspond to darker colors. On the other hand, larger distances towards the edges produce brighter colors.

We’re not aiming for a radial gradient, instead, we want a sharp circle. To achieve this, we’ll use a step function, which sets all values below a certain threshold to 0 and all values above the threshold to 1. This threshold effectively defines the radius of our circle.

void main() { 
    vec3 bg = vec3(254.0/255.0, 149.0/255.0, 2.0/255.0);
    vec3 fg = vec3(250.0 / 255.0, 229.0 / 255.0, 214.0 / 255.0);

    vec2 ratio = vec2(uSize.x / uSize.y, 1.0);
    
    vec2 c = vec2(0.5, 0.5) * ratio;
    vec2 r = vec2(vUv.x, vUv.y) * ratio;

    float circle = length(r - c);
    circle = step(circle, .25);
    vec3 color = mix(bg, fg, circle);

    gl_FragColor = vec4(color,  1.0);
}

And here is the output:

Circle Warping

For our effect, we don’t want a perfectly sharp circle, instead, we want a wavy pattern along its perimeter. To achieve this, we can apply a noise function around the circle’s circumference.

Since we don’t require a complex noise function, we can use a simple and efficient combination of sine and cosine waves. Let’s start by creating a noise function by combining multiple sine and cosine waves.

Wave 1
Wave 2
Wave 3

Let’s see all of them together:

We want to combine them in one single function, add them all together and do some range normalization. Here’s what we get:

Our goal is to add noise along the perimeter of the circle. To do this, we’ll use some trigonometry and apply the function based on the angle each vector makes in polar coordinates.

float noise(vec2 point) {
    float frequency = 1.0;
    float angle = atan(point.y,point.x);

    float w0 = (cos(angle * frequency) + 1.0) / 2.0; // normalize [0 - 1]
    float w1 = (sin(2.*angle * frequency) + 1.0) / 2.0; // normalize [0 - 1]
    float w2 = (cos(3.*angle * frequency) + 1.0) / 2.0; // normalize [0 - 1]
    float wave = (w0 + w1 + w2) / 3.0; // normalize [0 - 1]
    return wave;
}

float circleSDF(vec2 pos, float rad) {
    float amt = 0.5;
    float circle = length(pos);
    circle += noise(pos) * rad * amt;
    return step(circle, rad);
}

We multiply here by the radius, so that the wave can range between [0 – r] instead of [0 – 1]. We can further adjust using an amount value, in this case ranging from 0 to maximum half of the circle radius.

And we get the following SDF, which we can tweak further in order to get a pattern which satisfies us visually.

Pattern 1

If we play around with the frequency and the radius a bit, we can get the following patterns:

Pattern 2
Pattern 3

I’m satisfied with the third pattern, but now I want to introduce some motion and add an organic feel to the circle.

Adding Organic Feeling

Rotation

Let’s start by adding some rotation to our circle. In this case, we can achieve rotation by offsetting the angle around the circle with the value of time.

By adding the time value to the angle, the periodic nature of sine and cosine maintains the curve repetition as time increases.

float noise(vec2 point) {
	//...same as above
	float angle = atan(point.y,point.x) + uTime * 0.02;
	//...same as above
}

Additionally, we can adjust the circles warping by animating the noise intensity over time.

float circleSDF(vec2 pos, float rad) {
    float a = sin(uTime * 0.2) * 0.25; // range -0.25 - 0.25
    float amt = 0.5 + a;
    float circle = length(pos);
    circle += noise(pos) * rad * amt;
    return step(circle, rad);
}

We once again leverage the repetition of the sine wave, but this time we remap the output values to a range that suits our visual preferences.

After applying these steps, the result feels organic.

Merging the objects

We want to combine multiple circles for our transition effect. In the following sections, we’ll walk through how to achieve this.

Adding Multiple Circles

Let’s start by adding two organically moving objects to the screen with different offsets.

We can use the max function to draw both circles.

// circle 1
vec2 o1 = vec2(0.35) * uSize;
float c1 = circleSDF(coords - o1, rad);
   
// circle 2
vec2 o2 = vec2(0.65) * uSize;
float c2 = circleSDF(coords - o2, rad);
	  
// Merging together
float circle = max(c1, c2);
vec3 color = mix(bg, fg, circle);

This will result in something similar to the following:

Smoothing the Merge

You’ll notice that the max function is merging our shapes, but it results in rough edges, almost as if the objects are simply layered on top of one another. Let’s visualize the merging function on a graph to better understand this.

This function represents the merging of two offset noise functions in cartesian coordinates. You can see that the functions are merged, but the merging points are sharp and spiky. We want to achieve a smoother transition.

float softMax(float a, float b, float k) {
    return log(exp(k * a) + exp(k * b)) / k;
}

float softMin(float a, float b, float k) {
    return -softMax(-a, -b, k);
}

We’ll use an exponential factor-adjusted function for our soft min and soft max functions. While we won’t dive deep into the details of function smoothing in this article, you can find the reference for the function in the following article.

Here are our waves after merging them using soft minimums instead:

// circle 1
vec2 o1 = vec2(0.35) * uSize;
float c1 = circleSDF(coords - o1, rad);
   
// circle 2
vec2 o2 = vec2(0.65) * uSize;
float c2 = circleSDF(coords - o2, rad);
	  
// Merging together
float circle = softMin(c1, c2, 0.01);
circle = step(circle, rad);

And here are our objects merging smoothly after applying the soft merge:

And if we add a zoom effect to our transition, here’s what we get:

Adding More objects

We want our effect to feature more circles on the screen. While we could simply add them using hardcoded values for the offsets, which would work fine, I’ll make it more interesting by arranging them in a radial pattern.

Creating Radial Circles

Let’s start by drawing radial circles using GLSL, I will start with a basic approach and will follow up with a performance enhancing method.

float radialCircles(vec2 ratio) {
    float circleRadius = 0.021;
    vec2 center = vec2(0.5, 0.5) * ratio;
    vec2 pos = vUv * ratio;
    float d = circleSDF(pos - center, circleRadius); // center circle
    
    vec2 offsetVector = vec2(0.0);

    int layerCount = 4;
    int layerItemCount = 12;
    float itemOffsetAngle = (3.1415926535 * 2.0) / float(layerItemCount);
    float layerOffset = 0.5 / (float(layerCount));
    float r = 0.1;

    for (int i = 0; i < layerCount; i += 1) {
        vec2 curr = offsetVector;
        float angle = 0.0;

        for (int j = 0; j < layerItemCount; j += 1) {
            offsetVector = vec2(
                cos(angle) * r,
                sin(angle) * r
            );

            d = max(
                d,
                circleSDF(pos - offsetVector - center, circleRadius)
            );

            angle += itemOffsetAngle;
        }
        
        r += layerOffset;
    }

    return d;
}

float circleSDF(vec2 pos, float rad) {
    float circle = length(pos);
    circle = step(circle, rad);
    return circle;
}

As shown in the code above, I offset and rotate a vector within a sector using two for loops. At each step, I draw a circle, resulting in the following output:

Optimizing GPU Branching

The problem with the above code is that it uses a for loop for drawing the circles. Since we do not have too many circles that would not cause any huge issues, but we can optimize by using some math and leveraging symmetry:

float radialCircles(vec2 p, float o) {
    vec2 offset = vec2(o, o);

    float count = 3.0;
    float angle = (2. * 3.1415926535)/count;
    float s = round(atan(p.y, p.x)/angle);
    float an = angle * s;
    vec2 q = vec2(offset.x * cos(an), offset.y * sin(an));
    vec2 pos = p - q;
    float circle = circleSDF(pos, 25.0);
    return circle;
}

After drawing the desired amount of circles in a radial pattern, and adding the time animation, we get the following result:

The final step is to replace the solid colors with an image texture. We can achieve this by sampling the colors of an image at specific pixels using a texture in GLSL.

Here is the resulting animation after adding the texture:

void main() {
    vec4 bg = vec4(vec3(0.0), 1.0);
    vec4 texture = texture2D(uTexture,vUv);
    vec2 coords = vUv * uSize;
    vec2 o1 = vec2(0.1, 0.1) * uSize;

    float t = pow(uTime, 1.5); // easing
    float radius = 15.0;
    float rad = t * radius;
    float c1 = circleSDF(coords - o1, rad);

    vec2 p = (vUv - 0.5) * uSize;
    float r1 = radialCircles(p, 0.1 * uSize.x, 2.0);
    float r2 = radialCircles(p, 0.4 * uSize.x, 5.0);

    float circle = softMin(c1, r1, 0.01);
    circle = softMin(circle, r2, 0.01);

    circle = step(circle, rad);
    vec4 color = mix(bg, texture, circle);
    gl_FragColor = color;
}

Final Loading

And here is our final usage of the effect in a creative setting, where we reveal the images based on the user scroll.

In this article, we explored creating circle SDFs, adding noise along the circle’s perimeter, combining sine waves, merging objects with smooth min and max functions, and incorporating textures—along with many other implementation details.

If this post sparked your creativity or helped you level up your skills, follow me on LinkedIn and Bluesky for more creative content. Also consider to buy me a coffee!

Thank you for being awesome!

Check out our other content

Check out other tags:

Most Popular Articles