A flowing WebGL gradient, deconstructed

A few weeks ago I embarked on a journey to create a flowing gradient effect — here’s what I ended up making:

Loading canvas...

This effect is written in a WebGL shader using noise functions and some clever math.

In this post, I’ll break it down step by step. You need no prior knowledge of WebGL or shaders — we’ll start by building a mental model for writing shaders and then recreate the effect from scratch.

We’ll cover a lot in this post: writing shaders, interpolation, color mapping, gradient noise, and more. I’ll help you develop an intuition for these concepts using dozens of visual (and interactive!) explanations.

If you just want to see the final code, I’ll include a link to the shader code at the end of the post (this blog is open source so you can just take a look).

Let’s get started!

Color as a function of position

Building the gradient effect will boil down to writing a function that takes in a pixel position and returns a color value:

type Position = { x: number, y: number };
function pixelColor({ x, y }: Position): Color;

For every pixel on the canvas, we’ll invoke the color function with the pixel’s position to calculate its color. A canvas frame might be rendered like so:

for (let x = 0; x < canvas.width; x++) {
for (let y = 0; y < canvas.height; y++) {
const color = pixelColor({ x, y });
canvas.drawPixel({ x, y }, color);
}
}

To start, let’s write a color function that produces a linear gradient like so:

Loading canvas...

To produce this red-to-blue gradient we’ll blend red and blue with a blend factor that increases from to over the width of the canvas — let’s call that blending factor . We can calculate it like so:

function pixelColor({ x, y }: Position): Color {
const t = x / (canvas.width - 1);
}

Having calculated the blending factor , we’ll use it to mix red and blue:

const red = color("#ff0000");
const blue = color("#0000ff");
function pixelColor({ x, y }: Position): Color {
const t = x / (canvas.width - 1);
return mix(red, blue, t);
}

The mix function is an interpolation function that linearly interpolates between the two input colors using a blend factor between and ( in our case).

A mix function for two numbers can be implemented like so:

function mix(a: number, b: number, t: number) {
return a * (1 - t) + b * t;
}

The mix function is often called “lerp” — short for linear interpolation.

A mix function for two colors works the same way, except we mix the color components. To mix two RGB colors, for example, we’d mix the red, green, and blue channels.

function mix(a: Color, b: Color, t: number) {
return new Color(
a.r * (1 - t) + b.r * t,
a.g * (1 - t) + b.g * t,
a.b * (1 - t) + b.b * t,
);
}

Anyway, mix(red, blue, t) produces a red-to-blue gradient over the width of the canvas (the axis):

Loading canvas...

When x == 0 we get a value of , giving us 100% red. When x == canvas.width - 1 we get a value of , giving us 100% blue. If we get 70% red and 30% blue.

If we want an oscillating gradient (red to blue to red again, repeating), we can do that by using sin(x) to calculate the blending factor:

function pixelColor({ x, y }: Position): Color {
let t = sin(x);
t = (t + 1) / 2; // Normalize
return mix(red, blue, t);
}

Note: sin returns a value between and , but the mix function accepts a value between and . For this reason, we normalize by remapping to via .

This produces the following effect:

Loading canvas...

Those waves are very thin! That’s because we’re oscillating between red and blue every pixels.

We can control the rate of oscillation by defining a frequency multiplier. It will determine over how many pixels the gradient oscillates from red to blue and red again. To produce a wavelength of pixels, we’ll set the frequency multiplier to :

const L = 40;
const frequency = (2 * PI) / L;
function pixelColor({ x, y }: Position): Color {
let t = sin(x * frequency);
// ...
}

This produces an oscillating gradient with the desired wavelength — try changing the value of using the slider to see the effect:

Loading canvas...

Adding motion

So far we’ve only produced static images. To introduce motion, we’ll update our color function to take in a time value as well.

function pixelColor({ x, y }: Position, time: number): Color;

We’ll define time as the elapsed time, measured in seconds.

By adding time to the pixel’s position, we simulate the canvas “scrolling” to the right one pixel a second:

let t = sin((x + time) * frequency);

But scrolling one pixel a second is very slow. Let’s add a speed constant to control the speed of the scrolling motion and multiply time by it:

const S = 20;
let t = sin((x + time * S) * frequency);

Here’s the result — try adjusting the speed via the slider:

Loading canvas...

Voila, we’ve got movement!

These two inputs — time and the pixel’s position — will be the main components that drive our final effect.

We’ll spend the rest of the post writing a color function that will calculate a color for every pixel — with the pixel’s position and time as the function’s inputs. Together, the colors of each pixel constitute a single frame of animation.

Loading canvas...

But consider the amount of work that needs to be done. A canvas, like the one above,, for example, contains pixels. That’s invocations of our pixel function every frame — a ton of work for a CPU to perform 60 times a second! This is where WebGL comes in.

WebGL shaders run on the GPU, which is useful to us because the GPU is designed for highly parallel computation. A GPU can invoke our color function thousands of times in parallel, making the task of rendering a single frame a breeze.

Conceptually, nothing changes. We’re still going to be writing a single color function that takes a position and time value and returns a color. But instead of writing it in JavaScript and running it on the CPU, we’ll write it in GLSL and run it on the GPU.

WebGL and GLSL

WebGL can be thought of as a subset of OpenGL, which is a cross-platform API for graphics rendering. WebGL is based on OpenGL ES — an OpenGL spec for embedded systems (like mobile devices).

Here’s a page listing the differences between OpenGL and WebGL. We won’t encounter those differences in this post.

OpenGL shaders are written in GLSL, which stands for OpenGL Shading Language. It’s a strongly typed language with a C-like syntax.

There are two types of shaders, vertex shaders and fragment shaders, which serve different purposes. Our color function will run in a fragment shader (sometimes referred to as a “pixel shader”). That’s where we’ll spend most of our time.

There’s tons of boilerplate code involved in setting up a WebGL rendering pipeline. I’ll mostly omit it so that we can stay focused on our main goal, which is creating a cool gradient effect.

Throughout this post, I’ll link to resources I found helpful in learning about how to set up and work with WebGL.

Writing a fragment shader

Here’s a WebGL fragment shader that sets every pixel to the same color.

void main() {
vec4 color = vec4(0.7, 0.1, 0.4, 1.0);
gl_FragColor = color;
}

WebGL fragment shaders have a main function that is invoked once for each pixel. The main function sets the value of gl_FragColor — a special variable that specifies the color of the pixel.

