[index]

"Hello Triangle"
OpenGL 4 Up and Running

Anton Gerdelan. Last Updated 8 January 2025.

The idea of this article is to give a brief overview of all of the keys parts of an OpenGL 4 program, without looking at each part in any detail. If you have used another graphics API (or an older version of OpenGL) before, and you want an at-a-glance look at the differences, then this article is for you. If you have never done graphics programming before then this is also a nice way of getting started with something that "works", and can be modified. The following articles will step back and explain each part in more detail.

Project Set-Up

Get Main OpenGL Libraries

OpenGL is a bit weird in that you don't download a library from the website. The Khronos group that determines the specification only provides the interface. The actual implementations are done by video hardware manufacturers or other developers. You need to have a graphics processor (GPU) to support OpenGL. Not every platform supports the latest version, 4.6, and so here we will use version 4.1, that should run on most modern machines.

To get the latest OpenGL library on your system path:

Windows Just update your video drivers (Nvidia, AMD, or Intel).
Linux By default Mesa is used. If you have an AMD or Nvidia GPU, you will get optimal support by switching to proprietary drivers. e.g. The "Additional Drivers" tab in Ubuntu.
macOS Apple has its own OpenGL implementation, and this comes with each version of macOS. OpenGL is now technically deprecated, in favour of Metal, but you can still use OpenGL 4.1 on macOS.

Create a Hello World

OpenGL is a C API, so we will use the C programming language here. After trying this example, you could reproduce it using the available bindings and variants for other languages, including WebGL. Create a directory for your project, and a new main.c file. Make sure that you have a C compiler, and can run a Hello World program.

#include <stdio.h>

int main( void ) {
	printf("Hello world!\n");

	return 0;
}

Next, we'll add a couple of helper libraries, and expand this with OpenGL.

Hooking-Up OpenGL Functions with Glad

Note: in the earlier versions of this article, I used the alternative, GLEW, library in place of Glad. If you see another example using GLEW, you can switch that with Glad if you wish.

There's a library called Glad that creates an OpenGL interface for us, including hooking up the correct function pointers for the version of OpenGL that we want to use. It also makes sure that we include the latest version of OpenGL, and not a default, ancient, version on the system path, and it lets us add in OpenGL extensions. On Windows if you try to compile without something like Glad, you will see a list of unrecognised OpenGL functions and constants - that means you're using the '90s Microsoft OpenGL. You can use the Glad web service generate library files for you. Make sure to check the following fields in the web service:

For now, we are generating a C interface to desktop OpenGL, version 4.1, including only the core functions from version 4.1, and we want it to generate an OpenGL function loader for us. Add the generated files to your project's directory, such that it looks like this:

.
├── glad
│   ├── include
│   │   ├── glad
│   │   │   └── gl.h
│   │   └── KHR
│   │       └── khrplatform.h
│   └── src
│       └── gl.c
└── main.c

Note that if you are using an earlier version of Glad, the files will be called glad.c, glad.h, instead of gl.c, gl.h.

Starting the OpenGL Context With GLFW

GLFW is a helper library that will start the OpenGL context for us, so that it talks to almost any operating system in the same way. The context is a running instance of OpenGL, tied to a window on the operating system. Note: I've updated this tutorial to use GLFW version 3. The interface differs slightly from previous versions.

You may download the binary files for the library from the GLFW website, or install the library using a package manager such as apt on Ubuntu, or Homebrew on macOS. You may also compile the library from source yourself.

# Ubuntu
sudo apt install libglfw3-dev

# macOS
brew install glfw
	

We should include Glad before GLFW.

#include <glad/gl.h>
#include <GLFW/glfw3.h>

On Windows you may not have installed with a package manager. Because the default download for GLFW is just the source code, you should download the 64-bit Windows binaries package from the GLFW Downloads page. Unzip the contents of the package into your project folder, including GLFW's include folder, and the dynamic library files.

If you're using Visual Studio you'll see the GLFW download includes a subfolder for each major version. Drop the appropriate files from the subfolder matching your version instead. GLFW can also be built into your program as a static library. I'm not doing that in this example. For further explanation see the very helpful GLFW build_guide.html.

.
├── glad
│   ├── include
│   └── src
├── glfw
│   └── include
├── glfw3.dll
├── glfw3dll.lib  (if using Visual Studio)
├── libglfw3dll.a (if using MinGW)
└── main.c

Initialisation Code

Minimal Command-Line Program

We can now expand our Hello World program to start the OpenGL context, print the version, and quit. You might like to compile and run this in a terminal to make sure that everything is working so far, and that your video drivers can support OpenGL 4.1.

#include <glad/gl.h>
#include <GLFW/glfw3.h>
#include <stdio.h>

int main( void ) {
  // Start OpenGL context and OS window using the GLFW helper library.
  if ( !glfwInit() ) {
    fprintf( stderr, "ERROR: could not start GLFW3.\n" );
    return 1;
  }

  // Request an OpenGL 4.1, core, context from GLFW.
  glfwWindowHint( GLFW_CONTEXT_VERSION_MAJOR, 4 );
  glfwWindowHint( GLFW_CONTEXT_VERSION_MINOR, 1 );
  glfwWindowHint( GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE );
  glfwWindowHint( GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE );

  // Create a window on the operating system, then tie the OpenGL context to it.
  GLFWwindow* window = glfwCreateWindow( 800, 600, "Hello Triangle", NULL, NULL );
  if ( !window ) {
    fprintf( stderr, "ERROR: Could not open window with GLFW3.\n" );
    glfwTerminate();
    return 1;
  }
  glfwMakeContextCurrent( window );
                                  
  // Start Glad, so we can call OpenGL functions.
  int version_glad = gladLoadGL( glfwGetProcAddress );
  if ( version_glad == 0 ) {
    fprintf( stderr, "ERROR: Failed to initialize OpenGL context.\n" );
    return 1;
  }
  printf( "Loaded OpenGL %i.%i\n", GLAD_VERSION_MAJOR( version_glad ), GLAD_VERSION_MINOR( version_glad ) );

  // Try to call some OpenGL functions, and print some more version info.
  printf( "Renderer: %s.\n", glGetString( GL_RENDERER ) );
  printf( "OpenGL version supported %s.\n", glGetString( GL_VERSION ) );

  /* OTHER STUFF GOES HERE NEXT */
  
  // Close OpenGL window, context, and any other GLFW resources.
  glfwTerminate();
  return 0;
}

Windows Compile

On Windows the OpenGL library file is called OpenGL32, even on 64-bit Windows. We will use the dynamic build of GLFW, so we should define the macro GLFW_DLL, and link against the libglew32.dll.a link library. We also need to provide the path to the GLFW headers. I have this compile command, using MinGW GCC:

gcc -D GLFW_DLL main.c glad/src/gl.c libglfw3dll.a -I ./glad/include/ -I ./glfw/include -L ./ -lglfw3 -lopengl32 -lgdi32

Linux Compile

On Ubuntu the OpenGL library file is libGL. I have this compile command:

gcc main.c glad/src/gl.c -I ./glad/include/ -lglfw -lGL

macOS Compile

On macOS, the OpenGL library is part of a framework, called OpenGL. You will need a couple of additional system frameworks. On the command line, I have this command:

clang main.c glad/src/gl.c -I ./glad/include/ -lglfw -framework Cocoa -framework OpenGL -framework IOKit

If you're having trouble linking the libraries, then I suggest having a read through the libraries' instructions for your operating system. Do you have the correct version of the library for your compiler? If you are using an IDE, are you building a Debug or Release application, and are the libraries added to the corresponding Debug or Replace tab in the IDE menu? Are the paths you are giving the compiler matching the locations of the libraries?

If I run my program on the command line I get this:

$ ./a.out 
Loaded OpenGL 4.1
Renderer: NVIDIA GeForce GTX 1080 Ti/PCIe/SSE2.
OpenGL version supported 4.1.0 NVIDIA 550.120.

This tells me that my driver can run OpenGL version 4.1. If you're on Windows, and you don't see any output at all, then try running it by clicking on it in Explorer - you might get an error pop-up that your glfw.dll file isn't found in the same folder. Otherwise, if you get an error message on the command line - probably your video drivers aren't set up correctly, or don't support OpenGL 4.1.

Define a Triangle in a Vertex Buffer

Okay, let's define a triangle from 3 points. Later, we can look at doing transformations and perspective, but for now let's draw it flat onto the final screen area; x between -1 and 1, y between -1 and 1, and z = 0.


It always helps to draw your problem on paper first. Here I want to define a triangle, with the points given in clock-wise order, that fits into the screen area of -1:1 on x and y axes.

We will pack all of these points into a big array of floating-point numbers; 9 in total. We will start with the top point, and proceed clock-wise in order: xyzxyzxyz. The order should always be in the same winding direction, so that we can later determine which side is the front, and which side is the back. We can start writing this under the "/* OTHER STUFF GOES HERE NEXT */" comment, from above.

float points[] = {
   0.0f,  0.5f,  0.0f,
   0.5f, -0.5f,  0.0f,
  -0.5f, -0.5f,  0.0f
};

We will copy this chunk of memory onto the graphics card in a unit called a vertex buffer object (VBO). To do this we "generate" an empty buffer, set it as the current buffer in OpenGL's state machine by "binding", then copy the points into the currently bound buffer:

GLuint vbo = 0;
glGenBuffers( 1, &vbo );
glBindBuffer( GL_ARRAY_BUFFER, vbo );
glBufferData( GL_ARRAY_BUFFER, 9 * sizeof( float ), points, GL_STATIC_DRAW );

The last line tells OpenGL that the buffer is the size of 9 floating point numbers, and gives it the address of the first value.

Now an unusual step. Most 3D meshes will use a collection of one or more vertex buffer objects to hold vertex points, texture coordinates, vertex normals, etc. In older OpenGL implementations we would have to bind each one, and define their memory layout, every time that we draw the mesh. To simplify that, we have a thing called the vertex array object (VAO), which remembers all of the vertex buffers that you want to use, and the memory layout of each one. We will set up the vertex array object once per mesh. When we want to draw, all we do then is bind the VAO and draw.

GLuint vao = 0;
glGenVertexArrays( 1, &vao );
glBindVertexArray( vao );
glEnableVertexAttribArray( 0 );
glBindBuffer( GL_ARRAY_BUFFER, vbo );
glVertexAttribPointer( 0, 3, GL_FLOAT, GL_FALSE, 0, NULL );

Here we tell OpenGL to generate a new VAO for us. It sets an unsigned integer to identify it with later. We bind it to bring it in to focus in the state machine. This lets us enable the first attribute; 0. We are only using a single vertex buffer, so we know that it will be attribute location 0. The glVertexAttribPointer function defines the layout of our first vertex buffer; "0" means define the layout for attribute number 0. "3" means that the variables are a combination type called vec3 made from every 3 floats (GL_FLOAT) in the buffer.

You might try compiling at this point to make sure that there were no mistakes.

Shaders

We need to use a shader program, written in OpenGL Shader Language (GLSL), to define how to draw our shape from the vertex array object. You will see that the attribute pointer from the VAO will match up to our input variables in the shader.

This shader program is made from the minimum 2 parts; a vertex shader, which describes where each 3D vertex points should be placed in the display area, and a fragment shader which the colour of each pixel-sized fragment of the mesh surface. Both are be written in plain text, and look a lot like C programs. Loading these from plain text files would be nicer; I just wanted to save a bit of web real-estate by hard-coding them here.

const char* vertex_shader =
"#version 410 core\n"
"in vec3 vp;"
"void main() {"
"  gl_Position = vec4( vp, 1.0 );"
"}";

The first line says which version of the shading language to use; in this case the code 410 is a short-hand for "4.10.6", which was released with OpenGL 4.1. My vertex shader has 1 input variable; a vec3 (vector made from 3 floats), which matches up to our VAO's attribute pointer. This means that each vertex shader invocation gets 3 of the 9 floats from our buffer - therefore 3 vertex shaders will run concurrently; each one positioning 1 of the vertices. The output has a reserved name gl_Position and expects a 4D float type, vec4. You can see that I haven't modified this at all, just added a "1.0" to the 4th component. The "1.0" at the end just means "don't calculate any perspective".

const char* fragment_shader =
"#version 410 core\n"
"out vec4 frag_colour;"
"void main() {"
"  frag_colour = vec4( 0.5, 0.0, 0.5, 1.0 );"
"}";

We still haven't told OpenGL that it will be a single triangle. You can guess that for a triangle, we will have lots more fragment shaders running than vertex shaders for this shape, since our triangle will cover more than 3 pixels. The fragment shader has one job - setting the colour of each fragment. It therefore has 1 output - a 4D vector representing a colour made from red, blue, green, and alpha components - each component has a value between 0 and 1. We aren't using the alpha component, so I left it at "1.0". Can you guess what colour this is?

Before using the shaders we have to load the strings into an OpenGL shader.

GLuint vs = glCreateShader( GL_VERTEX_SHADER );
glShaderSource( vs, 1, &vertex_shader, NULL );
glCompileShader( vs );
GLuint fs = glCreateShader( GL_FRAGMENT_SHADER );
glShaderSource( fs, 1, &fragment_shader, NULL );
glCompileShader( fs );

Now, these compiled shaders must be combined into a single, executable GPU shader program. We create an empty program, attach the shaders, then link them together.

GLuint shader_program = glCreateProgram();
glAttachShader( shader_program, fs );
glAttachShader( shader_program, vs );
glLinkProgram( shader_program );

Drawing

We draw in a loop. Each iteration draws the screen once; a "frame" of rendering. The loop finishes if the window is closed. Later we can also ask GLFW if the escape key has been pressed, and use that as another way to close the program.

while ( !glfwWindowShouldClose( window ) ) {
  // Update window events.
  glfwPollEvents();
  
  // Wipe the drawing surface clear.
  glClear( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT );

  // Put the shader program, and the VAO, in focus in OpenGL's state machine.
  glUseProgram( shader_program );
  glBindVertexArray( vao );

  // Draw points 0-3 from the currently bound VAO with current in-use shader.
  glDrawArrays( GL_TRIANGLES, 0, 3 );
  
  // Put the stuff we've been drawing onto the visible area.
  glfwSwapBuffers( window );
}

GLFW3 requires that we manually call glfwPollEvents() to update non-graphical events like key-presses. We clear the drawing surface before we draw, then set the shader program that should be "in use" for all further drawing. We set our VAO (not the VBO) as the geometry input that should be used for all further drawing. Then we can draw, and we want to draw in triangles mode (1 triangle for every 3 points), and draw from point number 0, for 3 points. Finally, we swap the buffer we have been drawing to onto the visible area. Done!

Experimenting

Common Mistakes

In OpenGL, your mistakes are mostly from misusing the interface (it's not the most intuitive API ever... to put it kindly).

GLSL Mistakes

These mistakes often happen in the shaders. In the next article we will look at printing out any mistakes that are found when the shaders compile, and print any problems with matching the vertex shader to the fragment shader found during the linking process. This is going to catch almost all of your errors, so this should be your first port-of-call when diagnosing a problem.

OpenGL Function Parameter Mistakes

You can also easily make small mistakes in the C interface. OpenGL uses a lot of [unsigned] integers (aka "GLuint") to identify handles to variables i.e. the vertex buffer, the vertex array, the shaders, the shader program, and so on. OpenGL also uses a lot of enumerated or defined types like GL_TRIANGLES which also resolve to integers. This means that if you mix these up (by putting function parameters in the wrong order, for example), OpenGL will think that you have given it valid inputs, and won't complain. In other words, the OpenGL interface is very poor at using strong typing for picking up errors. These mistakes often result in a black screen, or "no effect", and can be very frustrating. The only way to find them is often to pick through, and check every OpenGL function against its prototype to make sure that you have given it the correct parameters. My most common error of this type is mixing up location numbers with other indices (happens often when setting up textures, for example) - which can be very hard to spot.

My advice is to read the documentation for each OpenGL function, when you first use it. I strongly recommend Jorge Rodríguez' docs.gl, where you can look up documentation for each function, by version of OpenGL, and also get some extra examples of how to use them.

OpenGL State Machine Mistakes

Another, very tricky to spot, source of error is knowing which states to set in the state machine before calling certain functions. I will try to point all of these out as they appear. An example from our code, above, is the glDrawArrays function. It will use the most recently bound vertex array, and the most recently used shader program to draw. If we want to draw a different set of buffers, then we need to bind that before drawing again. If no valid buffer or shader has been set in the state machine then it will crash.

Next Steps

We will look at initialisation in more detail - particularly logging helpful debugging information. We will discuss the functionality of shaders and the hardware architecture. We will create more complex objects with more than 1 vertex buffer, and look at loading geometry from a file.