Trying out Shaders

⚠️ This is a draft post. Read at your own risk!

Introduction

not a tutorial, just a devlog

This is going to be a different kind of post, because I will be trying out a different type of learning strategy. For context, I have almost 0 knowledge of shaders, and the ones I have used before are copy pasted from the internet. I will be using the top down approach, where I will decide what I want to do, and then search for the solution. The reason for it, is because shaders is simply too vast of a domain for me. For the stack, I will be using webgl for it.

The Most Basic Shader

The most basic shader will be to just display a solid color. Well we actually need two different kinds of shaders, the vertex shader and the fragment shader.

vertex.glsl
1attribute vec2 a_position;
2varying vec2 v_position;
3void main() {
4    gl_Position = vec4(a_position, 0.0, 1.0);
5    v_position = a_position;
6}

Vertex Shader handles the positions of points that define the shape you want to draw. In our case, we are just drawing the full viewport. It runs once for each vertex, and the output is passed to the fragment shader. Its main job is to figure out where that point should appear on the screen by transforming its position into a special coordinate system called clip space, which the GPU uses to decide what’s visible

fragment.glsl
1precision mediump float;
2
3void main() {
4    vec3 paperColor = vec3(0.94, 0.88, 0.71);
5    gl_FragColor = vec4(paperColor, 1.0);
6}

Fragment Shader is responsible for the color of each pixel. It runs once for each pixel, and the output is the color of that pixel. The fragment shader takes the output from the vertex shader and uses it to determine the color of each pixel in the shape. In our case, we are just setting the color to a solid color.

And this is what we have for now.

basic

Fantasy Map Shader

Next, now we try to convert this simple solid color into something like a fantasy map. The idea is to create this old map like texture and add things like lakes, mountains, forests, villages, islands and meadows. I will also try to add text to the map, as to indicate the names of the places. The first thing to do is -

Old Paper Texture

The first thing to get the old paper-y texture is to create random darkened spots on the paper, "stains" being a better word. So first of all, in the previous sentence alone, I used the word "random", and for that I'm using a very simple hash function to return pseudo random numbers.

fragment.glsl
1float random(vec2 st) {
2    return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
3}

This combination of dot, sin and fract is a very cheap way to generate pseudo random numbers. If you are confused, the fract function just returns the fractional part, so the output only ranges from 0 to 1.


Now we can this random function to create a function for noise, more specifically, Perlin noise. All we do is, for a cell of size 1, we take the random value of the four corners of the cell, and then interpolate between them. The interpolation is done using a smoothstep function, which is a built in function in GLSL. And in the end, we use bilenear interpolation using the inbuilt mix function to get the final value.

fragment.glsl
01float noise(vec2 st) {
02    vec2 i = floor(st);
03    vec2 f = fract(st);
04
05    float a = random(i);
06    float b = random(i + vec2(1.0, 0.0));
07    float c = random(i + vec2(0.0, 1.0));
08    float d = random(i + vec2(1.0, 1.0));
09    vec2 u = smoothstep(0.0, 1.0, f);
10    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
11}

Now to make a stain mark, we will just make a circle, and then use the noise function to warp it. It is a pretty simple function.

fragment.glsl
1float stain(vec2 uv, vec2 center, float size, float irregularity) {
2    float dist = length(uv - center);
3    float noise_val = noise(uv * 3.0) * irregularity;
4    return smoothstep(size + noise_val, size - 0.1 + noise_val, dist);
5}

length(uv - center) computes the Euclidean distance from the current fragment’s UV coordinate to the blotch’s center, yielding a perfect circle when plotted. To break that perfection and simulate natural irregular edges, the function samples noise(uv * 3.0) multiplying uv by 3.0 zooms the noise, so the irregularities occur at a finer scale.


Then I created a function to randomly generate a radius, center and irregularity and then call the stain function. In the main loop, I just call a for loop 30 times to create multiple random stains. To apply the stains, I just store the sum of all the stains in a variable, and then use the mix function to mix the color of the paper with the color of the stain.

fragment.glsl
1float totalStain = 0.0;
2
3for (int i = 1; i <= 30; i++) {
4    float seed = float(i) * random(vec2(0, 100));
5    totalStain += randomStain(uv, seed);
6}
7
8vec3 stainColor = vec3(0.34, 0.23, 0.05);
9vec3 finalColor = mix(paperColor, stainColor, totalStain);

Now we finally have a paper that looks old. But we can make it look older.

image

First thing you can do is to add a grainy texture to it. This is simply just giving a random value to each pixel and multiplying it with an intensity factor. Consider it like a tv static. And then like before, just add it to the final color.


