Creating glsl shader art


ON THIS PAGE

    🧩 Introduction

    I've always been fascinated by generative artworks, and have been learning glsl shaders in my spare time. While I've experimented with some techniques, I've never fully committed to creating some artwork, let alone sharing them with the public. This article documents my attempt to create something and share it with others. I am a learner. Feel free to hit me up on twitter to share feedback or your versions and tweaksss. Lets’s begin, shall we!!!

    🔲 Back To The Drawing Board (Literally)

    Let's dive right into the code! The first thing we'll create is a simple white square. This is the most basic example of a fragment shader.

    precision mediump float;

    varying vec2 vUv;

    void main() {
    gl_FragColor = vec4(1.0);
    }

    So what’s going on here for a little context.

    The first line of code declares the precision for the floating-point numbers used in the shader. In this case, it declares the precision as mediump, which means that floating-point calculations are done with a medium level of precision. This is a common choice for shaders because it provides a balance between accuracy and performance.

    The varying keyword is used to pass data from the vertex shader to the fragment shader. In this case, we're passing in the texture coordinate vUv, which we'll use to sample the texture.

    Finally, we have the gl_FragColor variable. This is a predefined variable that holds the output color of the fragment shader. In this case, we're setting it to a plain white color by using the vec4(1.0) function.

    Hurrah! We have a plain white square on the screen. This will be our base for creating more interesting artworks.

    I know what you’re thinking. Boooring.

    200.webp

    Yeah, I know. But hey, you've got to start somewhere, right? Right? 😅

    🌈 Add Colors & Gradients

    Cosine-based color palettes are a type of mathematical color palette that uses the cosine function to generate smooth color gradients. In a GLSL shader, you can use the cos function to generate colors based on a value that changes over TIME or SPACE.

    Helper functions

    // Glsl cos palettes
    // https://iquilezles.org/articles/palettes/


    vec3 palette(float t, vec3 brightness, vec3 contrast, vec3 oscillation, vec3 phase) {
    return brightness + contrast * cos(6.28318 * (oscillation * t + phase));
    }

    vec3 palette(vec3 t, vec3 brightness, vec3 contrast, vec3 oscillation, vec3 phase) {
    return brightness + contrast * cos(6.28318 * (oscillation * t + phase));
    }

    Both the following functions do the same, create a cosine-based color palette. They just differ in the input type of first parameter.

    If the color palette is being controlled by a time variable that changes over time, then the first function that takes a float input would be appropriate.On the other hand, if the color palette is being controlled by a vector input that changes over space or some other dimension, then the second function that takes a vector input would be appropriate.

    We also introduce a variable uv that will be used to utilise the texture coordinates. Additionally bring-in uTime and uOpacity uniforms, soon we’ll be working with them.

    Final plane.frag up till this point


    precision mediump float;

    uniform float uTime;
    uniform float uOpacity;

    varying vec2 vUv;

    vec3 palette(float t, vec3 brightness, vec3 contrast, vec3 oscillation, vec3 phase) {
    return brightness + contrast * cos(6.28318 * (oscillation * t + phase));
    }

    vec3 palette(vec3 t, vec3 brightness, vec3 contrast, vec3 oscillation, vec3 phase) {
    return brightness + contrast * cos(6.28318 * (oscillation * t + phase));
    }

    void main() {
    vec2 uv = vUv;

    vec3 c = vec3(1.0);
    c = palette(uv.x + 0.3, vec3(0.5, 0.5, 0.5), vec3(1.0, 0.41, 0.4), vec3(0.46, 0.41, 0.46), vec3(0.34, 0.35, 0.35));

    gl_FragColor.rgb = c;
    gl_FragColor.a = 1.0;
    gl_FragColor.a *= uOpacity;
    }

    Main thing is to look at the function call to palette. We plug in the uv.x and get a color based on x coordinate. This creates a beautiful gradient going from left to right.

    What if you wanted the gradient to go from top to bottom, well you could just plug in uv.y

    c = palette(uv.y + 0.3, vec3(0.5, 0.5, 0.5), vec3(1.0, 0.41, 0.4), vec3(0.46, 0.41, 0.46), vec3(0.34, 0.35, 0.35));

    The choice of the four color constants is what I settled on. Feel free to tweak them to make yours palette. 👍

    Some changes like the following are self explanatory.

    gl_FragColor.rgb = c;
    gl_FragColor.a = 1.0;
    gl_FragColor.a *= uOpacity;

    🕺 Don't Play Dead. Add Some Movement

    We don’t want a static picture. We want to move some things. Usually we add motion using some kind of parameter like position of mouse or time. We are going to use time here. We have an uniform called uTime in our shader code that tells us the time that has elapsed since the start of this page load. Let’s see how we can use that to add some motion.

    Before doing that, we are going to update the value we are passing to our palette function. Instead of a constant value, lets make it a 2D coordinate p1 and set it’s value to uv.xx

    But this is a vec2, we need some way to convert this into a floating point because that is what our palette function accepts. So we use this circle utitlity that gives us the Euclidean distance using our coordinates, something like radius.

    float circle(vec2 uv, vec2 o, float r) {
    return length(uv - o) - r;
    }

    The function circle() returns the signed distance of the current pixel to the perimeter of the circle. length(uv - o) - r produces a positive value if the current pixel is outside the circle and a negative value if the current pixel is inside the circle.

    The length() function computes the Euclidean distance. The Euclidean distance between two points in Euclidean space is the length of a line segment between the two points (always +ve). Thus this function just returns a +ve value of distance from the center [0, 0]

    After adding it at the top, change the uv.x we passed into the palette function to the radius we get as a result of calling the circle function with our point p1 as an argument. 🤯

    vec2 p1 = uv.xx;
    float ci = circle(p1, vec2(0.0), 0.0);
    vec3 c = palette(ci + 0.3, vec3(0.5, 0.5, 0.5), vec3(1.0, 0.41, 0.4), vec3(0.46, 0.41, 0.46), vec3(0.34, 0.35, 0.35));

    I know that’s a lot of math, but don’t worry about it.

    Now it is easier to control things with time as we can add time based components to our p1.

    Add a component (i.e. a vec2 coordinate) to p1 that will change based on the uniform uTime. For movement, we generally use sine and cosine, so lets try this:

    vec2 p1 = uv.xx + vec2(
    cos(uTime),
    sin(uTime)
    );

    Ok, it’s moving and grooving. 🕺💃

    After some tweaking and updating constants, we have

    vec2 p1 = uv.xx + vec2(
    cos(uTime * 1.0) * 2.0,
    sin(uTime * 2.0) * 2.0
    ) * 0.25;

    Great job you wonderful human

    🗺 Update Some Coordinates

    You want to center our artwork like it's the star of the show, not just a sidekick. So, let's move the origin from the boring old bottom left corner to the cool and happening center. Our art deserves to be the center of attention, am I right?

    vec2 uv = vUv * 2.0 - 1.0;

    vUv goes from [0, 1] and doing the following operation makes uv range from [-1, 1]

    This makes things symmetrical along x axis taking center as origin.

    📏 Divide Into Grid

    We have movement, we have symmetry. Now what. Lets make more of it by dividing up into grid. To make things easier to tweak, create a repeat variable. This would later be used to divide our grid into 16x16 cells.

    vec2 rp = vec2(1.0, 16.0);

    Update the uv

    uv = fract(vUv * rp) - 0.5;

    Explainer

    • vUv goes from 0 to 1 (imagine a straight line segment at 45 degrees angle)
    • vUv * rp goes from 0 to 16 (thus making the line longer)
    • doing a fract makes uv go from [0, 1] 16 times (breaking line into 16 shorter lines)
    • and then subtracting 0.5 from makes uv go from [-0.5, 0.5] (shifting the graph below)

    Looking a little better with that green out of sight. Now comes an important part so pay close attention. The division into cells.

    vec2 id = floor(vUv * rp);

    This line divides the artwork in 16x16 grid cells.

    But you won’t see any change because we are not yet doing anything with this division. All the cells are moving in the same direction and at the same time. One thing we can do to make this animation more interesting is to select rows from the grid and move them in alternate directions.

    We can choose if we want the horizontal grid lines or the vertical grid lines using id.x and id.y. For this example, we have chosen id.y. That means for even y values, it will have one direction and for odd y values it will have other direction.

    To implement this in code, we create direction variables d1 and d2 and plug in to something that is responsible for motion. ie. p1.

    float d1 = mix(-1.0, 1.0, mod(id.x, 2.0));
    float d2 = mix(-1.0, 1.0, mod(id.y, 2.0));

    d1 and d2 are creating movement based on even number strips. And then use it with p1 as

    vec2 p1 = uv.xx + vec2(
    cos(uTime * 1.0) * d2 * 2.0,
    sin(uTime * 2.0) * d1 * 2.0
    ) * 0.25;

    That looks beautiful. What do you say?

    🤪 Add Randomness & Phase Difference

    What we have currently looks fine but what if we wanted to add some randomness. That is how things in real life are. So lets begin by adding a helper called glsl-random

    highp float random(vec2 co)
    {
    highp float a = 12.9898;
    highp float b = 78.233;
    highp float c = 43758.5453;
    highp float dt= dot(co.xy ,vec2(a,b));
    highp float sn= mod(dt,3.14);
    return fract(sin(sn) * c);
    }

    And then use this to choose a cell from our grid at random and assign some phase.

    Phase is just a fancy word for where in time is our animation. Since cosine is a curve that repeats itself, so will the animation. You can think of phase as to where the seek of video is at this very moment. This will help us achieve a stagger effect.

    But along with phase, we also need to map the coordinates value from 0 to 1 to o to two pi to use it in the phase variable. So add another utility called map (tell what are map functions)

    float map(float value, float inMin, float inMax, float outMin, float outMax) {
    return outMin + (outMax - outMin) * (value - inMin) / (inMax - inMin);
    }

    vec2 map(vec2 value, vec2 inMin, vec2 inMax, vec2 outMin, vec2 outMax) {
    return outMin + (outMax - outMin) * (value - inMin) / (inMax - inMin);
    }

    vec3 map(vec3 value, vec3 inMin, vec3 inMax, vec3 outMin, vec3 outMax) {
    return outMin + (outMax - outMin) * (value - inMin) / (inMax - inMin);
    }

    vec4 map(vec4 value, vec4 inMin, vec4 inMax, vec4 outMin, vec4 outMax) {
    return outMin + (outMax - outMin) * (value - inMin) / (inMax - inMin);
    }

    and use it as such in the main function

    float rid = random(id);
    float ph = map(rid, 0.0, 1.0, 0.0, TWO_PI);

    Don’t forget to mention these macros in the beginning, just after the precision declaration.

    #define PI 3.1415926538
    #define TWO_PI 6.28318530718

    To see our all this hardwork in effect, come to the p1 variable in your main function and update the varying component by adding phase variable to it.

    cos(uTime * 1.0 + ph) * d2 * 2.0,
    sin(uTime * 2.0 + ph) * d1 * 2.0

    Voila! the magic begins. We have the final artwork

    🌊 Make Some Noise

    To add some more organic touch to it, we can add a grain like texture on top of this pattern that makes it look like as if it were drawn on paper. To do that we bring-in the texture image using uniform called tNoise.


    uniform sampler2D tNoise;

    Use noise texture in main function after the repeat variable

    vec4 ni = texture2D(tNoise, vUv);

    And add noise to the final color output c. You can change the intensity with which you super-impose noise by controlling the factor that is being multiplied.

    c.rgb += ni.rgb * 0.15;

    ✨ Final Touches

    If you're working with WebGL and shaders, you may encounter issues with alpha blending in different browsers. Let’s make it better by adding some cross-browser functionality. Add an uniform isSafari at the top. It is a boolean (true/false) variable that may be used to control some aspect of the shader behavior depending on whether the user is running the application on Safari browser or not.

    uniform bool isSafari;

    And use the following snippet to update the final render.

    if (isSafari) {
    gl_FragColor.rgb = mixColor(c, uOpacity);
    gl_FragColor.a = 1.0;
    } else {
    gl_FragColor.rgb = c;
    gl_FragColor.a = 1.0;
    gl_FragColor.a *= uOpacity;
    }

    You’ll also need this helper declared

    vec3 mixColor(vec3 color, float progress) {
    return mix(vec3(0.737, 0.843, 0.871), color, progress);
    }

    And we are done here. If you are here, thanks for sticking till the end. I know it can be confusing and I am also not very good at explaining things, but that is what this blog is. To be better at those things. All feedback is welcomed. Share your artworks with me as well on twitter.