We can think of main as the entry point of our color function and gl_FragColor as its return value.

WebGL colors are represented through vectors with 3 or 4 components: vec3 for RGB and vec4 for RGBA colors. The first three components (RGB) are the red, green, and blue components. For 4D vectors, the fourth component is the alpha component of the color — its opacity.

vec3 red = vec3(1.0, 0.0, 0.0);
vec3 blue = vec3(0.0, 0.0, 1.0);
vec3 white = vec3(1.0, 1.0, 1.0);
vec4 semi_transparent_green = vec4(0.0, 1.0, 0.0, 0.5);

WebGL colors use a fractional representation, where each components is a value between and . Consider the color in the shader:

void main() {
vec4 color = vec4(0.7, 0.1, 0.4, 1.0);
gl_FragColor = color;
}

We can trivially convert the fractional GLSL color vec3(0.7, 0.1, 0.4) to the percentage-based CSS color rgb(70%, 10%, 40%). We can also multiply the fraction by 255 to get the unsigned integer representation rgb(178, 25, 102).

Anyway, if we run the shader we see that every pixel is set to that color:

Loading canvas...

Let’s create a linear gradient that fades to another color over the axis. Let’s use rgb(229, 154, 25) — it corresponds to vec3(0.9, 0.6, 0.1) in GLSL.

vec3 color_1 = vec3(0.7, 0.1, 0.4);
vec3 color_2 = vec3(0.9, 0.6, 0.1);

I’ve been using this tool to convert from hex to GLSL colors, and vice versa

To gradually transition from color_1 to color_2 over the axis, we’ll need the coordinate of the current pixel. In WebGL fragment shaders, we get that via a special variable called gl_FragCoord:

float y = gl_FragCoord.y;

float corresponds to a 32-bit floating point number. We’ll only use the float and int number types in this post, both of which are 32-bit.

Similar to before, we’ll use the pixel’s coordinate to calculate a blend value by dividing by the canvas height.

const float CANVAS_WIDTH = 150.0;
float y = gl_FragCoord.y;
float t = y / (CANVAS_WIDTH - 1.0);

Note: I’ve configured the coordinates such that gl_FragCoord is (0.0, 0.0) at the lower-left corner and (CANVAS_WIDTH - 1, CANVAS_HEIGHT - 1) at the upper right corner. This will stay consistent throughout the post.

We’ll mix the two colors with GLSL’s built-in mix function.

vec3 color = mix(color_1, color_2, t);

GLSL has a bunch of built-in math functions such as sin, clamp, and pow.

We’ll assign our newly calculated color to gl_FragColor:

vec3 color = mix(color_1, color_2, t);
gl_FragColor = color;

But wait — we get a compile-time error.

ERROR: ‘assign’ : cannot convert from ‘3-component vector of float’ to ‘FragColor 4-component vector of float’

This error is a bit obtuse, but it’s telling us that we can’t assign our vec3 color to gl_FragColor because gl_FragColor is of type vec4.

In other words, we need to add an alpha component to color before passing it to gl_FragColor. We can do that like so:

vec3 color = mix(color_1, color_2, t);
gl_FragColor = vec4(color, 1.0);

This gives us a linear gradient!

Loading canvas...

Vector constructors

You may have raised an eyebrow at the vec4(color, 1.0) expression above — it’s equivalent to vec4(vec3(...), 1.0), which is perfectly valid in GLSL!

When passing a vector to a vector constructor, the components of the input vector are read left-to-right — similar to JavaScript’s spread syntax.

vec3 a;
// this:
vec4 foo = vec4(a.x, a.y, a.z, 1.0);
// is equivalent to this:
vec4 foo = vec4(a, 1.0);

You can combine number and vector inputs in any way you see fit as long as the total number of values passed to the vector constructor is correct:

vec4(1.0 vec2(2.0, 3.0), 4.0); // OK
vec4(vec2(1.0, 2.0), vec2(3.0, 4.0)); // OK
vec4(vec2(1.0, 2.0), 3.0); // Error, not enough components

Coloring areas different colors

Let’s color the bottom half of our canvas white, like so:

Loading canvas...

To do that, we’ll first calculate the position of the canvas’ midline:

const float MID_Y = CANVAS_HEIGHT * 0.5;

We can then determine the pixel’s signed distance from the line through subtraction:

float y = gl_FragCoord.y;
float dist = MID_Y - y;

What determines whether our pixel should be white or not is whether it’s below the line, which we can determine by reading the sign of the distance via the sign function. The sign function returns if the value is negative and if it’s positive.

float dist = MID_Y - y;
sign(dist); // -1.0 or 1.0

We can calculate an alpha (blend) value by normalizing the sign to or via .

float alpha = (sign(dist) + 1.0) / 2.0;

Blending color and white using alpha colors the bottom half of the canvas white:

color = mix(color, white, alpha);

Loading canvas...

Here, alpha represents how white our pixel is. If alpha == 1.0 the pixel is colored white, but if alpha == 0.0 the original value of color is retained.

Calculating an alpha value by normalizing the sign and passing that to the mix function may seem overly roundabout. Couldn’t you just use an if statement?

if (sign(dist) == 1.0) {
color = white;
}

You could, but only if you want to pick 100% of either color. As we extend this to smoothly blend between the colors, using conditionals won’t work.

As an additional point, you generally want to avoid branching (if-else statements) in code that runs on the GPU. There are nuances to the performance of branches in shader code, but branchless code is usually preferable. In our case, calculating the alpha and running the mix function boils down to sequential math instructions that GPUs excel at.

Drawing arbitrary curves

We’re currently coloring everything under MID_Y white, but the line doesn’t need to be determined by a constant — we can calculate the of a curve using an arbitrary expression and use that to calculate dist:

float curve_y = <some expression>;
float dist = curve_y - y;

That allows us to draw the area under any curve white. Let’s, for example, define the curve as a slanted line

where is the start position of the line, and is the incline of the line. We can put this into code like so:

const float Y = 0.4 * CANVAS_HEIGHT;
const float I = 0.2;
float x = gl_FragCoord.x;
float curve_y = Y + x * I;

This produces the slanted line in the canvas below — you can vary to see the effect:

Loading canvas...

We could also draw a parabola like so:

// Adjust x=0 to be in the middle of the canvas
float x = gl_FragCoord.x - CANVAS_WIDTH / 2.0;
float curve_y = Y + pow(x, 2.0) / 40.0;

Loading canvas...