The next thing I did was to add these line like structures to the paper which happen due to aging. Now I tried looking into this a bit, and found there is a whole field for this called anisotropy and there are literal research papers on the anisotropy of a paper. I, well will not go into that. But with the help of my friend, claude 3.7 I was able to find a simpler alternative. The idea is simple, create very long horizontal streaks, create vertical streaks and the mix them together. This is how the function for it looked like.

fragment.glsl
1float fibers(vec2 uv) {
2    float hPattern = noise(vec2(uv.x * 100.0, uv.y * 10.0)) * 0.5 + 0.5;
3    float vPattern = noise(vec2(uv.x * 10.0, uv.y * 100.0)) * 0.5 + 0.5;
4    
5    return mix(hPattern, vPattern, 0.3);
6}

Then I added some age spots to the paper, which are just random skewed figures with a slightly darker color. And then as a final touch for my old paper look, I darkened the edges and the corners of the paper. Now, we have a pretty decent and "semi-realistic" old paper texture.

save

Basic Terrain

For terrain, we will layer multiple fractorial brownian motion noise functions at decreasing frequencies. The idea is to create a base layer of noise, and then add more layers on top of it. The base layer will be the largest, and the top layer will be the smallest. The result is a more complex noise pattern that looks more like terrain.


In fractal noise, we use the same noise function, but we scale the input coordinates by a factor of 2.0 for each layer, and then we multiply the output by a factor of 0.5 for each layer. The result is a more complex noise pattern that looks more like terrain. You can read more about fractal noise in the book of shaders.

fragment.glsl
01float fbm(vec2 st) {
02    float value = 0.0;
03    float amplitude = 0.5;
04    float frequency = 1.0;
05    for (int i = 0; i < 7; i++) {
06        value += amplitude * noise(st * frequency);
07        frequency *= 2.0;
08        amplitude *= 0.5;
09    }
10    return value;
11}

Now we can generate the terrain value by

1float terrainValue = noise1 * 0.5 + noise2 * 0.3 + noise3 * 0.2 + noise4 * 0.1;

where noise1, noise2, noise3 and noise4 are the four layers of noise. The result is a value between 0 and 1, which we can use to determine the color of the terrain. For now, the threshold is set to 0.5, so anything above that will be considered land, and anything below that will be considered water. I also made the center of the map is emphasized by blending in a radial focus, making the center look more "developed" or "landmass-heavy." This is common in terrain generation to focus visual interest.


The last thing I did was to add a outline to the coastline. This is done by using the smoothstep function, which takes in 3 paramters and creates a smooth transition between two values. Then we take the modulus of the coastline value and sharpen it, we just apply an exponent.

fragment.glsl
1float coastline = smoothstep(0.5 - 0.02, 0.5 + 0.02, terrainValue);
2coastline = abs(coastline - 0.5) * 2.0;
3coastline = pow(coastline, 0.3);

Now we have a very basic map!

basicmap

Adding details

First, we can add some lattitude and longitude lines to the map. To add a repeating pattern, we use the absolute value of sin functions. And then we can distort the lines just a bit to make the irregular, to give it a hand drawn look. I also made the grid lines fade out at the edges of the map, which is done by the smoothstep function.

fragment.glsl
01float gridLines(vec2 uv, float lineWidth, float irregularity, float fadeEdges) {
02    vec2 distortUV = uv;
03    
04    distortUV.x += noise(uv * 5.0) * irregularity;
05    distortUV.y += noise((uv + vec(42.0, 17.0)) * 5.0) * irregularity;
06    
07    const float GRID_DENSITY = 30.0;
08    
09    float xGrid = abs(sin(distortUV.x * GRID_DENSITY * 3.14159));
10    float yGrid = abs(sin(distortUV.y * GRID_DENSITY * 3.14159));
11    
12    xGrid = smoothstep(1.0 - lineWidth, 1.0, xGrid);
13    yGrid = smoothstep(1.0 - lineWidth, 1.0, yGrid);
14    
15    float edgeFade = smoothstep(0.0, fadeEdges, uv.x) * 
16                     smoothstep(0.0, fadeEdges, uv.y) * 
17                     smoothstep(0.0, fadeEdges, 1.0 - uv.x) * 
18                     smoothstep(0.0, fadeEdges, 1.0 - uv.y);
19    
20    float grid = max(xGrid, yGrid);
21    
22    return grid * edgeFade;
23}

Another thing I did is to randomly place green patches on the map, to represent forests, and brown patches to represent hills and mountains. There is no actual logic to it, it is all random. And we have a pretty decent fantasy map now.

image

Synthwave Shader

