Interactive Hexagon Grid Tutorial Part 4 - Hexagon Shader

Published: Monday, June 24, 2024

Greetings, friends! In the previous three lessons, we learned how to make an interactive hexagon grid using the 2D HTML Canvas, but now it's time to start making a hexagon grid using the power of WebGL and hardware acceleration.

Introduction

This tutorial is the beginning of a new way of creating interactive hexagon grids. If you haven't read the past three tutorials, that's okay! You can start here if you're only interested in using shaders to make a hexagon grid.

In this tutorial, we'll learn how to make a hexagon in a fragment shader using Shadertoy and the OpenGL Shading Language (GLSL). In the next tutorial, we'll learn how to make a grid of hexagons. Then, we'll learn how to port Shadertoy code to Three.js.

Go ahead and throw away almost everything you learned in the previous three tutorials because where we're going, we don't need the 2D HTML canvas anymore. Just kidding...the previous three tutorials still taught useful skills about hexagon geometry. Read them if you're curious 🙂

Anyways, let's head off to a new adventure: to the land of shaders!

Review of GLSL

If you need a review of GLSL and Shadertoy, I highly recommend reading my Shadertoy Tutorial Series. This series will teach you the fundamentals of Shadertoy and how to make shaders using GLSL. Once you feel confident enough in GLSL, come back here!

Make sure to at least read Part 1 through Part 4 and Part 15 which discusses how to use channels, textures, and buffers in Shadertoy.

Starting a New Shader

Let's create a brand new shader in Shadertoy by going to https://www.shadertoy.com/new. Then, replace the default code with the following:

glsl
Copied! ⭐️
void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    vec3 col = vec3(0);

    // Normalized pixel coordinates (from 0 to 1)
    vec2 uv = fragCoord/iResolution.xy;

    fragColor = vec4(col,1.0);
}

This code simply sets the color for every pixel to black in the Shadertoy canvas. When starting a new shader in Shadertoy, the UV coordinates (the uv variable) of the canvas are normalized between zero and one across both the x-axis and y-axis, but we can adjust this range as we'll see later.

In shaders, the convention is typically that the x-axis increases as you go to the right. The y-axis increases as you go up which is opposite to the 2D HTML canvas where the y-direction increases as you go down. Keep this in mind when developing a hexagon grid using shaders.

Understanding the Dot Product

We will use the dot product to help make a hexagon, but let's first understand what it represents. By definition, the dot product between two vectors is equal to the sum of the products of each component of a vector. For example, our uv variable is a two-dimensional vector with a x-component and y-component. If we take the dot product of the vector, uv, and itself, it produces the following result:

text
Copied! ⭐️
dot(uv, uv)
  = uv.x * uv.x + uv.y * uv.y
  = uv.x ^ 2 + uv.y ^ 2

Let's look at another example. We'll take the dot product between vec2(1, 2) and vec2(3, 4).

text
Copied! ⭐️
dot(vec2(1, 2), vec2(3, 4))
  = 1 * 3 + 2 * 4
  = 3 + 8
  = 11

We multiply the x-component of the first vector with the x-component of the second vector. Then, we multiply the y-component of the first vector with the y-component of the second vector. The result is the sum between the two products.

What does the dot product tell us? What is the meaning of 11 in the dot product between vec2(1, 2) and vec2(3, 4)? When we calculate the dot product, we get back a scalar value. That is, we get back a regular number that is not a vector. This number can tell us a relation between the two vectors.

If the dot product between two numbers is zero, then we know the vectors are completely perpendicular with each other. If the dot product is a negative value, then the vectors are mostly facing opposite directions. If the product is positive, then the vectors are mostly facing toward the same direction.

Let's look at a visual example of this. Suppose we wanted to take the dot product between uv and vec2(0, 1). If we do some basic calculations, we can see that the x-component of uv goes away, and we're only left with the y-component of uv.

text
Copied! ⭐️
dot(uv, vec2(0, 1))
  = uv.x * 0 + uv.y * 1
  = uv.y

Let's visualize this dot product using GLSL code in Shadertoy.

glsl
Copied! ⭐️
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec3 col = vec3(0);

    vec2 uv = fragCoord/iResolution.xy;
    
    float d = dot(uv, vec2(0, 1));
    
    col = vec3(d);

    fragColor = vec4(col,1.0);
}