The point is that we can define the curve however we want.

Producing an animated wave

To draw a sine wave, we can define the curve as:

where is the wave’s baseline position, is the amplitude of the wave, and is the wave’s length in pixels. Putting this into code, we get:

const float Y = 0.5 * CANVAS_HEIGHT;
const float A = 15.0;
const float L = 75.0;
const float frequency = (2.0 * PI) / L;
float curve_y = Y + sin(x * frequency) * A;

This draws a sine wave:

Loading canvas...

At the moment, things are completely static. For our shader to produce any motion we’ll need to introduce a time variable. We can do that using uniforms.

uniform float u_time;

You can think of uniforms as per-draw call constants — global variables that the shader has read-only access to. The actual values of uniforms are controlled on the JavaScript side.

Within a given draw call, each shader invocation will have uniforms set to the same values. This is what the name “uniform” refers to — the values of uniforms are uniform across shader invocations within a given draw call. The JavaScript side can, however, change the values of uniforms between draw calls.

Uniforms are constant within draw calls but they are not compile-time constant, meaning you cannot use the value of a uniform in const variables.

Uniform variables can be of many types, such as floats, vectors, and textures (we’ll cover textures later). But what’s up with the u_ prefix?

uniform float u_time;

Prefixing uniform names with u_ is a GLSL convention. You won’t encounter compiler errors if you don’t, but prefixing uniform names with u_ is a very established pattern.

Note: There are similar conventions for the names of attributes and varyings (they’re prefixed with a_ and v_, respectively), but we won’t use attributes or varyings in this post.

Anyway, with u_time now accessible in our shader we can start producing motion. As a refresher, we’re currently calculating our curve’s value like so:

float curve_y = Y + sin(x * W) * A;

Adding u_time to the pixel’s position shifts the wave to the left over time:

float curve_y = Y + sin((x + u_time) * W) * A;

But moving one pixel a second is quite slow (u_time is measured in seconds), so we’ll add a speed constant to control the speed:

const float S = 25.0;
float curve_y = Y + sin((x + u_time * S) * W) * A;

Try varying to see the speed change:

Loading canvas...

Applying a gradient to the lower half

Instead of the lower half being a flat white color, let’s make it a gradient (like the upper half).

The upper half’s gradient is currently composed of two colors: color_1 and color_2. Let’s rename those to upper_color_1 and upper_color_2:

vec3 upper_color_1 = vec3(0.7, 0.1, 0.4);
vec3 upper_color_2 = vec3(0.9, 0.6, 0.1);

For the lower half’s gradient, we’ll introduce two new colors: lower_color_1 and lower_color_2.

vec3 lower_color_1 = vec3(1.0, 0.7, 0.5);
vec3 lower_color_2 = vec3(1.0, 1.0, 0.9);

We’ll calculate a value using the pixel’s position, using that to gradually mix the colors over the axis:

float t = y / (CANVAS_HEIGHT - 1.0);
vec3 upper_color = mix(upper_color_1, upper_color_2, t);
vec3 lower_color = mix(lower_color_1, lower_color_2, t);

With this, we’ve effectively calculated two gradients. We’ll then determine which gradient to use for the current pixel’s color using alpha:

float alpha = (sign(curve_y - y) + 1.0) / 2.0;
vec3 color = mix(upper_color, lower_color, alpha);

This applies the gradients to the halves:

Loading canvas...

Since the value of alpha is calculated using the sign of the distance, its value will abruptly change from to at the wave’s edge — that’s what gives us the sharp split.

Let’s look at how we can make the split smoother using blur.

Adding blur

Take another look at the final animation and consider the role that blur plays:

Loading canvas...

The blur isn’t applied uniformly over the wave — a variable amount of blur is applied to different parts of the wave, and the amount fluctuates over time.

How might we achieve that?

Gaussian blur

When thinking about how I’d approach the blur problem, my first thought was to use Gaussian blur. I figured I’d determine the amount of blur to apply via a noise function and then sample neighboring pixels according to the blur amount.

That’s a valid approach — progressive blur in WebGL is feasible — but in order to get a decent blur we’d need to sample lots of neighboring pixels, and the amount of pixels to sample only increases as the blur radius gets larger. The final effect requires a very large blur radius, so that becomes incredibly expensive very quickly.

Additionally, for us to be able to sample the alpha values of neighboring pixels with any reasonable performance, we’d need to calculate their alpha values up front. To do that we’d need to pre-render the alpha channel into a texture for us to sample, which would require setting up another shader and render pass. Not a huge deal, but it would add complexity.

I opted to take a different approach that doesn’t require sampling neighboring pixels. Let’s take a look.

Calculate blur using signed distance

Here’s how we’re currently calculating alpha:

float dist = curve_y - y;
float alpha = (sign(dist) + 1.0) / 2.0;

By taking the sign of our distance, we always get an opacity of 0% or 100% — either fully transparent or completely opaque. Let’s instead make alpha gradually transition from to over a number of pixels. Let’s define a constant for that:

const float BLUR_AMOUNT = 50.0;

We’ll change the calculation for alpha to just be dist / BLUR_AMOUNT.

float alpha = dist / BLUR_AMOUNT;

When dist == 0.0, the alpha will be , and as dist approaches BLUR_AMOUNT the alpha approaches . This will cause alpha to transition from to over the desired number of pixels, but we need to consider that

  1. when dist exceeds BLUR_AMOUNT the alpha will exceed , and
  2. the alpha becomes negative when dist is negative.

Both of those would cause problems (alpha values should only range from to ) so we’ll clamp alpha to the range using the built-in clamp function:

float alpha = dist / BLUR_AMOUNT;
alpha = clamp(alpha, 0.0, 1.0);

This produces a blur effect, but we can observe the wave “shifting down” as the blur increases — try varying the amount of blur using the slider:

Loading canvas...

We can fix the downshift by starting alpha at :

float alpha = 0.5 + dist / BLUR_AMOUNT;
alpha = clamp(alpha, 0.0, 1.0);

Starting at causes alpha to transition from to as dist ranges from -BLUR_AMOUNT / 2 to BLUR_AMOUNT / 2, which keeps the wave centered:

Loading canvas...

Let’s now make blur gradually increase from left to right. To gradually increase the blur, we can linearly interpolate from no blur to BLUR_AMOUNT over the axis like so:

float t = gl_FragCoord.x / (CANVAS_WIDTH - 1)
float blur_amount = mix(1.0, BLUR_AMOUNT, t);