Now that I have some experience with 2D shaders, it is time to one up the number of dimensions. I want to make one of those animated synthwave mountains + road + sun style shaders. To make them animated, we can use pass in the time variable from javascript. The time variable tells the amount of time elapsed since the start. For the most basic animated shader, we can just use the time variable to smoothly lighten and darken the color. Here is the basic code for that:

fragment.glsl
1uniform float u_time; 
2
3void main() {
4    vec3 paperColor = vec3(0.94, 0.88, 0.71);
5    
6    float variation = 0.2 * sin(u_time);
7    gl_FragColor = vec4(paperColor + variation, 1.0);
8}

Skybox

The first step is to create the skybox. Here the skybox is just a gradient that goes from purple to blue. Vertical gradients can be done by using the mix function and passing in uv.y in the third parameter.


The next thing I did was to randomly place stars in the upper half of the sky. We divide the upper half into cells, and then convert less than 3% into stars. The stars are just white dots with a random size and position. The stars are created by using the smoothstep function to create a circle. To make them twinkle, we can use the time variable to oscillate the brightness of the stars.

fragment.glsl
1float twinkle(vec2 gridCoord, float time) {
2    float speed = 1.0 + random(gridCoord + 3.0) * 2.0;
3    float phaseOffset = random(gridCoord + 4.0) * 6.28;
4    return 0.5 + 0.5 * sin(time * speed + phaseOffset);
5}
6

To not make it look uncanny, each star has it's own time period.

image

CRT Effect

To add more of the "retro" effect, we can add a CRT effect to the shader.

1scanlineY = fract(uv.y * scanlineCount + time * speed)

If it is not obvious scanlineCount is the number of lines you want (100 in tihs case) and speed is how fast you want the lines to move (10 in this case). Here, uv.y (0 to 1) is scaled by 100, so it ranges from 0 to 100 across the screen. Adding time * speed shifts this upward over time, and fract keeps it between 0 and 1, creating a repeating pattern of lines that scroll.

1float scanlineIntensity = 0.14;
2return 1.0 - scanlineIntensity * smoothstep(0.4, 0.6, scanlineY);

And then we can use the smoothstep function to smooth out this pattern. This defines a band where the scanline is strongest (around 0.5), fading at the edges.


CRT monitors also have a dark vingette effect, which is just a radial fade, enhancing focus on the center. I added that as well. It takes uv (0 to 1) and remaps it to -1 to 1 with uv * 2.0 - 1.0. At the center (0.5, 0.5), this becomes (0, 0), at the corners it’s (-1, -1) or similar. This centers the effect. The expression uv.x * uv.x + uv.y * uv.y computes the squared distance from the center, ranging from 0 at the center to 2 at the corners. Multiplying by vignetteStrength = 0.35 scales this, and subtracting from 1.0 inverts it, creating the desired effect.

fragment.glsl
1float vignette(vec2 uv) {
2    uv = uv * 2.0 - 1.0;
3    float vignetteStrength = 0.35;
4    return 1.0 - (uv.x * uv.x + uv.y * uv.y) * vignetteStrength;
5}

That is step 2 complete, we not have a shader that looks like this:

image

Adding The Sun

imgage

One of the most iconic parts of the synthwave aesthetic part is the sun in the middle. They are two core parts of the sun, the circular gradient and the lines that are cut off. Let us just appeoach this step by step.


The first task, was making a big circlular gradient. There was a problem I was running into while doing this, which was that the gradient looked more like an ellipse than a circle. The reason for this is that the uv coordinates are normalized to be from 0 to 1, and the aspect ratio in the browser is not always gonna be 1:1. So first I had to adjust the uv. This explained why my stars also looked kinda stretched on zooming but they look fine to the normal eye.

fragment.glsl
1float aspect = u_resolution.x / u_resolution.y;
2vec2 adjustedUV = vec2((uv.x - 0.5) * aspect + 0.5, uv.y);

Then we calculate the dsitance between adjustedUV and the center of the sun. The center of the sun is just a little above the center of the screen. So then we create a mask for the sun, which is just the step function which takes the distance we just mentioned and the size of the sun. For this example, the size is hardcoded to 0.24. We finally use the mix function to create a gradient for the sun and then multiply is with the mask to return the final value.


This creates a very basic sun. Now we need to replicate the lines in the lower half of the sun. So, my solution is not the neatest, but I harcoded the thickness and the position of the lines in 2 arrays. Then I loop 5 times to check if pixel’s uv.y is within the line's vertical band. If yes, then I set the mask to 0, which makes it transparent. After all of this, we now have a decent looking sun.

image

To add another layer of fun, I kept updating the position of the lines with respect to the time variable, to make an infintie panning animation, like the CRT effect. I just thought it looked nice. Sorry for the low quality gifs, but where I host images, I need to keep the size under 5mb.

final sun animated

19 April 2025