Link Search Menu Expand Document

Hello Window


With everything setup, we can now write our first program! But don’t get too excited since at this stage the best that we can do is create a blank window. Since the aim of this series of posts is to learn OpenGL, I’ll do my best to try and explain what each line of code does, and why it’s needed. If you want to check out the code yourself, you can find this page’s file here called hello_window.rs.

The first thing we need to do is create an OpenGL context. This begs the question, what is an OpenGL context? Reading the OpenGL documentation, an OpenGL context stores all of the state associated with a particular instance of OpenGL. So for an application using OpenGL, when the context is destroyed, OpenGL is destroyed.

For our purposes today, a program window needs to be tied to a context. The glutin library I’m using wraps these up into a single object. Moreover, glutin also ties to each window an event loop, which provides a way of retrieving events from the system and the windows. The glutin documentation specifies that calling EventLoop::new() initializes everything that will be required to create windows. So, we’ll have to create an event loop first.

With these lengthy explanations out of the way, let’s look at the code:

// Provides a way to retrieve events from the system and
// from the windows that were registered to the event loop.
let event_loop = glutin::event_loop::EventLoop::new();

// Sets up the window's context
let context_builder = glutin::ContextBuilder::new()
.with_gl(glutin::GlRequest::Specific(glutin::Api::OpenGl, (3, 3))) // OpenGL version 3.3
.with_gl_profile(glutin::GlProfile::Core); // OpenGL Core profile

// Sets up the window parameters
let window_builder = glutin::window::WindowBuilder::new()
.with_inner_size(glutin::dpi::LogicalSize::new(800.0, 600.0)) // LogicalSize respects dpi
.with_title("Learn OpenGL in Rust");

// Builds the window based on the context and parameters
let window_context = context_builder
.build_windowed(window_builder, &event_loop)
.map_err(|e| e.to_string())?;

In order to follow as closely as possible to the learnopengl.com tutorial, I’ve made these function calls a bit more complicated than needed. This is more for the sake of learning all about the API and OpenGL, than a particular need to do everything exactly as is laid out in the tutorial.

Most of this code is self explanatory, but I’ll go over it line by line. The first line simply declares and initializes an event loop that we’ll need to bind a window context to. Next, we create a context builder that sets the parameters that we want for the OpenGL context we’ll be using in our program’s window. In particular we use with_gl() to set the OpenGL version to 3.3 and with_gl_profile()to use the core profile which doesn’t include unneeded backwards-compatible features.

The third variable we declare is the window builder which specifies the attributes for our program’s window. with_inner_size() sets the window to be the dimensions specified and here I’ve used glutin’s LogicalSize struct. LogicalSize essentially scales the dimensions to fit with the device’s screen dpi such that the program scales with the screen’s resolution. This prevents the window from looking huge in a low-res display, or tiny in a high-res display. On the window builder, we also call with_title() to set the window’s title.

Finally, we create the actual window context using all three of the variables just declared. If you take a look at the full code, you’ll notice that my functions return type Result<(), String>. This is my way of making sure that I handle all errors, or otherwise propagate them down to the top level main() function. Hence, since the build_windowed() function returns a Result type, I translate the error to a String type and use the ? operator to pass the error up the call stack.

With our created window context, in order for any OpenGL commands to work, a context must be current. This means that all OpenGL commands affect the state of whichever context is current. So if we want the OpenGL to affect our window, we must set its context to current. The following code does just that:

// Built window context is not current so we make it current
let current_context;
unsafe {
  current_context = match window_context.make_current().ok() {
    Some(context) => context,
    None => return Err("Could not make context current.".to_string()),
  };
}

This piece of code simply sets the variable current_context to be the current version of out previous window_context, making sure that any errors in setting the context to be current, are handled properly. The match statement is simply there to homogenize the Result type’s error to be a string, since it can’t be converted directly using .to_string().

Now that we’ve set up the window’s context, we need to load the OpenGL functions themselves. This is done with the following function from the gl crate:

// Loads the OpenGL function pointers
gl::load_with(|symbol| current_context.get_proc_address(symbol));

load_with() takes a function as an argument that is meant to define to what address do we bind a process to. What ends up happening when we call this function, is that load_with() starts passing all of the OpenGL symbols into the functional argument, which should then return an address to bind these to. glutin’s get_proc_address returns the address of the OpenGL function specified by the symbol given, binding it to the context we created.