Using blur_amount to calculate the alpha, we get a gradually increasing blur:

float alpha = dist / blur_amount;
alpha = clamp(alpha, 0.0, 1.0);

Loading canvas...

This forms the basis for how we’ll produce the blur in the final effect.

The blur currently looks a bit “raw”, but let’s put that aside for the time being. We’ll make it look awesome later in the post.

Let’s now work on creating a natural-looking wave.

Stacking sine waves

I often reach for stacked sine waves when I need a simple and natural wave-like noise function. Here’s an example of a wave created using stacked sine waves:

Loading canvas...

The idea is to sum the output of multiple sine waves with different wavelengths, amplitudes, and phase speeds.

Take the following pure sine waves:

Loading canvas...

If you combine them into a single wave, you get an interesting final wave:

Loading canvas...

The equation for the individual sine waves is

where , and are variables that control different aspects of the wave:

  • determines the wavelength,
  • determines the phase evolution speed, and
  • determines the amplitude of the wave.

The final wave can be described as the sum of such waves:

Which put into code, looks like so:

float sum = 0.0;
sum += sin(x * L1 + u_time * S1) * A1;
sum += sin(x * L2 + u_time * S2) * A2;
sum += sin(x * L3 + u_time * S3) * A3;
...
return sum;

The problem, then, is finding , , values for each sine wave that, when stacked, produce a nice-looking final wave.

In finding those values, I first create a “baseline wave” with the , , components set to values that feel right. I picked these values:

const float L = 0.015;
const float S = 0.6;
const float A = 32.0;
float sum = sin(x * L + u_time * S) * A;

They produce the following wave:

Loading canvas...

This wave has the rough shape of what I want the final wave to look like, so these values serve as a good baseline.

I then add more sine waves that use the baseline , , values multiplied by some constants. After some trial and error, I ended up with the following:

float sum = 0.0;
sum += sin(x * (L / 1.000) + u_time * 0.90 * S) * A * 0.64;
sum += sin(x * (L / 1.153) + u_time * 1.15 * S) * A * 0.40;
sum += sin(x * (L / 1.622) + u_time * -0.75 * S) * A * 0.48;
sum += sin(x * (L / 1.871) + u_time * 0.65 * S) * A * 0.43;
sum += sin(x * (L / 2.013) + u_time * -1.05 * S) * A * 0.32;

Observe how is multiplied by a negative number for waves 3 and 5. Making some of the waves travel in the opposite direction prevents the final wave from feeling as if it’s moving in one direction at a constant rate.

These five sine waves give us a reasonably natural-looking final wave:

Loading canvas...

Because all of the sine waves are defined by , , , we can tune the waves together by adjusting those constants. Increase to make the waves faster, to make the waves shorter, and to make the waves taller. Try varying and :

Loading canvas...

We won’t actually make use of stacked sine waves in our final effect. We will, however, use the idea of stacking waves of different scales and speeds.

Simplex noise

Simplex noise is a family of -dimensional gradient noise functions developed by Ken Perlin. Ken first introduced “classic” Perlin noise in 1983 and later created simplex noise in 2001 to address some of the drawbacks of Perlin noise.

The dimensionality of a simplex noise function refers to how many numeric input values the function takes (the 2D simplex noise function takes two numeric arguments, the 3D function takes three). All simplex noise functions return a single numeric value between and .

2D simplex noise is frequently used, for example, to procedurally generate terrain in video games. Here’s an example texture created using 2D simplex noise that could be used as a height map:

Loading canvas...

It is generated by calculating the lightness of each pixel using the output of the 2D simplex noise function with the pixel’s and coordinates as inputs.

const float L = 0.02;
float x = gl_FragCoord.x * L;
float y = gl_FragCoord.y * L;
float lightness = (simplex_noise(x, y) + 1.0) / 2.0;
gl_FragColor = vec4(vec3(lightness), 1.0);

Note: The simplex_noise implementation I’m using can be found in this GitHub repository.

controls the scale of the coordinates. As increases, the noise becomes smaller. Here’s a canvas where you can adjust to see the effect:

Loading canvas...

We’ll use 2D simplex noise to create an animated 1D wave. The idea behind that may not be very obvious, so let’s see how it works.

1D animation using 2D noise

Consider the following points:

Loading 3D scene

The points are arranged in a grid configuration on the and axes, with the coordinate of each point calculated via simplex_noise(x, z):

for (const point of points) {
const { x, z } = point;
point.y = simplex_noise(x, z);
}

By doing this we’ve effectively created a 3D surface from a 2D input (the and coordinates).

Fair enough, but how does that relate to generating an animated wave?

Consider what happens if we use time as the coordinate. As time passes, the value of increases, giving us different 1D slices of the values of the surface along the axis. Here’s a visualization:

Loading 3D scene

Putting this into code for our 2D canvas is fairly simple:

uniform float u_time;
const float L = 0.0015;
const float S = 0.12;
const float A = 40.0;
float x = gl_FragCoord.x;
float curve_y = MID_Y + simplex_noise(x * L, u_time * S) * A;

This gives us a smooth animated wave:

Loading canvas...

A single simplex noise function call already produces a very natural-looking wave!

The same three , , variables determine the characteristics of our wave. We scale by to make the wave shorter or longer on the horizontal axis:

We scale by to control the evolution speed of our wave — the speed at which we move across the axis in the visualization above:

Lastly, we scale the output of the function by , which determines the amplitude (height) of our wave.

Simplex noise returns a value between and , so to make a wave with a height of you’d set to .

Even though the simplex wave feels natural, I find the peaks and valleys to look too evenly spaced and predictable.

That’s where stacking comes in. We can stack simplex waves of various lengths and speeds to get a more interesting final wave. I tweaked the constants and added a few increasingly large waves — some slower and some faster. Here’s what I ended up with:

const float L = 0.0018;
const float S = 0.04;
const float A = 40.0;
float noise = 0.0;
noise += simplex_noise(x * (L / 1.00), u_time * S * 1.00)) * A * 0.85;
noise += simplex_noise(x * (L / 1.30), u_time * S * 1.26)) * A * 1.15;
noise += simplex_noise(x * (L / 1.86), u_time * S * 1.09)) * A * 0.60;
noise += simplex_noise(x * (L / 3.25), u_time * S * 0.89)) * A * 0.40;

This produces a wave that feels natural, yet visually interesting.

Loading canvas...