After running this code, we should see a vertical gradient appear in the canvas.

Shadertoy canvas displaying a vertical gradient from black to white, starting at the bottom of the canvas and going toward the top of the canvas.

As the y-component of uv increases, the color becomes white. That is because we are taking the dot product everywhere in the canvas. For pixels that closely aligns with vec2(0, 1), their color will be more white. The top of the canvas has a coordinate of one. The bottom of the canvas has a coordinate of 0. Pixels with UV coordinates that are farther away from vec(0, 1) will therefore have a darker color.

The dot product therefore acts like a "signed distance function" (or SDF). It also shows us how closely aligned vectors are with each other. This is why you typically see the dot product expressed in terms of another equation:

text
Copied! ⭐️
dot(a, b) = length(a) * length(b) * cos(theta)

Where a and b are vectors and theta is the angle between the two vectors. If you were making a video game, you could make the character's line of sight a vector. If an enemy is within a few degrees of this line of sight, then you know the enemy is within attacking range.

Every pixel is a vector that is relative to the origin of the canvas. Currently, our UV coordinates are setup such that the origin is at the bottom-left corner of the canvas. This is why the pixels get darker when they're close to the bottom of the canvas.

Remapping our UV Coordinates

Currently, our UV coordinates go betwen zero and one on both the x-axis and y-axis. Let's make them go between -1 and 1 instead. This will let us create a coordinate space with four quadrants instead of just one.

glsl
Copied! ⭐️
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec3 col = vec3(0);

    vec2 uv = fragCoord/iResolution.xy * 2. - 1.;
    
    float d = dot(uv, vec2(0, 1));
    
    col = vec3(d);

    fragColor = vec4(col,1.0);
}
tip
Remember, we need to use 2. (with a decimal point) instead of 2 because GLSL expects a float to have a decimal in it when we're doing calculations that involve floats.

When we run the code, you may have noticed that the gradient has shifted. That is because the x-axis equals zero in the middle of the canvas instead of the bottom of the canvas. When the final color is produced, color values less than zero are clamped to black, and color values greater than one are clamped to white.

Shadertoy canvas displaying a vertical gradient from black to white, starting at the middle of the canvas and going toward the top of the canvas.

When working with shaders, coordinate space transformations are the key to drawing lots of shapes. It's like we're bending or warping space instead of drawing paths. This is a completely different approach than drawing shapes using the 2D HTML canvas and an entirely different way of thinking. Keep practicing, and it'll become second nature.

Drawing a Diamond

It's time to find the diamond in the rough. I mean, it's time to draw a diamond which might get rough lol. Why a diamond? Because drawing a diamond is one step closer to drawing a hexagon. We'll see why soon.

Now that we have remapped our UV coordinate space, let's try splitting the screen between black and white colors using the power of the dot product.

glsl
Copied! ⭐️
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec3 col = vec3(0);

    vec2 uv = fragCoord/iResolution.xy * 2. - 1.;
    
    float d = dot(uv, vec2(0, 1));
    float dStep = step(0., d);
    
    col = vec3(dStep);

    fragColor = vec4(col,1.0);
}

When we run this code, we can clearly see the canvas has been split between black and white, a yin-yang colored rectangle, if you will ☯.

Shadertoy canvas displaying two colors split by a horizontal line through the middle of the canvas. The top half the canvas is solid white, and the bottom half is solid black.

The step is a powerful function used in shader programming. It accepts two parameters: an edge and a value, x. If x is less than edge, a value of zero is returned. Otherwise, it will return a value of one.

We can actually control where the boundary lies by changing the 0. in our step function to something a bit higher such as 0.2. Let's see how the canvas looks after this change.

Shadertoy canvas displaying two colors split by a horizontal line slightly above the middle of the canvas. The top part of the canvas is solid white, and the bottom part is solid black.

Remember, the split between colors was made possible by the dot product. Let's now try making the boundary have a slope instead of a completely horizontal line. We can create such a boundary by using a diagonal vector in the dot product.