At this point we’ve set everything up we need to run the program which is done in the glutin API through the event loop. In particular we call the run() function on an EventLoop instance. Before looking at the code for this, we should first look at how this function works. Reading the glutin documentation we find that this function hijacks the calling thread and initializes the event loop with the provided closure. The closure is an argument we must pass to the event loop, which will be executed iteratively until we tell it to stop (from within the closure). This closure, called the event_handler, has three arguments:

  1. An Event: Describes a generic event which is sent to the closure where they get processed and used to modify the program state. So instead of polling for events, we have a closure that specifies the behavior we want on a given event.
  2. An &EventLoopWindowTarget: This variable associates windows with the running EventLoop, allowing us to create new windows during the program’s execution. We won’t need it for this program.
  3. A &mut ControlFlow: This argument is a reference to the EventLoop’s control flow which we change from within the closure to specify the control flow we want the program to take. Since it is accessible from within the closure, this allows us to dynamically change the program’s control flow. In particularly, we will be using it to tell the event loop to stop, when we click on the window’s close button.

With this overview of the run function, let’s look at the code:

// "move" captures a closure's environment by value
event_loop.run(move |event, _, control_flow| {
  // When the loop iteration finishes, immediately begin a new iteration
  *control_flow = glutin::event_loop::ControlFlow::Poll;

  use glutin::event::{DeviceEvent, Event, VirtualKeyCode, WindowEvent};
  match event {
    Event::LoopDestroyed => return (),
    Event::WindowEvent { event, .. } => match event {
      // Resizes the window context together with the window
      WindowEvent::Resized(phys_size) => current_context.resize(phys_size),
      // When window X is clicked
      WindowEvent::CloseRequested => {
        // Sends a LoopDestroyed event and stops the event loop
        *control_flow = glutin::event_loop::ControlFlow::Exit
      }
      _ => (),
    },

    Event::DeviceEvent { event, .. } => match event {
      // Gets the key's semantic code
      DeviceEvent::Key(key_input) => match key_input.virtual_keycode {
        // Close on keyboard press Escape
        Some(VirtualKeyCode::Escape) => {
          *control_flow = glutin::event_loop::ControlFlow::Exit
        }
        Some(_) => (),
        None => (),
      },
      _ => (),
    },

    // Is triggered when the window's contents have been invalidated (e.g. window resize)
    Event::RedrawRequested(_) => unsafe {
      // Sets color to clear into background
      gl::ClearColor(0.8863, 0.5294, 0.2627, 1.0);
      // Clears all buffers enabled for color writting
      gl::Clear(gl::COLOR_BUFFER_BIT);
      current_context.swap_buffers().unwrap();
    },
    _ => (),
  }
});

There’s a lot to unpack here but most of it is pretty straightforward.

First, we call the run() function, moving all variables in the event loop’s scope into the closure. This closure we define here, taking the arguments described above. Within the closure, the first thing we do, is set the control_flow to Poll which makes the event loop start a new iteration as soon as it finishes the previous one, regardless of whether new events are available to process. This is in contrast to Wait, which suspends the thread until another event arrives.

After the use statement, we begin the main match statement where we define the program’s behavior on a given event. The first type of event we handle is the LoopDestroyed event. This event is emitted when the event loop is being shut down, so we simply return out of the closure to end the event loop’s execution.

Next, we handle any WindowEvents, which come in different flavors, so we must use another match statement to distinguish between them. On a WindowEvent::Resized we update the current context’s size to the window’s new size. On a WindowEvent::CloseRequested event which gets emitted when we close the window, we set the control flow to Exit. This, sends a LoopDestroyed event, which is the last event sent, thus stopping the event loop.

We then handle any DeviceEvents which represent any raw hardware events that are not associated with any particular window. This then implies that some events get emitted as both DeviceEvent and WindowEvent, such as mouse movement over a window. There are also different types of DeviceEvent but for this program we’ll just handle when the Escape key is pressed, which should stop the program. Keyboard events are emitted as DeviceEvent::Key(key_input), where key_input is a struct containing the keyboard’s input’s information. We’re only interested in the virtual_keycode which gives us a readable name for the key that was pressed. Some keys, such as volume control keys found on some keyboards, don’t have virtual keycodes, hence the match on an Option type. In particular, we match Some(VirtualKeyCode::Escape), and exit the event loop when we receive such a key press.

Lastly, we handle any RedrawRequested events. These get emitted when a window should be redrawn, and is triggered when the OS has invalidated the window’s content (for example due to window resizing), or when the application has explicitly requested a redraw. glutin’s window handling library also groups any duplicate RedrawRequested events so that work doesn’t get done twice. For this program, we want the window’s background to be cleared every time the window gets resized. This brings us to our first actual OpenGL function calls, which are always done within unsafe blocks. We call gl::ClearColor() to set the background clear color, and then call gl::Clear() to actually clear the window. Specifically, we clear all buffers currently enabled for color writing. The last thing to do is swap the render buffers, to present the updated buffer on the window.

And that’s all. Quite the lengthy process just to set up a window. But with this out of the way, we can start creating more interesting programs. Make sure to check out the code here if you’re interested, and read the next page in this OpenGL learning journey where we’ll be drawing our first triangle.