Looks awesome, but there is one component I feel is missing, which is directional flow. The wave is too “still”, which makes it feel a bit artificial.

To make the wave flow left, we can add u_time to the x component, scaled by a constant that determines the amount of flow.

const float F = 0.043;
float noise = 0.0;
noise += simplex_noise(x * (L / 1.00) + F * u_time, ...) * ...;
noise += simplex_noise(x * (L / 1.30) + F * u_time, ...) * ...;
noise += simplex_noise(x * (L / 1.86) + F * u_time, ...) * ...;
noise += simplex_noise(x * (L / 3.25) + F * u_time, ...) * ...;

This adds a subtle flow to the wave. Try changing the amount of flow to feel the difference it makes:

Loading canvas...

The amount of flow may feel subtle, but that’s intentional. If the flow is easily noticeable, there’s too much of it.

I think we’ve got a good-looking wave. Let’s move on to the next step.

Multiple waves

Let’s update our shader to include multiple waves. As a first step, I’ll create a reusable wave_alpha function that takes in a position and height for the wave and returns an alpha value.

float wave_alpha(float Y, float height) {
// ...
}

To keep things clean, I’ll create a wave_noise function that returns the stacked simplex wave we defined in the last section:

float wave_noise() {
float noise = 0.0;
noise += simplex_noise(...) * ...;
noise += simplex_noise(...) * ...;
// ...
return noise;
}

We’ll use that in wave_alpha to calculate the wave’s position and the pixel’s distance to it:

float wave_alpha(float Y, float wave_height) {
float wave_y = Y + wave_noise() * wave_height;
float dist = wave_y - gl_FragCoord.y;
}

Using the distance to compute the alpha value:

float wave_alpha(float Y, float wave_height) {
float wave_y = Y + wave_noise() * wave_height;
float dist = wave_y - gl_FragCoord.y;
float alpha = clamp(0.5 + dist, 0.0, 1.0);
return alpha;
}

We’ll then use the wave_alpha function to calculate alpha values for two waves, each with their separate positions and heights:

const float WAVE1_HEIGHT = 24.0;
const float WAVE2_HEIGHT = 32.0;
const float WAVE1_Y = 0.80 * CANVAS_HEIGHT;
const float WAVE2_Y = 0.35 * CANVAS_HEIGHT;
float wave1_alpha = wave_alpha(WAVE1_Y, WAVE1_HEIGHT);
float wave2_alpha = wave_alpha(WAVE2_Y, WAVE2_HEIGHT);

Two waves split the canvas in three. I like to think of the upper third as the background, with two waves drawn in front of it (with wave 1 in the middle and wave 2 at the front).

To draw a background and two waves, we’ll need three colors. I picked these nice blue colors:

vec3 background_color = vec3(0.102, 0.208, 0.761);
vec3 wave1_color = vec3(0.094, 0.502, 0.910);
vec3 wave2_color = vec3(0.384, 0.827, 0.898);

Finally, we’ll calculate the pixel’s color by blending these colors using the two waves’ alpha values:

vec3 color = background_color;
color = mix(color, wave1_color, wave1_alpha);
color = mix(color, wave2_color, wave2_alpha);
gl_FragColor = vec4(color, 1.0);

This gives us the following result:

Loading canvas...

We do get two waves, but they’re completely in sync with each other.

This makes sense because the inputs to our noise function are the pixel’s position and the current time, which are the same for both waves.

To fix this we’ll introduce wave-specific offset values that we pass to the noise functions. One way to do that is just to provide each wave with a literal offset value and pass that to the noise function:

float wave_alpha(float Y, float wave_height, float offset) {
wave_noise(offset);
// ...
}
float w1_alpha = wave_alpha(WAVE1_Y, WAVE1_HEIGHT, -72.2);
float w2_alpha = wave_alpha(WAVE2_Y, WAVE2_HEIGHT, 163.9);

The wave_noise function can then add offset to u_time and use that when calculating the noise.

float wave_noise(float offset) {
float time = u_time + offset;
float noise = 0.0;
noise += simplex_noise(x * L + F * time, time * S) * A;
// ...
}

This produces identical waves, but offset in time. By making the offset large enough, we get waves spaced far enough apart in time that no one would notice that they’re the same wave.

But we don’t actually need to provide the offset manually. We can derive an offset in the wave_alpha function using the Y and wave_height arguments:

float wave_alpha(float Y, float wave_height) {
float offset = Y * wave_height;
wave_noise(offset);
// ...
}

Given the wave constants above and a canvas height of , we get the following offsets:

With these offsets, the waves differ in time by seconds. No one’s gonna notice that.

With the offsets added, we get two distinct waves:

Loading canvas...

Now that we’ve updated our shader to handle multiple waves, let’s move onto making the waves not be a single solid color.

Animated 2D noise

When generating the animated waves above, we used a 2D noise function to generate animated 1D noise.

That pattern holds for higher dimensions as well. When generating -dimensional noise, we use an -dimensional noise function with time as the value of the last dimension.

Here’s the static 2D simplex noise we saw earlier:

Loading canvas...

To animate it, we’ll use the 3D simplex noise function, passing the pixel’s and positions as the first two arguments and time as the third argument.

const float L = 0.02;
const float S = 0.6;
float x = gl_FragCoord.x;
float y = gl_FragCoord.y;
float noise = simplex_noise(x * L, y * L, u_time * S);

We’ll normalize the noise to and use it as a lightness value:

float lightness = (noise + 1.0) / 2.0;
gl_FragColor = vec4(vec3(lightness), 1.0);

This gives us animated 2D noise:

Loading canvas...

Our goal for this animated 2D noise is for it to eventually be used to create the colors of the waves in our final gradient:

Loading canvas...

For the noise to start looking like that we’ll need to make some adjustments. Let’s scale up the noise and also make the scale of the noise larger on the axis than the axis.

const float L = 0.0017;
const float S = 0.2;
const float Y_SCALE = 3.0;
float x = gl_FragCoord.x;
float y = gl_FragCoord.y * Y_SCALE;

I made around times smaller and introduced Y_SCALE to make the noise shorter on axis. I also reduced the speed by about 80%.

With these adjustments, we get the following noise:

Loading canvas...

Looks pretty good, but the noise feels a bit too evenly spaced. Yet again, we’ll use stacking to make the noise more interesting. Here’s what I came up with:

