Yury Samkevich
Posted on August 21, 2022
Motivation
Computer graphics is an exciting and enjoyable topic due to its combination of technology, art and creativity. In the past few years we've been seeing a rapid evolution in the field of VR and AR technologies that utilise computer graphics a lot. All this makes the topic of studying graphics APIs more popular than ever.
OpenGL is definitely the simplest graphics API available. There are other APIs one might consider looking at: DirectX, Metal, Vulcan. But all of them ether not cross-platform or much more low-level than OpenGL which makes them challenging to learn.
In this series of articles we are going to learn basics of OpenGL and try our hands on writing some graphics applications. We will use Rust as a programming language. Traditionally graphics programming closely related to С/C++ due to performance constraints. Rust is a modern alternative to C/C++ it's much safer and have good interoperability with C, which we are going to use calling OpenGL API. For this article experience of Rust is not a requirement although prior programming experience is nice to have. Rust book is a great resource to start if you want to learn more about Rust.
All the code for articles you can find on my github. The project will have branches pointed at the state of the code for each article in the series.
In this article we'll explore how to create a window, initialize OpenGL context and call some basic api to clear a window with a desired color.
A bit of OpenGL theory
Before we start our journey we should first define what OpenGL actually is. OpenGL is might be considered as an API that provides a large set of functions that could be used to manipulate graphics and images. However, OpenGL is not an API, but simply a specification, which specifies what the result/output of each function should be and how it should perform. OpenGL specification does't give implementation details, and implementation of library could be different as long as its results comply with the specification.
Usually we can think about an implementation of OpenGL as a large state machine: a collection of variables that define how OpenGL operates. The state of OpenGL is generally referred to as OpenGL context. Often we use OpenGL changing its state by setting some options, manipulating some buffers and then render using the current context.
When we tell OpenGL that we want to clear a buffer with blue color instead of black for example, we change the state of OpenGL by changing some context variable that sets how OpenGL should draw. Once we change the context by telling OpenGL it should clear with blue, the next drawing call will use blue color to fill a buffer by default.
OpenGL was developed with several abstractions in mind. One of those abstractions is object
in OpenGL. You can think about object here as a collection of options that represents a subset of OpenGL's state.
Imagine if we want to have an object that represents the settings of the drawing window. It could have the window's size, how many colors it supports and so on. Whenever we want to use objects in OpenGL we frequently will follow the next workflow: first create an object and store its id
, then bind the object by id
to the target location, set object's options and finally un-bind the object by setting the current object id
to 0
. An approximate example of how to change windows size might look like following:
// create object
let mut object_id = 0;
gl::GenObject(1, &mut object_id);
// bind/assign object to context
gl::BindObject(gl::WINDOW_TARGET, object_id);
// set options of object currently bound to gl::WINDOW_TARGET
gl::SetObjectOption(gl::WINDOW_TARGET, GL::OPTION_WINDOW_WIDTH, 800);
gl::SetObjectOption(gl::WINDOW_TARGET, GL::OPTION_WINDOW_HEIGHT, 600);
// set context target back to default
gl::BindObject(gl::WINDOW_TARGET, 0);
Now when we learned a bit about OpenGL as a specification and a library and how OpenGL approximately operates under the hood it's time to jump into something more practical.
Setup project
Let's start by creating a new Rust project from scratch. We well use Cargo
for that. Cargo is Rust’s build system and package manager. Here we assume that Rust and Cargo are already installed in the system, please refer to the Rust book if you have problems with that. To create a new project simply run:
$ cargo new learn_gl_with_rust
$ cd learn_gl_with_rust
If you list the files in the directory you’ll see that Cargo has generated two files and one directory for us: a Cargo.toml file and a src directory with a main.rs file inside. We will use main.rs as an entry point where we start writing out application.
Before we start creating graphics we need to create an OpenGL context and an application window to draw in. However, those operations are specific per operating system and OpenGL designed to abstract itself from these operations. This means we have to create a window, define a context, and handle user input all by ourselves.
Luckily, there are quite a few libraries out there that provide this functionality, some of them specifically aimed at OpenGL. Those libraries save us all the operation-system specific work and give us a window and an OpenGL context to render in. One of those libraries is glutin. It allows us to create an OpenGL context, define window parameters, and handle user input, which is plenty enough for our purposes.
In order to use the glutin
library, we need to add it as dependencies in our Cargo.toml file:
[dependencies]
glutin = "0.29.1"
Because OpenGL is only a standard/specification and there are many different versions of OpenGL implementation, the location of most of its functions is not known at compile-time and needs to be queried at run-time. It is then the task of the developer to retrieve the location of the functions they need and store them in function pointers for later use.
Thankfully, there are Rust crates for this purpose as well where gl is a popular one, which we are going to use in our project.
To add gl
library to dependencies we need to modify Cargo.toml
file as follows:
[dependencies]
glutin = "0.29.1"
gl = "0.14.0"
Creating a window
So far we set up a project and figured out which dependencies we need in order to create an application window and OpenGL context. Now it's time to actually create a window.
Initializing an OpenGL window with glutin
can be done using the following steps:
- Create an
EventLoop
for handling window and device events. - Specify window specific parameters using
glium::glutin::WindowBuilder::new()
. We set a window title here. - Specify OpenGL specific attributes using
glium::glutin::ContextBuilder::new()
and build OpenGL context. We tellglutin
that3.3
is the OpenGL version we want to use. - Make the context of the window current on the calling thread.
let event_loop = EventLoop::new();
let window = WindowBuilder::new().with_title("Learn OpenGL with Rust");
let gl_context = ContextBuilder::new()
.with_gl(GlRequest::Specific(Api::OpenGl, (3, 3)))
.build_windowed(window, &event_loop)
.expect("Cannot create windowed context");
let gl_context = unsafe {
gl_context
.make_current()
.expect("Failed to make context current")
};
Previously we mentioned that gl
crate manages function pointers for OpenGL so we want to initialize gl
before we call any OpenGL function:
gl::load_with(|ptr| gl_context.get_proc_address(ptr) as *const _);
So far as soon as the window has been created application immediately quit and close the window. We want the application to keep drawing images and handling user input until the program has been explicitly told to stop. For this reason we need to loop forever until we detect that a CloseRequested
event has been received. The following code shows how to do it using method run
of event_loop
:
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
match event {
Event::LoopDestroyed => (),
Event::WindowEvent { event, .. } => match event {
WindowEvent::CloseRequested => *control_flow = ControlFlow::Exit,
_ => (),
},
_ => (),
}
});
The same way we can handle Resized
window event that gets called each time the window is resized. We pass new size of the window to gl_context
to adjust viewport:
WindowEvent::Resized(physical_size) => gl_context.resize(physical_size),
OpenGL uses what is called double buffering. Instead of drawing directly to the window, we are drawing to an image stored in memory. Once we have finished drawing, this image is copied to the window. In order to do it we call swap_buffers
on gl_context
once we receive window event RedrawRequested
.
To test if things actually work we want to clear the screen with a color of our choice. Otherwise we would still see the results from the previous frame. We can clear the screen's color buffer using gl::Clear
where we pass gl::COLOR_BUFFER_BIT
.
Event::RedrawRequested(_) => {
unsafe {
gl::ClearColor(0.0, 0.0, 1.0, 1.0);
gl::Clear(gl::COLOR_BUFFER_BIT);
}
gl_context.swap_buffers().unwrap();
}
Now if you run cargo run
you should see a nice window with a blue background.
Summary
Today we've learned a bit of OpenGL theory as well as how to create a window, initialize OpenGL context and call some basic api to clear a window with a desired color.
Next time we are going to discuss how graphics pipeline of OpenGL works and how we can configure it with shaders. Stay in touch!
If you find the article interesting consider hit the like button and subscribe for updates.
Posted on August 21, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.