We'll describe shaders with a host of examples that you can play with. Fire up your text editor, because the fun lies in tweaking the code in the examples. In general, each example is set up so that with minor edits you can do fun changes. In many cases, the code will have sections that you can comment in or out that result in dramatic changes.
What makes shaders special is that they are designed to run in parallel. For example, some are designed to run once for every pixel on the screen. If you are drawing a window that is 1,000 x 1,00 pixels at 30 frames a second, that makes 30,000,000 commands every second. By making them run in parallel (usually on special hardware called the GPU), drawing programs can do all sorts of complicated things a lot faster.
Also note, there are some really great tutorials for learning shaders. Some are listed at the bottom of this page. This document is assembled for as a quick guide to play with over the course of a couple sessions. If you want to dive deeper, try those other resources.
A really boring example to show some basics. We are using a shader to color the sceen hot magenta. If all you want to do is color the page hot magenta, there are much easier ways to do this. But this demonstrates some of the hoops that you have to jump through in order to use shaders.
The other two files are what makes the shader. The first file, vert.glsl, is used to calculate the shape we draw on. It has to be there, but we don't do anything particularly interesting with it for the first examples. The other file, frag.glsl, is the fragment shader. Here, all we are doing is setting a each pixel to magenta. gl_FragColor is a special variable that represents the color for that pixel. vec4(1.0, 0.0, 1.0, 1.0) is four numbers for red, green, blue, and the alpha value. It's important to notice that in shaders, color values run from 0.0 to 1.0, not 0 to 255. So, vec4(1.0, 1.0, 1.0, 1.0) is full opacity white, vec4(1.0, 0.0, 0.0, 0.5) is half-transparent red. Feel free to edit these numbers and reload the page. And notice that you probably need to keep the decimals in there. Shaders are finicky, and won't accept an integer where a float is expected.
This builds off our really boring example to show how you can change things over time, or by position, or pass in mouse coordinates, or all three. Again, there are easier ways to do these. But the position example hints at some of the power of shaders. Here the shader is calculating the color for each pixel, one by one, independent of the pixels around it. Again, there are easier ways to get gradients, but this is the power we will soon unleash!
Each of these examples has a parameter defined at the top of frag.glsl, declared in a line like "uniform float u_time;" or "uniform vec2 u_resolution;". "u_time" or "u_resolution" is the name we give the parameter; it could be "larry", or "elapsed_milliseconds". The other words are required keywords in glsl. You may recognize "float": that indicates that this variable has a decimal value. There are also "int", and datatypes of "vec2", "vec3", and "vec4", which are a pair of floats, a set of three floats, and a set of four floats respectively. There are also datatypes for matrices ("mat2", "mat3", and "mat4") and for images ("sampler2D" and "samplerCube").
So, why shaders?
Here we finally start to show things that shaders make easy and performant.
First off, repeating a pattern is trivially easy. With just a couple lines, we can split the gradients we made before into multiple copies to create tiling effects. And we can create tiles of other shapes as well. With just a few tweaks of code, we can span generations of grooviness.
And since we are looking at things pixel by pixel, do you know what else is made of pixels? Pictures! In shader parlance, these are often called “textures”, since in early animations they were applied to different shapes to give them more, you guessed it, texture (since rendering lots of bumps or hairs is too costly). Here is a simple shader where we simply redraw every pixel in the image right to the screen. But in the frag.glsl file, there are all sorts of variations on the color that you can comment in to explore what you can do. And we can also re-apply all the color changing techniques that we did earlier with the gradients.
Beyond the scope of what we are covering here, it's worth noting that just messing with fragment shaders can lead to mind blowing results. For example, check out this virtual landscape all driven by tweaking pixel values, with noise, etc.
Out of Flatland
As noted before, a WebGL shader is made up of two parts, the vertex shader and the fragment shader. All the fun we have had so far is on the fragment shader side of things. Our only use for the vertex shader has been to layout where our pictures will go. We did that by putting a vertex at each corner of the window, and then letting the fragment shader fill in that rectangle. But we can put the vertices anywhere, which we will do in a moment.
But first, just a quick demo of a different way to define colors. In one of our earlier examples, we created gradients by having each pixel figure out where it was, and calculating its color based on its position. There is a simpler way. We can put a color at each vertex, and the fragment shader automagically will interpolate between the vertices, creating our gradients for us.
We start on the cube itself around line 100. There different ways to set it up, but all of them involve building it up out of a set of triangles. Each corner needs to be placed in 3d space, so it need an x, a y, and a z coordinate. Our cube is centered around the coordinate 0,0,0, or the very center of our 3d space. Note that when we define the colors for each corner (starting around line 140), we need to pass in a color for each vertex.
As mentioned before, 3d shapes themselves are built up of triangles. Mathematically, any shape with corners can be built up from triangles. For shapes that look round, the corners are placed close enough together that (hopefully) you can't tell they are there, like in this sphere. Here, the number of corners is set in the constant ARC_PRECISION at line 32. Try making that number smaller or larger: too small and you can see the corners. Too big, and the animation gets sluggish.
Bringing it All Together
So far, we focused on either fragment or vertex shaders separately. Together, they are even more powerful. In general, there are two broad ways to do this: you can draw an image every time you draw a point, or you can wrap an image around a shape.
The other case we want to look at is wrapping a complex 3d shape in an image. Let's take the cube we had earlier, but instead of putting a color at each vertex, let's put a picture on each face. This example illustrates why they got the name "textures": calculating the 3d coordinates for each of those bumps would take a fair amount of work, especially in the early days of computer animation. It's much easier to convey the bumpy texture with a picture.
Much like before, we need to pass in the corners of the cube, but we also have to let the shader know how the image should fit over those corners. So we build up each face of the cube with two triangles, and then stretch the image across those corners the same way for each face of the cube. Note that although the vertex coordinates go between -1 and 1, the texture coordinates from from 0 to 1. And the vertex coordinates go from 1 at the top to -1 at the bottom, the texture coordinates go from 0 at the top to 1 at the bottom. The changing scale and direction fits conventions defined by the graphics community, but seems inconsistent for first timers.
Even though putting images on a cube seems intuitive, you can see it gets a little more complicated when we have to build up the cube from triangles. The good news is that the same techniques apply when wrapping images around more complicated shapes. Building off the sphere example earlier, we can wrap a sphere in a texture to build a globe. Here we are using an image from NASA to convey an earth devoid of clouds, entirely in daylight. We can also make it a little more realistic by adding a second image, wrapping both of them around the sphere, but choosing which one we show (or even combining them) according to whether the Earth should be in daylight or not. Note the techniques for adding a second image highlight some of the administrative hassles of working with WebGL: before you can set it up, you have to tell the shader just which texture you are working with in a couple places. Also note, to compare our simple example with a real effort to model the globe, see this project by Robert Hodgin (many shaders, but not WebGL).
Driving It with Data
Our next goal is to turn this globe into a data tool, a chloropleth globe, where each country is colored according to some color value. For the data, we will use a some 2018 population data gathered from sources available from the UN.
There are several ways we could build out our data globe. For all of them, we need the shape of every country, and to color that shape according to the data. One method is to plot the borders of each country in 3d space, track the resulting vertices by country, and set a color for each vertex. But we are going to pursue a different technique that highlights some other quirks of WebGL, and that we have used in a few projects.
Our first resource will be a special map that we built just for this purpose. It's not particularly pretty, but it is special. Each country has its own unique color, specifically the red channel. When translated into a number between 0 and 255, the red value in the RGB color corresponds to the country data in our data file. We generated them specifically with this correposndence in mind. Here's an example: I go to the map, and look at the US. Using photoshop or something similar, I see the RGB values for the US are 1, 7, 17. Since the red value is "1", I know when I go to the data file, the US data will be at index position 1. Or let's say I am looking at eastern Europe, and I'm curious what that orange country is, but I don't know. Using the same tool as before, I get RGB of 243, 165, 35. Again, using the value of the red channel, I get the number 243. I go to the data file, and find that the item at index position 243 is "Serbia and Montenegro". While there, I notice the data file has "-1" for the total population; here, "-1" tells us that we don't have data for that country. Also, by the fact that the country is "Serbia and Montenegro", we know that the map is based on older data, since Montenegro split off into its own country in 2006. And that's true: we are repurposing a map we used for a project that had data stretchign back to 2001, so an older map was what we needed. Where the 2018 population countries don't align with the countries in our older project, there will be missing data. By the way, generating the map is a custom process, but has less to do with shaders, so we won't go into how to build that.
Now that we have our special map, we can use it to wrap the globe. If we want to inspect the red channel by itself, we can edit the fragment shader to color each country according to its red channel only. We can also check that the country lookup part of it is working by trying to color specific countries. With this step working, we have confidence that we can color each country correctly, which is a critical first step for a data globe.
The next step is set the color for each country according to a data value. Now things get a little tricky. Since we have a map where each country corresponds to an index position, it would be great if we could hand off data to the shader arranged the same way: the USA data would be at array position 1, and the Serbia and Montenegro values would be at index 243. We can't pass it in as an attribute, since those need separate values for each vertex; our vertices correspond to the shape of the globe, not the countries in the the image. So we need to pass the data over as a uniform, a set of data that is the same for every vertex. Ideally, we could biuld up an array of numbers, and then look up values in that array, but shaders don't allow dynamic lookup of array values. They do allow dynamic lookups in tetures though! So our solution will be to encode the data in an image, pass that image to the shader, and then have the shader decode the image into the data we want. This may seem like a gnarly workaround, but it is a pretty typical solution.
So how to encode the data in an image? Basically, we set up a system where each country corresponds to a pixel at a certain location, and then encode that countries data into the red, green, and blue values for that pixel. Since we have 253 countries in our list, we can use a picture with 256 pixels, or a 16x16 image. In our data, we have three data points per country: total population, urban population, and rural population. The values are reported in thousands: where it says "326767" for the total US population, that's 326 million, not 326 thousand. We could have a sticking point here: each channel only goes from 0-255, not up to 326 million. However, when we color the map, we want the area with the highest population to stand out the most, and everything else to be scaled to that. So rather than put the actual population number in the red channel, we will precalculate the number for each country as a percentage of the largest population. Then we will rescale that number to the 0-255 range, and put it in the red channel. Simlarluy, we can calculate each country's urban popluation as a percentage of its total population, and store that in the green channel. And repeat that with the rural population for the blue cahnnel. With our data arranged in rows of 16 pixels, we get an image that looks like this (note we made it larger to see the distinct squares for each pixel). Where the square is black, there is no data. The orange square in the top row is at index position 3, which corresponds to China, the world's most populous country. The pink square in the second row corresponds to India. Compared to those two, most countries have low populations, and thus low values in the red channel; hence all the other squares of green and blue.
Now we have a globe where we can set the color of all the countries, and a means of passing in data for all the countries. We can use these two results, and bring in the techniques for passing two images to the shader, and we will be almost there. Our last step is to have the fragment shader fetch the data from the correct pixel on the data image. Once that is retrieved, we can color the globe according to the total populatoin, the urban population, or the rural.