Skip to content

Instantly share code, notes, and snippets.

@kylewlacy
Last active August 10, 2022 14:45
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save kylewlacy/824e09131f0f3b4b9062 to your computer and use it in GitHub Desktop.
Save kylewlacy/824e09131f0f3b4b9062 to your computer and use it in GitHub Desktop.
Rust OpenGL API and partial borrowing

The following is an example of a Rust API that cannot be expressed without the addition of partial borrows. First, a bit of background:

OpenGL is a low-level, platform-agnostic(-ish) 3D graphics API. The API is expressed in terms of a virtual "state machine": objects are first bound to "registers" before being manipulated. Here's a simple demonstration of creating an OpenGL buffer and filling it with data from C:

GLuint buffer;
glGenBuffers(&buffer, 1);
glBindBuffer(GL_ARRAY_BUFFER, buffer);

float vector[] = { 1.0, 0.0, 1.0 };
glBufferData(GL_ARRAY_BUFFER, 3, (const GLvoid*)vector, GL_STREAM_DRAW);

Even with this simple example, so many things can go wrong. Here's a short list:

  • glBufferData(GL_ARRAY_BUFFER, ...) is perfectly valid C, even if GL_ARRAY_BUFFER isn't currently bound
  • glBufferData(0xDEAD, ...) is perfectly valid C, even if 0xDEAD isn't an actual buffer target
  • glBindBuffer(GL_ARRAY_BUFFER, 100) is perfectly valid C, even if 100 isn't a valid buffer object
  • glBufferData(GL_ARRAY_BUFFER, 5000, data, ...) is perfectly valid C, even if data isn't 5000 bytes long
  • In the above example, glBufferData is called with a size of 3, even though the real size of vector[] is 3*sizeof(float)
  • Also in the above example, buffer is leaked because glDeleteBuffers is never called
  • All of these calls would be invalid unless the OpenGL context was setup beforehand

Clearly, OpenGL is a perfect example of an API looking for the Rust treatment. Here's a simple example of an API design that would prevent all of the above issues:

let mut gl = window.gl_context(); // Get the GL context of a window
let buffer = gl.gen_buffer();

let gl_array_buffer = gl.bind_array_buffer(buffer);

let vertex = &[1.0, 0.0, 1.0]
gl_array_buffer.buffer_data(vertex);

By making window.gl_context() return a value, it's impossible to make any OpenGL calls before the current context is bound (at least from safe code). Similarly, by representing the array buffer binding as a separate value, it's impossible to call glBufferData(GL_ARRAY_BUFFER, ...) while the array buffer isn't bound.

Let's look at another example, demonstrating how this API design could utilize Rust's lifetime system:

let mut gl = window.gl_context();
let mut buffer1 = gl.gen_buffer();
let mut buffer2 = gl.gen_buffer();
let mut buffer3 = gl.gen_buffer();

let gl_buffer1 = gl.bind_array_buffer(&mut buffer1);
let gl_buffer2 = gl.bind_element_array_buffer(&mut buffer2);
//               ^~~~~~~~~~~~~~~~~~~~~~~~~~~~
//               This should be valid, since we aren't overwriting a binding
//               (GL_ARRAY_BUFFER is separate from GL_ELEMENT_ARRAY_BUFFER)

let gl_buffer3 = gl.bind_array_buffer(&mut buffer3);
//               ^~~~~~~~~~~~~~~~~~~~
//               This should be illegal, since `gl_buffer1` is still valid,
//               and we are overwriting it's binding
//               (we could call `gl_buffer1.buffer_data(...)` and it would
//                actually write data to `buffer3` since `buffer3` is currently
//                bound to the `GL_ARRAY_BUFFER`)

Alright, we have the gist of our API designed, so let's try to implement it! Also, because we're writing it in a systems programming language, we're going to do it as a zero-cost abstraction, in the sense that our code should get compiled into raw OpenGL calls! Why not, right? That means that we won't be using any Box or Rc types, or anything else with some sort of runtime overhead.

#![feature(raw)]
use std::raw::{Slice, Repr};
use std::mem;
use std::marker::PhantomData;



