Shadertoy Tutorial Part 4 - Multiple 2D Shapes and Mixing

Published: Monday, March 15, 2021
Updated: Monday, May 3, 2021

Canvas depicting a red heart, green square, blue circle, and yellow start on top of a background with a colored gradient that ranges from shades of purple at the bottom to shades of cyan at the top.

Greetings, friends! In the past couple tutorials, we've learned how to draw 2D shapes to the canvas using Shadertoy. In this article, I'd like to discuss a better approach to drawing 2D shapes, so we can easily add multiple shapes to the canvas. We'll also learn how to change the background color independent from the shape colors.

The Mix Function

Before we continue, let's take a look at the mix function. This function will be especially useful to us as we render multiple 2D shapes to the scene.

The mix function linearly interpolates between two values. In other shader languages such as HLSL, this function is known as lerp instead.

Linear interpolation for the function, mix(x, y, a), is based on the following formula:

text
Copied! ⭐️
x * (1 - a) + y * a

x = first value
y = second value
a = value that linearly interpolates between x and y

Think of the third parameter, a, as a slider that lets you choose values between x and y.

You will see the mix function used heavily in shaders. It's a great way to create color gradients. Let's look at an example:

glsl
Copied! ⭐️
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec2 uv = fragCoord/iResolution.xy; // <0, 1>

    float interpolatedValue = mix(0., 1., uv.x);
    vec3 col = vec3(interpolatedValue);

    // Output to screen
    fragColor = vec4(col,1.0);
}

In the above code, we are using the mix function to get an interpolated value per pixel on the screen across the x-axis. By using the same value across the red, green, and blue channels, we get a gradient that goes from black to white, with shades of gray in between.

Canvas that displays an example of a gradient starting at black on the left and going to white on the right with shades of gray in between.

We can also use the mix function along the y-axis:

glsl
Copied! ⭐️
float interpolatedValue = mix(0., 1., uv.y);

Canvas that displays an example of a gradient starting at black on the bottom and going to white on the top with shades of gray in between.

Using this knowledge, we can create a colored gradient in our pixel shader. Let's define a function specifically for setting the background color of the canvas.

glsl
Copied! ⭐️
vec3 getBackgroundColor(vec2 uv) {
    uv += 0.5; // remap uv from <-0.5,0.5> to <0,1>
    vec3 gradientStartColor = vec3(1., 0., 1.);
    vec3 gradientEndColor = vec3(0., 1., 1.);
    return mix(gradientStartColor, gradientEndColor, uv.y); // gradient goes from bottom to top
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec2 uv = fragCoord/iResolution.xy; // <0, 1>
    uv -= 0.5; // <-0.5,0.5>
    uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

    vec3 col = getBackgroundColor(uv);

    // Output to screen
    fragColor = vec4(col,1.0);
}

This will produce a cool gradient that goes between shades of purple and cyan.

Canvas that displays an example of a gradient starting at purple on the bottom and going to cyan on the top with shades of purple and cyan in between.

When using the mix function on vectors, it will use the third parameter to interpolate each vector on a component basis. It will run through the interpolator function for the red component (or x-component) of the gradientStartColor vector and the red component of the gradientEndColor vector. The same tactic will be applied to the green (y-component) and blue (z-component) channels of each vector.

We added 0.5 to the value of uv because in most situations, we will be working with values of uv that range between a negative number and positive number. If we pass a negative value into the final fragColor, then it'll be clamped to zero. We shift the range away from negative values for the purpose of displaying color in the full range.

An Alternative Way to Draw 2D Shapes

In the previous tutorials, we learned how to use 2D SDFs to create 2D shapes such as circles and squares. However, the sdfCircle and sdfSquare functions were returning a color in the form of a vec3 vector.

Typically, SDFs return a float and not vec3 value. Remember, "SDF" is an acronym for "signed distance fields." Therefore, we expect them to return a distance of type float. In 3D SDFs, this is usually true, but in 2D SDFs, I find it's more useful to return either a one or zero depending on whether the pixel is inside the shape or outside the shape as we'll see later.

The distance is relative to some point, typically the center of the shape. If a circle's center is at the origin, (0, 0), then we know that any point on the edge of the circle is equal to the radius of the circle, hence the equation:

text
Copied! ⭐️
x^2 + y^2 = r^2

Or, when rearranged,
x^2 + y^2 - r^2 = 0

where x^2 + y^2 - r^2 = distance = d

If the distance is greater than zero, then we know that we are outside the circle. If the distance is less than zero, then we are inside the circle. If the distance is equal to zero exactly, then we're on the edge of the circle. This is where the "signed" part of the "signed distance field" comes into play. The distance can be negative or positive depending on whether the pixel coordinate is inside or outside the shape.

In Part 2 of this tutorial series, we drew a blue circle using the following code:

glsl
Copied! ⭐️
vec3 sdfCircle(vec2 uv, float r) {
  float x = uv.x;
  float y = uv.y;

  float d = length(vec2(x, y)) - r;

  return d > 0. ? vec3(1.) : vec3(0., 0., 1.);
  // draw background color if outside the shape
  // draw circle color if inside the shape
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0,1>
  uv -= 0.5;
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = sdfCircle(uv, .2);

  // Output to screen
  fragColor = vec4(col,1.0);
}

The problem with this approach is that we're forced to draw a circle with the color, blue, and a background with the color, white.

We need to make the code a bit more abstract, so we can draw the background and shape colors independent of each other. This will allow us to draw multiple shapes to the scene and select any color we want for each shape and the background.

Let's look at an alternative way of drawing the blue circle:

glsl
Copied! ⭐️
float sdfCircle(vec2 uv, float r, vec2 offset) {
  float x = uv.x - offset.x;
  float y = uv.y - offset.y;

  return length(vec2(x, y)) - r;
}

vec3 drawScene(vec2 uv) {
  vec3 col = vec3(1);
  float circle = sdfCircle(uv, 0.1, vec2(0, 0));

  col = mix(vec3(0, 0, 1), col, step(0., circle));

  return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = drawScene(uv);

  // Output to screen
  fragColor = vec4(col,1.0);
}

In the code above, we are now abstracting out a few things. We have a drawScene function that will be responsible for rendering the scene, and the sdfCircle now returns a float that represents the "signed distance" between a pixel on the screen and a point on the circle.

We learned about the step function in Part 2. It returns a value of one or zero depending on the value of the second parameter. In fact, the following are equivalent:

glsl
Copied! ⭐️
float result = step(0., circle);
float result = circle > 0. ? 1. : 0.;

If the "signed distance" value is greater than zero, then that means, the point is inside the circle. If the value is less than or equal to zero, then the point is outside or on the edge of the circle.

Inside the drawScene function, we are using the mix function to blend the white background color with the color, blue. The value of circle will determine if the pixel is white (the background) or blue (the circle). In this sense, we can use the mix function as a "toggle" that will switch between the shape color or background color depending on the value of the third parameter.

Canvas with a white background and blue circle in the middle.

Using an SDF in this way basically lets us draw the shape only if the pixel is at a coordinate that lies within the shape. Otherwise, it should draw the color that was there before.

Let's add a square that is offset from the center a bit.

glsl
Copied! ⭐️
float sdfCircle(vec2 uv, float r, vec2 offset) {
  float x = uv.x - offset.x;
  float y = uv.y - offset.y;

  return length(vec2(x, y)) - r;
}

float sdfSquare(vec2 uv, float size, vec2 offset) {
  float x = uv.x - offset.x;
  float y = uv.y - offset.y;

  return max(abs(x), abs(y)) - size;
}

vec3 drawScene(vec2 uv) {
  vec3 col = vec3(1);
  float circle = sdfCircle(uv, 0.1, vec2(0, 0));
  float square = sdfSquare(uv, 0.07, vec2(0.1, 0));

  col = mix(vec3(0, 0, 1), col, step(0., circle));
  col = mix(vec3(1, 0, 0), col, step(0., square));

  return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = drawScene(uv);

  // Output to screen
  fragColor = vec4(col,1.0);
}

Using the mix function with this approach lets us easily render multiple 2D shapes to the scene!

Canvas with a white background and blue circle in the middle. A red square with a radius slightly smaller than the blue circle is placed on top of the blue circle and offset slightly to the right of the middle of the canvas.

Custom Background and Multiple 2D Shapes

With the knowledge we've learned, we can easily customize our background while leaving the color of our shapes intact. Let's add a function that returns a gradient color for the background and use it at the top of the drawScene function.

glsl
Copied! ⭐️
vec3 getBackgroundColor(vec2 uv) {
    uv += 0.5; // remap uv from <-0.5,0.5> to <0,1>
    vec3 gradientStartColor = vec3(1., 0., 1.);
    vec3 gradientEndColor = vec3(0., 1., 1.);
    return mix(gradientStartColor, gradientEndColor, uv.y); // gradient goes from bottom to top
}

float sdfCircle(vec2 uv, float r, vec2 offset) {
  float x = uv.x - offset.x;
  float y = uv.y - offset.y;

  return length(vec2(x, y)) - r;
}

float sdfSquare(vec2 uv, float size, vec2 offset) {
  float x = uv.x - offset.x;
  float y = uv.y - offset.y;
  return max(abs(x), abs(y)) - size;
}

vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);
  float circle = sdfCircle(uv, 0.1, vec2(0, 0));
  float square = sdfSquare(uv, 0.07, vec2(0.1, 0));

  col = mix(vec3(0, 0, 1), col, step(0., circle));
  col = mix(vec3(1, 0, 0), col, step(0., square));

  return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = drawScene(uv);

  // Output to screen
  fragColor = vec4(col,1.0);
}

Simply stunning! 🤩

Canvas with a gradient background ranging from shades of purple at the bottom to shades of cyan at the top. A blue circle in the middle. A red square with a radius slightly smaller than the blue circle is placed on top of the blue circle and offset slightly to the right of the middle of the canvas.

Would this piece of abstract digital art make a lot of money as a non-fungible token 🤔. Probably not, but one can hope 😅.

Conclusion

In this lesson, we created a beautiful piece of digital art. We learned how to use the mix function to create a color gradient and how to use it to render shapes on top of each other or on top of a background layer. In the next lesson, I'll talk about other 2D shapes we can draw such as hearts and stars.

Resources