Tutorials / Pixel Planet Shader

Simple OpenGL GLSL shader.
Little setup so long as you kind of know what you're doing if things do go wrong.
Can get you started on learning about the wonderful fun of GPU shader coding, or computing for art.
Feel free to reuse the starter Python script to create more projects, it's what I wrote it for anyhow.
Don't forget to read the docs!

Prerequisite Setup


Python Script

I didn't make this with Windows or MAC in mind, so you're on your own for ensuring the setup works.
Extract the zip I gave you and check main.py has project_dir = "BlankProject" on around line 5.
Run the runner.sh script, you should get something that looks like the following...

If it looks good, things are working and you can continue, if it's not, good luck, you're on your own!

Create The Project Directory

Make a copy of the BlankProject directory in the same directory it was in, and modify the project_dir variable in the Python script to the name you gave the new directory.

Maybe try running it once more to make sure it's getting the new directory.


Creating Your First Planet


Some Prerequisite Maths

Since we are working with a GPU fragment shader, we are running code on every pixel on screen at once while knowing some information about the pixel we are working on. If you notice the first line of the main() function, we are already cleaning up the pixel position from the starter code

vec2 uv = (gl_FragCoord.xy / u_resolution.xy) * 2.0 - 1.0;

This gives us the variable uv which stores (x, y) coordinates for the pixel as a value int he range [-1, 1] where (0, 0) represents the centre of the screen and you moving up and right gives positive values. Since we have access to where the centre of the screen is located in pixel-space, lets see how we colour pixels within specific ranges in pixel-space.

Recall, how do we find the distance between two points in 2D space? Your middle school math teacher will be pleased because it's time for the Pythagorean Theorem!

a2 = b2 + c2

Rearranging this we can implement this in GLSL with a function, since we might need to use this formula again later.

float distance(vec2 p1, vec2 p2) {
  return sqrt(pow(p1.x - p2.x, 2) + pow(p1.y - p2.y, 2));
}

Creating A Circle

Since we now can find the distance between two points, and we know where the centre of the screen is, we can check the distance of a given pixel to the centre of the screen, and if it's within some radius we like, it can be part of our planet.

vec3 col = bg_colour;

if (distance(uv, vec2(0.0)) < 0.7) {
  col = vec3(1);
}

I chose 0.7 as the distance cutoff, since that will leave a nice gap between the planet and the border of the screen, if it's too high it will get clipped at the exceeding bounds. Try it yourself.

Anyhow, you should get something that looks like...

Our planet shader works, but it is looking a little bit empty, and lifeless, and only one colour. Hell not even one colour, white and black are shades so it doesn't even get colour. It's a binary planet, a yes no planet, yes theres planet or no there's no planet. We don't want this boring yes no planet, we live on a blue marble for christ's sake, where's our blue fucking marble?

Lets see how do start forming some shapes on our planet.


Procedural Terrain Generation


Procedural Noise

Procedural noise is similar to TV static, except it has smoother transitions between the black and white. We can sample these values in a known set of positions to create a heightmap. A heightmap is an image with only one colour channel of black and white, where the brightness of a pixel represents the height of some terrain.

So how do we actually create noise to sample? Since even I am not crazy enough to try doing noise myself, so lets borrow someone else's implementation. In the project directory you created, you should find classic-3d.glsl. Include this in your fragment shader.

#include "classic-3d.glsl"

This library gives us function that takes a function that takes some position in 3D space and returns a float in the range [-1, 1]. We can set the color by sampling each pixel of the circle based on the height at the corresponding 3D point in the noise.

if (distance(uv, vec2(0)) < 0.7) {
  float height = cnoise(vec3(uv, 0.0));
  col = vec3(height);
}

Running the program we can see that our circle is no longer all white, but has a gradient smoothly transitioning between black and white, should look like...

This doesn't really look very nice, we can't see half our planet, so we can sample points of the heightmap at points further away. Part of this is caused by the fact our heightmap produces value in the range [-1, 1] while our colour vector expects values in the range [0, 1]. We could fix this by linearly transforming the height using this function. Try it yourself, you'll see more of the planet is white, but don't keep this change since we won't be using it as we continue.

