Shader Programs
At this point, you should have a basic understanding OpenGL buffer objects, such as VAOs, VBOs, and EBOs. If you have no idea what I'm talking about, go check out this other article: OpenGL Buffer Objects.
Let's assume we already have our vertices defined inside a VBO, our triangles defined inside an EBO, and both of them already bound and configured to our VAO. As a refresher, here's our data:
float vertices[] = {
// Position (xy) // Color (rgb)
0.5f, 0.5f, 1.0f, 0.0f, 0.0f // 0: top right, red
0.5f, -0.5f, 1.0f, 1.0f, 1.0f // 1: bottom right, white
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f // 2: bottom left, blue
-0.5f, 0.5f, 1.0f, 1.0f, 1.0f // 3: top left, white
};
unsigned int indices[] = {
0, 1, 3, // Triangle 1 (red)
1, 2, 3, // Triangle 2 (blue)
};
GLuint vao; // ...all of our stuff is loaded into this VAO...How do we display this on-screen? Why, with the power of ✨ shaders ✨ of course! A shader program is a special program that runs on the GPU, and is written in OpenGL Shading Language ("GLSL"). GLSL has a few ...uhh... derivations on the language that have slight differences between them. What I'll actually be using is OpenGL ES Shading Language (GLSL ES). The "ES" is for "Embedded Systems", and it's what Wayland compositors largely use for compatibility reasons.
There are two types of shader programs that we care about:
- Vertex Shaders, which are executed once for every vertex, and translates our possibly-3D vertices onto a 2D screen. It could scale, rotate, translate, skew, (etc), the VBO's source positions into their final place on the screen.
- Fragment Shaders, which are executed once per pixel, and determines what color to make that pixel. The fragment shader runs after the vertex shaders that cover its area, and is able to accept values passed in from the nearby vertex shaders.
These programs are executed massively in parallel by the GPU.
The Basics
Version Specifier
A shader program starts off by defining the version of GLSL (or GLSL ES in our case). We'll be using GLSL ES version 3.20, so our version line looks like this:
#version 320 esVariables
Next up comes all the data/variables we want to use. In addition to your usual data type and variable name like any other language has, there are also a bunch of other qualifiers that we can slap on:
layout(location = N)= specifies which attribute number this variable is bound to. Relevant for variables marked as inputs to the vertex shaders.in/out= specifies if this value is an input to this shader, or an output passed to the next shader.uniform= specifies a value which is the same for all parallel running instances of this shader. Textures and projection matrices are common for this.highp/mediump/lowp= the numeric precision of this value. Higher precision = lower performance. Some suggestions:highpis good for positions/projectionsmediumpis good for textureslowpis good for colors that don't need precision- Variable type comes next:
float,int,bool,vec2/vec3/vec4(1xN float vectors),mat2/mat3/mat4(NxN float matrices),sampler2D/samplerCube(texture samplers) - Variable name is last. There's a common convention people follow for variable names, and that's to give the name a prefix in certain situations. The three most common are:
a_for input attributesu_for values classified as uniformv_for values passed between shaders (called "varying" values)
Putting all these qualifiers together, your variable definitions might look something like this:
layout(location = 0) in highp vec2 a_position;
layout(location = 1) in lowp vec3 a_color;
out lowp vec3 v_color;in lowp vec3 v_color;
out lowp vec4 fragColor;The Program
Now we specify the main function with the actual shader logic. Vertex shaders emit their final result by setting a special gl_Position value. Continuing the above variable definitions, here's a very simple (but effective) main function for our vertex shader:
#version 320 es
layout(location = 0) in highp vec2 a_position;
layout(location = 1) in lowp vec3 a_color;
out lowp vec3 v_color;
void main() {
gl_Position = vec4(a_position.x, a_position.y, 0.0, 1.0); // x, y, z, and w.
v_color = a_color;
}Fragment shaders emit their final result by setting the value of an out variable. Here's a simple fragment shader:
#version 320 es
in lowp vec3 v_color;
out lowp vec3 fragColor;
void main() {
fragColor = v_color;
}If you've been following along by recreating this sample project on your own machine, my code assumes these two files exist at these locations:
./shaders/shader.vert.glsl./shaders/shader.frag.glsl
Random tidbits
Fragment Shader Inputs
There's this thing that might be tickling the back of your brain. ...something that just isn't quite adding up...
"A vertex shader is only run once per vertex, whereas a fragment shader is run for every pixel. There isn't a 1-to-1 mapping from vertex shader executions to fragment shader executions. ...so which vertex shader's outputs get sent in to each fragment shader?"
This is where some OpenGL magic comes in. The vertex shaders ultimately result in "geometric things" being drawn on the screen; let's say it draws a triangle, and each vertex of the triangle outputs a different color (R, G, B) to the fragment shader. The fragment shaders for the pixels that make up the triangle receive interpolated values based on their position within the triangle. Pixels that are close to the "R" vertex receive a mostly red value, pixels close to the "B" vertex receive a mostly blue value, and pixels that are in the middle receive purple-ish values.
Swizzling
Ok, this is going to blow your mind. ...well, maybe. I'd never come across swizzling before working with GLSL, but I remember getting really excited when I found out about it.
Our vector types commonly represent between 2-4 fields for the following categories of data:
- Positions:
x, y, z, w - Colors:
r, g, b, a - Textures:
s, t, p, q
You can refer to any of these values with dot notation. Earlier we did a_position.x to get the x-coordinate, but since the vector doesn't know whether it's storing position or color, we could have just as easily done a_position.r to get the x-coordinate.
But here's the cool thing. You can specify multiple values, pass them into new vector constructors, and mess around with the ordering:
// Basic use: X = x, Y = y, Z = 0.0, w = 1.0
gl_Position = vec4(a_position.xy, 0.0, 1.0);
// Repeating values: X and Y are both the value of "x"
gl_Position = vec4(a_position.xx, 0.0, 1.0);
// Rearranged values: Switches X and Y
gl_Position = vec4(a_position.yx, 0.0, 1.0);
// Setting color based on position
color = vec3(a_position.xyz);Is that not the coolest thing you've ever seen!? ...no? Oh.
Back to C land
(hah, "sea land".)
We have 2 hands. In one, we've defined our VAO containing all the things we want to draw. In the other, we have our vertex and fragment shader programs that do the rendering. ...Now how to we slap them together? 👏
In usual fashion, here's the "high-level" of what we need to do in our C program:
- Load and compile our shaders ("hey, that's the thing some video games say when they start up!")
- Create a "program" with our shaders attached
Then inside our render loop:
- Activate our program
- Activate our VAO
- Call our "draw" methods
Shader Program
Loading and compiling our shaders is fairly straightforward, but error handling can make it look quite intimidating. Omitting any error checking, it comes down to the following:
// Get our shader code somehow
const char *shader_code = /* load from file */;
// Create and compile our shader
GLuint shader = glCreateShader(GL_VERTEX_SHADER); // Or "GL_FRAGMENT_SHADER"
glShaderSource(shader, 1, &shader_code, NULL);
glCompileShader(shader);
// (then a bunch of error handling code)I'd actually recommend defining two helper functions that take care of this:
static char *load_file(const char *filename)
{
FILE *f = fopen(filename, "r");
// Get the file length
fseek(f, 0, SEEK_END);
const long length = ftell(f);
rewind(f);
// Get file contents
char *contents = malloc(sizeof(char) * (length+1));
fread(contents, sizeof(char), length, f);
contents[length] = '\0';
fclose(f);
return contents;
}
static GLuint compile_shader(GLenum type, const char *code)
{
// Create / compile the shader
GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &code, NULL);
glCompileShader(shader);
// Error checking / handling
GLint status;
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status == GL_FALSE)
{
// It failed, so read the latest message from the OpenGL shader log and print it out
char message[512];
glGetShaderInfoLog(shader, 512, NULL, message);
fprintf(stderr, "Shader error: %s\n", message);
}
// It all worked, return the goods!
return shader;
}Then to create and link our shaders to our program:
// Compile our shaders
char *vert_contents = load_file("./shaders/shader.vert.glsl");
char *frag_contents = load_file("./shaders/shader.frag.glsl");
GLuint vert_shader = compile_shader(GL_VERTEX_SHADER, vert_contents);
GLuint frag_shader = compile_shader(GL_FRAGMENT_SHADER, frag_contents);
// Create / link our program
GLuint shader_program = glCreateProgram();
glAttachShader(shader_program, vert_shader);
glAttachShader(shader_program, frag_shader);
glLinkProgram(shader_program);
// Error checking / handling
GLint status;
glGetProgramiv(shader_program, GL_LINK_STATUS, &status);
if (status == GL_FALSE)
{
// It failed, so read the latest message from the OpenGL program log and print it out
char message[512];
glGetProgramInfoLog(shader_program, 512, NULL, message);
fprintf(stderr, "Link error: %s\n", message);
}
// We no longer need our actual shader objects. They're built in to the program.
glDeleteShader(vert_shader);
glDeleteShader(frag_shader);
free(vert_contents);
free(frag_contents);Render Loop
Our shader files are loaded and compiled into a program, our VAO is ready to go, the last step is to render. Depending on what you're running OpenGL from, the "looping" mechanism will differ. If you're using something like GLFW, you'd probably have a traditional while loop:
while (!glfwWindowShouldClose(window)) {
// (render)
}If you're making a Wayland compositor, you'd likely incorporate the "render" step inside your compositor's event loop (not pictured). In either scenario, the render steps would look something like this:
// Clear the last render
glClear(GL_COLOR_BUFFER_BIT);
// Load our program and VAO
glUseProgram(shader_program);
glBindVertexArray(vao);
// Draw triangles from our EBO ("Element" Buffer Object).
// 2 triangles * 3 points = 6 points total.
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
// Swap the front and back buffer. The function for this will differ based on your backend.
// Here are two examples:
// glfwSwapBuffers(window);
// eglSwapBuffers(egl_display, egl_surface);A few bonus tidbits:
- Prior to calling
glClear(...), you can define what color to use to "clear" the screen by callingglClearColor(0.0f, 0.0f, 0.0f, 1.0f). This doesn't need to be run on every loop iteration, just once during your setup. - The
glDrawElements(...)method is only one of the draw methods, and is capable of drawing more than justGL_TRIANGLES. I'd recommend checking out https://docs.gl/ if you want to see more options.
You did it 🎉
If you've made it this far, congrats! We're finally done. These OpenGL notes have a lot to them, and because of that, the code snippets are spread out across a few articles. If you want to see all the code in one place, check out the "Complete Example" page.