// Represents a call to glGenBuffers(...), and contains the resulting
// OpenGL resource ID
pub struct Buffer(GLuint);

// Use RAII to destroy the buffer
impl Drop for Buffer {
    fn drop(&mut self) {
        unsafe {
            let mut buffers = [self.0];
            glDeleteBuffers(1, &mut buffers[0] as *mut u32);
        }
    }
}

// Represents any type of buffer binding (either array buffer or
// element array buffer)
pub trait BufferBinding {
    // Returns the internal OpenGL target enum value representing this
    // binding (`GL_ARRAY_BUFFER`/`GL_ELEMENT_ARRAY_BUFFER`)
    fn target(&self) -> GLenum;

    // data is &[f32] for the sake of example
    fn buffer_data(&mut self, data: &[f32]) {
        unsafe {
            let Slice { data: ptr, len } = data.repr();
            let size = len * mem::size_of::<f32>();
            glBufferData(self.target(), size, ptr, GL_STATIC_DRAW);
        }
    }
}

// Represents a call to glBindBuffer(GL_ARRAY_BUFFER, ...)
// Has a `PhantomData` because, conceptually, the binding is holding onto
// a reference to a Buffer (and the binding shouldn't outlive the buffer
// it's bound to)
struct ArrayBufferBinding<'a>(PhantomData<&'a Buffer>);
impl<'a> BufferBinding for ArrayBufferBinding<'a> {
    fn target(&self) -> GLenum { GL_ARRAY_BUFFER }
}

// Represents a call to glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ...)
struct ElementArrayBufferBinding<'a>(PhantomData<&'a Buffer>);
impl<'a> BufferBinding for ElementArrayBufferBinding<'a> {
    fn target(&self) -> GLenum { GL_ELEMENT_ARRAY_BUFFER }
}

// Represents a 'thing' that knows how to bind array buffer. This is designed
// to be owned by the `Context` struct, and is the secret to getting
// lifetimes right with the `BufferBinding` structures.
struct ArrayBufferBinder;
impl ArrayBufferBinder {
    pub fn bind<'a>(&'a mut self, buffer: &mut Buffer)
        -> ArrayBufferBinding<'a>
    {
        let binding = ArrayBufferBinding(PhantomData);
        unsafe {
            glBindBuffer(binding.target(), /* The OpenGL target constant */
                         buffer.0          /* The OpenGL resource ID */);
        }
        binding
    }
}

// Represents a 'thing' that knows how to bind an element array buffer
struct ElementArrayBufferBinder;
impl ElementArrayBufferBinder {
    pub fn bind<'a>(&'a mut self, buffer: &mut Buffer)
        -> ElementArrayBufferBinding<'a>
    {
        let binding = ElementArrayBufferBinding(PhantomData);
        unsafe {
            glBindBuffer(binding.target(), /* The OpenGL target constant */
                         buffer.0          /* The OpenGL resource ID */);
        }
        binding
    }
}

// The context owns a 'binder' for each kind of resource that must be
// bound in OpenGL
struct Context {
    array_buffer: ArrayBufferBinder,
    element_array_buffer: ElementArrayBufferBinder,
    // ...
    // One `Binder` for each kind of resource that can be bound
}

impl Context {
    // Returns the "current context". It's unsafe because it assumes
    // that the context has already been setup. For example,
    // a function `Window::gl_context` would call this after calling
    // the system-specific OpenGL context binding functions
    unsafe fn current_context() -> Context {
        Context {
            array_buffer: ArrayBufferBinder,
            element_array_buffer: ElementArrayBufferBinder
        }
    }

    pub fn gen_buffer(&mut self) -> Buffer {
        unsafe {
            // Will be initialized after calling glGenBuffers(...)
            let mut buffers : [u32; 1] = mem::uninitialized();

            // Generate a single buffer, storing the ID in buffers[0]
            glGenBuffers(1, &mut buffers[0] as *mut u32);
            Buffer(buffers[0])
        }
    }