const float L = 0.0015;
const float S = 0.13;
const float Y_SCALE = 3.0;
float x = gl_FragCoord.x;
float y = gl_FragCoord.y * Y_SCALE;
float noise = 0.5;
noise += simplex_noise(x * L * 1.0, y * L * 1.00, time * S + O1) * 0.30;
noise += simplex_noise(x * L * 0.6, y * L * 0.85, time * S + O2) * 0.26;
noise += simplex_noise(x * L * 0.4, y * L * 0.70, time * S + O3) * 0.22;
float lightness = clamp(noise, 0.0, 1.0);

The larger noise provides larger, sweeping fades, and the smaller noise gives us the finer details:

Loading canvas...

As a final cherry on top, I want to add a directional flow component. I’ll make two of the noises drift left, and the other drift right.

float F = 0.11 * u_time;
float sum = 0.5;
sum += simplex_noise(x ... + F * 1.0, ..., ...) * ...;
sum += simplex_noise(x ... + -F * 0.6, ..., ...) * ...;
sum += simplex_noise(x ... + F * 0.8, ..., ...) * ...;
float lightness = clamp(sum, 0.0, 1.0);

Here’s what that looks like:

Loading canvas...

This makes the noise feel like it flows to the left — but not uniformly so.

I think this is looking quite good! Let’s clean things up putting this into a background_noise function:

float background_noise() {
float noise = 0.5;
noise += simplex_noise(...);
noise += simplex_noise(...);
// ...
return clamp(noise, 0.0, 1.0);
}
float lightness = background_noise()
gl_FragColor = vec4(vec3(lightness), 1.0);

Now let’s move beyond black and white and add some color to the mix!

Color mapping

Loading canvas...

This red-to-blue gradient works by calculating a value based on the pixel’s coordinate and mapping it to a color — some blend of red and blue — using the value:

vec3 red = vec3(1.0, 0.0, 0.0);
vec3 blue = vec3(0.0, 0.0, 1.0);
float t = gl_FragCoord.x / (CANVAS_WIDTH - 1.0);
vec3 color = mix(red, blue, t);

What we can do is use our new background_noise function to calculate the value.

float t = background_noise();
vec3 color = mix(red, blue, t);

That has the effect of mapping the noise to a red-to-blue gradient:

Loading canvas...

That’s pretty cool, but I’d like to be able to map the value to any gradient, such as this one:

This gradient is a <div> element with its background set to this CSS gradient:

background: linear-gradient(
90deg,
rgb(8, 0, 143) 0%,
rgb(250, 0, 32) 50%,
rgb(255, 204, 43) 100%
);

We can replicate this in a shader. First, we’ll convert the three colors of the gradient to vec3 colors:

vec3 color1 = vec3(0.031, 0.0, 0.561);
vec3 color2 = vec3(0.980, 0.0, 0.125);
vec3 color3 = vec3(1.0, 0.8, 0.169);

We then mix the colors using and some clever math:

float t = gl_FragCoord.x / (CANVAS_WIDTH - 1.0);
vec3 color = color1;
color = mix(color, color2, min(1.0, t * 2.0));
color = mix(color, color3, max(0.0, (t - 0.5) * 2.0));
gl_FragColor = vec4(color, 1.0);

This replicates the CSS gradient perfectly:

Loading canvas...

I’ll move the color calculations into a calc_color function to clean things up:

vec3 calc_color(float t) {
vec3 color = color1;
color = mix(color, color2, min(1.0, t * 2.0));
color = mix(color, color3, max(0.0, (t - 0.5) * 2.0));
return color;
}

Now that we have a calc_color function that maps values to the gradient, we can easily map background_noise to it:

float t = background_noise();
gl_FragColor = vec4(calc_color(t), 1.0);

Here’s the result:

Loading canvas...

Our calc_color function is set up to handle 3-stop gradients, but we can update it to handle gradients with stops. Here is an example of a 5-stop gradient:

vec3 calc_color(float t) {
vec3 color1 = vec3(1.0, 0.0, 0.0);
vec3 color2 = vec3(1.0, 1.0, 0.0);
vec3 color3 = vec3(0.0, 1.0, 0.0);
vec3 color4 = vec3(0.0, 0.0, 1.0);
vec3 color5 = vec3(1.0, 0.0, 1.0);
float num_stops = 5.0;
float N = num_stops - 1.0;
vec3 color = mix(color1, color2, min(t * N, 1.0));
color = mix(color, color3, clamp((t - 1.0 / N) * N, 0.0, 1.0));
color = mix(color, color4, clamp((t - 2.0 / N) * N, 0.0, 1.0));
color = mix(color, color5, clamp((t - 3.0 / N) * N, 0.0, 1.0));
return color;
}

The above function produces the following:

Loading canvas...

This works, but defining the gradient in code like this is (obviously) not great. The colors of the gradient are hardcoded into our shader, and we need to manually adjust the function to handle the correct number of color stops.

We can make this more dynamic by reading the gradient from a texture.

Gradient texture

To pass image data — such as a linear gradient — from JavaScript to our shader, we can use textures. Textures are arrays of data that can, amongst other things, store a 2D image.

Firstly, we’ll generate an image containing a linear gradient in JavaScript. We’ll write that image to a texture and pass that texture to our WebGL shader. The shader can then read data from the texture.

Rendering a gradient to a canvas

I used this gradient generator to pick the following gradient:

It consists of these colors:

const colors = [
"hsl(204deg 100% 22%)",
"hsl(199deg 100% 29%)",
"hsl(189deg 100% 32%)",
"hsl(173deg 100% 33%)",
"hsl(154deg 100% 39%)",
"hsl( 89deg 70% 56%)",
"hsl( 55deg 100% 50%)",
];

To render the gradient to a canvas, we’ll first have to create one. We can do that like so:

const canvas = document.createElement("canvas");
canvas.height = 256;
canvas.width = 64;
const ctx = canvas.getContext("2d");

The gradient is written to a CanvasGradient like so:

const linearGradient = ctx.createLinearGradient(
0, 0, // Top-left corner
canvas.width, 0 // Top-right corner
);
for (const [i, color] of colors.entries()) {
const stop = i / (colors.length - 1);
linearGradient.addColorStop(stop, color);
}

Setting the gradient as the active fill style and drawing a rectangle over the dimensions renders the gradient.

ctx.fillStyle = linearGradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);

Loading canvas...

Now that we’ve rendered a linear gradient onto a canvas element, let’s write it into a texture and pass it to our shader.

Reading canvas contents from a shader

The following code creates a WebGL texture and writes the canvas contents to it:

const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
gl.bindTexture(gl.TEXTURE_2D, null);

I won’t cover how this works — I want to stay focused on shaders, not the WebGL API. I’ll refer you to this post on rendering to a texture if you want to explore this in more detail.

GLSL shaders read data from textures using samplers. A sampler is a function that takes texture coordinates and returns a value for the texture at that position. Emphasis on “a” value because when texture coordinates fall between data points, the sampler returns an interpolated result derived from surrounding values.

There are different sampler types for different value types: isampler for signed integers, usampler for unsigned integers, and sampler for floats. Our image texture contains floats so we’ll use the unprefixed sampler.

Samplers also have dimensionality. You can have 1D, 2D or 3D samplers. Since we’ll be reading from a 2D image texture, we’ll use a sampler2D. If you were reading unsigned integers from a 3D texture, you’d use a usampler3D.

In our shader, we’ll declare our sampler via a uniform. I’ll name it u_gradient:

uniform sampler2D u_gradient;

On the JavaScript side, we’ll make u_gradient point to our texture like so:

const gradientUniformLocation = gl.getUniformLocation(program, "u_gradient");
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(gradientUniformLocation, 0);

Again, I won’t cover how this works — I want to stay focused on the shader side — but I’ll refer you to this post on WebGL textures.

To read data from a texture (via a sampler) we’ll use one of OpenGL’s built-in texture lookup functions. In our case, we’re reading 2D image data, so we’ll use texture2D.

texture2D takes two arguments, a sampler and 2D texture coordinates. The coordinates are normalized so is the top-left corner of the texture and is the bottom-right corner of the texture.

Note: texture2D coordinates are typically normalized, but samplers may also use “texel space” coordinates which range from where is the size of the texture for that dimension.

Here’s our texture again, for reference:

Loading canvas...

The texture is uniform over the axis so we can just set the coordinate to (we could also use or , the result would be the same).

Since the texture is uniform over the axis, its height doesn’t matter. I’m using a height of because it looks nice for this post, but you could use a height of instead.

As for the axis, reading the color at should yield blue, and at we should get yellow. We can verify this with the following shader

uniform sampler2D u_gradient;
uniform float u_x;
void main() {
gl_FragColor = texture2D(u_gradient, vec2(u_x, 0.5));
}

In the canvas below, the slider controls the value of u_x. As you slide from to the color should change from blue to yellow:

Loading canvas...

It works! We can now map values between and to the gradient texture. This makes mapping background_noise to the gradient trivial:

uniform sampler2D u_gradient;
float t = background_noise();
gl_FragColor = texture2D(u_gradient, vec2(t, 0.5));

As we can see, this has the effect of applying our gradient to the background noise:

Loading canvas...

Defining the gradient in JavaScript and generating it dynamically gives us a lot of flexibility. We can easily change the gradient, say, to this funky pastel gradient:

const colors = [
"hsl(141 75% 72%)",
"hsl(41 90% 62%)",
"hsl(358 64% 50%)",
];

Loading canvas...

We’ll soon use this in the final effect, but before we get to that, let’s finish blending our waves.

Dynamic blur

In the final effect, we see varying amounts of blur applied to each wave, with the amount of blur evolving:

Loading canvas...

Our current waves, however, have sharp edges:

Loading canvas...

Let’s get started building a dynamic blur. As a refresher, we’re currently calculating the alpha of our waves like so:

float x = gl_FragCoord.x;
float y = gl_FragCoord.y;
float wave_alpha(float Y, float wave_height) {
float offset = Y * wave_height;
float wave_y = Y + wave_noise(offset) * wave_height;
float dist = wave_y - y;
float alpha = clamp(0.5 + dist, 0.0, 1.0);
return alpha;
}

Let’s define a calc_blur function that calculates the amount of blur to apply. We’ll start simple with an increasing left-to-right blur over the width of the canvas:

float calc_blur() {
float t = x / (CANVAS_WIDTH - 1.0);
float blur = mix(1.0, BLUR_AMOUNT, t);
return blur;
}

We’ll use it to calculate a blur value and divide dist by it — like we did earlier in this post:

float blur = calc_blur();
float alpha = clamp(0.5 + dist / blur, 0.0, 1.0);

This gives us a left-to-right blur:

Loading canvas...

To make the blur dynamic, we’ll yet again reach for the simplex noise function. The setup should feel familiar, it’s almost identical to the wave_noise function we defined earlier:

float calc_blur() {
const float L = 0.0018;
const float S = 0.1;
const float F = 0.034;
float noise = simplex_noise(x * L + F * u_time, u_time * S);
float t = (noise + 1.0) / 2.0;
float blur = mix(1.0, BLUR_AMOUNT, t);
return blur;
}

If we were to apply this as-is to our waves, each wave’s blur would look identical. For the wave blurs to be distinct we’ll need to add an offset to u_time.

Conveniently for us, we can reuse the same offset we calculated for the wave_noise function:

float calc_blur(float offset) {
float time = u_time * offset;
float noise = simplex_noise(x * L + F * time, time * S);
// ...
}
float wave_alpha(float Y, float wave_height) {
float offset = Y * wave_height;
float wave_y = Y + wave_noise(offset) * wave_height;
float blur = calc_blur(offset);
// ...
}

This gives us a dynamic blur:

Loading canvas...

But, honestly, the blur looks pretty bad. It feels like it has distinct “edges” at the top and bottom of each wave.

Also, the waves feel somewhat blurry all over, just unevenly so. We don’t seem to get those long, sharp edges that appear in the final effect:

Loading canvas...

Let’s start by fixing the harsh edges.

Making our blur look better

Consider how we’re calculating the alpha:

float alpha = clamp(0.5 + dist / blur, 0.0, 1.0);

The alpha equals when the distance is . It then linearly increases or decreases until it hits either or , at which point the clamp function kicks in.

Let’s chart the alpha curve so that we can see this visually:

The harsh stops at and produce the sharp-feeling edges that we observe at the edges of the blur.

Loading canvas...

The smoothstep function can help here. Smoothstep is a family of interpolation functions that, as the name suggests, smooth the transition from to .

I’m defining smoothstep like so:

float smoothstep(float t) {
return t * t * t * (t * (6.0 * t - 15.0) + 10.0);
}

This is the “quintic” variant of the smoothstep function. It applies a bit more smoothing than the “default” smoothstep implementation.

