OpenGL
Kevin| 12 min read| December 02, 2022| [Devlog] #OpenGL #Context #RendererWhy OpenGL
OpenGL development has been mostly abandoned for Vulkan nowadays, so why would anyone still want to target OpenGL?
For starters, it is widely supported, and the drivers are matured. It's also way simpler than Vulkan, but also makes cross-platform support way easier. It is possible to target Desktops, Web Browsers and mobile devices with almost the same subset.
However, the goal of this context abstraction is to be graphics API agnostic anyway, so it is always possible to add another one and will even be required for console support at some point. However, I also want to write a wgpu backend in the near future, just to test the "flexibility" of the context and compare the performance differences. Adding Vulkan later is also not off the table either.
So in short, OpenGL is a great entry-level API which runs almost everywhere, which makes it an ideal candidate for our first game.
I am also a bit more familiar with OpenGL than the other graphics API's, so I hope to hit the point where we can start with the actual game sooner :)
OpenGL Versions
Since we are going to use OpenGL, the first thing we should think about is about the OpenGL version we want to
target. While it is possible to target multiple versions with a single backend and infest our code base with if's
to pick the better alternative when supported, it adds an unnecessary overhead and bloat. Instead, we can target a specific version, poll some extensions when needed(for example, debug callback is a great extension in 3.3 to poll for), and when needed, just implement a different backend altogether for
another version. Let's say to have a dedicated 3.3 backend and a separate 4.6 one.
Which version we want to target depends first and foremost on the "OpenGL flavor" we are aiming for. There are basically 3.
-
Legacy OpenGL
Also known as "fixed function pipeline" or "immediate mode OpenGL", is a straightforward way to draw things, but comes with a worse performance. You will end up with far more API calls, need to send more data around, and is not nearly as flexible as the programmable pipeline. It's the OpenGL-style that usesglBegin
,glEnd
,glMatrixMode
, etc., and was deprecated with the release of OpenGL 3.0 -
"Modern" OpenGL
This is the "programmable pipeline", where you mostly deal with shaders, programs that run on the GPU in various stages of the process, and uses vertex array objects to store the format of vertex data. This is pretty much OpenGL 3.0 - 4.4. -
Bindless OpenGL
Bindless is about "Direct State Access"(DSA). This changes the previous way of globally binding objects to the context to "named" access. Functions take in the object that they are going to manipulate instead of relying on global state, where the latter often leads to accidentally querying or changing the state of the wrong object. This leads not only to fewer errors, but also to more efficiency(no need to constantly bind things or keep track of the current bindings). This is pretty much OpenGL >= 4.5.
There are also the core and the compatibility profiles, where the latter just enabled support for the fixed function pipeline in the newer versions, though they don't work with programmable pipeline ones(so it's not really possible to mix both ways). OpenGL features are generally additive, so this profile split was a way to separate the "new" OpenGL from the legacy luggage.
Which versions of these flavors we want to use depends on the functionality that we need from the API and the available support on the target platform. 3.3 is a solid choice for a wide range of hardware and driver maturity, but handy functions like compute shaders came with 4.3. Apple devices also stopped support for OpenGL versions above 4.1. Realistically speaking, most Desktop systems support the decade old 4.5 according to the steam hardware survey. More than 90% support DX-12 capable hardware, which is pretty much the same hardware that supports 4.5.
Extensions can muddle this choice further, because it is possible to create an older context, but get the "newer" functionality via extensions. So frankly speaking, it is kinda messy.
But which one to pick? Legacy OpenGL is an easy no for our purposes, because it doesn't make use of any modern hardware. The question between "modern" and bindless OpenGL is more difficult. Bindless makes things easier and more efficient, but "modern" supports a wider range of hardware. Since our goal is to support WebGL, non-bindless makes a lot of sense. Also, arguably, we would just pick Vulkan if we need state-of-the-art efficiency.
OpenGL 4.3 Core seems to be the best bet for us. It has a great overlap with GLes3, which is used as base for WebGL2, has compute shaders and debug callbacks. Historically, GLes3.0 was created from a subset of OpenGL 3.3, but OpenGL 4.3 includes functionality to increase the compatibility between them. It might be a bummer to be unable to support macOS, but frankly speaking, I don't care about this developer-unfriendly platform.
Creating the context
Before we can do any fancy OpenGL calls, we need to create an OpenGL context.
We are going to use the raw-gl-context crate.
It works with RawWindowHandle
's, so it's pretty straight forward with winit
.
//create a context from the existing winit window
let context = create
.unwrap;
//actually use the context
context.make_current;
Let's not forget to add it to our Cargo.toml
.
[]
= "0.*"
Note Note: On my machine, X11 requires zero alpha bits in the context creation with Nvidia drivers. In theory, just using the default should be fine. It's a known issue with some workarounds, but since I don't need alpha bits for now, it is a problem for future me.
That's it.
Creating an OpenGL-context using the Core profile, requesting the version 4.3, then "activating" the context. Activating means making the OpenGL-context the current context of the calling thread. A thread can only have one context being bound, and a context can only be bound to a single thread at a time. This makes multi-threading in OpenGL a major pain, so we will use an alternative to bypass this issue later.
Finally, in our event_loop, we will call
context.swap_buffers;
to swap the front with the back buffer. The details are not important, but to
put it simply, it "updates" the content of the screen. We are generally drawing
to the back buffer, but the window displays the front buffer. So to actually see
what we have drawn, we swap them. We haven't drawn anything yet, so the screen is
just black. swap_buffers
waits for the issued calls to be finished before it
swaps. Keep in mind, this is just a minimalistic explanation and in reality, it
is not uncommon for having more than two buffers(e.g. triple-buffering) or
swap_buffers
blocking until the monitor refreshes before swapping(vsync).
Generating the Bindings
Now that we have an OpenGL-context, we can load the OpenGL functions. The easiest way is to use any bindings generator, which loads the function pointers we need for us.
We can use a web-service like glad to download the files
with the bindings, write a simple build.rs
that uses
gl_generator or use any of the already
generated bindings like gl(OpenGL 4.6 Core),
gl33(OpenGL 3.3).
We will go with gl_generator
for no reason in particular.
The example code shows creating a build.rs
in the project and uses the output
directory environment variable to include it into the modules, but since this
should pretty much never change, we can create a simple throwaway project to generate
the bindings just once, and copy them into our project. Feel free to stick with
the version as described in the docs, where you re-generate the bindings
every time they change/you create a new clean build. I just think that the
extra build.rs in our project is unnecessary noise, especially once we
need to generate other bindings. The drawback is that we have now a 27K lines of
code, >800kb monster in our repo.
#in some empty dir
add gl_generator
as build dependency:
[]
#OpenGL Context Creation
= "*"
Now we create a build.rs
in the root of the project:
use File;
use ;
Building the project with cargo build
should now create a file with the name gl43_core.rs
in
the project root.
gl_generator
also allows us to adjust the generated bindings. The GlobalGenerator
allows us to access OpenGL functions from anywhere in the project(that has
access to the gl43_core module), without passing some context around. This makes
things far simpler for us. We are not going to expose the gl lib to the public
and we will use a different way to tie the lifetime to the actual gl context.
We just copy the generated file into the src dir of our context member.(don't forget to delete the throwaway gl_bindings
project afterwards, it's just clutter on our hard drive now)
To access the bindings from our playground, we need to modify the existing
lib.rs
on our src directory. While we are at, we remove the existing content,
and for the sake of our sanity, we will just mute clippy for the gl module,
because we will get 2k warnings otherwise. We do that by adding some allow
attributes right before we declare the module, or to avoid noise in our source
files, we add the "module wide" attributes at the top of the gl43_core.rs, which
can take a while depending on how great our text editor deals with huge files.
Also, while we are dealing with lints, let's add the stricter pedantic
and the
experimental nursery
lints to our crate, just because Clippy is that useful.
Fun fact, allow(clippy::all)
doesn't disable all lints. Pedantic and nursery
lints, both of which we added to our lib.rs
, must be disabled additionally.
Alternatively, we could manually enable the warnings for the lints we care
about, like how EmbarkStudios does
it.
Now we need to modify our examples/playground.rs
, to alias the gl module with a
more ergonomic name.
use gl43_core as gl;
Finally, we can load the OpenGL functions right after we made our context current:
context.make_current;
load_with;
The bindings generated with gl_generator
expose a load_with function that will load the function pointers of our OpenGL functions one by one.
Now, we can test it by calling the following 2 OpenGL functions in our playground.
Before the event_loop.run(..)
//set a clear color for later
unsafe
and before swap_buffers()
// clear the "screen" with the previously set clear color
unsafe
Because they are just function pointers, calling functions over FFI(basically calling non-Rust functions), they need to be in unsafe blocks. Unsafe might sound dangerous and scary, but it just means that Rust can't guarantee that these functions follow its safety principles, so the burden is on the programmer. Basically just another day for any C++ programmer, just limited to a handful lines of code.
The final playground should look like this:
use gl43_core as gl;
use GlContext;
use ;
If everything works, there should now be a window with a red background when running the example. And we can dive into the next steps.
Understanding the gl calls above is not important for now, the main thing is to
just test whether OpenGL works. If it fails, there might be some system
dependencies missing, but simply said glClear(GL_COLOR_BUFFER_BIT)
clears the "screen", in a color that
we specify with glClearColor
.
Note I will use the OpenGL notation when referring to the functions in text, instead of the Rust notation with the module as namespace. The reason for that is that it makes finding the docs easier, but also less dependent on the OpenGL bindings solution(and imports). E.g. glClear(GL_COLOR_BUFFER_BIT) instead of gl::Clear(gl::COLOR_BUFFER_BIT)
Now we can get our hands dirty.