height = (height + 1.0) / 2.0

A noticable problem with how we are sampling our noise is our sampling points are far too close together, so when we call cnoise(vec3), we can multiply our pixel position vector by some scale to facilitate this. A larger scale will give more detail, smaller scale will give less detail. We can start with a value of 20.0

float height = cnoise(vec3(uv, 0.0) * 20);

Organizing Our Code


What & Why?

We may only have around 30 lines of code so far, so what exactly are we organizing, and why? We should start by moving any rendering related code to its own function, and get it out of main(). We also should try getting rid of any magic numbers and give them variables with meaningful names so when we go back over older code to change or add parts of our planet, we have an understanding of what's going on. We also could add a little bit of clarity to our functions by using three GLSL keywords in, out, and inout. Functions in GLSL always default all parameters to be in. However if we want to be able to modify a parameter and have that change reflected in the caller function, use out. Can you guess what inout does? If you guessed it does both in and out you'd be right!

How?

To start we can pick off an easy target, the magic numbers. So far we only have two that we should deal with, 0.7 and 20.0 being our noise frequency and planet size. So lets name them.

float planet_size = 0.7;
float frequency = 20.0;

Next, lets take our rendering code out and put it away into its own happy little function, thought be wary, this function will be neither happy nor little by the end of this tutorial.

void render(inout vec3 col, in vec2 uv) {
  vec3 f_col = bg_colour;

  if (distance(uv, vec2(0.0)) < planet_size) {
    float height = cnoise(vec3(uv, 0.0) * frequency);
    f_col = vec3(height);
  }

  col = f_col;
}

Two things to note, first that we have added an intermediary variable

vec3 f_col = bg_colour;

This is because I think its nicer to pass a completed value, I dont think it actually has any affect on the final output. Second thing to notice is the use of the new keywords in the function head. Lets add this to the distance function too.

float distance(in vec3 p1, in vec3 p2) {...}

Lastly we need to update our function main() to call render so we can get our hard work back.

vec3 col;
render(col, uv);

Make sure to run it again and ensure things are still working


Goodbye, Flatland!


Upward, Not Northward

By the power invested in me by mathematics, we can exit the weak world of two dimensions. Using a slight rearangement of the lovely little formula for a sphere, we can convert our 2D point on the screen UV into a 3D point on a sphere. This will allow us to use the z component of the vec3() that we pass to our noise function, and sample 3D noise.
Flatland is a book by the way and you should read it, it's good

vec3 mapToSphere(in vec2 p, in float radius) {
  float x = p.x * radius;
  float y = p.y * radius;
  float z = sqrt(1.0 - p.x * p.x - p.y * p.y) * radius;
  return vec3(x, y, z);
}

Now looking back at our render() function, comment out the if block we made back at the start. Above the now commented if block, we can call our new sphere mapping function and sample the noise.

vec3 sphere_point_planet = mapToSphere(uv, planet_size);
float height = cnoise(sphere_point_planet * frequency);
f_col = vec3(height);

Looking at this we can see we have shed our shameful two dimensional limitation, and have broken through to the great glory of the third dimension. Although we are looking a little big, so we should fix that.

Fractional Brownian Motion


Oh Dear God It's Time For Math Again

float fbm(vec3 sphere_point, float scale, int octaves) {
  float value = 0.0;
  float amplitude = 0.5;
  float frequency = scale;

  // Octaves
  for (int i = 0; i < octaves; i++) {
    value += amplitude * cnoise(sphere_point * frequency);
    frequency *= 2.00; // Increase frequency for the next octave
    amplitude *= 0.50; // Decrease amplitude for the next octave
  }

  return value;
}

World Of Colour


Subheading

Cloudy With A Chance Of More Clouds


Subheading

You Spin Me Right Around


Subheading

And God Said, "Let There Be Light,"


And There Was Light.

Shader Shenanigan


Pixelization

Fractional