Applying smoothstep to our alpha curve is quite simple:

float alpha = clamp(0.5 + dist / blur, 0.0, 1.0);
alpha = smoothstep(alpha);

Below is a chart showing the smoothed alpha curve — I’ll include the original non-smoothed curve for comparison:

This results in a much smoother blur:

Loading canvas...

Following is a side-by-side comparison. The blur to the left is smoothed, while the right one is not.

Loading canvas...

That takes care of the sharp edges. Let’s now tackle the issue of the wave as a whole being too blurry.

Making the wave less uniformly blurry

Here’s our calc_blur method as we left it:

float calc_blur() {
// ...
float noise = simplex_noise(x * L + F * u_time, u_time * S);
float t = (noise + 1.0) / 2.0;
float blur = mix(1.0, BLUR_AMOUNT, t);
return blur;
}

The edge becomes sharper as approaches , and blurrier as approaches . However, the wave only becomes sharp when is very close to zero.

The canvas below has a visualization that illustrates this. The lower half is a chart showing the value of over the axis (with at the bottom to at the top):

Loading canvas...

You’ll notice that the wave becomes sharp when the chart gets close to touching the bottom — at values near zero — but it rarely dips that low. The value of lingers around the middle too much, causing the wave to be somewhat blurry over its entire length.

We can bias low values of to get close to by raising to a power — i.e. applying an exponent.

Consider how an exponent affects values between and . Numbers close to experiece a strong pull towards while larger numbers experience less pull. For example, , a 90% reduction, while , only a reduction of 10%.

The level of pull depends on the exponent. Here’s a chart of for values of between and :

This effect becomes more pronounced as we increase the exponent:


got smaller while got roughly smaller!

The following chart shows over the range for different values of :

Notice how an exponent of has no effect.

As you can see, a higher exponent translates to a stronger pull towards zero.

With that, let’s apply an exponent to . We can do that with the built-in pow function:

float t = (noise + 1.0) / 2.0;
t = pow(t, exponent);

Below is a canvas that lets you vary the value of exponent from to . I intentionally set exponent to a default value of (no effect) so that you can see the effect of increasing the exponent directly (the light-blue line that stays behind represents the value of prior to applying the exponent).

Loading canvas...

As the exponent increases, tends to “hug” the bottom of the chart more and more. This produces noticeable periods of relative sharpness while not muting higher values of too much. I feel like an exponent of to gives good results — I’ll go with .

Let’s bring back the other wave and see what we’ve got:

Loading canvas...

Applying an exponent does dampen the strength of the blur, so let’s ramp the blur amount up — I’ll increase it from to .

Loading canvas...

Now we’re talking! We’ve got a pretty great-looking blur going!

Putting it all together

We’ve got all of the individual pieces we need to construct our final effect — let’s finally put it together!

Each wave is represented by an alpha value:

float w1_alpha = wave_alpha(WAVE1_Y, WAVE1_HEIGHT);
float w2_alpha = wave_alpha(WAVE2_Y, WAVE2_HEIGHT);

We’re currently using those alpha values to blend three colors — these three shades of blue from the previous section:

vec3 bg_color = vec3(0.102, 0.208, 0.761);
vec3 wave1_color = vec3(0.094, 0.502, 0.910);
vec3 wave2_color = vec3(0.384, 0.827, 0.898);

The trick to our final effect lies in substituting each of those colors with a unique background noise and blending those.

We need our three background noises to be distinct. To support that we’ll update our background_noise function to take an offset value and add that to u_time. We’ve done this twice before so at this point this is just routine:

float background_noise(float offset) {
float time = u_time + offset;
float noise = 0.5;
noise += simplex_noise(..., time * S) * ...;
noise += simplex_noise(..., time * S) * ...;
// ...
return clamp(noise, 0.0, 1.0);
}

We can now easily generate multiple distinct background noises. Let’s start by interpreting the background noises as lightness values:

float bg_lightness = background_noise(0.0);
float w1_lightness = background_noise(200.0);
float w2_lightness = background_noise(400.0);

We can blend these lightness values using the wave alpha values to calculate a final lightness value and pass vec3(lightness) to gl_FragColor:

float lightness = bg_lightness;
lightness = mix(lightness, w1_lightness, w1_alpha);
lightness = mix(lightness, w2_lightness, w2_alpha);
gl_FragColor = vec4(vec3(lightness), 1.0);

This gives us the following effect:

Loading canvas...

Just try to tell me that this effect doesn’t look absolutely sick! It’s smooth, flowing, and quite dramatic at times.

The obvious next step is to map the final lightness value to a gradient. Let’s use this one:

Loading canvas...

Like before, we’ll get the texture into our shader via a uniform sampler2D:

uniform sampler2D u_gradient;

We then map the lightness value to the gradient like so:

gl_FragColor = texture2D(u_gradient, vec2(lightness, 0.5));

This applies the gradient to our effect:

Loading canvas...

Looks gorgeous. We can make this more sleek by increasing the height of the canvas a bit and adding a skew:

Loading canvas...

Sick, right? This could be used to add a modern and elegant touch to any landing page. Implementing the skew effect is deceptively simple — it’s just a transform of skewY(-6deg).

Since we’re generating the gradient in JavaScript, we can easily swap out the gradient. Here’s a canvas with a few cool gradients I picked:

Loading canvas...

It took a long time to get here, but we’ve ended up with something really cool.

Final words

I hope this was a good introduction to writing shaders, and I hope I provided you with the tools and intuition to get started writing shaders yourself!

At the beginning of the post I promised to link to the final shader code, so here it is.

The final shader includes a few additional elements that were not covered in the post. For example, the blur is calculated in multiple parts using an exponent range, adding a “haziness” element to the effect. I also added an oscillating “blur bias” to introduce periods of global blurriness and sharpness.

Take a look at this black-and-white version of the final effect and see if you can spot those elements (it’s much easier to see without color):

Loading canvas...

I didn’t cover these additional elements because they’re not core to the effect — they just add a layer of refinement. There are loads of ways in which you could tweak or add to the effect. I tried tons of ideas and kept those around because they worked very well. I suggest tweaking the code and trying to add some refinements yourself!

Huge thanks to my friends, Gunnlaugur Þór Briem and Eiríkur Fannar Torfason, for reading a draft version of this post — they provided great feedback.

Thanks so much for reading. Take what you learned and go write some awesome shaders!

— Alex Harri

Mailing list

To be notified of new posts, subscribe to my mailing list.