glsl
Copied! ⭐️
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec3 col = vec3(0);

    vec2 uv = fragCoord/iResolution.xy * 2. - 1.;
    
    float d = dot(uv, vec2(0.5 * sqrt(3.), 0.5));
    float dStep = step(0.2, d);
    
    col = vec3(dStep);

    fragColor = vec4(col,1.0);
}

When we run this code, we should see the following image.

Shadertoy canvas displaying two colors split by a diagonal line. The left part the canvas is solid black, and the right part is solid white.

You might be wondering where the vector, vec2(0.5 * sqrt(3.), 0.5), came from. We can draw a triangle inside the hexagon such that the slanted edge of the hexagon is the hypotenuse of the triangle.

A right triangle inside the hexagon where the hypotenuse is a side of the hexagon. The hypotenuse is the slope. The bottom leg of the triangle equals the radius times the cosine of 60 degrees. The left leg of the triangle equals the radius times the sine of 60 degrees. The angle between the x-axis and the hypotenuse equals 60 degrees.

What we care about is the hypotenuse of this triangle, which is the slope of the slanted edge of the hexagon. If you remember from school, the slope is typically "rise over run" which means we would find the slope by dividing the height of the triangle by the base. However, we're making a shader, so we can represent the slope as a vector.

For a hexagon circumscribed by a circle with a radius of one, the height of the triangle is equal to 0.5 * sqrt(3), and the base is equal to 0.5. Therefore, we can represent the slope of the hexagon's slanted edge as the vector, vec2(0.5 * sqrt(3), 0.5). We can then use the slope as a diagonal line that splits the canvas in two.

With the canvas split with a diagonal line, it seems like we kinda have a quarter of a diamond. How do we make it a full diamond? We fold/warp space again using the power of math! Since our UV coordinates are currently setup to go between -1 and 1, we can take the absolute value of the UV coordinates to make each quadrant of our coordinate system equal to mirror images of the top-right quadrant.

Let's see what happens when we take the absolute value of uv along only the x-axis.

glsl
Copied! ⭐️
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec3 col = vec3(0);

    vec2 uv = fragCoord/iResolution.xy * 2. - 1.;
    uv.x = abs(uv.x);

    float d = dot(uv, vec2(0.5 * sqrt(3.), 0.5));
    float dStep = step(0.2, d);
    
    col = vec3(dStep);

    fragColor = vec4(col,1.0);
}

After running the code, it looks like we have the top half of a diamond now!

Shadertoy canvas displaying the top half of a diamond, colored in black. The canvas is white elsewhere.

We are effectively using the absolute value to trim away parts of the graph to make a shape. This is similar to the techniques people use to draw artwork in Desmos. By taking the absolute value of the x-component, we can effectively mirror a function across the y-axis.

Let's now take the absolute value of both components of the uv vector. This will mirror the top half of the diamond across the y-axis to form a complete diamond.

glsl
Copied! ⭐️
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec3 col = vec3(0);

    vec2 uv = fragCoord/iResolution.xy * 2. - 1.;
    uv = abs(uv);

    float d = dot(uv, vec2(0.5 * sqrt(3.), 0.5));
    float dStep = step(0.2, d);
    
    col = vec3(dStep);

    fragColor = vec4(col,1.0);
}

Shadertoy canvas displaying a small diamond in the center of the canvas, colored in black. The canvas is white elsewhere.

Yay! We have a small little diamond now! We can easily control the size by adjusting the first parameter of the step function. The next step is to cut off pieces of the top and bottom parts of the diamond to make our hexagon.

Drawing a Hexagon

We drew a diamond by using the dot product and warping space (the UV coordinate system), so now let's slice off the pointy parts of the diamond using the max function in GLSL.

glsl
Copied! ⭐️
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec3 col = vec3(0);

    vec2 uv = fragCoord/iResolution.xy * 2. - 1.;
    uv = abs(uv);

    float d = max(dot(uv, vec2(0.5 * sqrt(3.), 0.5)), uv.y);
    float dStep = step(0.2, d);
    
    col = vec3(dStep);

    fragColor = vec4(col,1.0);
}

If you run this code, we should have a hexagon! Kinda. It seems a bit squashed...

Shadertoy canvas displaying a small hexagon in the center of the canvas, colored in black. The canvas is white elsewhere. The hexagon is a bit squashed on the top and bottom of it.