    // Bind a buffer to GL_ARRAY_BUFFER, returning an `ArrayBufferBinding`.
    // This is where we start running into problems...
    // The resulting ArrayBufferBinding type should have a lifetime
    // parameter that lives as long as `&mut self.array_buffer`,
    // NOT `&mut self`.
    pub fn bind_array_buffer<'a>(&'a mut self, buffer: &mut Buffer)
        -> ArrayBufferBinding<'a>
    {
        self.array_buffer.bind(buffer)
    }

    // Bind a buffer to GL_ELEMENT_ARRAY_BUFFER, returning an
    // `ElementArrayBufferBinding`
    pub fn bind_element_array_buffer<'a>(&'a mut self, buffer: &mut Buffer)
        -> ElementArrayBufferBinding<'a>
    {
        self.element_array_buffer.bind(buffer)
    }
}

(You can play with this sample code in a playpen by using the file included below)

Let's apply what pieces we have to the original API sample we started with:

let mut gl = unsafe { Context::current_context() }; // Assume a valid OpenGL context has been bound
let mut buffer1 = gl.gen_buffer();
let mut buffer2 = gl.gen_buffer();

let mut gl_buffer1 = gl.bind_array_buffer(&mut buffer1);
let mut gl_buffer2 = gl.bind_element_array_buffer(&mut buffer2);
//               ^~
// error: cannot borrow `gl` as mutable more than once at a time

gl_buffer1.buffer_data(&[1.0, 0.0, 1.0]);
gl_buffer2.buffer_data(&[1.0, 2.0, 3.0]);

In this particular case, it would be possible to refactor code in one of two ways:

// Wrapping the borrows in explicit blocks
{
    let mut gl = unsafe { Context::current_context(); }
    let mut buffer1 = gl.gen_buffer();
    let mut buffer2 = gl.gen_buffer();
    {
        let mut gl_buffer1 = gl.bind_array_buffer(&mut buffer1);
        gl_buffer1.buffer_data(&[1.0, 0.0, 1.0]);
    }
    {
        let mut gl_buffer2 = gl.bind_array_buffer(&mut buffer2);
        gl_buffer2.buffer_data(&[1.0, 2.0, 3.0]);
    }
}

// Using the fields of `Context` directly
{
    let mut gl = unsafe { Context::current_context(); }
    let mut buffer1 = gl.gen_buffer();
    let mut buffer2 = gl.gen_buffer();

    let mut gl_buffer1 = gl.array_buffer.bind(&mut buffer1);
    let mut gl_buffer2 = gl.array_buffer.bind(&mut buffer2);
}

Both of these refactorings work around the limitation that a function cannot return a borrow that lives only as long as a struct field. In this particular case, it is a fairly small change, but consider another OpenGL example: binding a framebuffer

GLuint framebuffer;
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);

This snippet is interesting, because it has two possible meanings. In OpenGL ES 2.0, there is only one possible target for glBindFramebuffer: GL_FRAMEBUFFER. However, in OpenGL ES 3/OpenGL 3/OpenGL 4, there are two possible targets: GL_DRAW_FRAMEBUFFER or GL_READ_FRAMEBUFFER, and using GL_FRAMEBUFFER binds the framebuffer object to both other targets.

Therefore, applying the above modified API design, calling glBindFramebuffer would have to look like this:

// gl_fbo must live as long as both gl.read_fb and gl.draw_fb; currently,
// the only possible way of doing this is explicitly passing borrows of
// both struct fields to another function:

let mut fbo =  gl.gen_framebuffer();
let mut gl_fbo = bind_both_framebuffers(&mut gl.read_fb,
                                        &mut gl.draw_fb,
                                        &mut fbo);

...unless we are targetting OpenGL ES 2.0, in which case there is no gl.read_fb and gl.draw_fb:

let mut fbo = gl.gen_framebuffer();
let mut gl_fbo = gl.fb.bind(&mut fbo);

So, code designed to target multiple OpenGL versions that attempts to bind a framebuffer object would need to use a #[cfg(...)] statement.

Our simple and safe OpenGL wrapper has exploded in complexity, and there are probably even more condemning edge cases lurking within the raw OpenGL API.

This added API complexity could be avoided by adding "partial borrowing" to Rust. Here's a condensed version:

