What are we doing in this chapter?
We're going to make the simplest OpenGL program possible. One that just opens a window and clears it to pretty colors.
Sounds boring. But, we're going to learn a couple of fundamental concepts along the way to remove some of the mystery of
how OpenGL programs work under the hood. And, why setting them up seems more complicated than necessary. (Even if it's less
complicated than Vulkan :P )
We're also going to save ourselves a ton of self-inflicted pain and suffering by setting up the debugging facilities of
OpenGL right from the start. That way the driver can at least give you a hint about what you are doing wrong instead of
just refusing to work and silently judging you.
First thing we need to do is to create a window to draw into and an OpenGL "context" to draw with.
Why use SDL to create a window and OpenGL context?
I am a big advocate of do-it-yourself, cutting out abstractions, understanding what's under the
hood, etc... So, why kick off everything with a window abstraction library? Frankly, because the
details of window handling on each platform are extensive, esoteric, boring and irrelevant to
starting off learning OpenGL.
For quite a long time, possibly forever, what you will want to do with windows are:
- Make a window. Use it for a while. Close it.
- Make an OpenGL “context” that can render to the window.
- Get mouse/keyboard/gamepad input from the user.
- Get the window handle for use with other libraries.
- Maybe make the window fullscreen or hidden for certain situations.
- Maybe make multiple windows.
That's about it for most people. Doing this manually with Windows/OSX/Linux APIs manually is a
lot of work that involves navigating historic quirks accumulated over the past
40 years. OpenGL has enough of that to keep you busy all by itself ;) And, SDL makes all of
that easy. It does pretty much what you would write yourself to get everything correct in all the
situations on all the platforms with all the quirks.
Why not use GLFW?
GLFW is a great library. It is used by many tutorials because it is simple to download, compile and link
into your projects. It doesn't try to have the wide range of features that SDL has.
It just focuses on windowing and input. So, it's a natural choice for a simple tutorial like this.
However...
- Integrating SDL3 has become very easy with CMAKE.
- SDL3 has had tremendously more development effort put into it than GLFW, making it more robust and reliable.
- It is used by major companies like Valve and Epic Games (at least on Linux) who contribute patches back to the project.
- https://wiki.libsdl.org/SDL3/README-platforms is a a very long list.
- It does also have lots of great non-graphics features such as possibly the widest support anywhere for obscure joysticks and gamepads.
In the end, I have to recommend that shipping products bootstrap with SDL3 just for the robust handling of corner cases
in window management. Either way, the total amount of SDL code we will be using is tiny. Use whatever makes you happy.
Alright? Alright. Let's make a window!
Let's make a window!
Well, that was easy! We specify the what kind of OpenGL context we want, the window width, height, title.
And, Boom! We get a window!
Note that throughout this tutorial I will be using a stylistic pattern of auto result; { inline code block }
as a sort of “manually inlined function”. This is to keep temporary object lifetimes visibly bounded while
also keeping the reading flow the code linear from top-to-bottom rather than jumping around between broken-out functions.
We asked SDL to create an OpenGL 4.6 Core Profile context for us. AKA: The Latest and Greatest OpenGL Context Possible
with all of the deprecated, legacy API cut out. If we wanted the deprecated stuff, we could have asked for a
Compatibility Profile context instead. But, we don't.
We also asked for a Debug Context. This slows down the API a bit. So, you don't want to ship your product with this.
But, it speeds up developement a lot. So, you want to always use this while you are developing.
If you are using a Mac (or a very old driver in Windows or Linux), you will only be able to get an OpenGL 4.1 context.
You can still follow along with the tutorial, but you will need to make some adjustments for features that were added after OpenGL 4.1.
So, what is an "OpenGL Context" anyway?
An “OpenGL Context” is an instance of a GPU driver's OpenGL interface. Like, if you were to run
two separate processes that both happen to use OpenGL simultaneously, you would naturally
expect them to operate independently and not interfere with each other. Two instances of OpenGL
in a single process also operate just as independently. Most times you only
want one instance of OpenGL in your one process. But, you can have multiple if you
really want to by creating multiple OpenGL contexts in your process.
The vast majority of OpenGL programs only ever use a single context.
Uses for multiple contexts include:
- Rendering different content to multiple windows independently.
- Note that multiple contexts are not required or even recommended for multi-window rendering.
- Using multiple GPUs independently.
- Note that slow communication between GPUs means this is not an easy way to render a single frame faster.
- Using one context to load content in one thread then sharing with another context to render it in another thread.
- Note that there are ways to approximate this with a single context. Some even claim that multi-context loading is often broken at the driver level.
A context must be explicitly associated with a single CPU thread at any given time. At most one
context at a time can be associated with with a single thread. That means you cannot use multiple
threads to call the OpenGL API faster for your single context.
By default, one context cannot access resources loaded into a different context. But, there is the
option to create contexts that share resources. This is tricky because they contexts are not
implicitly synchronized. They operate on different threads on the CPU and on similarly
independent timelines on the GPU. Working with a single resource from multiple contexts simultaneously
is not safe. It is undefined behavior on both the CPU side and the GPU side of the API.
Using fences and barriers is necessary to synchronize reading and writing resources on different timelines
safely. That can be a challenge. We'll get into it later ;)
One more time to be very clear: OpenGL is not a thread-safe API. An OpenGL context can only be
"current" on one thread at a time. Which means in practice that you can only call OpenGL functions
from one thread at a time.
Loading the OpenGL API? (glad)
glad is the second and final abstraction library we are going to use in this example. What it does
for us requires a ton of precise, verbose code that is much better suited for a code generator than
for hand-coding. And, therefore: glad is a code generator!
But, why tho?
OpenGL looks like a C library like many others. You would expect an opengl.h, opengl.lib,
opengl.dll combo that is full of plain-old-C functions. And, sometimes it looks like it is
implemented that way. But, it is actually implemented as a collection of function pointers that
are queried from the driver. Even implementations that look like plain-old functions are actually
internally loading function pointers from the driver to call on your behalf.
This is because OpenGL is technically just a specification
for drivers to implement. It is not a specific library implementation.
It is also because OpenGL is a community-driven project that is designed to be vendor-extensible.
The various GPU hardware vendors (Nvidia, AMD, Intel, Apple, ARM, Qualcomm, Imagination
and others) are all competing to deliver new, innovative features to make their hardware stand
out. But, they also need to cooperate to help software vendors like you easily use common
features in a consistent way while have the option to use hardware-specific features when
desired.
This extension mechanism is core to the evolution of OpenGL. The vendors can independently
publish extensions to expose cutting-edge functionality of their newest hardware and drivers.
With those features available as opt-in-when-available, other vendors can observe how these
features work out in practice without first needing the committee to integrate them into the core
of OpenGL forever. From there, multi-vendor extensions can be negotiated for features that turn
out to be popular. Popular multi-vendor extensions can be promoted by the committee to
become required features of later versions of OpenGL. Additionally, common required features
of later versions of OpenGL can be back-ported as optionally-available extensions to early
OpenGL versions. That way you can enhance your GL 3.3 app with a feature from GL 4.6 if it is
available in the current driver.
Given all of that, which vendors' extensions get to be lucky enough to be included in the official
opengl.h? And, what are we to do next week when new features become available? The answer
is that there is no official opengl.h even though it looks like one ships in many
OSes/development environments. Those versions are just an arbitrary subsets/snapshots of the
available features. And, often a very, very old ones. (Windows/VisualStudio ships with a
header/lib/dll combo for OpenGL 1.1!)
Instead of an official OpenGL header, the committee maintains a frequently-updated collection
of machine-readable XML files in The OpenGL Registry.
It specifies the interfaces of thousands, maybe tens of thousands of
functions that have been exposed by various GPU drivers over the 30+ years of OpenGL's
evolution.
Which finally brings us to glad. glad is a Python program that downloads the XML spec files
and generates a fresh new OpenGL header just for you; customized to the functionality you
require. It also generates all of the C function wrappers around those function pointers. And,
you don't even need to download and run glad yourself because it is available as a web service at
https://gen.glad.sh/ The glad/gl.h included in this project was generated that way.
glad can generate C or Rust headers for Vulkan, Vulkan Safety Critical, OpenGL, OpenGL Safety
Critical and OpenGL for Embedded Systems. It also handles WGL
for Windows, <a href="https://en.wikipedia.org/wiki/EGL_(API)">EGL</a> for
Wayland, GLX for XWindows and CGL
for OSX. WGL/EGL/GLX/CGL are supplemental APIs that are needed
for OpenGL to actually interact with other libraries —such as your operating system's windows!
The OpenGL spec technically doesn't mention windowing systems. So, APIs like EGL are
necessary to form the bridge between OpenGL and Win32/XWindows/etc... Thankfully,
between glad and SDL, we don't have to deal with that here.
So, what's going on under the hood?
When you call glClear() and are using OpenGL via glad on Windows, you are actually calling
the function pointer glad_glClear that was loaded from the driver by gladLoadGLLoader().
The glad_glClear function pointer is defined by:
Be grateful https://gen.glad.sh/ generates thousands of lines of code like that so you don't have to.
You do not need to "Download and install OpenGL." OpenGL is part of the GPU driver
you already have installed. All glad does is ask your driver for pointers to the
OpenGL functions that are already in the driver.
Debugging your use of the API
We are finally ready to actually write some OpenGL code!
I know we all want to get straight to
drawing something. But, the first thing we need to set up is a way to get feedback from OpenGL
in the event that we do something incorrectly and trigger an error inside of the API.
OpenGL functions do not throw exceptions (It's a C API). They don't even return error codes!
Instead, for a long time the only way to get error feedback from the API was through
glGetError()
and it was pretty awful. You had to manually put some form of
handleOpenGlError(glGetError()); after every single OpenGL API call or you would get
confusing feedback at a time when you really need clarity. It was a slow and error-prone error-handling system.
Thankfully, for over a decade now we have had glDebugMessageCallback.
You can use that to register a callback that triggers at the point an error is detected. And, we have
glDebugMessageControl
to specify in annoyingly fine detail exactly what kinds of error messages we do and do not want to receive.
The error messages will tell you when you are trying to put OpenGL into a bad state. But, what if
your scene does render, just not how you expected it to? In that case you are going to want to
use a GPU debugger such as Nvidia Nsight Graphics
or RenderDoc to observe the changes to
GPU state that you actually made (as opposed to the changes you thought you made).
OK! We've made it through all the boring, responsible setup stuff. Let's get to some fun stuff.
Let's finally draw something! Anything! Please...
We're just going to fill the whole window with a solid color that changes over time until the user
closes the application.
glClear() fills the entire framebuffer with a solid color. If you don't start out by clearing the buffer,
you'll start the frame with whatever happened to be in the buffer two frames ago. That can cause some obvious
visual glitches, but it also causes more subtle problems.
Most modern GPUs have hardware framebuffer "compression". This is a lossless compression scheme that reduces
bandwidth and power consumption while drawing by storing the framebuffer in a compressed format in memory.
It does not save space in memory. Clearing the screen resets the buffer to a highly compressed state.
In turn, the GPU has specific hardware support for performing the clear operation suprisingly quickly by only
bothering to modify the compressed representation of the framebuffer instead of actually writing out every pixel.
This is why you should specifically use glClear() instead of just drawing a big rectangle that fills the screen.
Getting even more specific, lots of hardware really
wants you to clear to 0.0 or 1.0 in each channel.
And, beyond that, explicitly clearing the buffer informs the driver that what you draw after does not have any
lingering dependencies on the previous contents of the framebuffer. This sometimes allows the driver to perform
optimizations on your behalf like using an extra buffer you can start drawing into early before the previous one
is completely finished with some final step in the pipeline.
It also helps mobile GPU drivers that need to carefully manage dependencies between operations on framebuffers because
of the detials of their tiled rendering architectures. But, that's a topic for another day ;)
So, yeah. Always clear the framebuffer to black or white at the start of each frame. It's simple, easy and helps a lot
more than is obvious at first glance.
Ending the frame by "swapping buffers"
I mentioned earler that if you don't clear the framebuffer at the start of the frame, you might see the contents of
the frame from two frames ago. This is because you need two framebuffers to draw a smooth animation. While you are
drawing into one framebuffer, the other framebuffer is scanned out from VRAM, over your video cable, to the screen.
If you only used a single buffer that was both being scanned out and rendered into at the same time, you would all
kinds of weird artifacts on the screen. Each pixel could be from some arbitrary and incomplete step of the rendering process.
Instead, the common approach is to draw into one buffer while the screen is scanning the other buffer. Then, when it's
time to display an new frame, you "swap" which buffer is pointed to by the screen and which is used as the new render target.
Using two buffers like this is, unsurprisingly, called "double buffering". And, you can use more if you want.
Triple buffering is somewhat common. Having a third buffer lets you start rendering the next frame without waiting
for the screen to completely finish scanning out the buffer from two frames ago. But, it also adds latency because the screen
is not going to display the new frame any sooner even though you started it early. So, the total time from start to display
is longer and starting early means you only have older user inputs to work with instead of waiting until the last instant
start to know what to draw.
SDL defaults to waiting for the screen to complete scanning out the current buffer before swapping (vsync). You can
disable vsync with SDL_GL_SetSwapInterval(0); to run at an uncapped framerate. But, you'll see "tearing" artifacts as a
side effect of swapping to scanning out from a different render target in the middle of a scan.
Congrats! You are officially an OpenGL programmer!
You made a window and filled it with pretty colors! What more could you want?
If you've read all the way to the end of this chapter, you have a better attention span than most.
I know this first couple of chapters has been slow. But, strap in. Because from here on out, things
are going to start moving fast :)