Fixing the Aspect Ratio

The reason for the distortion is due to the aspect ratio of the Shadertoy canvas. The Shadertoy canvas typically has a width/height ratio of 16/9. We need to correct for the difference in size between the width and height. We can do this by changing the uv variable at the top of our code.

glsl
Copied! ⭐️
vec2 uv = (fragCoord - iResolution.xy * .5) / iResolution.y;

Assuming an aspect ratio of 16/9, our UV coordinates along the x-axis will range between approximately -0.88 and 0.88. The y-axis will range between -0.5 and 0.5. Our code should now look like the following.

glsl
Copied! ⭐️
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec3 col = vec3(0);

    // Take aspect ratio into consideration
    vec2 uv = (fragCoord - iResolution.xy * .5) / iResolution.y;
    uv = abs(uv);

    float d = max(dot(uv, vec2(0.5 * sqrt(3.), 0.5)), uv.y);
    float dStep = step(0.2, d);
    
    col = vec3(dStep);

    fragColor = vec4(col,1.0);
}

Once we run this code, we should have a regular hexagon. Behold! A shader-driven hexagon! ⬣

Shadertoy canvas displaying a small regular hexagon in the center of the canvas, colored in black. The canvas is white elsewhere.

In the max function, we're taking the max between the function used to draw the diamond and a value of uv.y. Since we're setting uv = abs(uv), we are effectively trimming the top and bottom of the hexagon at the same time using a horizontal line at y = 0.2 and y = -0.2. It's like we took a saw and sliced off the pointy parts of the diamond.

The first parameter we pass into the step function will control the size of the hexagon. A value of 0.2 will make the hexagon have a total height of 0.4.

Please see this Desmos graph I made if you would like to get more insight into how we made our hexagon. You can hide/show different functions in the graph. By using a combination of absolute values and the max function, we are able to create a plot of a hexagon.

On the right side: Desmos graph of a hexagon. On the left side: equations used to help design the final formula for creating a hexagon in Desmos.

Code Cleanup

Let's move our logic for building a hexagon in our shader to a function called hexD (for hexagonal distance) because we will need to utilize it later when we start creating a hexagon grid.

We'll also create a global variable at the top of the shader called hexSide that will store the vector, vec2(1.7320508, 1). This vector is double of the vector we were using previously to make our hexagons. Therefore, we need to multiply it by 0.5 in our dot product. We're using the doubled version of the vector because it will be more useful later on.

Our finished code should look like the following.

glsl
Copied! ⭐️
const vec2 hexSide = vec2(1.7320508, 1); // proportion between the two legs of a 30-60-90 triangle

// hexagonal distance
float hexD(in vec2 p)
{    
    p = abs(p);
    
    return max(dot(p, hexSide * .5), p.y);
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec3 col = vec3(0);

    // Take aspect ratio into consideration
    vec2 uv = (fragCoord - iResolution.xy*.5)/iResolution.y;

    float d = hexD(uv);
    float dStep = step(0.2, d);
    
    col = vec3(dStep);

    fragColor = vec4(col,1.0);
}

We have now created our very own hexagon signed distanced field/function (SDF). We can use the hexD function to get the distance between the center point of the hexagon and an edge of the hexagon. We can visualize the signed distance field by looking at the color of d instead of dStep:

glsl
Copied! ⭐️
col = vec3(d);

We can see that the colors are darker toward the center of the hexagon and get brighter as you move toward the edges or corners of the hexagon.

Shadertoy canvas displaying a distance field of a hexagon. The colors go toward black in the center of the hexagon and go toward white as the pixels move away from the center of the hexagon and toward the corners or sides of the hexagon.

Conclusion

In this tutorial, we learned how to make an SDF for a hexagon. This lets us detect the distance between the center of the hexagon and its edges and corners. This will be useful later when we start implementing an interactive hexagon grid.

In the next tutorial, we'll learn how to convert our cartesian coordinates into hexagonal coordinates. I'll put the "nate" in coordinate! Get it? Because my nickname is Nate. Funny, right? 😅

Wait, no, don't leave! See you over at the next tutorial! Right? Yes? Yes.

Resources