#[cfg(gl == "OpenGL ES2")]
struct Context {
    array_buffer: ArrayBufferBinder,
    framebuffer: FramebufferBinder,
    // ...
}

#[cfg(not(gl == "OpenGL ES2"))]
struct Context {
    array_buffer: ArrayBufferBinder,
    read_framebuffer: ReadFramebufferBinder,
    draw_framebuffer: DrawFramebufferBinder,
    // ...
}

#[cfg(gl == "OpenGL ES2")]
impl Context {
    // Returns a reference that lives as long as &mut self.array_buffer:
    fn bind_array_buffer<'a>(&mut self, &mut Buffer)
        -> ArrayBufferBinding<'a>
        where 'a: &mut self.array_buffer
    { /* ... */ }

    // Returns a reference that lives as long as &mut self.framebuffer
    fn bind_framebuffer<'a>(&mut self, &mut Framebuffer)
        -> FramebufferBinding<'a>
        where 'a: &mut self.framebuffer
    { /* ... */ }
}



#[cfg(not(gl == "OpenGL ES2"))]
impl Context {
    // Returns a reference that lives as long as &mut self.array_buffer:
    fn bind_array_buffer<'a>(&mut self, &mut Buffer)
        -> ArrayBufferBinding<'a>
        where 'a: &mut self.array_buffer
    { /* ... */ }

    // Returns a reference that lives as long as
    // &mut self.read_framebuffer AND &mut self.draw_framebuffer
    fn bind_framebuffer<'r, 'd, 'a>(&mut self, &mut Framebuffer)
        -> FramebufferBinding<'a>
        where 'r: &mut self.read_framebuffer,
              'd: &mut self.draw_framebuffer,
              'a: 'r + 'd
    { /* ... */ }
}

With an implementation that followed a similar pattern, a client could do this:

let mut gl = unsafe { Context::current_context() };
let fbo = gl.gen_framebuffer();
let gl_fb = gl.bind_framebuffer(fbo);

...providing the same simplicity between GL versions that the raw C API provided (with added safety, of course!)

To a large degree, this implementation is still somewhat complex (considering that implementations differ between on OpenGL versions). However, this is complexity that stems from the discrepancies of the different OpenGL versions, and is implicit in the design of such an API. Also, this gives us even better safety checks than before:

// (Assuming an OpenGL version >ES2, so we have different FBO targets)
let mut gl = unsafe { Context::current_context() };
let mut fbo1 = gl.gen_framebuffer();
let mut fbo2 = gl.gen_framebuffer();

let mut gl_fbo1 = gl.bind_draw_framebuffer(&mut fbo1);
let mut gl_fbo2 = gl.bind_framebuffer(&mut fbo2);
//                ^~~~~~~~~~~~~~~~~~~
//                This is an error, since gl.bind_framebuffer is trying
//                to borrow gl.draw_framebuffer (which was already borrowed!)

Keep in mind, this API was designed to be a zero-cost abstraction, and this code would be inlined to raw OpenGL calls. In other words, there would be literally no runtime cost for using this API over the raw OpenGL API.

This is the kind of thing that Rust was designed for!

