Introduction and Setting Up
Welcome to the first lesson in this tutorial series covering the basics of rendering graphics!
This lesson will be a quick one hopefully - we're going to look at how to setup and layout your workspace so that you can build and run the code for the rest of the lessons.
Libraries
Before we get into that though, I just want to take a minute to look at the libraries we will be using to build our program, and justify our choice over other similar tools.
OpenGL
In these tutorials, we will be using OpenGL, an open graphics library. OpenGL basically serves as a API to draw graphics to our screen in a way that can fully and automatically exploit the power and speed of GPUs. This API is provided by your graphics card driver, so should always be present on any up-to-date desktop computer, and means your code to draw something to the screen will run on pretty much any graphics card without alteration. Its widely known, supported, and kept up-to-date.
The two main alternatives to OpenGL are DirectX and Vulkan.
DirectX is locked to the Windows platform so cross-platform programs are not possible. Meanwhile, an OpenGL program can be compiled and run on Windows, Linux and Mac without any changes to the code!
Vulkan is a new graphics API released by the same community which organises OpenGL. While the two APIs perform similar roles, Vulkan is not a replacement for OpenGL. Vulkan provides really low-level raw access to the GPU, while OpenGL leaves a lot of the really low-level stuff up to the GPU driver to perform.
As a result Vulkan programs can be ultra-optimised and so slightly faster, but it is far more complex code-wise, and the speed advantage is unlikely to be worth the extra effort unless you really are chasing after every possible performance gain. For this reason, OpenGL still has it's place, as its much easier and faster to code, and far more beginner friendly. If you are new to graphics, this is the place to start. And if you do decide to switch to Vulkan in the future, many of the concepts learned for OpenGL will directly translate to Vulkan.
SDL
OpenGL offers us a graphics API, but it is primarily focused on rendering. It will allow us to draw fantastic images, but we still need to create a window on our desktop to display it in - something OpenGL doesn't handle as it's only concern is rendering. To handle cross-platform window functions, we'll also incorporate SDL, a library designed exactly for this purpose. It handles creating windows on our desktop, getting mouse and keyboard input, handling window resizing and much more. Importantly again it abstracts all of these functions so that your code doesn't need to change whether you are compiling it on Windows, Linux or Mac. It's a very widely used tool, and is what major developers such as Valve use.
There are various alternatives to SDL, for example GLFW and GLUT. Both are excellent libraries. However SDL offers slightly more functionality in terms of audio and controller support, and is what I personally have been using for many years, and for that reason is the library we will use in these tutorials.
NOTE: For these tutorials, I will be using SDL version 3. This is the latest version as of the time of writing (2024), but is currently still under development. The good news is that if you are following these lessons your program will be future-proof and this will be beneficial to your programs in the long run (there are some fantastic new features which encouraged me to target an as-of-yet unreleased library). In the short term though you may have some extra difficulty in setting the library up, at least until it is fully released.
Installation
To begin, we will of course need a C++ compiler installed, as well as the SDL and OpenGL libraries.
C++ Compiler
If you don't already have a compiler, you can get the open-source g++ compiler as part of the MinGW "compiler kit" here. They provide the raw source code, but as you don't yet have a compiler you cannot do anything with it. They do however provide links to others who regularly compile the latest version of the code. I'm currently using the version they link to here.
I use the x86_64
version as I'm on a 64-bit computer.
I use the Posix thread model over the win32 model for cross-platform compatibility reasons - although your choice won't make much difference.
If you're interested in the difference, you can read about it here.
Finally, I use the UCRT run-time which is the more modern approach compared to MSVCRT - more information is available on the differences here.
Once you've downloaded and decompressed the compiler, you will need to add the binaries to your system's PATH
variable.
When you type in a program's name into the command line, this variable defines the folders the system will look in to find that program.
To add the compiler's folder to the PATH
variable, right-click the start button, and then click on "system".
In this window, click the search bar and type in "environment variables" and click on the option "Edit environment variables for your account".
Create a new entry here, then click the browse button and navigate to where you downloaded MinGW.
Navigate into the "bin" folder, and then you click "Ok" to add the folder as a new entry to our PATH
.
Click "Ok" on this window also to finalise our new PATH
variable.
Any existing terminals will need to be fully closed and re-opened for this change to take effect.
You should now be able to open up a terminal, and type g++
to use the MinGW compiler.
If everything is working properly, you should see an error message complaining "fatal error: no input files".
That's actually good!
The program was found and run correctly, it was just expecting us to pass in some source code files as a command line argument which we want it to compile.
SDL3
To get SDL3 working with our program, we will need three things. We need to include it's header files, we need to link to it's functions, and we need to put its DLL alongside any compiled executables.
The first step is easy. The latest version of SDL can be downloaded from GitHub here. Click the large "Code" button and download a zip of the source code. As long as the branch is set to "main", the default, then you will be downloading the source for SDL3. Extract the files somewhere convenient, and then we can just tell our compiler that the header files are located in here.
Step 2 and 3 are slightly more complicated. They rely on SDL being compiled to get access to it's functions. As SDL3 is still in development and hasn't had a formal released yet, they haven't released any compiled versions of the library. Fortunately, rather than compile the library ourselves, a user on GitHub has kindly set up a repository with the latest version of the library pre-compiled each week, available here.
On the right-hand side of the GitHub page, click Releases. Each weekly release will have an "Assets" section, from where you can download a Zip file of the compiled version of the library. Grab this file and extract it somewhere convenient on your hard drive. This will contain all the libraries and DLLs you need for compiling! Note that I will update this section to simplify it when SDL3 is officially released!
GLEW
The GLEW library is available from it's GitHub page. Again on the right-hand side click on "Releases". The latest release should be at the top, and in the "Assets" section you will find a "win32" Zip containing everything needed for compiling on Windows.
DLLs
Both the SDL and GLEW libraries each provide their code as a single dynamic link library ("DLL"). Your system will need to access these files whenever the program is run (not just compiled!).
The SDL DLL can be found inside the pre-compiled version's bin
folder, named SDL3.dll
.
The GLEW DLL is inside the folder bin\Release\x64
, named glew32.dll
.
There are two approaches to making these available to your program.
The first way is to copy both files into C:\Windows\System32
.
This way they will always be found on your system.
That's great if never want to think about this step again, even if you switch to a different project in the future.
The second approach is to copy each of these DLLs into the same folder as your compiled executable. This is generally the preferred approach because it doesn't clutter up your system, and because if you distribute the folder with your executable and DLLs to a different PC, you won't need to install the DLLs there as they'll already be in the correct place. It also ensures that your program has access to the exact same version of the library, whereas if you rely on the DLLs being installed in the system directly, the version may differ slightly and potentially cause subtle problems.
Makefiles
With our libraries installed, let's build a test program to check everything is working properly. To do this, we'll make use of a makefile. If you've never used or heard of these before, don't worry. A makefile is basically a file which explains how your program should be built. Perhaps you have traditionally compiled your code by typing something like this into a terminal:
|
Where g++
is our C++ compiler, main.cpp
is our source code file, and we tell it to name the output executable my_program.exe
.
However, this is a pretty trivial example. Sometimes we have many, many files that need to be compiled, many libraries to link, etc. When things become more complicated, we don't want to keep typing out this command in full each time we want to compile, so that's where makefiles come in! And they're incredibly simple too.
Our example above can be rewritten as the following makefile:
1. |
|
2. |
|
NOTE: Indentations in makefiles must be full TAB indentations! Using spaces as indentations will not work. I don't want this website to start any political arguments about spaces vs tabs so let's just leave it at that.
To compile your hypothetical main.cpp
code using this, you can save the above example as a regular text file named Makefile
(note the capital "M", and no file extension) in the same directory as your code.
To run it, we just need to be in the same directory in our terminal, and type mingw32-make.exe
.
You can probably just start typing a few letters and press tab to auto-complete.
Its as easy as that!
So what does the keyword all
mean here?
Well, the makefile allows us to specify different targets, which are just components that we can build individually.
This way, if you had a particularly large project, you could just type into your terminal mingw32-make.exe gui
and just that particular part of your code can be (re-)compiled.
By convention, we name the target which compiles everything, the whole program, as "all", and place it as the first target in our Makefiles.
If there is not a specific target typed into the terminal, make will run the first target found in the makefile, which by convention should compile the whole program.
So in our example above, if we typed make all
into our terminal, make would look for the the indented block of commands after the all
target in our makefile and run those, building our program.
Alternatively, if we typed just make
, by virtue of being the first target of perhaps many defined in the makefile, the same all
target would be run.
Note that the commands here for each target are just ordinary terminal commands.
Therefore you are not limited to just compiling things with g++
, but can run regular commands like cd
, ls
, or zip
here across multiple indented lines.
Makefiles also allow you to set up many other things, such as dependencies, but I will keep this lesson simple for now.
Let's introduce a few variables into our makefile to make it easier to customise in the future:
1. |
|
2. |
|
3. |
|
4. |
|
5. |
|
6. |
|
7. |
|
8. |
|
9. |
|
10. |
|
Alright, that's quite a lot of variables actually! Let's take it line by line.
We started by defining a variable to hold the name of our compiler, CC
.
The idea is that if you are compiling lots of things and at some point wish to change the compiler, you just need to edit this one variable, instead of every line where something is compiled.
We define a variable to hold the names of all the objects we wish to compile (source files), and one to hold the names of the libraries we want to link against.
Another variable is used to hold any compiler flags we want to use - in this case just -Wall
which means to throw all compiler warnings so we get into good habits from the start!
We variablise the output program name too.
If you don't provide a name, g++ will give your executable the fairly ugly default name of a.out
, so we'll use this variable to give your program a reasonable name.
Finally, at the very bottom of our makefile, we write what's known as the recipe for our target, similar to what we did before.
After the target's name, all
, we start by adding the $(OBJS)
variable on the same line to make it a dependency.
The $(OBJS)
variable will immediately be substituted for each filename of our source code.
Make will therefore know that those files must exist in order to continue compiling.
Then to actually compile our program, we just combine the rest of our variables together to form our compile command.
If we save this file and run it (terminal, go to the right folder, type mingw32-make.exe
), make will display the commands it is running, and we will see that the above script ends up being translated to this in the command line:
1. |
|
Which is exactly what we want! To actually make this useful for us though, we need to go one step further.
To build the code for the rest of this tutorial series, g++ will need to know where to find the necessary header files and libraries for SDL and GLEW.
Let's create three new variables to hold the include directories (where to find the header files), linker directories (where the linker should look for the libraries), and the library names:
1. |
|
2. |
|
3. |
|
4. |
|
5. |
|
6. |
|
7. |
|
8. |
|
9. |
|
10. |
|
11. |
|
12. |
|
13. |
|
14. |
|
15. |
|
16. |
|
We set up the INCLUDE_DIRS
variable to tell the compiler where to look for header files using the -I
flag, immediately followed by the path without a space.
This is done twice, first for the path to the SDL headers and then for GLEW headers.
Obviously, you should adjust these locations according to where you unzipped the libraries.
Likewise we use the -L
flag in the same way to pass in the folders where the libraries can be found, and this time store it in the LINKER_DIRS
variable.
Just the paths of the libraries is not enough though, so in the LIBRARIES
variable we explicitly state the name of the libraries we want our code to be linked against - this time using a lower-case -l
flag.
We start by including the MinGW run-time library - a necessity of the compiler.
This is then followed by the SDL library, OpenGL itself, and then finally to GLEW.
We've also added another variable called FLAGS
for passing some extra settings to our compiler.
The first flag here, -Wall
turns on all code warnings, to ensure we're writing good code.
For the second flag, we need to understand that in Windows, for historical reasons, programs can be either console-based, or window-based. Sometimes you may have noticed when running a program a small command line appears in the background - this means you're looking at a console-based program! Console-based programs can have windows, it's just that they start by opening a console, whereas window programs start by opening a window. While having a console alongside our program is great for debugging, it is common to then switch to a regular window-based program before publishing your app, without a console window alongside.
While there is a default setting, you will likely want to change this depending on whether you're compiling to test and debug, or whether you're creating a release for users.
So we'll explicitly set this parameter in our FLAGS
variable, which you can then adjust depending on what you want to do.
Using the flag -Wl,-subsystem,windows
will tell the linker that it should use the window subsystem, without an accompanying console, while -Wl,-subsystem,console
will tell the linker you want the accompanying console to be present where any print statements will appear.
We finish up with a variable for the output executable name, and that's essentially it.
Now, our makefile can build pretty much any program from this tutorial series.
It's far easier to now just type mingw32-make.exe
each time we want to compile than typing out the whole compile command (and don't forget to press tab while typing this to auto-complete!).
Realistically the only time we will ever need to change this file is if we need to compile an extra source code file, or perhaps if we link to another library.
Cleaning Up
There is one more thing I want to show you about makefiles before we start building a real program.
Quite often, compiling creates files which "pollute" our workspace. This can be temporary files from our text editor, other temporary files, or perhaps object files if you choose to do some really fancy compiling procedure. Even the executable file.
Therefore it is common to add a target to your makefile called "clean". The idea is that this will just go through and delete all the unnecessary files you have accumulated. It will get your workspace back to a "pure" state, untouched by your compiler.
Right now, the only thing I'll do is to remove the executable, but you can modify this later if you want to remove anything else. Let's add it to the bottom of our makefile:
1. |
|
2. |
|
3. |
|
4. |
|
5. |
|
6. |
|
7. |
|
8. |
|
9. |
|
10. |
|
11. |
|
12. |
|
13. |
|
14. |
|
15. |
|
16. |
|
17. |
|
18. |
|
19. |
|
Now, when someone types mingw32-make.exe clean
into the terminal, make will look for a target with that name, and run the corresponding terminal commands.
In this case, we have a terminal command to check if the executable file exists, and if so delete it.
Testing our set-up
With the above makefile now in place, let's perform a quick test that everything is working correctly before moving on with our lessons.
Copy-and-paste the following code into a file named main.cpp
in the same folder as the makefile:
1. |
|
2. |
|
3. |
|
4. |
|
5. |
|
6. |
|
7. |
|
8. |
|
9. |
|
10. |
|
11. |
|
Don't worry about the actual code too much here - we cover it in the next lesson. For now we just want to check the compiler can find our libraries and build our program.
Just for this simple program, we'll need to compile with the subsystem flag -Wl,-subsystem,console
(the default) to ensure that a terminal will be attached to the program so we can see our output.
For future programs you can run with either though.
Navigate your terminal to the folder with the code and makefile in, run mingw32-make.exe
to start compiling, and hopefully no errors appear!
Then run ./00-Introduction-and-Setting-Up.exe
or whatever you chose to name your executable in a terminal, and if everything is working you should see "We are running!" appear.
Note that if you run the program by double clicking the executable, a terminal will appear and show what's printed, but then will close immediately when the program finishes, so we will not get a chance to check the output.
So for this lesson at least, you need to run the executable from the terminal.
If something goes wrong, make sure you have SDL and GLEW included and linked properly, and that both the makefile and main.cpp
are in the same directory, and that you are currently in that folder in your terminal when typing mingw32-make.exe
.
Remember that a Makefile needs to be named with a capital "M", and use only tabs for indentations.
If you can compile the program but cannot run it, it's likely that the program cannot find the DLLs, so make sure they are in the same directory.
Alternatively, if you're having problems you can download and try my version of the files using the buttons just below, and try to compile those.
Otherwise if everything is working then you can move onto the next tutorial!