// Try this out on https://play.rust-lang.org
// NOTE: You'll probably need to run it on the nightly channel,
// due to the use of the 'raw' feature
#![feature(raw)]
use std::raw::{Slice, Repr};
use std::mem;
use std::marker::PhantomData;
// API usage example:
fn main() {
let mut gl = unsafe { Context::current_context() };
// Here is the desired outcome of this API:
/*
// This should be illegal:
{
let mut buffer1 = gl.gen_buffer();
let mut buffer2 = gl.gen_buffer();
let mut buffer3 = gl.gen_buffer();
let mut gl_buffer1 = gl.bind_array_buffer(&mut buffer1);
let mut gl_buffer2 = gl.bind_element_array_buffer(&mut buffer2);
gl_buffer1.buffer_data(&[1., 0., 1.]);
gl_buffer2.buffer_data(&[1., 2., 3.]);
let mut gl_buffer3 = gl.bind_array_buffer(&mut buffer3);
// ^~~~~~~~~~~~~~~~~~~~
// error: cannot borrow `gl.array_buffer` as mutable more than once
// at a time
}
// This should be legal:
{
let mut buffer1 = gl.gen_buffer();
let mut buffer2 = gl.gen_buffer();
let mut buffer3 = gl.gen_buffer();
{
let mut gl_buffer1 = gl.bind_array_buffer(&mut buffer1);
let mut gl_buffer2 = gl.bind_element_array_buffer(&mut buffer2);
gl_buffer1.buffer_data(&[1., 0., 1.]);
gl_buffer2.buffer_data(&[1., 2., 3.]);
}
{
let mut gl_buffer3 = gl.bind_array_buffer(&mut buffer3);
gl_buffer3.buffer_data(&[4., 5., 6.]);
}
}
*/
// Here is what's currently necessary, since
// partial borrowing isn't implemented (yet):
// Workaround #1
{
let mut buffer1 = gl.gen_buffer();
let mut buffer2 = gl.gen_buffer();
let mut buffer3 = gl.gen_buffer();
{
let mut gl_buffer1 = gl.bind_array_buffer(&mut buffer1);
gl_buffer1.buffer_data(&[1., 0., 1.]);
}
{
let mut gl_buffer2 = gl.bind_element_array_buffer(&mut buffer2);
gl_buffer2.buffer_data(&[1., 2., 3.]);
}
{
let mut gl_buffer3 = gl.bind_element_array_buffer(&mut buffer3);
gl_buffer3.buffer_data(&[4., 5., 6.]);
}
}
// Workaround #2
{
let mut buffer1 = gl.gen_buffer();
let mut buffer2 = gl.gen_buffer();
let mut buffer3 = gl.gen_buffer();
{
let mut gl_buffer1 = gl.array_buffer.bind(&mut buffer1);
let mut gl_buffer2 = gl.element_array_buffer.bind(&mut buffer2);
gl_buffer1.buffer_data(&[1., 0., 1.]);
gl_buffer2.buffer_data(&[1., 2., 3.]);
}
{
let mut gl_buffer3 = gl.array_buffer.bind(&mut buffer3);
gl_buffer3.buffer_data(&[4., 5., 6.]);
}
}
}
// Basic API implementation:
// Represents a call to glGenBuffers(...), and contains the resulting
// OpenGL resource ID
pub struct Buffer(GLuint);
// Use RAII to destroy the buffer
impl Drop for Buffer {
fn drop(&mut self) {
unsafe {
let mut buffers = [self.0];
glDeleteBuffers(1, &mut buffers[0] as *mut u32);
}
}
}
// Represents any type of buffer binding (either array buffer or
// element array buffer)
pub trait BufferBinding {
// Returns the internal OpenGL target enum value representing this
// binding (`GL_ARRAY_BUFFER`/`GL_ELEMENT_ARRAY_BUFFER`)
fn target(&self) -> GLenum;
// data is &[f32] for the sake of example
fn buffer_data(&mut self, data: &[f32]) {
unsafe {
let Slice { data: ptr, len } = data.repr();
let size = len * mem::size_of::<f32>();
glBufferData(self.target(), size, ptr, GL_STATIC_DRAW);
}
}
}
// Represents a call to glBindBuffer(GL_ARRAY_BUFFER, ...)
// Has a `PhantomData` because, conceptually, the binding is holding onto
// a reference to a Buffer (and the binding shouldn't outlive the buffer
// it's bound to)
struct ArrayBufferBinding<'a>(PhantomData<&'a Buffer>);
impl<'a> BufferBinding for ArrayBufferBinding<'a> {
fn target(&self) -> GLenum { GL_ARRAY_BUFFER }
}
// Represents a call to glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ...)
struct ElementArrayBufferBinding<'a>(PhantomData<&'a Buffer>);
impl<'a> BufferBinding for ElementArrayBufferBinding<'a> {
fn target(&self) -> GLenum { GL_ELEMENT_ARRAY_BUFFER }
}
// Represents a 'thing' that knows how to bind array buffer. This is designed
// to be owned by the `Context` struct, and is the secret to getting
// lifetimes right with the `BufferBinding` structures.
struct ArrayBufferBinder;
impl ArrayBufferBinder {
pub fn bind<'a>(&'a mut self, buffer: &mut Buffer)
-> ArrayBufferBinding<'a>
{
let binding = ArrayBufferBinding(PhantomData);
unsafe {
glBindBuffer(binding.target(), /* The OpenGL target constant */
buffer.0 /* The OpenGL resource ID */);
}
binding
}
}
// Represents a 'thing' that knows how to bind an element array buffer
struct ElementArrayBufferBinder;
impl ElementArrayBufferBinder {
pub fn bind<'a>(&'a mut self, buffer: &mut Buffer)
-> ElementArrayBufferBinding<'a>
{
let binding = ElementArrayBufferBinding(PhantomData);
unsafe {
glBindBuffer(binding.target(), /* The OpenGL target constant */
buffer.0 /* The OpenGL resource ID */);
}
binding
}
}
// The context owns a 'binder' for each kind of resource that must be
// bound in OpenGL
struct Context {
array_buffer: ArrayBufferBinder,
element_array_buffer: ElementArrayBufferBinder,
// ...
// One `Binder` for each kind of resource that can be bound
}
impl Context {
// Returns the "current context". It's unsafe because it assumes
// that the context has already been setup. For example,
// a function `Window::gl_context` would call this after calling
// the system-specific OpenGL context binding functions
unsafe fn current_context() -> Context {
Context {
array_buffer: ArrayBufferBinder,
element_array_buffer: ElementArrayBufferBinder
}
}
pub fn gen_buffer(&mut self) -> Buffer {
unsafe {
// Will be initialized after calling glGenBuffers(...)
let mut buffers : [u32; 1] = mem::uninitialized();
// Generate a single buffer, storing the ID in buffers[0]
glGenBuffers(1, &mut buffers[0] as *mut u32);
Buffer(buffers[0])
}
}
// Bind a buffer to GL_ARRAY_BUFFER, returning an `ArrayBufferBinding`.
// This is where we start running into problems...
// The resulting ArrayBufferBinding type should have a lifetime
// parameter that lives as long as `&mut self.array_buffer`,
// NOT `&mut self`.
pub fn bind_array_buffer<'a>(&'a mut self, buffer: &mut Buffer)
-> ArrayBufferBinding<'a>
{
self.array_buffer.bind(buffer)
}
// Bind a buffer to GL_ELEMENT_ARRAY_BUFFER, returning an
// `ElementArrayBufferBinding`
pub fn bind_element_array_buffer<'a>(&'a mut self, buffer: &mut Buffer)
-> ElementArrayBufferBinding<'a>
{
self.element_array_buffer.bind(buffer)
}
}
// Stub OpenGL FFI fns:
#[derive(Debug)]
#[allow(non_camel_case_types)]
pub enum GLenum {
GL_ARRAY_BUFFER = 123,
GL_ELEMENT_ARRAY_BUFFER = 456,
GL_STATIC_DRAW
}
use GLenum::*;
type GLuint = u32;
static mut buffer_id : GLuint = 1;
#[allow(non_snake_case)]
unsafe extern fn glGenBuffers(size: usize, buffer: *mut GLuint) {
// NOTE: This stub only supports generating a single buffer (size == 1)
assert_eq!(size, 1);
*buffer = buffer_id;
println!("Created buffer {}", buffer_id);
buffer_id += 1;
}
#[allow(non_snake_case)]
unsafe extern fn glDeleteBuffers(size: usize, buffer: *mut GLuint) {
// NOTE: This stub only supports deleting a single buffer (size == 1)
assert_eq!(size, 1);
println!("Deleted buffer {}", *buffer);
}
#[allow(non_snake_case)]
unsafe extern fn glBindBuffer(target: GLenum, buffer: GLuint) {
println!("Bound buffer {} to target {:?}", buffer, target);
}
#[allow(non_snake_case)]
unsafe extern fn glBufferData(target: GLenum,
size: usize,
data: *const f32,
usage: GLenum)
{
println!("Buffered {} bytes into target {:?} (ptr {:?}, usage {:?})",
size, target, data, usage);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment